From 40c7fbb4f86130c83fbd9029e83c5e7550e3219d Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 6 Jul 2025 20:45:29 +0200 Subject: [PATCH] refactor: move to a typed-tracks system and add support for text --- apps/web/src/app/contributors/page.tsx | 2 +- apps/web/src/components/development-debug.tsx | 56 +- .../editor/media-panel/views/media.tsx | 16 +- .../editor/media-panel/views/text.tsx | 1 - .../src/components/editor/preview-panel.tsx | 226 ++- .../components/editor/properties-panel.tsx | 30 +- ...timeline-clip.tsx => timeline-element.tsx} | 213 ++- .../components/editor/timeline-toolbar.tsx | 2 +- .../src/components/editor/timeline-track.tsx | 1545 +++++++++-------- apps/web/src/components/editor/timeline.tsx | 468 +++-- apps/web/src/components/footer.tsx | 2 +- apps/web/src/components/header.tsx | 2 +- apps/web/src/components/landing/hero.tsx | 7 +- apps/web/src/components/ui/draggable-item.tsx | 18 +- apps/web/src/hooks/use-drag-clip.ts | 89 +- apps/web/src/hooks/use-playback-controls.ts | 91 +- ...{fetchGhStars.ts => fetch-github-stars.ts} | 2 +- apps/web/src/stores/media-store.ts | 38 +- apps/web/src/stores/timeline-store.ts | 387 +++-- apps/web/src/types/timeline.ts | 73 +- 20 files changed, 1799 insertions(+), 1469 deletions(-) rename apps/web/src/components/editor/{timeline-clip.tsx => timeline-element.tsx} (60%) rename apps/web/src/lib/{fetchGhStars.ts => fetch-github-stars.ts} (91%) diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index feee251..778df18 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -47,7 +47,7 @@ async function getContributors(): Promise { return []; } - const contributors = await response.json(); + const contributors = (await response.json()) as Contributor[]; const filteredContributors = contributors.filter( (contributor: Contributor) => contributor.type === "User" diff --git a/apps/web/src/components/development-debug.tsx b/apps/web/src/components/development-debug.tsx index aef6999..f407e2e 100644 --- a/apps/web/src/components/development-debug.tsx +++ b/apps/web/src/components/development-debug.tsx @@ -1,20 +1,17 @@ "use client"; -import { - useTimelineStore, - type TimelineClip, - type TimelineTrack, -} from "@/stores/timeline-store"; +import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useMediaStore, type MediaItem } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; import { Button } from "@/components/ui/button"; import { useState } from "react"; +import type { TimelineElement } from "@/types/timeline"; // Only show in development const SHOW_DEBUG_INFO = process.env.NODE_ENV === "development"; -interface ActiveClip { - clip: TimelineClip; +interface ActiveElement { + element: TimelineElement; track: TimelineTrack; mediaItem: MediaItem | null; } @@ -28,31 +25,32 @@ export function DevelopmentDebug() { // Don't render anything in production if (!SHOW_DEBUG_INFO) return null; - // Get active clips at current time - const getActiveClips = (): ActiveClip[] => { - const activeClips: ActiveClip[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; tracks.forEach((track) => { - track.clips.forEach((clip) => { - const clipStart = clip.startTime; - const clipEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); - if (currentTime >= clipStart && currentTime < clipEnd) { + if (currentTime >= elementStart && currentTime < elementEnd) { const mediaItem = - clip.mediaId === "test" - ? null // Test clips don't have a real media item - : mediaItems.find((item) => item.id === clip.mediaId) || null; + element.type === "media" + ? mediaItems.find((item) => item.id === element.mediaId) || null + : null; // Text elements don't have media items - activeClips.push({ clip, track, mediaItem }); + activeElements.push({ element, track, mediaItem }); } }); }); - return activeClips; + return activeElements; }; - const activeClips = getActiveClips(); + const activeElements = getActiveElements(); return (
@@ -71,28 +69,30 @@ export function DevelopmentDebug() { {showDebug && (
- Active Clips ({activeClips.length}) + Active Elements ({activeElements.length})
- {activeClips.map((clipData, index) => ( + {activeElements.map((elementData, index) => (
{index + 1}
-
{clipData.clip.name}
+
{elementData.element.name}
- {clipData.mediaItem?.type || "test"} + {elementData.element.type === "media" + ? elementData.mediaItem?.type || "media" + : "text"}
))} - {activeClips.length === 0 && ( + {activeElements.length === 0 && (
- No active clips + No active elements
)}
diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index 50fb6fa..f975496 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -73,18 +73,22 @@ export function MediaView() { // Remove a media item from the store e.stopPropagation(); - // Remove tracks automatically when delete media + // Remove elements automatically when delete media const { tracks, removeTrack } = useTimelineStore.getState(); tracks.forEach((track) => { - const clipsToRemove = track.clips.filter((clip) => clip.mediaId === id); - clipsToRemove.forEach((clip) => { - useTimelineStore.getState().removeClipFromTrack(track.id, clip.id); + const elementsToRemove = track.elements.filter( + (element) => element.type === "media" && element.mediaId === id + ); + elementsToRemove.forEach((element) => { + useTimelineStore + .getState() + .removeElementFromTrack(track.id, element.id); }); - // Only remove track if it becomes empty and has no other clips + // Only remove track if it becomes empty and has no other elements const updatedTrack = useTimelineStore .getState() .tracks.find((t) => t.id === track.id); - if (updatedTrack && updatedTrack.clips.length === 0) { + if (updatedTrack && updatedTrack.elements.length === 0) { removeTrack(track.id); } }); diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index ab69852..bec32c8 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -17,7 +17,6 @@ export function TextView() { content: "Default text", }} aspectRatio={1} - className="w-24" showLabel={false} />
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index eba8ac0..9a02560 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,9 +2,9 @@ import { useTimelineStore, - type TimelineClip, type TimelineTrack, } from "@/stores/timeline-store"; +import { TimelineElement } from "@/types/timeline"; import { useMediaStore, type MediaItem, @@ -21,13 +21,13 @@ import { DropdownMenuTrigger, DropdownMenuSeparator, } from "@/components/ui/dropdown-menu"; -import { Play, Pause, Volume2, VolumeX, Plus, Square } from "lucide-react"; +import { Play, Pause } from "lucide-react"; import { useState, useRef, useEffect } from "react"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; -interface ActiveClip { - clip: TimelineClip; +interface ActiveElement { + element: TimelineElement; track: TimelineTrack; mediaItem: MediaItem | null; } @@ -35,8 +35,8 @@ interface ActiveClip { export function PreviewPanel() { const { tracks } = useTimelineStore(); const { mediaItems } = useMediaStore(); - const { currentTime, muted, toggleMute, volume } = usePlaybackStore(); - const { canvasSize, canvasPresets, setCanvasSize } = useEditorStore(); + const { currentTime } = usePlaybackStore(); + const { canvasSize } = useEditorStore(); const previewRef = useRef(null); const containerRef = useRef(null); const [previewDimensions, setPreviewDimensions] = useState({ @@ -104,97 +104,139 @@ export function PreviewPanel() { return () => resizeObserver.disconnect(); }, [canvasSize.width, canvasSize.height]); - // Get active clips at current time - const getActiveClips = (): ActiveClip[] => { - const activeClips: ActiveClip[] = []; + // Get active elements at current time + const getActiveElements = (): ActiveElement[] => { + const activeElements: ActiveElement[] = []; tracks.forEach((track) => { - track.clips.forEach((clip) => { - const clipStart = clip.startTime; - const clipEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + track.elements.forEach((element) => { + const elementStart = element.startTime; + const elementEnd = + element.startTime + (element.duration - element.trimStart - element.trimEnd); - if (currentTime >= clipStart && currentTime < clipEnd) { - const mediaItem = - clip.mediaId === "test" - ? null // Test clips don't have a real media item - : mediaItems.find((item) => item.id === clip.mediaId) || null; + if (currentTime >= elementStart && currentTime < elementEnd) { + let mediaItem = null; + + // Only get media item for media elements + if (element.type === "media") { + mediaItem = element.mediaId === "test" + ? null // Test elements don't have a real media item + : mediaItems.find((item) => item.id === element.mediaId) || null; + } - activeClips.push({ clip, track, mediaItem }); + activeElements.push({ element, track, mediaItem }); } }); }); - return activeClips; + return activeElements; }; - const activeClips = getActiveClips(); + const activeElements = getActiveElements(); - // Check if there are any clips in the timeline at all - const hasAnyClips = tracks.some((track) => track.clips.length > 0); + // Check if there are any elements in the timeline at all + const hasAnyElements = tracks.some((track) => track.elements.length > 0); - // Render a clip - const renderClip = (clipData: ActiveClip, index: number) => { - const { clip, mediaItem } = clipData; + // Render an element + const renderElement = (elementData: ActiveElement, index: number) => { + const { element, mediaItem } = elementData; - // Test clips - if (!mediaItem || clip.mediaId === "test") { + // Text elements + if (element.type === "text") { return (
-
-
🎬
-

{clip.name}

+
+ {element.content}
); } - // Video clips - if (mediaItem.type === "video") { - return ( -
- -
- ); - } - - // Image clips - if (mediaItem.type === "image") { - return ( -
- {mediaItem.name} -
- ); - } - - // Audio clips (visual representation) - if (mediaItem.type === "audio") { - return ( -
-
-
🎵
-

{mediaItem.name}

+ // Media elements + if (element.type === "media") { + // Test elements + if (!mediaItem || element.mediaId === "test") { + return ( +
+
+
🎬
+

{element.name}

+
-
- ); + ); + } + + // Video elements + if (mediaItem.type === "video") { + return ( +
+ +
+ ); + } + + // Image elements + if (mediaItem.type === "image") { + return ( +
+ {mediaItem.name} +
+ ); + } + + // Audio elements (visual representation) + if (mediaItem.type === "audio") { + return ( +
+
+
🎵
+

{mediaItem.name}

+
+
+ ); + } } return null; @@ -206,7 +248,7 @@ export function PreviewPanel() { ref={containerRef} className="flex-1 flex flex-col items-center justify-center p-3 min-h-0 min-w-0 gap-4" > - {hasAnyClips ? ( + {hasAnyElements ? (
- {activeClips.length === 0 ? ( + {activeElements.length === 0 ? (
- No clips at current time + No elements at current time
) : ( - activeClips.map((clipData, index) => renderClip(clipData, index)) + activeElements.map((elementData, index) => renderElement(elementData, index)) )}
) : ( @@ -230,13 +272,13 @@ export function PreviewPanel() { )} - +
); } -function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { +function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) { const { isPlaying, toggle, currentTime } = usePlaybackStore(); const { canvasSize, @@ -261,13 +303,15 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { const getOriginalAspectRatio = () => { // Find first video or image in timeline for (const track of tracks) { - for (const clip of track.clips) { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") - ) { - return getMediaAspectRatio(mediaItem); + for (const element of track.elements) { + if (element.type === "media") { + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if ( + mediaItem && + (mediaItem.type === "video" || mediaItem.type === "image") + ) { + return getMediaAspectRatio(mediaItem); + } } } } @@ -291,7 +335,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) {

{formatTimeCode(currentTime, "HH:MM:SS:CS")}/ @@ -302,7 +346,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { variant="text" size="icon" onClick={toggle} - disabled={!hasAnyClips} + disabled={!hasAnyElements} > {isPlaying ? ( @@ -316,7 +360,7 @@ function PreviewToolbar({ hasAnyClips }: { hasAnyClips: boolean }) { diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 96ef66e..0de7898 100644 --- a/apps/web/src/components/editor/properties-panel.tsx +++ b/apps/web/src/components/editor/properties-panel.tsx @@ -25,27 +25,29 @@ export function PropertiesPanel() { const [backgroundType, setBackgroundType] = useState("blur"); const [backgroundColor, setBackgroundColor] = useState("#000000"); - // Get the first video clip for preview (simplified) - const firstVideoClip = tracks - .flatMap((track) => track.clips) - .find((clip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + // Get the first video element for preview (simplified) + const firstVideoElement = tracks + .flatMap((track) => track.elements) + .find((element) => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); return mediaItem?.type === "video"; }); - const firstVideoItem = firstVideoClip - ? mediaItems.find((item) => item.id === firstVideoClip.mediaId) + const firstVideoItem = firstVideoElement && firstVideoElement.type === "media" + ? mediaItems.find((item) => item.id === firstVideoElement.mediaId) : null; - const firstImageClip = tracks - .flatMap((track) => track.clips) - .find((clip) => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + const firstImageElement = tracks + .flatMap((track) => track.elements) + .find((element) => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); return mediaItem?.type === "image"; }); - const firstImageItem = firstImageClip - ? mediaItems.find((item) => item.id === firstImageClip.mediaId) + const firstImageItem = firstImageElement && firstImageElement.type === "media" + ? mediaItems.find((item) => item.id === firstImageElement.mediaId) : null; return ( @@ -62,7 +64,7 @@ export function PropertiesPanel() {

(null); - const [clipMenuOpen, setClipMenuOpen] = useState(false); + const [elementMenuOpen, setElementMenuOpen] = useState(false); - const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; - const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); // Use real-time position during drag, otherwise use stored position - const isBeingDragged = dragState.clipId === clip.id; - const clipStartTime = + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = isBeingDragged && dragState.isDragging ? dragState.currentTime - : clip.startTime; - const clipLeft = clipStartTime * 50 * zoomLevel; + : element.startTime; + const elementLeft = elementStartTime * 50 * zoomLevel; - const getTrackColor = (type: string) => { + const getTrackColor = (type: TrackType) => { switch (type) { - case "video": + case "media": return "bg-blue-500/20 border-blue-500/30"; + case "text": + return "bg-purple-500/20 border-purple-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"; } }; - // Resize handles for trimming clips + // Resize handles for trimming elements const handleResizeStart = ( e: React.MouseEvent, - clipId: string, + elementId: string, side: "left" | "right" ) => { e.stopPropagation(); e.preventDefault(); setResizing({ - clipId, + elementId, side, startX: e.clientX, - initialTrimStart: clip.trimStart, - initialTrimEnd: clip.trimEnd, + initialTrimStart: element.trimStart, + initialTrimEnd: element.trimEnd, }); }; @@ -105,20 +105,20 @@ export function TimelineClip({ const newTrimStart = Math.max( 0, Math.min( - clip.duration - clip.trimEnd - 0.1, + element.duration - element.trimEnd - 0.1, resizing.initialTrimStart + deltaTime ) ); - updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd); + updateElementTrim(track.id, element.id, newTrimStart, element.trimEnd); } else { const newTrimEnd = Math.max( 0, Math.min( - clip.duration - clip.trimStart - 0.1, + element.duration - element.trimStart - 0.1, resizing.initialTrimEnd - deltaTime ) ); - updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd); + updateElementTrim(track.id, element.id, element.trimStart, newTrimEnd); } }; @@ -130,96 +130,111 @@ export function TimelineClip({ setResizing(null); }; - const handleDeleteClip = () => { - removeClipFromTrack(track.id, clip.id); - setClipMenuOpen(false); - toast.success("Clip deleted"); + const handleDeleteElement = () => { + removeElementFromTrack(track.id, element.id); + setElementMenuOpen(false); }; - const handleSplitClip = () => { - const effectiveStart = clip.startTime; + const handleSplitElement = () => { + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip to split"); + toast.error("Playhead must be within element to split"); return; } - const secondClipId = splitClip(track.id, clip.id, currentTime); - if (secondClipId) { - toast.success("Clip split successfully"); - } else { - toast.error("Failed to split clip"); + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); } - setClipMenuOpen(false); + setElementMenuOpen(false); }; const handleSplitAndKeepLeft = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip"); + toast.error("Playhead must be within element"); return; } - splitAndKeepLeft(track.id, clip.id, currentTime); - toast.success("Split and kept left portion"); - setClipMenuOpen(false); + splitAndKeepLeft(track.id, element.id, currentTime); + setElementMenuOpen(false); }; const handleSplitAndKeepRight = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within clip"); + toast.error("Playhead must be within element"); return; } - splitAndKeepRight(track.id, clip.id, currentTime); - toast.success("Split and kept right portion"); - setClipMenuOpen(false); + splitAndKeepRight(track.id, element.id, currentTime); + setElementMenuOpen(false); }; const handleSeparateAudio = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - - if (!mediaItem || mediaItem.type !== "video") { - toast.error("Audio separation only available for video clips"); + if (element.type !== "media") { + toast.error("Audio separation only available for media elements"); return; } - const audioClipId = separateAudio(track.id, clip.id); - if (audioClipId) { - toast.success("Audio separated to audio track"); - } else { + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video elements"); + return; + } + + const audioElementId = separateAudio(track.id, element.id); + if (!audioElementId) { toast.error("Failed to separate audio"); } - setClipMenuOpen(false); + setElementMenuOpen(false); }; const canSplitAtPlayhead = () => { - const effectiveStart = clip.startTime; + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); return currentTime > effectiveStart && currentTime < effectiveEnd; }; const canSeparateAudio = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); - return mediaItem?.type === "video" && track.type === "video"; + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + return mediaItem?.type === "video" && track.type === "media"; }; - const renderClipContent = () => { - const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + const renderElementContent = () => { + if (element.type === "text") { + return ( +
+ + + {element.content} + +
+ ); + } + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); if (!mediaItem) { return ( - {clip.name} + + {element.name} + ); } @@ -248,18 +263,19 @@ export function TimelineClip({ />
- {clip.name} + {element.name}
); } + // Render audio element -> if (mediaItem.type === "audio") { return (
@@ -269,24 +285,26 @@ export function TimelineClip({ } return ( - {clip.name} + + {element.name} + ); }; - const handleClipMouseDown = (e: React.MouseEvent) => { - if (onClipMouseDown) { - onClipMouseDown(e, clip); + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); } }; return (
onClipClick && onClipClick(e, clip)} - onMouseDown={handleClipMouseDown} - onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)} + onClick={(e) => onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } >
- {renderClipContent()} + {renderElementContent()}
{isSelected && ( <>
handleResizeStart(e, clip.id, "left")} + onMouseDown={(e) => handleResizeStart(e, element.id, "left")} />
handleResizeStart(e, clip.id, "right")} + onMouseDown={(e) => handleResizeStart(e, element.id, "right")} /> )}
- + - {/* Split operations - only available when playhead is within clip */} + {/* Split operations - only available when playhead is within element */} Split - + Split at Playhead @@ -357,7 +380,7 @@ export function TimelineClip({ - {/* Audio separation - only available for video clips */} + {/* Audio separation - only available for video elements */} {canSeparateAudio() && ( <> @@ -370,11 +393,11 @@ export function TimelineClip({ - Delete Clip + Delete {element.type === "text" ? "text" : "clip"} diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx index 5146479..e887efa 100644 --- a/apps/web/src/components/editor/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline-toolbar.tsx @@ -102,7 +102,7 @@ export function TimelineToolbar({ variant="outline" size="sm" onClick={() => { - const trackId = addTrack("video"); + const trackId = addTrack("media"); addClipToTrack(trackId, { mediaId: "test", name: "Test Clip", diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index 4d32511..dd4219f 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -1,690 +1,855 @@ -"use client"; - -import { useRef, useState, useEffect } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; -import { useMediaStore } from "@/stores/media-store"; -import { toast } from "sonner"; -import { Copy, Scissors, Trash2 } from "lucide-react"; -import { TimelineClip } from "./timeline-clip"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "../ui/context-menu"; -import { - TimelineTrack, - TimelineClip as TypeTimelineClip, -} from "@/stores/timeline-store"; -import { usePlaybackStore } from "@/stores/playback-store"; - -export function TimelineTrackContent({ - track, - zoomLevel, -}: { - track: TimelineTrack; - zoomLevel: number; -}) { - const { mediaItems } = useMediaStore(); - const { - tracks, - moveClipToTrack, - updateClipStartTime, - addClipToTrack, - selectedClips, - selectClip, - deselectClip, - dragState, - startDrag: startDragAction, - updateDragTime, - endDrag: endDragAction, - } = useTimelineStore(); - - const timelineRef = useRef(null); - const [isDropping, setIsDropping] = useState(false); - const [dropPosition, setDropPosition] = useState(null); - const [wouldOverlap, setWouldOverlap] = useState(false); - const dragCounterRef = useRef(0); - const [mouseDownLocation, setMouseDownLocation] = useState<{ - x: number; - y: number; - } | null>(null); - - // Set up mouse event listeners for drag - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - - // On first mouse move during drag, ensure the clip is selected - if (dragState.clipId && dragState.trackId) { - const isSelected = selectedClips.some( - (c) => - c.trackId === dragState.trackId && c.clipId === dragState.clipId - ); - - if (!isSelected) { - // Select this clip (replacing other selections) since we're dragging it - selectClip(dragState.trackId, dragState.clipId, false); - } - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const snappedTime = Math.round(adjustedTime * 10) / 10; - - updateDragTime(snappedTime); - }; - - const handleMouseUp = () => { - if (!dragState.clipId || !dragState.trackId) return; - - const finalTime = dragState.currentTime; - - // Check for overlaps and update position - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const movingClip = sourceTrack?.clips.find( - (c) => c.id === dragState.clipId - ); - - if (movingClip) { - const movingClipDuration = - movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = finalTime + movingClipDuration; - - const targetTrack = tracks.find((t) => t.id === track.id); - const hasOverlap = targetTrack?.clips.some((existingClip) => { - if ( - dragState.trackId === track.id && - existingClip.id === dragState.clipId - ) { - return false; - } - const existingStart = existingClip.startTime; - const existingEnd = - existingClip.startTime + - (existingClip.duration - - existingClip.trimStart - - existingClip.trimEnd); - return finalTime < existingEnd && movingClipEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === track.id) { - updateClipStartTime(track.id, dragState.clipId, finalTime); - } else { - moveClipToTrack(dragState.trackId, track.id, dragState.clipId); - requestAnimationFrame(() => { - updateClipStartTime(track.id, dragState.clipId!, finalTime); - }); - } - } - } - - endDragAction(); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - }, [ - dragState.isDragging, - dragState.clickOffsetTime, - dragState.clipId, - dragState.trackId, - dragState.currentTime, - zoomLevel, - tracks, - track.id, - updateDragTime, - updateClipStartTime, - moveClipToTrack, - endDragAction, - ]); - - const handleClipMouseDown = (e: React.MouseEvent, clip: TypeTimelineClip) => { - setMouseDownLocation({ x: e.clientX, y: e.clientY }); - - // Detect right-click (button 2) and handle selection without starting drag - const isRightClick = e.button === 2; - const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; - - if (isRightClick) { - // Handle right-click selection - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - // If clip is not selected, select it (keep other selections if multi-select) - if (!isSelected) { - selectClip(track.id, clip.id, isMultiSelect); - } - // If clip is already selected, keep it selected - - // Don't start drag action for right-clicks - return; - } - - // Handle multi-selection for left-click with modifiers - if (isMultiSelect) { - selectClip(track.id, clip.id, true); - } - - // Calculate the offset from the left edge of the clip to where the user clicked - const clipElement = e.currentTarget as HTMLElement; - const clipRect = clipElement.getBoundingClientRect(); - const clickOffsetX = e.clientX - clipRect.left; - const clickOffsetTime = clickOffsetX / (50 * zoomLevel); - - startDragAction( - clip.id, - track.id, - e.clientX, - clip.startTime, - clickOffsetTime - ); - }; - - const handleClipClick = (e: React.MouseEvent, clip: TypeTimelineClip) => { - e.stopPropagation(); - - // Check if mouse moved significantly - if (mouseDownLocation) { - const deltaX = Math.abs(e.clientX - mouseDownLocation.x); - const deltaY = Math.abs(e.clientY - mouseDownLocation.y); - // If it moved more than a few pixels, consider it a drag and not a click. - if (deltaX > 5 || deltaY > 5) { - setMouseDownLocation(null); // Reset for next interaction - return; - } - } - - // Skip selection logic for multi-selection (handled in mousedown) - if (e.metaKey || e.ctrlKey || e.shiftKey) { - return; - } - - // Handle single selection - const isSelected = selectedClips.some( - (c) => c.trackId === track.id && c.clipId === clip.id - ); - - if (!isSelected) { - // If clip is not selected, select it (replacing other selections) - selectClip(track.id, clip.id, false); - } - // If clip is already selected, keep it selected (do nothing) - }; - - 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) { - console.error("Error parsing dropped media item:", 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"; - setWouldOverlap(true); - setDropPosition(Math.round(dropTime * 10) / 10); - return; - } - - e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy"; - 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); - }; - - 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); - setWouldOverlap(false); - setDropPosition(null); - } - }; - - const handleTrackDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - // Reset all drag states - dragCounterRef.current = 0; - setIsDropping(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, - clickOffsetTime = 0, - } = JSON.parse(timelineClipData); - - // Find the clip being moved - const sourceTrack = tracks.find( - (t: TimelineTrack) => t.id === fromTrackId - ); - const movingClip = sourceTrack?.clips.find( - (c: TypeTimelineClip) => c.id === clipId - ); - - if (!movingClip) { - toast.error("Clip not found"); - return; - } - - // Adjust position based on where user clicked on the clip - const adjustedStartTime = snappedTime - clickOffsetTime; - const finalStartTime = Math.max( - 0, - Math.round(adjustedStartTime * 10) / 10 - ); - - // Check for overlaps with existing clips (excluding the moving clip itself) - const movingClipDuration = - movingClip.duration - movingClip.trimStart - movingClip.trimEnd; - const movingClipEnd = finalStartTime + 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 finalStartTime < 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, finalStartTime); - } else { - // Moving to different track - moveClipToTrack(fromTrackId, track.id, clipId); - requestAnimationFrame(() => { - updateClipStartTime(track.id, clipId, finalStartTime); - }); - } - } 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, - }); - } - } catch (error) { - console.error("Error handling drop:", error); - toast.error("Failed to add media to track"); - } - }; - - return ( -
{ - // If clicking empty area (not on a clip), deselect all clips - if (!(e.target as HTMLElement).closest(".timeline-clip")) { - const { clearSelectedClips } = useTimelineStore.getState(); - clearSelectedClips(); - } - }} - onDragOver={handleTrackDragOver} - onDragEnter={handleTrackDragEnter} - onDragLeave={handleTrackDragLeave} - onDrop={handleTrackDrop} - > -
- {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 - ); - - const handleClipSplit = () => { - const { currentTime } = usePlaybackStore(); - const { updateClipTrim, addClipToTrack } = useTimelineStore(); - 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("Clip split successfully"); - } else { - toast.error("Playhead must be within clip to split"); - } - }; - - const handleClipDuplicate = () => { - const { addClipToTrack } = useTimelineStore.getState(); - 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("Clip duplicated"); - }; - - const handleClipDelete = () => { - const { removeClipFromTrack } = useTimelineStore.getState(); - removeClipFromTrack(track.id, clip.id); - toast.success("Clip deleted"); - }; - - return ( - - -
- -
-
- - - - Split at Playhead - - - - Duplicate Clip - - - - - Delete Clip - - -
- ); - })} - - )} -
-
- ); -} +"use client"; + +import { useRef, useState, useEffect } from "react"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { useMediaStore } from "@/stores/media-store"; +import { toast } from "sonner"; +import { Copy, Scissors, Trash2 } from "lucide-react"; +import { TimelineElement } from "./timeline-element"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; +import { TimelineTrack } from "@/stores/timeline-store"; +import { usePlaybackStore } from "@/stores/playback-store"; +import type { + TimelineElement as TimelineElementType, + DragData, +} from "@/types/timeline"; + +export function TimelineTrackContent({ + track, + zoomLevel, +}: { + track: TimelineTrack; + zoomLevel: number; +}) { + const { mediaItems } = useMediaStore(); + const { + tracks, + moveElementToTrack, + updateElementStartTime, + addElementToTrack, + selectedElements, + selectElement, + dragState, + startDrag: startDragAction, + updateDragTime, + endDrag: endDragAction, + clearSelectedElements, + insertTrackAt, + } = useTimelineStore(); + + const timelineRef = useRef(null); + const [isDropping, setIsDropping] = useState(false); + const [dropPosition, setDropPosition] = useState(null); + const [wouldOverlap, setWouldOverlap] = useState(false); + const dragCounterRef = useRef(0); + const [mouseDownLocation, setMouseDownLocation] = useState<{ + x: number; + y: number; + } | null>(null); + + // Set up mouse event listeners for drag + useEffect(() => { + if (!dragState.isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + if (!timelineRef.current) return; + + // On first mouse move during drag, ensure the element is selected + if (dragState.elementId && dragState.trackId) { + const isSelected = selectedElements.some( + (c) => + c.trackId === dragState.trackId && + c.elementId === dragState.elementId + ); + + if (!isSelected) { + // Select this element (replacing other selections) since we're dragging it + selectElement(dragState.trackId, dragState.elementId, false); + } + } + + const timelineRect = timelineRef.current.getBoundingClientRect(); + const mouseX = e.clientX - timelineRect.left; + const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); + const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); + const snappedTime = Math.round(adjustedTime * 10) / 10; + + updateDragTime(snappedTime); + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!dragState.elementId || !dragState.trackId) return; + + // Check if the mouse is actually over this track + const timelineRect = timelineRef.current?.getBoundingClientRect(); + if (!timelineRect) return; + + const isMouseOverThisTrack = + e.clientY >= timelineRect.top && e.clientY <= timelineRect.bottom; + + // Only handle if mouse is over this track + if (!isMouseOverThisTrack) return; + + const finalTime = dragState.currentTime; + + // Check for overlaps and update position + const sourceTrack = tracks.find((t) => t.id === dragState.trackId); + const movingElement = sourceTrack?.elements.find( + (c) => c.id === dragState.elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalTime + movingElementDuration; + + const targetTrack = tracks.find((t) => t.id === track.id); + const hasOverlap = targetTrack?.elements.some((existingElement) => { + if ( + dragState.trackId === track.id && + existingElement.id === dragState.elementId + ) { + return false; + } + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return finalTime < existingEnd && movingElementEnd > existingStart; + }); + + if (!hasOverlap) { + if (dragState.trackId === track.id) { + updateElementStartTime(track.id, dragState.elementId, finalTime); + } else { + moveElementToTrack( + dragState.trackId, + track.id, + dragState.elementId + ); + requestAnimationFrame(() => { + updateElementStartTime(track.id, dragState.elementId!, finalTime); + }); + } + } + } + + endDragAction(); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [ + dragState.isDragging, + dragState.clickOffsetTime, + dragState.elementId, + dragState.trackId, + dragState.currentTime, + zoomLevel, + tracks, + track.id, + updateDragTime, + updateElementStartTime, + moveElementToTrack, + endDragAction, + selectedElements, + selectElement, + ]); + + const handleElementMouseDown = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + setMouseDownLocation({ x: e.clientX, y: e.clientY }); + + // Detect right-click (button 2) and handle selection without starting drag + const isRightClick = e.button === 2; + const isMultiSelect = e.metaKey || e.ctrlKey || e.shiftKey; + + if (isRightClick) { + // Handle right-click selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + // If element is not selected, select it (keep other selections if multi-select) + if (!isSelected) { + selectElement(track.id, element.id, isMultiSelect); + } + // If element is already selected, keep it selected + + // Don't start drag action for right-clicks + return; + } + + // Handle multi-selection for left-click with modifiers + if (isMultiSelect) { + selectElement(track.id, element.id, true); + } + + // Calculate the offset from the left edge of the element to where the user clicked + const elementElement = e.currentTarget as HTMLElement; + const elementRect = elementElement.getBoundingClientRect(); + const clickOffsetX = e.clientX - elementRect.left; + const clickOffsetTime = clickOffsetX / (50 * zoomLevel); + + startDragAction( + element.id, + track.id, + e.clientX, + element.startTime, + clickOffsetTime + ); + }; + + const handleElementClick = ( + e: React.MouseEvent, + element: TimelineElementType + ) => { + e.stopPropagation(); + + // Check if mouse moved significantly + if (mouseDownLocation) { + const deltaX = Math.abs(e.clientX - mouseDownLocation.x); + const deltaY = Math.abs(e.clientY - mouseDownLocation.y); + // If it moved more than a few pixels, consider it a drag and not a click. + if (deltaX > 5 || deltaY > 5) { + setMouseDownLocation(null); // Reset for next interaction + return; + } + } + + // Skip selection logic for multi-selection (handled in mousedown) + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + + // Handle single selection + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + if (!isSelected) { + // If element is not selected, select it (replacing other selections) + selectElement(track.id, element.id, false); + } + // If element is already selected, keep it selected (do nothing) + }; + + const handleTrackDragOver = (e: React.DragEvent) => { + e.preventDefault(); + + // Handle both timeline elements and media items + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + // Calculate drop position for overlap checking + const trackContainer = e.currentTarget.querySelector( + ".track-elements-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 dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + // Text elements have default duration of 5 seconds + const newElementDuration = 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + } else { + // Media elements + const mediaItem = mediaItems.find( + (item) => item.id === dragData.id + ); + if (mediaItem) { + const newElementDuration = mediaItem.duration || 5; + const snappedTime = Math.round(dropTime * 10) / 10; + const newElementEnd = snappedTime + newElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && newElementEnd > existingStart + ); + }); + } + } + } + } catch (error) { + // Continue with default behavior + } + } else if (hasTimelineElement) { + try { + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (timelineElementData) { + const { elementId, trackId: fromTrackId } = + JSON.parse(timelineElementData); + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: any) => c.id === elementId + ); + + if (movingElement) { + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const snappedTime = Math.round(dropTime * 10) / 10; + const movingElementEnd = snappedTime + movingElementDuration; + + wouldOverlap = track.elements.some((existingElement) => { + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + return ( + snappedTime < existingEnd && movingElementEnd > existingStart + ); + }); + } + } + } catch (error) { + // Continue with default behavior + } + } + + if (wouldOverlap) { + e.dataTransfer.dropEffect = "none"; + setWouldOverlap(true); + setDropPosition(Math.round(dropTime * 10) / 10); + return; + } + + e.dataTransfer.dropEffect = hasTimelineElement ? "move" : "copy"; + setWouldOverlap(false); + setDropPosition(Math.round(dropTime * 10) / 10); + }; + + const handleTrackDragEnter = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current++; + setIsDropping(true); + }; + + const handleTrackDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + dragCounterRef.current--; + + if (dragCounterRef.current === 0) { + setIsDropping(false); + setWouldOverlap(false); + setDropPosition(null); + } + }; + + const handleTrackDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Reset all drag states + dragCounterRef.current = 0; + setIsDropping(false); + setWouldOverlap(false); + + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" + ); + const hasMediaItem = e.dataTransfer.types.includes( + "application/x-media-item" + ); + + if (!hasTimelineElement && !hasMediaItem) return; + + const trackContainer = e.currentTarget.querySelector( + ".track-elements-container" + ) as HTMLElement; + if (!trackContainer) return; + + const rect = trackContainer.getBoundingClientRect(); + const mouseX = Math.max(0, e.clientX - rect.left); + const mouseY = e.clientY - rect.top; // Get Y position relative to this track + const newStartTime = mouseX / (50 * zoomLevel); + const snappedTime = Math.round(newStartTime * 10) / 10; + + // Calculate drop position relative to tracks + const TRACK_HEIGHT = 60; + const currentTrackIndex = tracks.findIndex((t) => t.id === track.id); + + // Determine drop zone within the track (top 20px, middle 20px, bottom 20px) + let dropPosition: "above" | "on" | "below"; + if (mouseY < 20) { + dropPosition = "above"; + } else if (mouseY > 40) { + dropPosition = "below"; + } else { + dropPosition = "on"; + } + + try { + if (hasTimelineElement) { + // Handle timeline element movement + const timelineElementData = e.dataTransfer.getData( + "application/x-timeline-element" + ); + if (!timelineElementData) return; + + const { + elementId, + trackId: fromTrackId, + clickOffsetTime = 0, + } = JSON.parse(timelineElementData); + + // Find the element being moved + const sourceTrack = tracks.find( + (t: TimelineTrack) => t.id === fromTrackId + ); + const movingElement = sourceTrack?.elements.find( + (c: TimelineElementType) => c.id === elementId + ); + + if (!movingElement) { + toast.error("Element not found"); + return; + } + + // Adjust position based on where user clicked on the element + const adjustedStartTime = snappedTime - clickOffsetTime; + const finalStartTime = Math.max( + 0, + Math.round(adjustedStartTime * 10) / 10 + ); + + // Check for overlaps with existing elements (excluding the moving element itself) + const movingElementDuration = + movingElement.duration - + movingElement.trimStart - + movingElement.trimEnd; + const movingElementEnd = finalStartTime + movingElementDuration; + + const hasOverlap = track.elements.some((existingElement) => { + // Skip the element being moved if it's on the same track + if (fromTrackId === track.id && existingElement.id === elementId) + return false; + + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return ( + finalStartTime < existingEnd && movingElementEnd > existingStart + ); + }); + + if (hasOverlap) { + toast.error( + "Cannot move element here - it would overlap with existing elements" + ); + return; + } + + if (fromTrackId === track.id) { + // Moving within same track + updateElementStartTime(track.id, elementId, finalStartTime); + } else { + // Moving to different track + moveElementToTrack(fromTrackId, track.id, elementId); + requestAnimationFrame(() => { + updateElementStartTime(track.id, elementId, finalStartTime); + }); + } + } else if (hasMediaItem) { + // Handle media item drop + const mediaItemData = e.dataTransfer.getData( + "application/x-media-item" + ); + if (!mediaItemData) return; + + const dragData: DragData = JSON.parse(mediaItemData); + + if (dragData.type === "text") { + let targetTrackId = track.id; + let targetTrack = track; + + // Handle position-aware track creation for text + if (track.type !== "text" || dropPosition !== "on") { + // Determine where to insert the new text track + let insertIndex: number; + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is not text type + insertIndex = currentTrackIndex + 1; + } + + targetTrackId = insertTrackAt("text", insertIndex); + // Get the updated tracks array after creating the new track + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + + // Check for overlaps with existing elements in target track + const newElementDuration = 5; // Default text duration + const newElementEnd = snappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + let targetTrackId = track.id; + + // Check if track type is compatible + const isVideoOrImage = + dragData.type === "video" || dragData.type === "image"; + const isAudio = dragData.type === "audio"; + const isCompatible = + (track.type === "media" && isVideoOrImage) || + (track.type === "audio" && isAudio); + + let targetTrack = tracks.find((t) => t.id === targetTrackId); + + // Handle position-aware track creation for media elements + if (!isCompatible || dropPosition !== "on") { + const needsNewTrack = !isCompatible || dropPosition !== "on"; + + if (needsNewTrack) { + // Determine where to insert the new track + let insertIndex: number; + if (dropPosition === "above") { + insertIndex = currentTrackIndex; + } else if (dropPosition === "below") { + insertIndex = currentTrackIndex + 1; + } else { + // dropPosition === "on" but track is incompatible + insertIndex = currentTrackIndex + 1; + } + + if (isVideoOrImage) { + // For video/image, check if main media track is empty and at the right position + const mainMediaTrack = tracks.find((t) => t.type === "media"); + if ( + mainMediaTrack && + mainMediaTrack.elements.length === 0 && + dropPosition === "on" + ) { + targetTrackId = mainMediaTrack.id; + targetTrack = mainMediaTrack; + } else { + targetTrackId = insertTrackAt("media", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } else if (isAudio) { + targetTrackId = insertTrackAt("audio", insertIndex); + const updatedTracks = useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); + if (!newTargetTrack) return; + targetTrack = newTargetTrack; + } + } + } + + if (!targetTrack) return; + + // Check for overlaps with existing elements in target track + const newElementDuration = mediaItem.duration || 5; + const newElementEnd = snappedTime + newElementDuration; + + const hasOverlap = targetTrack.elements.some((existingElement) => { + const existingStart = existingElement.startTime; + const existingEnd = + existingElement.startTime + + (existingElement.duration - + existingElement.trimStart - + existingElement.trimEnd); + + // Check if elements overlap + return snappedTime < existingEnd && newElementEnd > existingStart; + }); + + if (hasOverlap) { + toast.error( + "Cannot place element here - it would overlap with existing elements" + ); + return; + } + + addElementToTrack(targetTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: snappedTime, + trimStart: 0, + trimEnd: 0, + }); + } + } + } catch (error) { + console.error("Error handling drop:", error); + toast.error("Failed to add media to track"); + } + }; + + return ( +
{ + // If clicking empty area (not on an element), deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); + } + }} + onDragOver={handleTrackDragOver} + onDragEnter={handleTrackDragEnter} + onDragLeave={handleTrackDragLeave} + onDrop={handleTrackDrop} + > +
+ {track.elements.length === 0 ? ( +
+ {isDropping + ? wouldOverlap + ? "Cannot drop - would overlap" + : "Drop element here" + : "Drop media here"} +
+ ) : ( + <> + {track.elements.map((element) => { + const isSelected = selectedElements.some( + (c) => c.trackId === track.id && c.elementId === element.id + ); + + const handleElementSplit = () => { + const { currentTime } = usePlaybackStore(); + const { splitElement } = useTimelineStore(); + const splitTime = currentTime; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (splitTime > effectiveStart && splitTime < effectiveEnd) { + const secondElementId = splitElement( + track.id, + element.id, + splitTime + ); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicate = () => { + const { addElementToTrack } = useTimelineStore.getState(); + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDelete = () => { + const { removeElementFromTrack } = useTimelineStore.getState(); + removeElementFromTrack(track.id, element.id); + }; + + return ( + + +
+ +
+
+ + + + Split at playhead + + + + Duplicate {element.type === "text" ? "text" : "clip"} + + + + + Delete {element.type === "text" ? "text" : "clip"} + + +
+ ); + })} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index e9f44e6..283f6b0 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -42,23 +42,22 @@ import { SelectValue, } from "../ui/select"; import { TimelineTrackContent } from "./timeline-track"; +import type { DragData } from "@/types/timeline"; export function Timeline() { - // Timeline shows all tracks (video, audio, effects) and their clips. + // Timeline shows all tracks (video, audio, effects) and their elements. // You can drag media here to add it to your project. - // Clips can be trimmed, deleted, and moved. + // elements can be trimmed, deleted, and moved. const { tracks, addTrack, - addClipToTrack, - removeTrack, - toggleTrackMute, - removeClipFromTrack, + addElementToTrack, + removeElementFromTrack, getTotalDuration, - selectedClips, - clearSelectedClips, - setSelectedClips, - splitClip, + selectedElements, + clearSelectedElements, + setSelectedElements, + splitElement, splitAndKeepLeft, splitAndKeepRight, separateAudio, @@ -116,36 +115,28 @@ export function Timeline() { const lastRulerSync = useRef(0); const lastTracksSync = useRef(0); - // New refs for direct playhead DOM manipulation - const rulerPlayheadRef = useRef(null); - const tracksPlayheadRef = useRef(null); - - // Refs to store initial mouse and scroll positions for drag calculations - const initialMouseXRef = useRef(0); - const initialTimelineScrollLeftRef = useRef(0); - // Update timeline duration when tracks change useEffect(() => { const totalDuration = getTotalDuration(); setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline }, [tracks, setDuration, getTotalDuration]); - // Keyboard event for deleting selected clips + // Keyboard event for deleting selected elements useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( (e.key === "Delete" || e.key === "Backspace") && - selectedClips.length > 0 + selectedElements.length > 0 ) { - selectedClips.forEach(({ trackId, clipId }) => { - removeClipFromTrack(trackId, clipId); + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); }); - clearSelectedClips(); + clearSelectedElements(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedClips, removeClipFromTrack, clearSelectedClips]); + }, [selectedElements, removeElementFromTrack, clearSelectedElements]); // Keyboard event for undo (Cmd+Z) useEffect(() => { @@ -190,9 +181,9 @@ export function Timeline() { // Add new click handler for deselection const handleTimelineClick = (e: React.MouseEvent) => { - // If clicking empty area (not on a clip) and not starting marquee, deselect all clips - if (!(e.target as HTMLElement).closest(".timeline-clip")) { - clearSelectedClips(); + // If clicking empty area (not on an element) and not starting marquee, deselect all elements + if (!(e.target as HTMLElement).closest(".timeline-element")) { + clearSelectedElements(); } }; @@ -218,7 +209,7 @@ export function Timeline() { }; }, [marquee]); - // On marquee end, select clips in box + // On marquee end, select elements in box useEffect(() => { if (!marquee || marquee.active) return; const timeline = timelineRef.current; @@ -240,56 +231,54 @@ export function Timeline() { 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 }[] = []; + let newSelection: { trackId: string; elementId: 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; + track.elements.forEach((element) => { + const clipLeft = element.startTime * 50 * zoomLevel; const clipTop = trackIdx * 60; const clipBottom = clipTop + 60; - const clipRight = clipLeft + 60; // Set a fixed width for time display + const clipRight = clipLeft + 60; if ( bx1 < clipRight && bx2 > clipLeft && by1 < clipBottom && by2 > clipTop ) { - newSelection.push({ trackId: track.id, clipId: clip.id }); + newSelection.push({ trackId: track.id, elementId: element.id }); } }); }); if (newSelection.length > 0) { if (marquee.additive) { const selectedSet = new Set( - selectedClips.map((c) => c.trackId + ":" + c.clipId) + selectedElements.map((c) => c.trackId + ":" + c.elementId) ); newSelection = [ - ...selectedClips, + ...selectedElements, ...newSelection.filter( - (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + (c) => !selectedSet.has(c.trackId + ":" + c.elementId) ), ]; } - setSelectedClips(newSelection); + setSelectedElements(newSelection); } else if (!marquee.additive) { - clearSelectedClips(); + clearSelectedElements(); } setMarquee(null); }, [ marquee, tracks, zoomLevel, - selectedClips, - setSelectedClips, - clearSelectedClips, + selectedElements, + setSelectedElements, + clearSelectedElements, ]); 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")) { + // Don't show overlay for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { return; } dragCounterRef.current += 1; @@ -305,8 +294,8 @@ export function Timeline() { 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")) { + // Don't update state for timeline elements - they're handled by tracks + if (e.dataTransfer.types.includes("application/x-timeline-element")) { return; } @@ -317,44 +306,74 @@ export function Timeline() { }; const handleDrop = async (e: React.DragEvent) => { - // When media is dropped, add it as a new track/clip + // When media is dropped, add it as a new track/element 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" + // Ignore timeline element drags - they're handled by track-specific handlers + const hasTimelineElement = e.dataTransfer.types.includes( + "application/x-timeline-element" ); - if (hasTimelineClip) { + if (hasTimelineElement) { return; } - const mediaItemData = e.dataTransfer.getData("application/x-media-item"); - if (mediaItemData) { - // Handle media item drops by creating new tracks + const itemData = e.dataTransfer.getData("application/x-media-item"); + if (itemData) { try { - const { id, type } = JSON.parse(mediaItemData); - const mediaItem = mediaItems.find((item) => item.id === id); - if (!mediaItem) { - toast.error("Media item not found"); - return; + const dragData: DragData = JSON.parse(itemData); + + if (dragData.type === "text") { + // Always create new text track to avoid overlaps + const newTrackId = addTrack("text"); + + addElementToTrack(newTrackId, { + type: "text", + name: dragData.name || "Text", + content: dragData.content || "Default Text", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + }); + } else { + // Handle media items + const mediaItem = mediaItems.find((item) => item.id === dragData.id); + if (!mediaItem) { + toast.error("Media item not found"); + return; + } + + const trackType = dragData.type === "audio" ? "audio" : "media"; + let targetTrack = tracks.find((t) => t.type === trackType); + const newTrackId = targetTrack ? targetTrack.id : addTrack(trackType); + + addElementToTrack(newTrackId, { + type: "media", + mediaId: mediaItem.id, + name: mediaItem.name, + duration: mediaItem.duration || 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + }); } - // 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, - }); } catch (error) { - // Show error if parsing fails - console.error("Error parsing media item data:", error); - toast.error("Failed to add media to timeline"); + console.error("Error parsing dropped item data:", error); + toast.error("Failed to add item to timeline"); } } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks @@ -374,9 +393,10 @@ export function Timeline() { ); if (addedItem) { const trackType = - processedItem.type === "audio" ? "audio" : "video"; + processedItem.type === "audio" ? "audio" : "media"; const newTrackId = addTrack(trackType); - addClipToTrack(newTrackId, { + addElementToTrack(newTrackId, { + type: "media", mediaId: addedItem.id, name: addedItem.name, duration: addedItem.duration || 5, @@ -502,175 +522,134 @@ export function Timeline() { // Action handlers for toolbar const handleSplitSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } let splitCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { + selectedElements.forEach(({ trackId, elementId }) => { const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - if (clip && track) { - const effectiveStart = clip.startTime; + const element = track?.elements.find((c) => c.id === elementId); + if (element && track) { + const effectiveStart = element.startTime; const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + element.startTime + + (element.duration - element.trimStart - element.trimEnd); if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const newClipId = splitClip(trackId, clipId, currentTime); - if (newClipId) splitCount++; + const newElementId = splitElement(trackId, elementId, currentTime); + if (newElementId) splitCount++; } } }); - if (splitCount > 0) { - toast.success(`Split ${splitCount} clip(s) at playhead`); - } else { - toast.error("Playhead must be within selected clips to split"); + if (splitCount === 0) { + toast.error("Playhead must be within selected elements to split"); } }; const handleDuplicateSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } - selectedClips.forEach(({ trackId, clipId }) => { + const canDuplicate = selectedElements.length === 1; + if (!canDuplicate) return; + + const newSelections: { trackId: string; elementId: string }[] = []; + + selectedElements.forEach(({ trackId, elementId }) => { 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, + const element = track?.elements.find((el) => el.id === elementId); + + if (element) { + const newStartTime = + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1; + + // Create element without id (will be generated by store) + const { id, ...elementWithoutId } = element; + + addElementToTrack(trackId, { + ...elementWithoutId, + startTime: newStartTime, }); + + // We can't predict the new id, so just clear selection for now + // TODO: addElementToTrack could return the new element id } }); - toast.success("Duplicated selected clip(s)"); + + clearSelectedElements(); }; 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.info("Freeze frame functionality coming soon!"); }; + const handleSplitAndKeepLeft = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); return; } - - let splitCount = 0; - 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 effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitAndKeepLeft(trackId, clipId, currentTime); - splitCount++; - } - } - }); - - if (splitCount > 0) { - toast.success(`Split and kept left portion of ${splitCount} clip(s)`); - } else { - toast.error("Playhead must be within selected clips"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; } + splitAndKeepLeft(trackId, elementId, currentTime); }; const handleSplitAndKeepRight = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one element"); return; } - - let splitCount = 0; - 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 effectiveStart = clip.startTime; - const effectiveEnd = - clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - splitAndKeepRight(trackId, clipId, currentTime); - splitCount++; - } - } - }); - - if (splitCount > 0) { - toast.success(`Split and kept right portion of ${splitCount} clip(s)`); - } else { - toast.error("Playhead must be within selected clips"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + const element = track?.elements.find((c) => c.id === elementId); + if (!element) return; + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected element"); + return; } + splitAndKeepRight(trackId, elementId, currentTime); }; const handleSeparateAudio = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length !== 1) { + toast.error("Select exactly one media element to separate audio"); return; } - - let separatedCount = 0; - selectedClips.forEach(({ trackId, clipId }) => { - const track = tracks.find((t) => t.id === trackId); - const clip = track?.clips.find((c) => c.id === clipId); - const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId); - - if ( - clip && - track && - mediaItem?.type === "video" && - track.type === "video" - ) { - const audioClipId = separateAudio(trackId, clipId); - if (audioClipId) separatedCount++; - } - }); - - if (separatedCount > 0) { - toast.success(`Separated audio from ${separatedCount} video clip(s)`); - } else { - toast.error("Select video clips to separate audio"); + const { trackId, elementId } = selectedElements[0]; + const track = tracks.find((t) => t.id === trackId); + if (!track || track.type !== "media") { + toast.error("Select a media element to separate audio"); + return; } + separateAudio(trackId, elementId); }; + const handleDeleteSelected = () => { - if (selectedClips.length === 0) { - toast.error("No clips selected"); + if (selectedElements.length === 0) { + toast.error("No elements selected"); return; } - selectedClips.forEach(({ trackId, clipId }) => { - removeClipFromTrack(trackId, clipId); + selectedElements.forEach(({ trackId, elementId }) => { + removeElementFromTrack(trackId, elementId); }); - clearSelectedClips(); - toast.success("Deleted selected clip(s)"); + clearSelectedElements(); }; // Prevent explorer zooming in/out when in timeline @@ -754,7 +733,7 @@ export function Timeline() { return (
setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} @@ -783,9 +762,7 @@ export function Timeline() { {isPlaying ? "Pause (Space)" : "Play (Space)"} -
- {/* Time Display */}
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
- {/* Test Clip Button - for debugging */} {tracks.length === 0 && ( <> @@ -804,8 +780,9 @@ export function Timeline() { variant="outline" size="sm" onClick={() => { - const trackId = addTrack("video"); - addClipToTrack(trackId, { + const trackId = addTrack("media"); + addElementToTrack(trackId, { + type: "media", mediaId: "test", name: "Test Clip", duration: 5, @@ -823,18 +800,15 @@ export function Timeline() { )} -
- - Split clip (Ctrl+S) + Split element (Ctrl+S) - - Duplicate clip (Ctrl+D) + Duplicate element (Ctrl+D) - - Delete clip (Delete) + Delete element (Delete) - -
- - {/* Speed Control */} +
c{/* Speed Control */}