From f8e7ffe2c312ca2243273bff36e3eeda17cf34eb Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Thu, 26 Jun 2025 06:07:31 +0530 Subject: [PATCH 1/2] fix: synchronize timeline ruler and tracks scrolling - Add scroll synchronization between ruler and tracks - Auto-scroll to playhead when it moves outside visible area - Fix issue where long videos (15+ min) only showed 4 minutes on timeline - Ensure clips are visible when scrolling to any position (e.g., 6 minutes) - Optimize code with single useEffect for scroll handling --- apps/web/src/components/editor/timeline.tsx | 200 +++++++++++--------- 1 file changed, 113 insertions(+), 87 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ef45eb5db..1a42ead71 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -60,6 +60,8 @@ export function Timeline() { updateClipTrim, undo, redo, + updateClipStartTime, + moveClipToTrack, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { @@ -97,6 +99,13 @@ export function Timeline() { const [isScrubbing, setIsScrubbing] = useState(false); const [scrubTime, setScrubTime] = useState(null); + // Dynamic timeline width calculation based on playhead position and duration + const dynamicTimelineWidth = Math.max( + (duration || 0) * 50 * zoomLevel, // Base width from duration + (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer + timelineRef.current?.clientWidth || 1000 // Minimum width + ); + // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); @@ -564,6 +573,42 @@ export function Timeline() { }; }, [isInTimeline]); + // Scroll synchronization and auto-scroll to playhead + const rulerScrollRef = useRef(null); + const tracksScrollRef = useRef(null); + + useEffect(() => { + if (!rulerScrollRef.current || !tracksScrollRef.current) return; + + const rulerViewport = rulerScrollRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + const tracksViewport = tracksScrollRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + + if (!rulerViewport || !tracksViewport) return; + + // Sync scroll between ruler and tracks + const handleRulerScroll = () => tracksViewport.scrollLeft = rulerViewport.scrollLeft; + const handleTracksScroll = () => rulerViewport.scrollLeft = tracksViewport.scrollLeft; + + rulerViewport.addEventListener('scroll', handleRulerScroll); + tracksViewport.addEventListener('scroll', handleTracksScroll); + + // Auto-scroll to playhead when it moves significantly + const playheadPosition = currentTime * 50 * zoomLevel; + const containerWidth = rulerViewport.clientWidth; + const scrollLeft = rulerViewport.scrollLeft; + + if (playheadPosition < scrollLeft || playheadPosition > scrollLeft + containerWidth - 100) { + const targetScrollLeft = Math.max(0, playheadPosition - containerWidth / 2); + rulerViewport.scrollLeft = targetScrollLeft; + tracksViewport.scrollLeft = targetScrollLeft; + } + + return () => { + rulerViewport.removeEventListener('scroll', handleRulerScroll); + tracksViewport.removeEventListener('scroll', handleTracksScroll); + }; + }, [currentTime, zoomLevel, tracks.length]); + return (
- {currentTime.toFixed(1)}s / {duration.toFixed(1)}s + {currentTime.toFixed(1)}s / {duration?.toFixed(1) || "N/A"}s
{/* Test Clip Button - for debugging */} @@ -746,11 +791,11 @@ export function Timeline() { {/* Timeline Ruler */}
- +
{ // Calculate the clicked time position and seek to it @@ -791,108 +836,80 @@ export function Timeline() { }`} style={{ left: `${time * 50 * zoomLevel}px` }} > - - {(() => { - 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); - })()} - + {isMainMarker && ( +
+ {(() => { + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + if (mins > 0) { + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + return `${secs}s`; + }; + return formatTime(time); + })()} +
+ )}
); - }).filter(Boolean); + }); })()} - {/* Playhead in ruler (scrubbable) */} + {/* Playhead for ruler */}
-
-
+ />
- {/* Tracks Area */} + {/* Timeline Tracks */}
{/* Track Labels */} - {tracks.length > 0 && ( -
-
- {tracks.map((track) => ( +
+ {tracks.map((track, index) => ( +
+
{ - e.preventDefault(); - setContextMenu({ - type: "track", - trackId: track.id, - x: e.clientX, - y: e.clientY, - }); - }} - > -
-
- - {track.name} - -
- {track.muted && ( - - Muted - - )} -
- ))} + className={`w-3 h-3 rounded-full ${ + track.type === "video" + ? "bg-blue-500" + : track.type === "audio" + ? "bg-green-500" + : "bg-purple-500" + }`} + /> + + {track.name} + +
+
+ {track.muted && ( + + )} +
-
- )} + ))} +
{/* Timeline Tracks Content */}
-
- {/* Timeline grid and clips area (with left margin for sifdebar) */} +
)}
-
+
@@ -1144,6 +1161,14 @@ function TimelineTrackContent({ selectClip, deselectClip, } = useTimelineStore(); + const { currentTime, duration } = usePlaybackStore(); + + // Dynamic timeline width calculation for this track + const dynamicTimelineWidth = Math.max( + (duration || 0) * 50 * zoomLevel, // Base width from duration + (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer + 1000 // Minimum width + ); // Mouse-based drag hook const { isDragging, startDrag, endDrag, timelineRef } = @@ -1584,7 +1609,8 @@ function TimelineTrackContent({ >
{track.clips.length === 0 ? (
); -} +} \ No newline at end of file From 794a81a4380425b721ab4652741048503e0e1b4d Mon Sep 17 00:00:00 2001 From: Karan Singh Date: Thu, 26 Jun 2025 06:41:09 +0530 Subject: [PATCH 2/2] fix: optimize scroll synchronization performance - Separate scroll event sync from auto-scroll logic - Add isUpdating flag to prevent infinite scroll loops - Improve null checks and cleanup handling - Remove tracks.length from dependencies - Only trigger auto-scroll on currentTime/zoomLevel changes --- apps/web/src/components/editor/timeline.tsx | 60 +++++++++++++-------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 1a42ead71..3461a091f 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -576,38 +576,58 @@ export function Timeline() { // Scroll synchronization and auto-scroll to playhead const rulerScrollRef = useRef(null); const tracksScrollRef = useRef(null); + const isUpdatingRef = useRef(false); + // Setup scroll event synchronization (runs once when refs are available) useEffect(() => { - if (!rulerScrollRef.current || !tracksScrollRef.current) return; - - const rulerViewport = rulerScrollRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; - const tracksViewport = tracksScrollRef.current.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + const rulerViewport = rulerScrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; if (!rulerViewport || !tracksViewport) return; - // Sync scroll between ruler and tracks - const handleRulerScroll = () => tracksViewport.scrollLeft = rulerViewport.scrollLeft; - const handleTracksScroll = () => rulerViewport.scrollLeft = tracksViewport.scrollLeft; + // Sync scroll between ruler and tracks with infinite loop prevention + const handleRulerScroll = () => { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + tracksViewport.scrollLeft = rulerViewport.scrollLeft; + requestAnimationFrame(() => { isUpdatingRef.current = false; }); + }; + + const handleTracksScroll = () => { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + rulerViewport.scrollLeft = tracksViewport.scrollLeft; + requestAnimationFrame(() => { isUpdatingRef.current = false; }); + }; rulerViewport.addEventListener('scroll', handleRulerScroll); tracksViewport.addEventListener('scroll', handleTracksScroll); - // Auto-scroll to playhead when it moves significantly + return () => { + rulerViewport?.removeEventListener('scroll', handleRulerScroll); + tracksViewport?.removeEventListener('scroll', handleTracksScroll); + }; + }, []); // Only run once when component mounts + + // Auto-scroll to playhead when it moves significantly (separate effect) + useEffect(() => { + const rulerViewport = rulerScrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + const tracksViewport = tracksScrollRef.current?.querySelector('[data-radix-scroll-area-viewport]') as HTMLElement; + + if (!rulerViewport || !tracksViewport || isUpdatingRef.current) return; + const playheadPosition = currentTime * 50 * zoomLevel; const containerWidth = rulerViewport.clientWidth; const scrollLeft = rulerViewport.scrollLeft; if (playheadPosition < scrollLeft || playheadPosition > scrollLeft + containerWidth - 100) { const targetScrollLeft = Math.max(0, playheadPosition - containerWidth / 2); + isUpdatingRef.current = true; rulerViewport.scrollLeft = targetScrollLeft; tracksViewport.scrollLeft = targetScrollLeft; + requestAnimationFrame(() => { isUpdatingRef.current = false; }); } - - return () => { - rulerViewport.removeEventListener('scroll', handleRulerScroll); - tracksViewport.removeEventListener('scroll', handleTracksScroll); - }; - }, [currentTime, zoomLevel, tracks.length]); + }, [currentTime, zoomLevel]); // Only trigger on time/zoom changes return (
))} @@ -1145,11 +1166,13 @@ function TimelineTrackContent({ zoomLevel, setContextMenu, contextMenu, + timelineWidth, }: { track: TimelineTrack; zoomLevel: number; setContextMenu: (menu: ContextMenuState | null) => void; contextMenu: ContextMenuState | null; + timelineWidth: number; }) { const { mediaItems } = useMediaStore(); const { @@ -1163,13 +1186,6 @@ function TimelineTrackContent({ } = useTimelineStore(); const { currentTime, duration } = usePlaybackStore(); - // Dynamic timeline width calculation for this track - const dynamicTimelineWidth = Math.max( - (duration || 0) * 50 * zoomLevel, // Base width from duration - (currentTime + 30) * 50 * zoomLevel, // Width to show current time + 30 seconds buffer - 1000 // Minimum width - ); - // Mouse-based drag hook const { isDragging, startDrag, endDrag, timelineRef } = useDragClip(zoomLevel); @@ -1610,7 +1626,7 @@ function TimelineTrackContent({
{track.clips.length === 0 ? (