From 2aa37920d5f183f9c0de2c42076fa57c5b48320e Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 24 Jun 2025 17:51:34 +0200 Subject: refactor: overhaul timeline component, separate into different files --- .../components/editor/timeline/timeline-clip.tsx | 192 +++++ .../editor/timeline/timeline-toolbar.tsx | 296 ++++++++ .../editor/timeline/timeline-track-content.tsx | 593 +++++++++++++++ .../src/components/editor/timeline/timeline.tsx | 798 +++++++++++++++++++++ 4 files changed, 1879 insertions(+) create mode 100644 apps/web/src/components/editor/timeline/timeline-clip.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-toolbar.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-track-content.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline.tsx (limited to 'apps/web/src/components/editor/timeline') diff --git a/apps/web/src/components/editor/timeline/timeline-clip.tsx b/apps/web/src/components/editor/timeline/timeline-clip.tsx new file mode 100644 index 00000000..69740cd7 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-clip.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { type TimelineClip } from "@/types/timeline"; +import { useState } from "react"; + +interface TimelineClipProps { + clip: TimelineClip; + track: TimelineTrack; + zoomLevel: number; + isSelected: boolean; + onResizeStart: ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => void; + onClipDragStart: (e: React.DragEvent, clip: any) => void; + onClipDragEnd: (e: React.DragEvent) => void; + onSelect: (e: React.MouseEvent) => void; + onContextMenu: (e: React.MouseEvent) => void; + getTrackColor: (type: string) => string; +} + +export function TimelineClip({ + clip, + track, + zoomLevel, + isSelected, + onResizeStart, + onClipDragStart, + onClipDragEnd, + onSelect, + onContextMenu, + getTrackColor, +}: TimelineClipProps) { + const { mediaItems } = useMediaStore(); + const { addClipToTrack, updateClipTrim, removeClipFromTrack } = + useTimelineStore(); + const { currentTime } = usePlaybackStore(); + const [clipMenuOpen, setClipMenuOpen] = useState(null); + + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const clipLeft = clip.startTime * 50 * zoomLevel; + + const renderClipContent = (clip: TimelineClip) => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem) { + return ( + {clip.name} + ); + } + + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + return ( +
+
+ {mediaItem.name} +
+ + {clip.name} + +
+ ); + } + + // Fallback for audio or videos without thumbnails + return ( + {clip.name} + ); + }; + + const handleSplitClip = (clip: TimelineClip) => { + // Use current playback time as split point + const splitTime = currentTime; + + // Only split if splitTime is within the clip's effective range + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + const firstDuration = splitTime - effectiveStart; + const secondDuration = effectiveEnd - splitTime; + + // First part: adjust original clip + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + secondDuration + ); + + // Second part: add new clip after split + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (cut)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + firstDuration, + trimEnd: clip.trimEnd, + }); + }; + + const handleDeleteClip = (clipId: string) => { + removeClipFromTrack(track.id, clipId); + }; + + return ( +
+ {/* Left trim handle */} +
onResizeStart(e, clip.id, "left")} + /> + + {/* Clip content */} +
onClipDragStart(e, clip)} + onDragEnd={onClipDragEnd} + > + {renderClipContent(clip)} + + {/* Clip options menu */} +
+ + {clipMenuOpen === clip.id && ( +
+ + +
+ )} +
+
+ + {/* Right trim handle */} +
onResizeStart(e, clip.id, "right")} + /> +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx new file mode 100644 index 00000000..3778ccf8 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { Button } from "../../ui/button"; +import { + Scissors, + ArrowLeftToLine, + ArrowRightToLine, + Trash2, + Snowflake, + Copy, + SplitSquareHorizontal, + Pause, + Play, +} from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, + TooltipProvider, +} from "../../ui/tooltip"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../ui/select"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { toast } from "sonner"; + +interface TimelineToolbarProps {} + +export function TimelineToolbar({}: TimelineToolbarProps) { + const { + tracks, + addTrack, + addClipToTrack, + removeClipFromTrack, + selectedClips, + clearSelectedClips, + updateClipTrim, + } = useTimelineStore(); + const { currentTime, duration, isPlaying, toggle, setSpeed, speed } = + usePlaybackStore(); + + const handleSplitSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const splitTime = currentTime; + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + updateClipTrim( + track.id, + clip.id, + clip.trimStart, + clip.trimEnd + (effectiveEnd - splitTime) + ); + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (split)", + duration: clip.duration, + startTime: splitTime, + trimStart: clip.trimStart + (splitTime - effectiveStart), + trimEnd: clip.trimEnd, + }); + } + } + }); + toast.success("Split selected clip(s)"); + }; + + const handleDuplicateSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (copy)", + duration: clip.duration, + startTime: + clip.startTime + + (clip.duration - clip.trimStart - clip.trimEnd) + + 0.1, + trimStart: clip.trimStart, + trimEnd: clip.trimEnd, + }); + } + }); + toast.success("Duplicated selected clip(s)"); + }; + + const handleFreezeSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + // Add a new freeze frame clip at the playhead + addClipToTrack(track.id, { + mediaId: clip.mediaId, + name: clip.name + " (freeze)", + duration: 1, // 1 second freeze frame + startTime: currentTime, + trimStart: 0, + trimEnd: clip.duration - 1, + }); + } + }); + toast.success("Freeze frame added for selected clip(s)"); + }; + + const handleDeleteSelected = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + selectedClips.forEach(({ trackId, clipId }) => { + removeClipFromTrack(trackId, clipId); + }); + clearSelectedClips(); + toast.success("Deleted selected clip(s)"); + }; + + return ( +
+ + {/* Play/Pause Button */} + + + + + + {isPlaying ? "Pause (Space)" : "Play (Space)"} + + + +
+ + {/* Time Display */} +
+ {Math.floor(currentTime * 10) / 10}s /{" "} + {Math.floor(duration * 10) / 10}s +
+ +
+ + {/* Test Clip Button - for debugging */} + {tracks.length === 0 && ( + + + + + Add a test clip to try playback + + )} + +
+ + + + + + Split clip (S) + + + + + + + Split and keep left (A) + + + + + + + Split and keep right (D) + + + + + + + Separate audio (E) + + + + + + + Duplicate clip (Ctrl+D) + + + + + + + Freeze frame (F) + + + + + + + Delete clip (Delete) + + +
+ + {/* Speed Control */} + + + + + Playback Speed + + +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-track-content.tsx b/apps/web/src/components/editor/timeline/timeline-track-content.tsx new file mode 100644 index 00000000..a33fb3ee --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-track-content.tsx @@ -0,0 +1,593 @@ +"use client"; + +import { useState, useRef } from "react"; +import { toast } from "sonner"; +import { TimelineClip } from "./timeline-clip"; +import { useMediaStore } from "@/stores/media-store"; +import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; + +interface TimelineTrackContentProps { + track: TimelineTrack; + zoomLevel: number; + setContextMenu: ( + menu: { + type: "track" | "clip"; + trackId: string; + clipId?: string; + x: number; + y: number; + } | null + ) => void; +} + +export function TimelineTrackContent({ + track, + zoomLevel, + setContextMenu, +}: TimelineTrackContentProps) { + const { mediaItems } = useMediaStore(); + const { + tracks, + moveClipToTrack, + updateClipTrim, + updateClipStartTime, + addClipToTrack, + removeClipFromTrack, + toggleTrackMute, + selectedClips, + selectClip, + deselectClip, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState(null); + const [isDraggedOver, setIsDraggedOver] = useState(false); + const [wouldOverlap, setWouldOverlap] = useState(false); + const [resizing, setResizing] = useState<{ + clipId: string; + side: "left" | "right"; + startX: number; + initialTrimStart: number; + initialTrimEnd: number; + } | null>(null); + const dragCounterRef = useRef(0); + + const handleResizeStart = ( + e: React.MouseEvent, + clipId: string, + side: "left" | "right" + ) => { + e.stopPropagation(); + e.preventDefault(); + + const clip = track.clips.find((c) => c.id === clipId); + if (!clip) return; + + setResizing({ + clipId, + side, + startX: e.clientX, + initialTrimStart: clip.trimStart, + initialTrimEnd: clip.trimEnd, + }); + }; + + const updateTrimFromMouseMove = (e: { clientX: number }) => { + if (!resizing) return; + + const clip = track.clips.find((c) => c.id === resizing.clipId); + if (!clip) return; + + const deltaX = e.clientX - resizing.startX; + const deltaTime = deltaX / (50 * zoomLevel); + + if (resizing.side === "left") { + const newTrimStart = Math.max( + 0, + Math.min( + clip.duration - clip.trimEnd - 0.1, + resizing.initialTrimStart + deltaTime + ) + ); + updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); + } else { + const newTrimEnd = Math.max( + 0, + Math.min( + clip.duration - clip.trimStart - 0.1, + resizing.initialTrimEnd - deltaTime + ) + ); + updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + } + }; + + const handleResizeMove = (e: React.MouseEvent) => { + updateTrimFromMouseMove(e); + }; + + const handleResizeEnd = () => { + setResizing(null); + }; + + const handleClipDragStart = (e: React.DragEvent, clip: any) => { + const dragData = { clipId: clip.id, trackId: track.id, name: clip.name }; + + e.dataTransfer.setData( + "application/x-timeline-clip", + JSON.stringify(dragData) + ); + e.dataTransfer.effectAllowed = "move"; + + // Add visual feedback to the dragged element + const target = e.currentTarget.parentElement as HTMLElement; + target.style.opacity = "0.5"; + target.style.transform = "scale(0.95)"; + }; + + const handleClipDragEnd = (e: React.DragEvent) => { + // Reset visual feedback + const target = e.currentTarget.parentElement as HTMLElement; + target.style.opacity = ""; + target.style.transform = ""; + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline clips and media items + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const { type } = JSON.parse(mediaItemData); + const isCompatible = + (track.type === "video" && + (type === "video" || type === "image")) || + (track.type === "audio" && type === "audio"); + + if (!isCompatible) { + e.dataTransfer.dropEffect = "none"; + return; + } + } + } catch (error) {} + } + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + let dropTime = 0; + if (trackContainer) { + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + dropTime = mouseX / (50 * zoomLevel); + } + + // Check for potential overlaps and show appropriate feedback + let wouldOverlap = false; + + if (hasMediaItem) { + try { + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (mediaItemData) { + const { id } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + if (mediaItem) { + const newClipDuration = mediaItem.duration || 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newClipEnd = snappedTime + newClipDuration; + + wouldOverlap = track.clips.some((existingClip) => { + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && newClipEnd > existingStart; + }); + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineClip) { + try { + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (timelineClipData) { + const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingClip = sourceTrack?.clips.find( + (c: any) => c.id === clipId + ); + + if (movingClip) { + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const snappedTime = Math.round(dropTime * 10) / 10; + const movingClipEnd = snappedTime + movingClipDuration; + + wouldOverlap = track.clips.some((existingClip) => { + if (fromTrackId === track.id && existingClip.id === clipId) + return false; + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + return snappedTime < existingEnd && movingClipEnd > existingStart; + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setIsDraggedOver(true); + setWouldOverlap(true); + setDropPosition(Math.round(dropTime * 10) / 10); + return; + } + + e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; + setIsDraggedOver(true); + setWouldOverlap(false); + setDropPosition(Math.round(dropTime * 10) / 10); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + setIsDraggedOver(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setIsDraggedOver(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setIsDraggedOver(false); + setWouldOverlap(false); + const currentDropPosition = dropPosition; + setDropPosition(null); + + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineClip && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-clips-container" + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const newStartTime = mouseX / (50 * zoomLevel); + const snappedTime = Math.round(newStartTime * 10) / 10; + + try { + if (hasTimelineClip) { + // Handle timeline clip movement + const timelineClipData = e.dataTransfer.getData( + "application/x-timeline-clip" + ); + if (!timelineClipData) return; + + const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData); + + // Find the clip being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingClip = sourceTrack?.clips.find((c: any) => c.id === clipId); + + if (!movingClip) { + toast.error("Clip not found"); + return; + } + + // Check for overlaps with existing clips (excluding the moving clip itself) + const movingClipDuration = + movingClip.duration - movingClip.trimStart - movingClip.trimEnd; + const movingClipEnd = snappedTime + movingClipDuration; + + const hasOverlap = track.clips.some((existingClip) => { + // Skip the clip being moved if it's on the same track + if (fromTrackId === track.id && existingClip.id === clipId) + return false; + + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + // Check if clips overlap + return snappedTime < existingEnd && movingClipEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot move clip here - it would overlap with existing clips" + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + updateClipStartTime(track.id, clipId, snappedTime); + } else { + // Moving to different track + moveClipToTrack(fromTrackId, track.id, clipId); + requestAnimationFrame(() => { + updateClipStartTime(track.id, clipId, snappedTime); + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const { id, type } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + // Check if track type is compatible + const isCompatible = + (track.type === "video" && (type === "video" || type === "image")) || + (track.type === "audio" && type === "audio"); + + if (!isCompatible) { + toast.error(`Cannot add ${type} to ${track.type} track`); + return; + } + + // Check for overlaps with existing clips + const newClipDuration = mediaItem.duration || 5; + const newClipEnd = snappedTime + newClipDuration; + + const hasOverlap = track.clips.some((existingClip) => { + const existingStart = existingClip.startTime; + const existingEnd = + existingClip.startTime + + (existingClip.duration - + existingClip.trimStart - + existingClip.trimEnd); + + // Check if clips overlap + return snappedTime < existingEnd && newClipEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place clip here - it would overlap with existing clips" + ); + return; + } + + addClipToTrack(track.id, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + }); + + toast.success(`Added ${mediaItem.name} to ${track.name}`); + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + const getTrackColor = (type: string) => { + switch (type) { + case "video": + return "bg-blue-500/20 border-blue-500/30"; + case "audio": + return "bg-green-500/20 border-green-500/30"; + case "effects": + return "bg-purple-500/20 border-purple-500/30"; + default: + return "bg-gray-500/20 border-gray-500/30"; + } + }; + + return ( +
{ + e.preventDefault(); + // Only show track menu if we didn't click on a clip + if (!(e.target as HTMLElement).closest(".timeline-clip")) { + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + onMouseMove={handleResizeMove} + onMouseUp={handleResizeEnd} + onMouseLeave={handleResizeEnd} + > +
+ {track.clips.length === 0 ? ( +
+ {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop clip here" + : "Drop media here"} +
+ ) : ( + <> + {track.clips.map((clip) => { + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + return ( + { + e.stopPropagation(); + const isSelected = selectedClips.some( + (c) => c.trackId === track.id && c.clipId === clip.id + ); + + if (e.metaKey || e.ctrlKey || e.shiftKey) { + // Multi-selection mode: toggle the clip + selectClip(track.id, clip.id, true); + } else if (isSelected) { + // If clip is already selected, deselect it + deselectClip(track.id, clip.id); + } else { + // If clip is not selected, select it (replacing other selections) + selectClip(track.id, clip.id, false); + } + }} + onContextMenu={(e) => { + e.preventDefault(); + e.stopPropagation(); + setContextMenu({ + type: "clip", + trackId: track.id, + clipId: clip.id, + x: e.clientX, + y: e.clientY, + }); + }} + getTrackColor={getTrackColor} + /> + ); + })} + + {/* Drop position indicator */} + {isDraggedOver && dropPosition !== null && ( +
+
+
+
+ {wouldOverlap ? "⚠️" : ""} + {dropPosition.toFixed(1)}s +
+
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline/timeline.tsx b/apps/web/src/components/editor/timeline/timeline.tsx new file mode 100644 index 00000000..5f3dd5a8 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline.tsx @@ -0,0 +1,798 @@ +"use client"; + +import { ScrollArea } from "../../ui/scroll-area"; +import { + SplitSquareHorizontal, + Volume2, + VolumeX, + Trash2, + Scissors, + Copy, +} from "lucide-react"; +import { TimelineTrackContent } from "./timeline-track-content"; +import { TimelineToolbar } from "./timeline-toolbar"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts"; +import { processMediaFiles } from "@/lib/media-processing"; +import { toast } from "sonner"; +import { useState, useRef, useEffect, useCallback } from "react"; + +export function Timeline() { + // Timeline shows all tracks (video, audio, effects) and their clips. + // You can drag media here to add it to your project. + // Clips can be trimmed, deleted, and moved. + const { + tracks, + addTrack, + addClipToTrack, + removeTrack, + toggleTrackMute, + removeClipFromTrack, + getTotalDuration, + selectedClips, + clearSelectedClips, + setSelectedClips, + updateClipTrim, + } = useTimelineStore(); + const { mediaItems, addMediaItem } = useMediaStore(); + const { currentTime, duration, seek, setDuration } = usePlaybackStore(); + const [isDragOver, setIsDragOver] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1); + const dragCounterRef = useRef(0); + const timelineRef = useRef(null); + const [isInTimeline, setIsInTimeline] = useState(false); + + // Unified context menu state + const [contextMenu, setContextMenu] = useState<{ + type: "track" | "clip"; + trackId: string; + clipId?: string; + x: number; + y: number; + } | null>(null); + + // Marquee selection state + const [marquee, setMarquee] = useState<{ + startX: number; + startY: number; + endX: number; + endY: number; + active: boolean; + additive: boolean; + } | null>(null); + + // Playhead scrubbing state + const [isScrubbing, setIsScrubbing] = useState(false); + const [scrubTime, setScrubTime] = useState(null); + + // Initialize keyboard shortcuts + useKeyboardShortcuts(); + + // Update timeline duration when tracks change + useEffect(() => { + const totalDuration = getTotalDuration(); + setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline + }, [tracks, setDuration, getTotalDuration]); + + // Close context menu on click elsewhere + useEffect(() => { + const handleClick = () => setContextMenu(null); + if (contextMenu) { + window.addEventListener("click", handleClick); + return () => window.removeEventListener("click", handleClick); + } + }, [contextMenu]); + + // Mouse down on timeline background to start marquee + const handleTimelineMouseDown = (e: React.MouseEvent) => { + if (e.target === e.currentTarget && e.button === 0) { + setMarquee({ + startX: e.clientX, + startY: e.clientY, + endX: e.clientX, + endY: e.clientY, + active: true, + additive: e.metaKey || e.ctrlKey || e.shiftKey, + }); + } + }; + + // Mouse move to update marquee + useEffect(() => { + if (!marquee || !marquee.active) return; + const handleMouseMove = (e: MouseEvent) => { + setMarquee( + (prev) => prev && { ...prev, endX: e.clientX, endY: e.clientY } + ); + }; + const handleMouseUp = (e: MouseEvent) => { + setMarquee( + (prev) => + prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } + ); + }; + window.addEventListener("mousemove", handleMouseMove); + window.addEventListener("mouseup", handleMouseUp); + return () => { + window.removeEventListener("mousemove", handleMouseMove); + window.removeEventListener("mouseup", handleMouseUp); + }; + }, [marquee]); + + // On marquee end, select clips in box + useEffect(() => { + if (!marquee || marquee.active) return; + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x1 = Math.min(marquee.startX, marquee.endX) - rect.left; + const x2 = Math.max(marquee.startX, marquee.endX) - rect.left; + const y1 = Math.min(marquee.startY, marquee.endY) - rect.top; + const y2 = Math.max(marquee.startY, marquee.endY) - rect.top; + // Validation: skip if too small + if (Math.abs(x2 - x1) < 5 || Math.abs(y2 - y1) < 5) { + setMarquee(null); + return; + } + // Clamp to timeline bounds + const clamp = (val: number, min: number, max: number) => + Math.max(min, Math.min(max, val)); + const bx1 = clamp(x1, 0, rect.width); + const bx2 = clamp(x2, 0, rect.width); + const by1 = clamp(y1, 0, rect.height); + const by2 = clamp(y2, 0, rect.height); + let newSelection: { trackId: string; clipId: string }[] = []; + tracks.forEach((track, trackIdx) => { + track.clips.forEach((clip) => { + const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; + const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const clipLeft = clip.startTime * 50 * zoomLevel; + const clipTop = trackIdx * 60; + const clipBottom = clipTop + 60; + const clipRight = clipLeft + clipWidth; + if ( + bx1 < clipRight && + bx2 > clipLeft && + by1 < clipBottom && + by2 > clipTop + ) { + newSelection.push({ trackId: track.id, clipId: clip.id }); + } + }); + }); + if (newSelection.length > 0) { + if (marquee.additive) { + const selectedSet = new Set( + selectedClips.map((c) => c.trackId + ":" + c.clipId) + ); + newSelection = [ + ...selectedClips, + ...newSelection.filter( + (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + ), + ]; + } + setSelectedClips(newSelection); + } else if (!marquee.additive) { + clearSelectedClips(); + } + setMarquee(null); + }, [ + marquee, + tracks, + zoomLevel, + selectedClips, + setSelectedClips, + clearSelectedClips, + ]); + + const handleDragEnter = (e: React.DragEvent) => { + // When something is dragged over the timeline, show overlay + e.preventDefault(); + // Don't show overlay for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + dragCounterRef.current += 1; + if (!isDragOver) { + setIsDragOver(true); + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + // Don't update state for timeline clips - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + return; + } + + dragCounterRef.current -= 1; + if (dragCounterRef.current === 0) { + setIsDragOver(false); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + // When media is dropped, add it as a new track/clip + e.preventDefault(); + setIsDragOver(false); + dragCounterRef.current = 0; + + // Ignore timeline clip drags - they're handled by track-specific handlers + const hasTimelineClip = e.dataTransfer.types.includes( + "application/x-timeline-clip" + ); + if (hasTimelineClip) { + return; + } + + const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + if (mediaItemData) { + // Handle media item drops by creating new tracks + try { + const { id, type } = JSON.parse(mediaItemData); + const mediaItem = mediaItems.find((item) => item.id === id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + // Add to video or audio track depending on type + const trackType = type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + toast.success(`Added ${mediaItem.name} to new ${trackType} track`); + } catch (error) { + // Show error if parsing fails + console.error("Error parsing media item data:", error); + toast.error("Failed to add media to timeline"); + } + } else if (e.dataTransfer.files?.length > 0) { + // Handle file drops by creating new tracks + setIsProcessing(true); + try { + const processedItems = await processMediaFiles(e.dataTransfer.files); + for (const processedItem of processedItems) { + addMediaItem(processedItem); + const currentMediaItems = useMediaStore.getState().mediaItems; + const addedItem = currentMediaItems.find( + (item) => + item.name === processedItem.name && item.url === processedItem.url + ); + if (addedItem) { + const trackType = + processedItem.type === "audio" ? "audio" : "video"; + const newTrackId = addTrack(trackType); + addClipToTrack(newTrackId, { + mediaId: addedItem.id, + name: addedItem.name, + duration: addedItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + // Show error if file processing fails + console.error("Error processing external files:", error); + toast.error("Failed to process dropped files"); + } finally { + setIsProcessing(false); + } + } + }; + + const handleSeekToPosition = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickedTime = clickX / (50 * zoomLevel); + const clampedTime = Math.max(0, Math.min(duration, clickedTime)); + + seek(clampedTime); + }; + + const handleTimelineAreaClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + clearSelectedClips(); + + // Calculate the clicked time position and seek to it + handleSeekToPosition(e); + } + }; + + const handleWheel = (e: React.WheelEvent) => { + // Only zoom if user is using pinch gesture (ctrlKey or metaKey is true) + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoomLevel((prev) => Math.max(0.1, Math.min(10, prev + delta))); + } + // Otherwise, allow normal scrolling + }; + + // --- Playhead Scrubbing Handlers --- + const handlePlayheadMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsScrubbing(true); + handleScrub(e); + }, + [duration, zoomLevel] + ); + + const handleScrub = useCallback( + (e: MouseEvent | React.MouseEvent) => { + const timeline = timelineRef.current; + if (!timeline) return; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const time = Math.max(0, Math.min(duration, x / (50 * zoomLevel))); + setScrubTime(time); + seek(time); // update video preview in real time + }, + [duration, zoomLevel, seek] + ); + + useEffect(() => { + if (!isScrubbing) return; + const onMouseMove = (e: MouseEvent) => handleScrub(e); + const onMouseUp = (e: MouseEvent) => { + setIsScrubbing(false); + if (scrubTime !== null) seek(scrubTime); // finalize seek + setScrubTime(null); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [isScrubbing, scrubTime, seek, handleScrub]); + + const playheadPosition = + isScrubbing && scrubTime !== null ? scrubTime : currentTime; + + const dragProps = { + onDragEnter: handleDragEnter, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + onDrop: handleDrop, + }; + + // Prevent explorer zooming in/out when in timeline + useEffect(() => { + const preventZoom = (e: WheelEvent) => { + // if (isInTimeline && (e.ctrlKey || e.metaKey)) { + if ( + isInTimeline && + (e.ctrlKey || e.metaKey) && + timelineRef.current?.contains(e.target as Node) + ) { + e.preventDefault(); + } + }; + + document.addEventListener("wheel", preventZoom, { passive: false }); + + return () => { + document.removeEventListener("wheel", preventZoom); + }; + }, [isInTimeline]); + + return ( +
setIsInTimeline(true)} + onMouseLeave={() => setIsInTimeline(false)} + onWheel={handleWheel} + > + {/* Show overlay when dragging media over the timeline */} + {isDragOver && ( +
+
+ Drop media here to add to timeline +
+
+ )} + + {/* Toolbar */} + + + {/* Timeline Container */} +
+ {/* Timeline Header with Ruler */} +
+ {/* Track Labels Header */} +
+ + Tracks + +
+ {zoomLevel.toFixed(1)}x +
+
+ + {/* Timeline Ruler */} +
+ +
{ + // Calculate the clicked time position and seek to it + handleSeekToPosition(e); + }} + > + {/* 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 (scrubbable) */} +
+
+
+
+ +
+
+ + {/* Tracks Area */} +
+ {/* Track Labels */} + {tracks.length > 0 && ( +
+
+ {tracks.map((track) => ( +
{ + e.preventDefault(); + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + }} + > +
+
+ + {track.name} + +
+ {track.muted && ( + + Muted + + )} +
+ ))} +
+
+ )} + + {/* Timeline Tracks Content */} +
+
+ {/* Timeline grid and clips area (with left margin for sifdebar) */} +
+ {tracks.length === 0 ? ( +
+
+
+ +
+

+ Drop media here to start +

+
+
+ ) : ( + <> + {tracks.map((track, index) => ( +
{ + e.preventDefault(); + setContextMenu({ + type: "track", + trackId: track.id, + x: e.clientX, + y: e.clientY, + }); + }} + > + +
+ ))} + + {/* Playhead for tracks area (scrubbable) */} + {tracks.length > 0 && ( +
+ )} + + )} +
+
+
+
+
+ + {/* Clean Unified Context Menu */} + {contextMenu && ( +
e.preventDefault()} + > + {contextMenu.type === "track" ? ( + // Track context menu + <> + +
+ + + ) : ( + // Clip context menu + <> + + +
+ + + )} +
+ )} +
+ ); +} -- cgit v1.3.1