From bbf9aae234ac0813b925b12c2533aac536267340 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Tue, 24 Jun 2025 17:54:41 +0200 Subject: Revert "refactor: overhaul timeline component, separate into different files" This reverts commit 2aa37920d5f183f9c0de2c42076fa57c5b48320e. --- .../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 deletions(-) delete mode 100644 apps/web/src/components/editor/timeline/timeline-clip.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-toolbar.tsx delete mode 100644 apps/web/src/components/editor/timeline/timeline-track-content.tsx delete 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 deleted file mode 100644 index 69740cd7..00000000 --- a/apps/web/src/components/editor/timeline/timeline-clip.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"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 deleted file mode 100644 index 3778ccf8..00000000 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ /dev/null @@ -1,296 +0,0 @@ -"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 deleted file mode 100644 index a33fb3ee..00000000 --- a/apps/web/src/components/editor/timeline/timeline-track-content.tsx +++ /dev/null @@ -1,593 +0,0 @@ -"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 deleted file mode 100644 index 5f3dd5a8..00000000 --- a/apps/web/src/components/editor/timeline/timeline.tsx +++ /dev/null @@ -1,798 +0,0 @@ -"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