"use client"; import { Button } from "../ui/button"; import { Scissors, ArrowLeftToLine, ArrowRightToLine, Trash2, Snowflake, Copy, SplitSquareHorizontal, Pause, Play, Video, Music, TypeIcon, Lock, LockOpen, Link, } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger, TooltipProvider, } from "../ui/tooltip"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "../ui/context-menu"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { useProjectStore } from "@/stores/project-store"; import { useTimelineZoom } from "@/hooks/use-timeline-zoom"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; import { TimelineTrackContent } from "./timeline-track"; import { TimelinePlayhead, useTimelinePlayheadRuler, } from "./timeline-playhead"; import { SelectionBox } from "./selection-box"; import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "./snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack } from "@/types/timeline"; import { getTrackHeight, getCumulativeHeightBefore, getTotalTracksHeight, TIMELINE_CONSTANTS, snapTimeToFrame, } from "@/constants/timeline-constants"; import { TimelineZoomControl } from "./timeline-zoom-control"; import { useTimelineZoomActions } from "@/hooks/use-timeline-zoom-actions"; export function Timeline() { const { tracks, addTrack, addElementToTrack, removeElementFromTrack, removeElementFromTrackWithRipple, getTotalDuration, calculateFitToWindowZoom, selectedElements, clearSelectedElements, setSelectedElements, splitElement, splitAndKeepLeft, splitAndKeepRight, toggleTrackMute, separateAudio, snappingEnabled, toggleSnapping, rippleEditingEnabled, toggleRippleEditing, dragState, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { activeProject } = useProjectStore(); const { currentTime, duration, seek, setDuration, isPlaying, toggle } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); const dragCounterRef = useRef(0); const timelineRef = useRef(null); const rulerRef = useRef(null); const [isInTimeline, setIsInTimeline] = useState(false); // Track mouse down/up for distinguishing clicks from drag/resize ends const mouseTrackingRef = useRef({ isMouseDown: false, downX: 0, downY: 0, downTime: 0, }); // Fit to window function const handleFitToWindow = useCallback(() => { if (timelineRef.current) { const containerWidth = timelineRef.current.clientWidth; const optimalZoom = calculateFitToWindowZoom(containerWidth); setZoomLevel(optimalZoom); } }, [calculateFitToWindowZoom]); // Timeline zoom functionality const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({ containerRef: timelineRef, isInTimeline, onFitToWindow: handleFitToWindow, }); // Dynamic timeline width calculation based on playhead position and duration const dynamicTimelineWidth = Math.max( (duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, (currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, timelineRef.current?.clientWidth || 1000 ); // Scroll synchronization and auto-scroll to playhead const rulerScrollRef = useRef(null); const tracksScrollRef = useRef(null); const trackLabelsRef = useRef(null); const playheadRef = useRef(null); const trackLabelsScrollRef = useRef(null); const isUpdatingRef = useRef(false); const lastRulerSync = useRef(0); const lastTracksSync = useRef(0); const lastVerticalSync = useRef(0); // Timeline playhead ruler handlers const { handleRulerMouseDown } = useTimelinePlayheadRuler({ currentTime, duration, zoomLevel, seek, rulerRef, rulerScrollRef, tracksScrollRef, playheadRef, }); // Zoom actions useTimelineZoomActions({ zoomLevel, setZoomLevel, onFitToWindow: handleFitToWindow, }); // Selection box functionality const tracksContainerRef = useRef(null); const { selectionBox, handleMouseDown: handleSelectionMouseDown, isSelecting, justFinishedSelecting, } = useSelectionBox({ containerRef: tracksContainerRef, playheadRef, onSelectionComplete: (elements) => { console.log(JSON.stringify({ onSelectionComplete: elements.length })); setSelectedElements(elements); }, }); // Calculate snap indicator state const [currentSnapPoint, setCurrentSnapPoint] = useState( null ); const showSnapIndicator = dragState.isDragging && snappingEnabled && currentSnapPoint !== null; // Callback to handle snap point changes from TimelineTrackContent const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => { setCurrentSnapPoint(snapPoint); }, []); // Track mouse down to distinguish real clicks from drag/resize ends const handleTimelineMouseDown = useCallback((e: React.MouseEvent) => { // Only track mouse down on timeline background areas (not elements) const target = e.target as HTMLElement; const isTimelineBackground = !target.closest(".timeline-element") && !playheadRef.current?.contains(target) && !target.closest("[data-track-labels]"); if (isTimelineBackground) { const now = Date.now(); mouseTrackingRef.current = { isMouseDown: true, downX: e.clientX, downY: e.clientY, downTime: now, }; } }, []); // Track mouse up globally to reset mouse tracking useEffect(() => { const handleGlobalMouseUp = () => { mouseTrackingRef.current.isMouseDown = false; }; window.addEventListener("mouseup", handleGlobalMouseUp); return () => window.removeEventListener("mouseup", handleGlobalMouseUp); }, []); // Handle clicks on timeline content (not playhead/ruler) const handleTimelineContentClick = useCallback((e: React.MouseEvent) => { // Only handle clicks if we didn't move much (distinguishing from drag end) const { isMouseDown, downX, downY, downTime } = mouseTrackingRef.current; const now = Date.now(); const distance = Math.sqrt( Math.pow(e.clientX - downX, 2) + Math.pow(e.clientY - downY, 2) ); // Not a real click if: // - Mouse wasn't pressed down in timeline background // - Too much movement (likely drag end) // - Too much time passed (likely hold or slow drag) if (!isMouseDown || distance > 5 || now - downTime > 300) { return; } // Only proceed if we're not currently selecting and didn't just finish selecting if (isSelecting || justFinishedSelecting) { return; } // Get timeline content element and click position const timelineContent = e.currentTarget as HTMLElement; const rect = timelineContent.getBoundingClientRect(); const clickX = e.clientX - rect.left; // Convert click position to time const timeFromClick = clickX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel); // Snap to frame boundary for accurate seeking const snappedTime = snapTimeToFrame(timeFromClick, 30); // Seek to the clicked time seek(Math.max(0, snappedTime)); }, [zoomLevel, seek, isSelecting, justFinishedSelecting]); // Scroll synchronization effect useEffect(() => { const rulerViewport = rulerScrollRef.current; const tracksViewport = tracksScrollRef.current?.parentElement; const trackLabelsViewport = trackLabelsScrollRef.current?.parentElement; if (!rulerViewport || !tracksViewport) return; // Horizontal scroll synchronization between ruler and tracks const handleRulerScroll = () => { const now = Date.now(); if (isUpdatingRef.current || now - lastRulerSync.current < 16) return; lastRulerSync.current = now; isUpdatingRef.current = true; tracksViewport.scrollLeft = rulerViewport.scrollLeft; isUpdatingRef.current = false; }; const handleTracksScroll = () => { const now = Date.now(); if (isUpdatingRef.current || now - lastTracksSync.current < 16) return; lastTracksSync.current = now; isUpdatingRef.current = true; rulerViewport.scrollLeft = tracksViewport.scrollLeft; isUpdatingRef.current = false; }; rulerViewport.addEventListener("scroll", handleRulerScroll); tracksViewport.addEventListener("scroll", handleTracksScroll); // Vertical scroll synchronization between track labels and tracks content if (trackLabelsViewport) { const handleTrackLabelsScroll = () => { const now = Date.now(); if (isUpdatingRef.current || now - lastVerticalSync.current < 16) return; lastVerticalSync.current = now; isUpdatingRef.current = true; tracksViewport.scrollTop = trackLabelsViewport.scrollTop; isUpdatingRef.current = false; }; const handleTracksVerticalScroll = () => { const now = Date.now(); if (isUpdatingRef.current || now - lastVerticalSync.current < 16) return; lastVerticalSync.current = now; isUpdatingRef.current = true; trackLabelsViewport.scrollTop = tracksViewport.scrollTop; isUpdatingRef.current = false; }; trackLabelsViewport.addEventListener("scroll", handleTrackLabelsScroll); tracksViewport.addEventListener("scroll", handleTracksVerticalScroll); return () => { rulerViewport.removeEventListener("scroll", handleRulerScroll); tracksViewport.removeEventListener("scroll", handleTracksScroll); trackLabelsViewport.removeEventListener("scroll", handleTrackLabelsScroll); tracksViewport.removeEventListener("scroll", handleTracksVerticalScroll); }; } return () => { rulerViewport.removeEventListener("scroll", handleRulerScroll); tracksViewport.removeEventListener("scroll", handleTracksScroll); }; }, []); return (
setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} > {/* Toolbar */}
{/* Play/Pause Button */} {isPlaying ? "Pause (Space)" : "Play (Space)"}
{/* Time Display */}
{(() => { const formatTime = (seconds: number) => { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 100); return h > 0 ? `${h}:${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}` : `${m}:${s.toString().padStart(2, "0")}.${ms.toString().padStart(2, "0")}`; }; return `${formatTime(currentTime)} / ${formatTime(duration)}`; })()}
{/* Timeline Settings */} {snappingEnabled ? "Disable Snapping" : "Enable Snapping"} {rippleEditingEnabled ? "Disable Ripple Editing" : "Enable Ripple Editing"} {/* Timeline Zoom Control */}
{/* Timeline Container */}
{/* Timeline Header with Ruler */}
{/* Track Labels Header */}
.
{/* Timeline Ruler */}
{/* Time markers */} {(() => { const getTimeInterval = (zoom: number) => { const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom; if (pixelsPerSecond >= 200) return 0.1; if (pixelsPerSecond >= 100) return 0.5; if (pixelsPerSecond >= 50) return 1; if (pixelsPerSecond >= 25) return 2; if (pixelsPerSecond >= 12) return 5; if (pixelsPerSecond >= 6) return 10; return 30; }; 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); })()}
{/* Tracks Area */}
{/* Track Labels */} {tracks.length > 0 && (
{tracks.map((track) => (
{track.muted && ( Muted )}
))}
)} {/* Timeline Tracks Content */}
{ handleTimelineMouseDown(e); handleSelectionMouseDown(e); }} onClick={handleTimelineContentClick} ref={tracksContainerRef} >
{tracks.length === 0 ? (
) : ( <> {tracks.map((track, index) => (
{ if ( !(e.target as HTMLElement).closest(".timeline-element") ) { clearSelectedElements(); } }} >
toggleTrackMute(track.id)} > {track.muted ? "Unmute Track" : "Mute Track"} Track settings (soon)
))} )}
); } function TrackIcon({ track }: { track: TimelineTrack }) { return ( <> {track.type === "media" && (