diff options
| author | Hyteq <DerpsDer.es@gmail.com> | 2025-06-23 09:44:07 +0300 |
|---|---|---|
| committer | Hyteq <DerpsDer.es@gmail.com> | 2025-06-23 09:44:07 +0300 |
| commit | e57618a08fc318759029e1ce4535399fb79a8730 (patch) | |
| tree | bdce46a8bb4e2936ba3cc65b2f3087c6581ffb21 /apps | |
| parent | 50931c487b14ec0b9802e9877bf4f423be19c2cb (diff) | |
| download | opencut-e57618a08fc318759029e1ce4535399fb79a8730.tar.gz | |
feat: cleanup timeline further, easier to view durations / view larger videos, design makes more sense, more responsive
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 316 |
1 files changed, 217 insertions, 99 deletions
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b16c47c0..03f7f6a7 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -249,63 +249,187 @@ export function Timeline() { </TooltipProvider>
</div>
- {/* Tracks Area */}
- <ScrollArea className="flex-1">
- <div
- ref={timelineRef}
- className="min-w-[800px] relative cursor-pointer select-none"
- onClick={handleTimelineClick}
- onWheel={handleWheel}
- >
- {/* Timeline Header */}
- <div className="py-3 relative bg-muted/30 border-b">
- {/* Zoom indicator */}
- <div className="absolute top-1 right-2 text-xs text-muted-foreground">
+ {/* Timeline Container */}
+ <div className="flex-1 flex flex-col overflow-hidden">
+ {/* Timeline Header with Ruler */}
+ <div className="flex border-b bg-background sticky top-0 z-10">
+ {/* Track Labels Header */}
+ <div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
+ <span className="text-sm font-medium text-muted-foreground">Tracks</span>
+ <div className="text-xs text-muted-foreground">
{zoomLevel.toFixed(1)}x
</div>
</div>
- {/* Timeline Tracks */}
- <div className="relative">
+ {/* Timeline Ruler */}
+ <div className="flex-1 relative overflow-hidden">
+ <ScrollArea className="w-full">
+ <div
+ className="relative h-12 bg-muted/30"
+ style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px` }}
+ >
+ {/* Time markers */}
+ {(() => {
+ // Calculate appropriate time interval based on zoom level
+ const getTimeInterval = (zoom: number) => {
+ const pixelsPerSecond = 50 * zoom;
+ if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
+ if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
+ if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
+ if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
+ if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
+ if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
+ return 30; // Every 30s when extremely zoomed out
+ };
+
+ const interval = getTimeInterval(zoomLevel);
+ const markerCount = Math.ceil(duration / interval) + 1;
+
+ return Array.from({ length: markerCount }, (_, i) => {
+ const time = i * interval;
+ if (time > duration) return null;
+
+ const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
+
+ return (
+ <div
+ key={i}
+ className={`absolute top-0 bottom-0 ${isMainMarker
+ ? 'border-l border-muted-foreground/40'
+ : 'border-l border-muted-foreground/20'
+ }`}
+ style={{ left: `${time * 50 * zoomLevel}px` }}
+ >
+ <span className={`absolute top-1 left-1 text-xs ${isMainMarker
+ ? 'text-muted-foreground font-medium'
+ : 'text-muted-foreground/70'
+ }`}>
+ {(() => {
+ const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ if (hours > 0) {
+ return `${hours}:${minutes.toString().padStart(2, '0')}:${Math.floor(secs).toString().padStart(2, '0')}`;
+ } else if (minutes > 0) {
+ return `${minutes}:${Math.floor(secs).toString().padStart(2, '0')}`;
+ } else if (interval >= 1) {
+ return `${Math.floor(secs)}s`;
+ } else {
+ return `${secs.toFixed(1)}s`;
+ }
+ };
+ return formatTime(time);
+ })()}
+ </span>
+ </div>
+ );
+ }).filter(Boolean);
+ })()}
+
+ {/* Playhead in ruler */}
+ <div
+ className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
+ style={{ left: `${currentTime * 50 * zoomLevel}px` }}
+ >
+ <div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
+ </div>
+ </div>
+ </ScrollArea>
+ </div>
+ </div>
+
+ {/* Tracks Area */}
+ <div className="flex-1 flex overflow-hidden">
+ {/* Track Labels */}
+ <div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
{tracks.length === 0 ? (
- <div className="flex flex-col items-center justify-center py-12 text-center">
- <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
- <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ <div className="flex flex-col items-center justify-center h-full py-8 text-center px-4">
+ <div className="w-12 h-12 rounded-full bg-muted/30 flex items-center justify-center mb-3">
+ <SplitSquareHorizontal className="h-6 w-6 text-muted-foreground" />
</div>
- <p className="text-sm text-muted-foreground">
- No tracks in timeline
- </p>
+ <p className="text-sm text-muted-foreground">No tracks</p>
<p className="text-xs text-muted-foreground/70 mt-1">
- Add a video or audio track to get started
+ Drop media to create tracks
</p>
</div>
) : (
- <div className="flex flex-col gap-2.5">
+ <div className="flex flex-col">
{tracks.map((track) => (
- <TimelineTrackComponent key={track.id} track={track} zoomLevel={zoomLevel} />
+ <div
+ key={track.id}
+ className="h-[60px] flex items-center px-3 border-b border-muted/30 bg-background"
+ >
+ <div className="flex items-center gap-2 flex-1 min-w-0">
+ <div className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === 'video' ? 'bg-blue-500' :
+ track.type === 'audio' ? 'bg-green-500' : 'bg-purple-500'
+ }`} />
+ <span className="text-sm font-medium truncate">{track.name}</span>
+ </div>
+ </div>
))}
</div>
)}
+ </div>
- {/* Playhead for tracks area */}
- {tracks.length > 0 && (
+ {/* Timeline Tracks Content */}
+ <div className="flex-1 relative">
+ <ScrollArea className="h-full w-full">
<div
- className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-20"
+ ref={timelineRef}
+ className="relative cursor-pointer select-none"
style={{
- left: `${currentTime * 50 * zoomLevel + 128}px`,
+ width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
+ minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : '200px'
}}
+ onClick={handleTimelineClick}
+ onWheel={handleWheel}
>
- <div className="absolute -top-8 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
+ {tracks.length === 0 ? (
+ <div className="absolute inset-0 flex items-center justify-center">
+ <div className="text-center">
+ <div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
+ <SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
+ </div>
+ <p className="text-sm text-muted-foreground">Drop media here to start</p>
+ </div>
+ </div>
+ ) : (
+ <>
+ {tracks.map((track, index) => (
+ <div
+ key={track.id}
+ className="absolute left-0 right-0 border-b border-muted/30"
+ style={{
+ top: `${index * 60}px`,
+ height: '60px'
+ }}
+ >
+ <TimelineTrackContent track={track} zoomLevel={zoomLevel} />
+ </div>
+ ))}
+
+ {/* Playhead for tracks area */}
+ <div
+ className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
+ style={{
+ left: `${currentTime * 50 * zoomLevel}px`,
+ height: `${tracks.length * 60}px`
+ }}
+ />
+ </>
+ )}
</div>
- )}
+ </ScrollArea>
</div>
</div>
- </ScrollArea>
+ </div>
</div>
);
}
-function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
+function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
const { mediaItems } = useMediaStore();
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false);
@@ -546,81 +670,75 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo };
return (
- <div className="flex items-center px-2">
- <div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
- {track.name}
- </div>
+ <div
+ className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
+ ? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
+ : "hover:bg-muted/20"
+ }`}
+ onDragOver={handleTrackDragOver}
+ onDragEnter={handleTrackDragEnter}
+ onDragLeave={handleTrackDragLeave}
+ onDrop={handleTrackDrop}
+ onMouseMove={handleResizeMove}
+ onMouseUp={handleResizeEnd}
+ onMouseLeave={handleResizeEnd}
+ >
+ <div className="h-full relative track-clips-container min-w-full">
+ {track.clips.length === 0 ? (
+ <div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
+ }`}>
+ {isDropping ? "Drop clip here" : "Drop media here"}
+ </div>
+ ) : (
+ <>
+ {track.clips.map((clip) => {
+ const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
+ const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
+ const clipLeft = clip.startTime * 50 * zoomLevel;
- <div
- className={`flex-1 h-[60px] transition-all duration-150 ease-out ${isDraggedOver
- ? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg transform scale-[1.02]"
- : "border border-transparent hover:bg-muted/30"
- }`}
- onDragOver={handleTrackDragOver}
- onDragEnter={handleTrackDragEnter}
- onDragLeave={handleTrackDragLeave}
- onDrop={handleTrackDrop}
- onMouseMove={handleResizeMove}
- onMouseUp={handleResizeEnd}
- onMouseLeave={handleResizeEnd}
- >
- <div className="h-full relative track-clips-container min-w-full">
- {track.clips.length === 0 ? (
- <div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
- }`}>
- {isDropping ? "Drop clip here" : "Drop media here"}
- </div>
- ) : (
- <>
- {track.clips.map((clip) => {
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
- const clipLeft = clip.startTime * 50 * zoomLevel;
+ return (
+ <div
+ key={clip.id}
+ className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
+ style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
+ >
+ <div
+ className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
+ />
- return (
<div
- key={clip.id}
- className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
- style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
+ className="flex-1 cursor-grab active:cursor-grabbing"
+ draggable={true}
+ onDragStart={(e) => handleClipDragStart(e, clip)}
+ onDragEnd={handleClipDragEnd}
>
- <div
- className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
- />
-
- <div
- className="flex-1 cursor-grab active:cursor-grabbing"
- draggable={true}
- onDragStart={(e) => handleClipDragStart(e, clip)}
- onDragEnd={handleClipDragEnd}
- >
- {renderClipContent(clip)}
- </div>
-
- <div
- className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
- />
+ {renderClipContent(clip)}
</div>
- );
- })}
- {/* Drop position indicator */}
- {isDraggedOver && dropPosition !== null && (
- <div
- className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out"
- 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 bg-blue-500 rounded-full border-2 border-white shadow-md" />
- <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
- <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap">
- {dropPosition.toFixed(1)}s
- </div>
+ <div
+ className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
+ />
</div>
- )}
- </>
- )}
- </div>
+ );
+ })}
+
+ {/* Drop position indicator */}
+ {isDraggedOver && dropPosition !== null && (
+ <div
+ className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out"
+ 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 bg-blue-500 rounded-full border-2 border-white shadow-md" />
+ <div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
+ <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap">
+ {dropPosition.toFixed(1)}s
+ </div>
+ </div>
+ )}
+ </>
+ )}
</div>
</div>
);
|
