summaryrefslogtreecommitdiff
path: root/apps/web/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components')
-rw-r--r--apps/web/src/components/editor/audio-waveform.tsx115
-rw-r--r--apps/web/src/components/editor/media-panel.tsx88
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx67
-rw-r--r--apps/web/src/components/editor/timeline.tsx122
-rw-r--r--apps/web/src/components/header.tsx13
-rw-r--r--apps/web/src/components/landing/hero.tsx28
-rw-r--r--apps/web/src/components/ui/video-player.tsx196
7 files changed, 421 insertions, 208 deletions
diff --git a/apps/web/src/components/editor/audio-waveform.tsx b/apps/web/src/components/editor/audio-waveform.tsx
new file mode 100644
index 00000000..75a5ae06
--- /dev/null
+++ b/apps/web/src/components/editor/audio-waveform.tsx
@@ -0,0 +1,115 @@
+import React, { useEffect, useRef, useState } from 'react';
+import WaveSurfer from 'wavesurfer.js';
+
+interface AudioWaveformProps {
+ audioUrl: string;
+ height?: number;
+ className?: string;
+}
+
+const AudioWaveform: React.FC<AudioWaveformProps> = ({
+ audioUrl,
+ height = 32,
+ className = ''
+}) => {
+ const waveformRef = useRef<HTMLDivElement>(null);
+ const wavesurfer = useRef<WaveSurfer | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initWaveSurfer = async () => {
+ if (!waveformRef.current || !audioUrl) return;
+
+ try {
+ // Clean up any existing instance
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+
+ wavesurfer.current = WaveSurfer.create({
+ container: waveformRef.current,
+ waveColor: 'rgba(255, 255, 255, 0.6)',
+ progressColor: 'rgba(255, 255, 255, 0.9)',
+ cursorColor: 'transparent',
+ barWidth: 2,
+ barGap: 1,
+ height: height,
+ normalize: true,
+ interact: false,
+ });
+
+ // Event listeners
+ wavesurfer.current.on('ready', () => {
+ if (mounted) {
+ setIsLoading(false);
+ setError(false);
+ }
+ });
+
+ wavesurfer.current.on('error', (err) => {
+ console.error('WaveSurfer error:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ });
+
+ await wavesurfer.current.load(audioUrl);
+
+ } catch (err) {
+ console.error('Failed to initialize WaveSurfer:', err);
+ if (mounted) {
+ setError(true);
+ setIsLoading(false);
+ }
+ }
+ };
+
+ initWaveSurfer();
+
+ return () => {
+ mounted = false;
+ if (wavesurfer.current) {
+ try {
+ wavesurfer.current.destroy();
+ } catch (e) {
+ // Silently ignore destroy errors
+ }
+ wavesurfer.current = null;
+ }
+ };
+ }, [audioUrl, height]);
+
+ if (error) {
+ return (
+ <div className={`flex items-center justify-center ${className}`} style={{ height }}>
+ <span className="text-xs text-foreground/60">Audio unavailable</span>
+ </div>
+ );
+ }
+
+ return (
+ <div className={`relative ${className}`}>
+ {isLoading && (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <span className="text-xs text-foreground/60">Loading...</span>
+ </div>
+ )}
+ <div
+ ref={waveformRef}
+ className={`w-full transition-opacity duration-200 ${isLoading ? 'opacity-0' : 'opacity-100'}`}
+ style={{ height }}
+ />
+ </div>
+ );
+};
+
+export default AudioWaveform; \ No newline at end of file
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index 95cfa0a8..cdd67a86 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -17,27 +17,28 @@ export function MediaPanel() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const processFiles = async (files: FileList | File[]) => {
- // If no files, do nothing
- if (!files?.length) return;
-
+ if (!files || files.length === 0) return;
setIsProcessing(true);
+ setProgress(0);
try {
// Process files (extract metadata, generate thumbnails, etc.)
- const items = await processMediaFiles(files);
+ const processedItems = await processMediaFiles(files, (p) =>
+ setProgress(p)
+ );
// Add each processed media item to the store
- items.forEach((item) => {
- addMediaItem(item);
- });
+ processedItems.forEach((item) => addMediaItem(item));
} catch (error) {
- // Show error if processing fails
- console.error("File processing failed:", error);
+ // Show error toast if processing fails
+ console.error("Error processing files:", error);
toast.error("Failed to process files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
};
@@ -84,17 +85,20 @@ export function MediaPanel() {
useEffect(() => {
const filtered = mediaItems.filter((item) => {
- if (mediaFilter && mediaFilter !== 'all' && item.type !== mediaFilter) {
+ if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) {
return false;
}
-
- if (searchQuery && !item.name.toLowerCase().includes(searchQuery.toLowerCase())) {
+
+ if (
+ searchQuery &&
+ !item.name.toLowerCase().includes(searchQuery.toLowerCase())
+ ) {
return false;
}
-
+
return true;
});
-
+
setFilteredMediaItems(filtered);
}, [mediaItems, mediaFilter, searchQuery]);
@@ -209,23 +213,23 @@ export function MediaPanel() {
{/* Button to add/upload media */}
<div className="flex gap-2">
{/* Search and filter controls */}
- <select
- value={mediaFilter}
- onChange={(e) => setMediaFilter(e.target.value)}
- className="px-2 py-1 text-xs border rounded bg-background"
- >
- <option value="all">All</option>
- <option value="video">Video</option>
- <option value="audio">Audio</option>
- <option value="image">Image</option>
- </select>
- <input
- type="text"
- placeholder="Search media..."
- className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
+ <select
+ value={mediaFilter}
+ onChange={(e) => setMediaFilter(e.target.value)}
+ className="px-2 py-1 text-xs border rounded bg-background"
+ >
+ <option value="all">All</option>
+ <option value="video">Video</option>
+ <option value="audio">Audio</option>
+ <option value="image">Image</option>
+ </select>
+ <input
+ type="text"
+ placeholder="Search media..."
+ className="min-w-[60px] flex-1 px-2 py-1 text-xs border rounded bg-background"
+ value={searchQuery}
+ onChange={(e) => setSearchQuery(e.target.value)}
+ />
{/* Add media button */}
<Button
@@ -233,21 +237,23 @@ export function MediaPanel() {
size="sm"
onClick={handleFileSelect}
disabled={isProcessing}
- className="flex-none min-w-[80px] whitespace-nowrap"
+ className="flex-none min-w-[30px] whitespace-nowrap overflow-hidden px-2 justify-center items-center"
>
{isProcessing ? (
<>
- <Upload className="h-4 w-4 mr-2 animate-spin" />
- Processing...
+ <Upload className="h-4 w-4 animate-spin" />
+ <span className="hidden md:inline ml-2">{progress}%</span>
</>
) : (
<>
<Plus className="h-4 w-4" />
- Add
+ <span className="hidden sm:inline ml-2" aria-label="Add file">
+ Add
+ </span>
</>
)}
</Button>
- </div>
+ </div>
</div>
<div className="flex-1 overflow-y-auto p-2">
@@ -276,7 +282,15 @@ export function MediaPanel() {
<AspectRatio ratio={item.aspectRatio}>
{renderPreview(item)}
</AspectRatio>
- <span className="text-xs truncate px-1">{item.name}</span>
+ <span
+ className="text-xs truncate px-1 max-w-full"
+ aria-label={item.name}
+ title={item.name}
+ >
+ {item.name.length > 8
+ ? `${item.name.slice(0, 4)}...${item.name.slice(-3)}`
+ : item.name}
+ </span>
</Button>
{/* Show remove button on hover */}
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 624a549e..33bfe688 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -5,16 +5,17 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
-import { Play, Pause } from "lucide-react";
+import { Play, Pause, Volume2, VolumeX } from "lucide-react";
import { useState, useRef } from "react";
// Debug flag - set to false to hide active clips info
-const SHOW_DEBUG_INFO = process.env.NODE_ENV === 'development';
+const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development";
export function PreviewPanel() {
const { tracks } = useTimelineStore();
const { mediaItems } = useMediaStore();
- const { isPlaying, toggle, currentTime } = usePlaybackStore();
+ const { isPlaying, toggle, currentTime, muted, toggleMute, volume } =
+ usePlaybackStore();
const [canvasSize, setCanvasSize] = useState({ width: 1920, height: 1080 });
const [showDebug, setShowDebug] = useState(SHOW_DEBUG_INFO);
const previewRef = useRef<HTMLDivElement>(null);
@@ -30,12 +31,14 @@ export function PreviewPanel() {
tracks.forEach((track) => {
track.clips.forEach((clip) => {
const clipStart = clip.startTime;
- const clipEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ const clipEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
if (currentTime >= clipStart && currentTime < clipEnd) {
- const mediaItem = clip.mediaId === "test"
- ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
- : mediaItems.find((item) => item.id === clip.mediaId);
+ const mediaItem =
+ clip.mediaId === "test"
+ ? { type: "test", name: clip.name, url: "", thumbnailUrl: "" }
+ : mediaItems.find((item) => item.id === clip.mediaId);
if (mediaItem || clip.mediaId === "test") {
activeClips.push({ clip, track, mediaItem });
@@ -134,13 +137,19 @@ export function PreviewPanel() {
<select
value={`${canvasSize.width}x${canvasSize.height}`}
onChange={(e) => {
- const preset = canvasPresets.find(p => `${p.width}x${p.height}` === e.target.value);
- if (preset) setCanvasSize({ width: preset.width, height: preset.height });
+ const preset = canvasPresets.find(
+ (p) => `${p.width}x${p.height}` === e.target.value
+ );
+ if (preset)
+ setCanvasSize({ width: preset.width, height: preset.height });
}}
className="bg-background border rounded px-2 py-1 text-xs"
>
- {canvasPresets.map(preset => (
- <option key={preset.name} value={`${preset.width}x${preset.height}`}>
+ {canvasPresets.map((preset) => (
+ <option
+ key={preset.name}
+ value={`${preset.width}x${preset.height}`}
+ >
{preset.name} ({preset.width}×{preset.height})
</option>
))}
@@ -154,12 +163,30 @@ export function PreviewPanel() {
onClick={() => setShowDebug(!showDebug)}
className="text-xs"
>
- Debug {showDebug ? 'ON' : 'OFF'}
+ Debug {showDebug ? "ON" : "OFF"}
</Button>
)}
- <Button variant="outline" size="sm" onClick={toggle} className="ml-auto">
- {isPlaying ? <Pause className="h-3 w-3 mr-1" /> : <Play className="h-3 w-3 mr-1" />}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={toggleMute}
+ className="ml-auto"
+ >
+ {muted || volume === 0 ? (
+ <VolumeX className="h-3 w-3 mr-1" />
+ ) : (
+ <Volume2 className="h-3 w-3 mr-1" />
+ )}
+ {muted || volume === 0 ? "Unmute" : "Mute"}
+ </Button>
+
+ <Button variant="outline" size="sm" onClick={toggle}>
+ {isPlaying ? (
+ <Pause className="h-3 w-3 mr-1" />
+ ) : (
+ <Play className="h-3 w-3 mr-1" />
+ )}
{isPlaying ? "Pause" : "Play"}
</Button>
</div>
@@ -177,7 +204,9 @@ export function PreviewPanel() {
>
{activeClips.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-white/50">
- {tracks.length === 0 ? "Drop media to start editing" : "No clips at current time"}
+ {tracks.length === 0
+ ? "Drop media to start editing"
+ : "No clips at current time"}
</div>
) : (
activeClips.map((clipData, index) => renderClip(clipData, index))
@@ -188,7 +217,9 @@ export function PreviewPanel() {
{/* Debug Info Panel - Conditionally rendered */}
{showDebug && (
<div className="border-t bg-background p-2 flex-shrink-0">
- <div className="text-xs font-medium mb-1">Debug: Active Clips ({activeClips.length})</div>
+ <div className="text-xs font-medium mb-1">
+ Debug: Active Clips ({activeClips.length})
+ </div>
<div className="flex gap-2 overflow-x-auto">
{activeClips.map((clipData, index) => (
<div
@@ -199,7 +230,9 @@ export function PreviewPanel() {
{index + 1}
</span>
<span>{clipData.clip.name}</span>
- <span className="text-muted-foreground">({clipData.mediaItem?.type || 'test'})</span>
+ <span className="text-muted-foreground">
+ ({clipData.mediaItem?.type || "test"})
+ </span>
</div>
))}
{activeClips.length === 0 && (
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ce0ffa30..584a45f7 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -35,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import AudioWaveform from "./audio-waveform";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -68,6 +69,7 @@ export function Timeline() {
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -333,8 +335,12 @@ export function Timeline() {
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -362,6 +368,7 @@ export function Timeline() {
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -566,15 +573,6 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Show overlay when dragging media over the timeline */}
- {isDragOver && (
- <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
- <div>
- Drop media here to add to timeline
- </div>
- </div>
- )}
-
{/* Toolbar */}
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
@@ -602,10 +600,11 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
- >
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
@@ -953,6 +952,15 @@ export function Timeline() {
)}
</>
)}
+ {isDragOver && (
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
+ </div>
+ )}
</div>
</div>
</div>
@@ -1233,7 +1241,18 @@ function TimelineTrackContent({
};
const handleClipDragStart = (e: React.DragEvent, clip: any) => {
- const dragData = { clipId: clip.id, trackId: track.id, name: clip.name };
+ // Calculate the offset from the left edge of the clip to where the user clicked
+ const clipElement = e.currentTarget.parentElement as HTMLElement;
+ const clipRect = clipElement.getBoundingClientRect();
+ const clickOffsetX = e.clientX - clipRect.left;
+ const clickOffsetTime = clickOffsetX / (50 * zoomLevel);
+
+ const dragData = {
+ clipId: clip.id,
+ trackId: track.id,
+ name: clip.name,
+ clickOffsetTime: clickOffsetTime,
+ };
e.dataTransfer.setData(
"application/x-timeline-clip",
@@ -1459,7 +1478,11 @@ function TimelineTrackContent({
);
if (!timelineClipData) return;
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ const {
+ clipId,
+ trackId: fromTrackId,
+ clickOffsetTime = 0,
+ } = JSON.parse(timelineClipData);
// Find the clip being moved
const sourceTrack = tracks.find(
@@ -1472,10 +1495,17 @@ function TimelineTrackContent({
return;
}
+ // Adjust position based on where user clicked on the clip
+ const adjustedStartTime = snappedTime - clickOffsetTime;
+ const finalStartTime = Math.max(
+ 0,
+ Math.round(adjustedStartTime * 10) / 10
+ );
+
// Check for overlaps with existing clips (excluding the moving clip itself)
const movingClipDuration =
movingClip.duration - movingClip.trimStart - movingClip.trimEnd;
- const movingClipEnd = snappedTime + movingClipDuration;
+ const movingClipEnd = finalStartTime + movingClipDuration;
const hasOverlap = track.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
@@ -1490,7 +1520,7 @@ function TimelineTrackContent({
existingClip.trimEnd);
// Check if clips overlap
- return snappedTime < existingEnd && movingClipEnd > existingStart;
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
});
if (hasOverlap) {
@@ -1502,12 +1532,12 @@ function TimelineTrackContent({
if (fromTrackId === track.id) {
// Moving within same track
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
} else {
// Moving to different track
moveClipToTrack(fromTrackId, track.id, clipId);
requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
});
}
} else if (hasMediaItem) {
@@ -1604,6 +1634,7 @@ function TimelineTrackContent({
src={mediaItem.url}
alt={mediaItem.name}
className="w-full h-full object-cover"
+ draggable={false}
/>
</div>
);
@@ -1617,6 +1648,7 @@ function TimelineTrackContent({
src={mediaItem.thumbnailUrl}
alt={mediaItem.name}
className="w-full h-full object-cover rounded-sm"
+ draggable={false}
/>
</div>
<span className="text-xs text-foreground/80 truncate flex-1">
@@ -1626,7 +1658,21 @@ function TimelineTrackContent({
);
}
- // Fallback for audio or videos without thumbnails
+ if (mediaItem.type === "audio") {
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
+ </div>
+ );
+ }
+
+ // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
@@ -1662,13 +1708,7 @@ function TimelineTrackContent({
return (
<div
- className={`w-full h-full transition-all duration-150 ease-out ${
- isDraggedOver
- ? wouldOverlap
- ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
- : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
- : "hover:bg-muted/20"
- }`}
+ className="w-full h-full hover:bg-muted/20"
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
@@ -1722,7 +1762,7 @@ function TimelineTrackContent({
return (
<div
key={clip.id}
- className={`timeline-clip absolute h-full border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
+ className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
onClick={(e) => {
e.stopPropagation();
@@ -1813,30 +1853,6 @@ function TimelineTrackContent({
</div>
);
})}
-
- {/* Drop position indicator */}
- {isDraggedOver && dropPosition !== null && (
- <div
- className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- style={{
- left: `${dropPosition * 50 * zoomLevel}px`,
- transform: "translateX(-50%)",
- }}
- >
- <div
- className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- >
- {wouldOverlap ? "⚠️" : ""}
- {dropPosition.toFixed(1)}s
- </div>
- </div>
- )}
</>
)}
</div>
diff --git a/apps/web/src/components/header.tsx b/apps/web/src/components/header.tsx
index 27503cd0..74ba9359 100644
--- a/apps/web/src/components/header.tsx
+++ b/apps/web/src/components/header.tsx
@@ -29,8 +29,7 @@ export function Header() {
const leftContent = (
<Link href="/" className="flex items-center gap-3">
- <Image src="/logo.png" alt="OpenCut Logo" width={24} height={24} />
- <span className="font-medium tracking-tight">OpenCut</span>
+ <Image src="/logo.svg" alt="OpenCut Logo" width={142} height={32} />
</Link>
);
@@ -51,7 +50,7 @@ export function Header() {
) : (
<Link href="https://github.com/OpenCut-app/OpenCut" target="_blank">
<Button size="sm" className="text-sm ml-4">
- GitHub
+ GitHub {star}+
<ArrowRight className="h-4 w-4" />
</Button>
</Link>
@@ -59,5 +58,11 @@ export function Header() {
</nav>
);
- return <HeaderBase leftContent={leftContent} rightContent={rightContent} />;
+ return (
+ <HeaderBase
+ className="bg-[#1D1D1D] border border-white/10 rounded-2xl max-w-3xl mx-auto mt-4 pl-4 pr-[14px]"
+ leftContent={leftContent}
+ rightContent={rightContent}
+ />
+ );
}
diff --git a/apps/web/src/components/landing/hero.tsx b/apps/web/src/components/landing/hero.tsx
index 7b4588c8..af33a3dd 100644
--- a/apps/web/src/components/landing/hero.tsx
+++ b/apps/web/src/components/landing/hero.tsx
@@ -8,6 +8,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { getStars } from "@/lib/fetchGhStars";
+import Image from "next/image";
interface HeroProps {
signupCount: number;
@@ -82,7 +83,7 @@ export function Hero({ signupCount }: HeroProps) {
};
return (
- <div className="min-h-[calc(100vh-4rem)] flex flex-col justify-between items-center text-center px-4">
+ <div className="min-h-[calc(100vh-6rem)] flex flex-col justify-between items-center text-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -93,14 +94,23 @@ export function Hero({ signupCount }: HeroProps) {
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.8 }}
- className="inline-block"
+ className="inline-block font-bold tracking-tighter text-4xl md:text-[4rem]"
>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter">
- The open source
- </h1>
- <h1 className="text-5xl sm:text-7xl font-bold tracking-tighter mt-2">
- video editor
- </h1>
+ <h1>The open source</h1>
+ <div className="flex justify-center gap-4 leading-[4rem] mt-0 md:mt-2">
+ <div className="relative -rotate-[2.76deg] max-w-[250px] md:max-w-[454px] mt-2">
+ <Image
+ className="mt-2.5 md:mt-0"
+ src="/frame.svg"
+ height={79}
+ width={459}
+ alt="frame"
+ />
+ <span className="absolute inset-0 flex items-center justify-center">
+ Video Editor
+ </span>
+ </div>
+ </div>
</motion.div>
<motion.p
@@ -119,7 +129,7 @@ export function Hero({ signupCount }: HeroProps) {
animate={{ opacity: 1 }}
transition={{ delay: 0.6, duration: 0.8 }}
>
- <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg">
+ <form onSubmit={handleSubmit} className="flex gap-3 w-full max-w-lg flex-col sm:flex-row">
<Input
type="email"
placeholder="Enter your email"
diff --git a/apps/web/src/components/ui/video-player.tsx b/apps/web/src/components/ui/video-player.tsx
index 32a12916..d8a72e40 100644
--- a/apps/web/src/components/ui/video-player.tsx
+++ b/apps/web/src/components/ui/video-player.tsx
@@ -4,108 +4,128 @@ import { useRef, useEffect } from "react";
import { usePlaybackStore } from "@/stores/playback-store";
interface VideoPlayerProps {
- src: string;
- poster?: string;
- className?: string;
- clipStartTime: number;
- trimStart: number;
- trimEnd: number;
- clipDuration: number;
+ src: string;
+ poster?: string;
+ className?: string;
+ clipStartTime: number;
+ trimStart: number;
+ trimEnd: number;
+ clipDuration: number;
}
export function VideoPlayer({
- src,
- poster,
- className = "",
- clipStartTime,
- trimStart,
- trimEnd,
- clipDuration
+ src,
+ poster,
+ className = "",
+ clipStartTime,
+ trimStart,
+ trimEnd,
+ clipDuration,
}: VideoPlayerProps) {
- const videoRef = useRef<HTMLVideoElement>(null);
- const { isPlaying, currentTime, volume, speed } = usePlaybackStore();
+ const videoRef = useRef<HTMLVideoElement>(null);
+ const { isPlaying, currentTime, volume, speed, muted } = usePlaybackStore();
- // Calculate if we're within this clip's timeline range
- const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
- const isInClipRange = currentTime >= clipStartTime && currentTime < clipEndTime;
+ // Calculate if we're within this clip's timeline range
+ const clipEndTime = clipStartTime + (clipDuration - trimStart - trimEnd);
+ const isInClipRange =
+ currentTime >= clipStartTime && currentTime < clipEndTime;
- // Sync playback events
- useEffect(() => {
- const video = videoRef.current;
- if (!video || !isInClipRange) return;
+ // Sync playback events
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video || !isInClipRange) return;
- const handleSeekEvent = (e: CustomEvent) => {
- // Always update video time, even if outside clip range
- const timelineTime = e.detail.time;
- const videoTime = Math.max(trimStart, Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- ));
- video.currentTime = videoTime;
- };
+ const handleSeekEvent = (e: CustomEvent) => {
+ // Always update video time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const videoTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
+ video.currentTime = videoTime;
+ };
- const handleUpdateEvent = (e: CustomEvent) => {
- // Always update video time, even if outside clip range
- const timelineTime = e.detail.time;
- const targetTime = Math.max(trimStart, Math.min(
- clipDuration - trimEnd,
- timelineTime - clipStartTime + trimStart
- ));
+ const handleUpdateEvent = (e: CustomEvent) => {
+ // Always update video time, even if outside clip range
+ const timelineTime = e.detail.time;
+ const targetTime = Math.max(
+ trimStart,
+ Math.min(
+ clipDuration - trimEnd,
+ timelineTime - clipStartTime + trimStart
+ )
+ );
- if (Math.abs(video.currentTime - targetTime) > 0.5) {
- video.currentTime = targetTime;
- }
- };
+ if (Math.abs(video.currentTime - targetTime) > 0.5) {
+ video.currentTime = targetTime;
+ }
+ };
- const handleSpeed = (e: CustomEvent) => {
- video.playbackRate = e.detail.speed;
- };
+ const handleSpeed = (e: CustomEvent) => {
+ video.playbackRate = e.detail.speed;
+ };
- window.addEventListener("playback-seek", handleSeekEvent as EventListener);
- window.addEventListener("playback-update", handleUpdateEvent as EventListener);
- window.addEventListener("playback-speed", handleSpeed as EventListener);
+ window.addEventListener("playback-seek", handleSeekEvent as EventListener);
+ window.addEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.addEventListener("playback-speed", handleSpeed as EventListener);
- return () => {
- window.removeEventListener("playback-seek", handleSeekEvent as EventListener);
- window.removeEventListener("playback-update", handleUpdateEvent as EventListener);
- window.removeEventListener("playback-speed", handleSpeed as EventListener);
- };
- }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
+ return () => {
+ window.removeEventListener(
+ "playback-seek",
+ handleSeekEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-update",
+ handleUpdateEvent as EventListener
+ );
+ window.removeEventListener(
+ "playback-speed",
+ handleSpeed as EventListener
+ );
+ };
+ }, [clipStartTime, trimStart, trimEnd, clipDuration, isInClipRange]);
- // Sync playback state
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
+ // Sync playback state
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
- if (isPlaying && isInClipRange) {
- video.play().catch(() => { });
- } else {
- video.pause();
- }
- }, [isPlaying, isInClipRange]);
+ if (isPlaying && isInClipRange) {
+ video.play().catch(() => {});
+ } else {
+ video.pause();
+ }
+ }, [isPlaying, isInClipRange]);
- // Sync volume and speed
- useEffect(() => {
- const video = videoRef.current;
- if (!video) return;
+ // Sync volume and speed
+ useEffect(() => {
+ const video = videoRef.current;
+ if (!video) return;
- video.volume = volume;
- video.playbackRate = speed;
- }, [volume, speed]);
+ video.volume = volume;
+ video.muted = muted;
+ video.playbackRate = speed;
+ }, [volume, speed, muted]);
- return (
- <video
- ref={videoRef}
- src={src}
- poster={poster}
- className={`w-full h-full object-cover ${className}`}
- playsInline
- preload="auto"
- controls={false}
- disablePictureInPicture
- disableRemotePlayback
- style={{ pointerEvents: 'none' }}
- onContextMenu={(e) => e.preventDefault()}
- />
- );
-} \ No newline at end of file
+ return (
+ <video
+ ref={videoRef}
+ src={src}
+ poster={poster}
+ className={`w-full h-full object-cover ${className}`}
+ playsInline
+ preload="auto"
+ controls={false}
+ disablePictureInPicture
+ disableRemotePlayback
+ style={{ pointerEvents: "none" }}
+ onContextMenu={(e) => e.preventDefault()}
+ />
+ );
+}