diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ae6b98d..15e5fc1 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -1,14 +1,13 @@ -"use client"; +'use client'; -import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore } from "@/stores/media-store"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; +import { processMediaFiles } from '@/lib/media-processing'; +import { useMediaStore } from '@/stores/media-store'; +import { usePlaybackStore } from '@/stores/playback-store'; +import { useTimelineStore, type TimelineTrack } from '@/stores/timeline-store'; import { ArrowLeftToLine, ArrowRightToLine, Copy, - MoreVertical, Pause, Play, Scissors, @@ -17,28 +16,27 @@ import { Trash2, Volume2, VolumeX, -} from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { Button } from "../ui/button"; -import { ScrollArea } from "../ui/scroll-area"; +} from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { Button } from '../ui/button'; +import { ScrollArea } from '../ui/scroll-area'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "../ui/select"; +} from '../ui/select'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from "../ui/tooltip"; - -import AudioWaveform from "./audio-waveform"; +} from '../ui/tooltip'; +import AudioWaveform from './audio-waveform'; export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. @@ -79,7 +77,7 @@ export function Timeline() { // Unified context menu state const [contextMenu, setContextMenu] = useState<{ - type: "track" | "clip"; + type: 'track' | 'clip'; trackId: string; clipId?: string; x: number; @@ -110,8 +108,8 @@ export function Timeline() { useEffect(() => { const handleClick = () => setContextMenu(null); if (contextMenu) { - window.addEventListener("click", handleClick); - return () => window.removeEventListener("click", handleClick); + window.addEventListener('click', handleClick); + return () => window.removeEventListener('click', handleClick); } }, [contextMenu]); @@ -119,7 +117,7 @@ export function Timeline() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if ( - (e.key === "Delete" || e.key === "Backspace") && + (e.key === 'Delete' || e.key === 'Backspace') && selectedClips.length > 0 ) { selectedClips.forEach(({ trackId, clipId }) => { @@ -128,35 +126,35 @@ export function Timeline() { clearSelectedClips(); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedClips, removeClipFromTrack, clearSelectedClips]); // Keyboard event for undo (Cmd+Z) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) { + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [undo]); // Keyboard event for redo (Cmd+Shift+Z or Cmd+Y) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) { + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && e.shiftKey) { e.preventDefault(); redo(); - } else if ((e.metaKey || e.ctrlKey) && e.key === "y") { + } else if ((e.metaKey || e.ctrlKey) && e.key === 'y') { e.preventDefault(); redo(); } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); }, [redo]); // Mouse down on timeline background to start marquee @@ -187,11 +185,11 @@ export function Timeline() { prev && { ...prev, endX: e.clientX, endY: e.clientY, active: false } ); }; - window.addEventListener("mousemove", handleMouseMove); - window.addEventListener("mouseup", handleMouseUp); + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); }; }, [marquee]); @@ -239,12 +237,12 @@ export function Timeline() { if (newSelection.length > 0) { if (marquee.additive) { const selectedSet = new Set( - selectedClips.map((c) => c.trackId + ":" + c.clipId) + selectedClips.map((c) => c.trackId + ':' + c.clipId) ); newSelection = [ ...selectedClips, ...newSelection.filter( - (c) => !selectedSet.has(c.trackId + ":" + c.clipId) + (c) => !selectedSet.has(c.trackId + ':' + c.clipId) ), ]; } @@ -266,7 +264,7 @@ export function Timeline() { // 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")) { + if (e.dataTransfer.types.includes('application/x-timeline-clip')) { return; } dragCounterRef.current += 1; @@ -283,7 +281,7 @@ export function Timeline() { e.preventDefault(); // Don't update state for timeline clips - they're handled by tracks - if (e.dataTransfer.types.includes("application/x-timeline-clip")) { + if (e.dataTransfer.types.includes('application/x-timeline-clip')) { return; } @@ -301,24 +299,24 @@ export function Timeline() { // Ignore timeline clip drags - they're handled by track-specific handlers const hasTimelineClip = e.dataTransfer.types.includes( - "application/x-timeline-clip" + 'application/x-timeline-clip' ); if (hasTimelineClip) { return; } - const mediaItemData = e.dataTransfer.getData("application/x-media-item"); + 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"); + toast.error('Media item not found'); return; } // Add to video or audio track depending on type - const trackType = type === "audio" ? "audio" : "video"; + const trackType = type === 'audio' ? 'audio' : 'video'; const newTrackId = addTrack(trackType); addClipToTrack(newTrackId, { mediaId: mediaItem.id, @@ -331,8 +329,8 @@ export function Timeline() { 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"); + 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 @@ -348,7 +346,7 @@ export function Timeline() { ); if (addedItem) { const trackType = - processedItem.type === "audio" ? "audio" : "video"; + processedItem.type === 'audio' ? 'audio' : 'video'; const newTrackId = addTrack(trackType); addClipToTrack(newTrackId, { mediaId: addedItem.id, @@ -362,8 +360,8 @@ export function Timeline() { } } catch (error) { // Show error if file processing fails - console.error("Error processing external files:", error); - toast.error("Failed to process dropped files"); + console.error('Error processing external files:', error); + toast.error('Failed to process dropped files'); } finally { setIsProcessing(false); } @@ -429,11 +427,11 @@ export function Timeline() { if (scrubTime !== null) seek(scrubTime); // finalize seek setScrubTime(null); }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); return () => { - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); }; }, [isScrubbing, scrubTime, seek, handleScrub]); @@ -450,7 +448,7 @@ export function Timeline() { // Action handlers for toolbar const handleSplitSelected = () => { if (selectedClips.length === 0) { - toast.error("No clips selected"); + toast.error('No clips selected'); return; } selectedClips.forEach(({ trackId, clipId }) => { @@ -470,7 +468,7 @@ export function Timeline() { ); addClipToTrack(track.id, { mediaId: clip.mediaId, - name: clip.name + " (split)", + name: clip.name + ' (split)', duration: clip.duration, startTime: splitTime, trimStart: clip.trimStart + (splitTime - effectiveStart), @@ -479,12 +477,12 @@ export function Timeline() { } } }); - toast.success("Split selected clip(s)"); + toast.success('Split selected clip(s)'); }; const handleDuplicateSelected = () => { if (selectedClips.length === 0) { - toast.error("No clips selected"); + toast.error('No clips selected'); return; } selectedClips.forEach(({ trackId, clipId }) => { @@ -493,7 +491,7 @@ export function Timeline() { if (clip && track) { addClipToTrack(track.id, { mediaId: clip.mediaId, - name: clip.name + " (copy)", + name: clip.name + ' (copy)', duration: clip.duration, startTime: clip.startTime + @@ -504,12 +502,12 @@ export function Timeline() { }); } }); - toast.success("Duplicated selected clip(s)"); + toast.success('Duplicated selected clip(s)'); }; const handleFreezeSelected = () => { if (selectedClips.length === 0) { - toast.error("No clips selected"); + toast.error('No clips selected'); return; } selectedClips.forEach(({ trackId, clipId }) => { @@ -519,7 +517,7 @@ export function Timeline() { // Add a new freeze frame clip at the playhead addClipToTrack(track.id, { mediaId: clip.mediaId, - name: clip.name + " (freeze)", + name: clip.name + ' (freeze)', duration: 1, // 1 second freeze frame startTime: currentTime, trimStart: 0, @@ -527,19 +525,19 @@ export function Timeline() { }); } }); - toast.success("Freeze frame added for selected clip(s)"); + toast.success('Freeze frame added for selected clip(s)'); }; const handleDeleteSelected = () => { if (selectedClips.length === 0) { - toast.error("No clips selected"); + toast.error('No clips selected'); return; } selectedClips.forEach(({ trackId, clipId }) => { removeClipFromTrack(trackId, clipId); }); clearSelectedClips(); - toast.success("Deleted selected clip(s)"); + toast.success('Deleted selected clip(s)'); }; // Prevent explorer zooming in/out when in timeline @@ -555,16 +553,16 @@ export function Timeline() { } }; - document.addEventListener("wheel", preventZoom, { passive: false }); + document.addEventListener('wheel', preventZoom, { passive: false }); return () => { - document.removeEventListener("wheel", preventZoom); + document.removeEventListener('wheel', preventZoom); }; }, [isInTimeline]); return (
setIsInTimeline(true)} onMouseLeave={() => setIsInTimeline(false)} @@ -590,14 +588,15 @@ export function Timeline() { - {isPlaying ? "Pause (Space)" : "Play (Space)"} + {isPlaying ? 'Pause (Space)' : 'Play (Space)'}
{/* Time Display */} -
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s @@ -613,10 +612,10 @@ export function Timeline() { variant="outline" size="sm" onClick={() => { - const trackId = addTrack("video"); + const trackId = addTrack('video'); addClipToTrack(trackId, { - mediaId: "test", - name: "Test Clip", + mediaId: 'test', + name: 'Test Clip', duration: 5, startTime: 0, trimStart: 0, @@ -782,15 +781,15 @@ export function Timeline() {
{(() => { @@ -800,9 +799,9 @@ export function Timeline() { const secs = seconds % 60; if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "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")}`; + return `${minutes}:${Math.floor(secs).toString().padStart(2, '0')}`; } else if (interval >= 1) { return `${Math.floor(secs)}s`; } else { @@ -843,7 +842,7 @@ export function Timeline() { onContextMenu={(e) => { e.preventDefault(); setContextMenu({ - type: "track", + type: 'track', trackId: track.id, x: e.clientX, y: e.clientY, @@ -852,11 +851,11 @@ export function Timeline() { >
@@ -879,7 +878,7 @@ export function Timeline() {
{/* Timeline grid and clips area (with left margin for sifdebar) */}
{ e.preventDefault(); setContextMenu({ - type: "track", + type: 'track', trackId: track.id, x: e.clientX, y: e.clientY, @@ -950,7 +949,7 @@ export function Timeline() { className="absolute left-0 right-0 border-2 border-dashed border-accent flex items-center justify-center text-muted-foreground" style={{ top: `${tracks.length * 60}px`, - height: "60px", + height: '60px', }} >
Drop media here to add a new track
@@ -969,7 +968,7 @@ export function Timeline() { style={{ left: contextMenu.x, top: contextMenu.y }} onContextMenu={(e) => e.preventDefault()} > - {contextMenu.type === "track" ? ( + {contextMenu.type === 'track' ? ( // Track context menu <> - {clipMenuOpen === clip.id && ( -
- - -
- )} -
{/* Right trim handle */}
handleResizeStart(e, clip.id, "right")} + onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')} />
); @@ -1834,22 +1803,22 @@ function TimelineTrackContent({ {/* Drop position indicator */} {isDraggedOver && dropPosition !== null && (
- {wouldOverlap ? "⚠️" : ""} + {wouldOverlap ? '⚠️' : ''} {dropPosition.toFixed(1)}s
@@ -1860,4 +1829,3 @@ function TimelineTrackContent({
); } -