From e57618a08fc318759029e1ce4535399fb79a8730 Mon Sep 17 00:00:00 2001 From: Hyteq Date: Mon, 23 Jun 2025 09:44:07 +0300 Subject: [PATCH] feat: cleanup timeline further, easier to view durations / view larger videos, design makes more sense, more responsive --- apps/web/src/components/editor/timeline.tsx | 322 +++++++++++++------- 1 file changed, 220 insertions(+), 102 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b16c47c..03f7f6a 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -249,63 +249,187 @@ export function Timeline() { - {/* Tracks Area */} - -
- {/* Timeline Header */} -
- {/* Zoom indicator */} -
+ {/* Timeline Container */} +
+ {/* Timeline Header with Ruler */} +
+ {/* Track Labels Header */} +
+ Tracks +
{zoomLevel.toFixed(1)}x
- {/* Timeline Tracks */} -
- {tracks.length === 0 ? ( -
-
- + {/* Timeline Ruler */} +
+ +
+ {/* 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 ( +
+ + {(() => { + 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); + })()} + +
+ ); + }).filter(Boolean); + })()} + + {/* Playhead in ruler */} +
+
-

- No tracks in timeline -

+
+ +
+
+ + {/* Tracks Area */} +
+ {/* Track Labels */} +
+ {tracks.length === 0 ? ( +
+
+ +
+

No tracks

- Add a video or audio track to get started + Drop media to create tracks

) : ( -
+
{tracks.map((track) => ( - +
+
+
+ {track.name} +
+
))}
)} +
- {/* Playhead for tracks area */} - {tracks.length > 0 && ( + {/* Timeline Tracks Content */} +
+
0 ? `${tracks.length * 60}px` : '200px' }} + onClick={handleTimelineClick} + onWheel={handleWheel} > -
+ {tracks.length === 0 ? ( +
+
+
+ +
+

Drop media here to start

+
+
+ ) : ( + <> + {tracks.map((track, index) => ( +
+ +
+ ))} + + {/* Playhead for tracks area */} +
+ + )}
- )} +
-
+
); } -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 ( -
-
- {track.name} -
+
+
+ {track.clips.length === 0 ? ( +
+ {isDropping ? "Drop clip here" : "Drop media here"} +
+ ) : ( + <> + {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; -
-
- {track.clips.length === 0 ? ( -
- {isDropping ? "Drop clip here" : "Drop media here"} -
- ) : ( - <> - {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 ( -
-
handleResizeStart(e, clip.id, 'left')} - /> - -
handleClipDragStart(e, clip)} - onDragEnd={handleClipDragEnd} - > - {renderClipContent(clip)} -
- -
handleResizeStart(e, clip.id, 'right')} - /> -
- ); - })} - - {/* Drop position indicator */} - {isDraggedOver && dropPosition !== null && ( + return (
-
-
-
- {dropPosition.toFixed(1)}s +
handleResizeStart(e, clip.id, 'left')} + /> + +
handleClipDragStart(e, clip)} + onDragEnd={handleClipDragEnd} + > + {renderClipContent(clip)}
+ +
handleResizeStart(e, clip.id, 'right')} + />
- )} - - )} -
+ ); + })} + + {/* Drop position indicator */} + {isDraggedOver && dropPosition !== null && ( +
+
+
+
+ {dropPosition.toFixed(1)}s +
+
+ )} + + )}
);