summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/components/editor')
-rw-r--r--apps/web/src/components/editor/media-panel.tsx28
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx266
-rw-r--r--apps/web/src/components/editor/timeline-toolbar.tsx219
-rw-r--r--apps/web/src/components/editor/timeline.tsx820
4 files changed, 807 insertions, 526 deletions
diff --git a/apps/web/src/components/editor/media-panel.tsx b/apps/web/src/components/editor/media-panel.tsx
index dfe08365..f717234d 100644
--- a/apps/web/src/components/editor/media-panel.tsx
+++ b/apps/web/src/components/editor/media-panel.tsx
@@ -1,14 +1,15 @@
"use client";
-import { Button } from "../ui/button";
-import { AspectRatio } from "../ui/aspect-ratio";
-import { DragOverlay } from "../ui/drag-overlay";
-import { useMediaStore, type MediaItem } from "@/stores/media-store";
-import { processMediaFiles } from "@/lib/media-processing";
-import { Plus, Image, Video, Music, Trash2, Upload } from "lucide-react";
import { useDragDrop } from "@/hooks/use-drag-drop";
+import { processMediaFiles } from "@/lib/media-processing";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+import { Image, Music, Plus, Trash2, Upload, Video } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
+import { AspectRatio } from "../ui/aspect-ratio";
+import { Button } from "../ui/button";
+import { DragOverlay } from "../ui/drag-overlay";
// MediaPanel lets users add, view, and drag media (images, videos, audio) into the project.
// You can upload files or drag them from your computer. Dragging from here to the timeline adds them to your video project.
@@ -58,6 +59,21 @@ export function MediaPanel() {
const handleRemove = (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation();
+
+
+ // Remove tracks 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);
+ });
+ // Only remove track if it becomes empty and has no other clips
+ const updatedTrack = useTimelineStore.getState().tracks.find(t => t.id === track.id);
+ if (updatedTrack && updatedTrack.clips.length === 0) {
+ removeTrack(track.id);
+ }
+ });
removeMediaItem(id);
};
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx
index 4c0d3b77..b9d3b9a6 100644
--- a/apps/web/src/components/editor/timeline-clip.tsx
+++ b/apps/web/src/components/editor/timeline-clip.tsx
@@ -2,14 +2,32 @@
import { useState } from "react";
import { Button } from "../ui/button";
-import { MoreVertical, Scissors, Trash2 } from "lucide-react";
+import {
+ MoreVertical,
+ Scissors,
+ Trash2,
+ SplitSquareHorizontal,
+ Music,
+ ChevronRight,
+ ChevronLeft,
+} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
-import { useDragClip } from "@/hooks/use-drag-clip";
import AudioWaveform from "./audio-waveform";
import { toast } from "sonner";
import { TimelineClipProps, ResizeState } from "@/types/timeline";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+} from "../ui/dropdown-menu";
+import { isDragging } from "motion/react";
export function TimelineClip({
clip,
@@ -21,8 +39,16 @@ export function TimelineClip({
onClipClick,
}: TimelineClipProps) {
const { mediaItems } = useMediaStore();
- const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } =
- useTimelineStore();
+ const {
+ updateClipTrim,
+ addClipToTrack,
+ removeClipFromTrack,
+ dragState,
+ splitClip,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
+ } = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [resizing, setResizing] = useState<ResizeState | null>(null);
@@ -52,6 +78,7 @@ export function TimelineClip({
}
};
+ // Resize handles for trimming clips
const handleResizeStart = (
e: React.MouseEvent,
clipId: string,
@@ -107,44 +134,85 @@ export function TimelineClip({
const handleDeleteClip = () => {
removeClipFromTrack(track.id, clip.id);
setClipMenuOpen(false);
+ toast.success("Clip deleted");
};
const handleSplitClip = () => {
- // Use current playback time as split point
- const splitTime = currentTime;
- // Only split if splitTime is within the clip's effective range
const effectiveStart = clip.startTime;
const effectiveEnd =
clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) {
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within clip to split");
return;
}
- const firstDuration = splitTime - effectiveStart;
- const secondDuration = effectiveEnd - splitTime;
+ const secondClipId = splitClip(track.id, clip.id, currentTime);
+ if (secondClipId) {
+ toast.success("Clip split successfully");
+ } else {
+ toast.error("Failed to split clip");
+ }
+ setClipMenuOpen(false);
+ };
- // First part: adjust original clip
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + secondDuration
- );
+ const handleSplitAndKeepLeft = () => {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
- // Second part: add new clip after split
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (cut)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + firstDuration,
- trimEnd: clip.trimEnd,
- });
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within clip");
+ return;
+ }
+
+ splitAndKeepLeft(track.id, clip.id, currentTime);
+ toast.success("Split and kept left portion");
+ setClipMenuOpen(false);
+ };
+
+ const handleSplitAndKeepRight = () => {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+
+ if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
+ toast.error("Playhead must be within clip");
+ return;
+ }
+
+ splitAndKeepRight(track.id, clip.id, currentTime);
+ toast.success("Split and kept right portion");
+ setClipMenuOpen(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");
+ return;
+ }
+
+ const audioClipId = separateAudio(track.id, clip.id);
+ if (audioClipId) {
+ toast.success("Audio separated to audio track");
+ } else {
+ toast.error("Failed to separate audio");
+ }
setClipMenuOpen(false);
- toast.success("Clip split successfully");
+ };
+
+ const canSplitAtPlayhead = () => {
+ const effectiveStart = clip.startTime;
+ const effectiveEnd =
+ clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
+ return currentTime > effectiveStart && currentTime < effectiveEnd;
+ };
+
+ const canSeparateAudio = () => {
+ const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
+ return mediaItem?.type === "video" && track.type === "video";
};
const renderClipContent = () => {
@@ -201,76 +269,112 @@ export function TimelineClip({
);
}
- // Fallback for videos without thumbnails
return (
<span className="text-xs text-foreground/80 truncate">{clip.name}</span>
);
};
+ const handleClipMouseDown = (e: React.MouseEvent) => {
+ if (onClipMouseDown) {
+ onClipMouseDown(e, clip);
+ }
+ };
+
return (
<div
- className={`timeline-clip absolute h-full border ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""} ${isBeingDragged ? "shadow-lg z-20" : ""}`}
- style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
- onMouseDown={(e) => onClipMouseDown(e, clip)}
- onClick={(e) => onClipClick(e, clip)}
- onMouseMove={handleResizeMove}
- onMouseUp={handleResizeEnd}
- onMouseLeave={handleResizeEnd}
- tabIndex={0}
- onContextMenu={(e) => onContextMenu(e, clip.id)}
+ className={`absolute top-0 h-full select-none transition-all duration-75 ${
+ isBeingDragged ? "z-50" : "z-10"
+ } ${isSelected ? "ring-2 ring-primary" : ""}`}
+ style={{
+ left: `${clipLeft}px`,
+ width: `${clipWidth}px`,
+ }}
+ onMouseMove={resizing ? handleResizeMove : undefined}
+ onMouseUp={resizing ? handleResizeEnd : undefined}
+ onMouseLeave={resizing ? handleResizeEnd : undefined}
>
- {/* Left trim handle */}
<div
- className={`absolute left-0 top-0 bottom-0 w-2 cursor-w-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
-
- {/* Clip content */}
- <div className="flex-1 relative">
- {renderClipContent()}
+ className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor(
+ track.type
+ )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`}
+ onClick={(e) => onClipClick && onClipClick(e, clip)}
+ onMouseDown={handleClipMouseDown}
+ onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)}
+ >
+ <div className="absolute inset-1 flex items-center p-1">
+ {renderClipContent()}
+ </div>
- {/* Clip options menu */}
- <div className="absolute top-1 right-1 z-10">
- <Button
- variant="text"
- size="icon"
- className="opacity-0 group-hover:opacity-100 transition-opacity"
- onClick={(e) => {
- e.stopPropagation();
- setClipMenuOpen(!clipMenuOpen);
- }}
- onMouseDown={(e) => e.stopPropagation()}
- >
- <MoreVertical className="h-4 w-4" />
- </Button>
+ <div
+ className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize hover:bg-primary/50 transition-colors"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
+ />
+ <div
+ className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize hover:bg-primary/50 transition-colors"
+ onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
+ />
- {clipMenuOpen && (
- <div
- className="absolute right-0 mt-2 w-32 bg-white border rounded shadow z-50"
- onMouseDown={(e) => e.stopPropagation()}
- >
- <button
- className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
- onClick={handleSplitClip}
+ <div className="absolute top-1 right-1">
+ <DropdownMenu open={clipMenuOpen} onOpenChange={setClipMenuOpen}>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
+ onClick={(e) => {
+ e.stopPropagation();
+ setClipMenuOpen(true);
+ }}
>
- <Scissors className="h-4 w-4 mr-2" /> Split
- </button>
- <button
- className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
+ <MoreVertical className="h-3 w-3" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-56">
+ {/* Split operations - only available when playhead is within clip */}
+ <DropdownMenuSub>
+ <DropdownMenuSubTrigger disabled={!canSplitAtPlayhead()}>
+ <Scissors className="mr-2 h-4 w-4" />
+ Split
+ </DropdownMenuSubTrigger>
+ <DropdownMenuSubContent>
+ <DropdownMenuItem onClick={handleSplitClip}>
+ <SplitSquareHorizontal className="mr-2 h-4 w-4" />
+ Split at Playhead
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleSplitAndKeepLeft}>
+ <ChevronLeft className="mr-2 h-4 w-4" />
+ Split and Keep Left
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={handleSplitAndKeepRight}>
+ <ChevronRight className="mr-2 h-4 w-4" />
+ Split and Keep Right
+ </DropdownMenuItem>
+ </DropdownMenuSubContent>
+ </DropdownMenuSub>
+
+ {/* Audio separation - only available for video clips */}
+ {canSeparateAudio() && (
+ <>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onClick={handleSeparateAudio}>
+ <Music className="mr-2 h-4 w-4" />
+ Separate Audio
+ </DropdownMenuItem>
+ </>
+ )}
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
onClick={handleDeleteClip}
+ className="text-destructive"
>
- <Trash2 className="h-4 w-4 mr-2" /> Delete
- </button>
- </div>
- )}
+ <Trash2 className="mr-2 h-4 w-4" />
+ Delete Clip
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
</div>
</div>
-
- {/* Right trim handle */}
- <div
- className={`absolute right-0 top-0 bottom-0 w-2 cursor-e-resize transition-opacity bg-blue-500/50 hover:bg-blue-500 ${isSelected ? "opacity-100" : "opacity-0"}`}
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
</div>
);
}
diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx
new file mode 100644
index 00000000..51464794
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-toolbar.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import type { TrackType } from "@/types/timeline";
+import {
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Copy,
+ Pause,
+ Play,
+ Scissors,
+ Snowflake,
+ SplitSquareHorizontal,
+ Trash2,
+} from "lucide-react";
+import { Button } from "../ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+
+interface TimelineToolbarProps {
+ isPlaying: boolean;
+ currentTime: number;
+ duration: number;
+ speed: number;
+ tracks: any[];
+ toggle: () => void;
+ setSpeed: (speed: number) => void;
+ addTrack: (type: TrackType) => string;
+ addClipToTrack: (trackId: string, clip: any) => void;
+ handleSplitSelected: () => void;
+ handleDuplicateSelected: () => void;
+ handleFreezeSelected: () => void;
+ handleDeleteSelected: () => void;
+}
+
+export function TimelineToolbar({
+ isPlaying,
+ currentTime,
+ duration,
+ speed,
+ tracks,
+ toggle,
+ setSpeed,
+ addTrack,
+ addClipToTrack,
+ handleSplitSelected,
+ handleDuplicateSelected,
+ handleFreezeSelected,
+ handleDeleteSelected,
+}: TimelineToolbarProps) {
+ return (
+ <div className="border-b flex items-center px-2 py-1 gap-1">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("video");
+ addClipToTrack(trackId, {
+ mediaId: "test",
+ name: "Test Clip",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split clip (S)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (A)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (E)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete clip (Delete)</TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Speed Control */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Select
+ value={speed.toFixed(1)}
+ onValueChange={(value) => setSpeed(parseFloat(value))}
+ >
+ <SelectTrigger className="w-[90px] h-8">
+ <SelectValue placeholder="1.0x" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="0.5">0.5x</SelectItem>
+ <SelectItem value="1.0">1.0x</SelectItem>
+ <SelectItem value="1.5">1.5x</SelectItem>
+ <SelectItem value="2.0">2.0x</SelectItem>
+ </SelectContent>
+ </Select>
+ </TooltipTrigger>
+ <TooltipContent>Playback Speed</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index 2d60102a..ee0a2565 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -1,27 +1,37 @@
"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 { ScrollArea } from "../ui/scroll-area";
+import { Button } from "../ui/button";
import {
+ Scissors,
ArrowLeftToLine,
ArrowRightToLine,
- Copy,
- MoreVertical,
- Pause,
- Play,
- Scissors,
+ Trash2,
Snowflake,
+ Copy,
SplitSquareHorizontal,
- Trash2,
Volume2,
VolumeX,
+ Pause,
+ Play,
} from "lucide-react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+ TooltipProvider,
+} from "../ui/tooltip";
+import {
+ useTimelineStore,
+ type TimelineTrack,
+ type TimelineClip as TypeTimelineClip,
+} from "@/stores/timeline-store";
+import { useMediaStore } from "@/stores/media-store";
+import { usePlaybackStore } from "@/stores/playback-store";
+import { useDragClip } from "@/hooks/use-drag-clip";
+import { processMediaFiles } from "@/lib/media-processing";
import { toast } from "sonner";
-import { Button } from "../ui/button";
-import { ScrollArea } from "../ui/scroll-area";
+import { useState, useRef, useEffect, useCallback } from "react";
import {
Select,
SelectContent,
@@ -29,16 +39,8 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
-
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "../ui/tooltip";
-
-import AudioWaveform from "./audio-waveform";
-
+import { TimelineClip } from "./timeline-clip";
+import { ContextMenuState } from "@/types/timeline";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -56,6 +58,10 @@ export function Timeline() {
clearSelectedClips,
setSelectedClips,
updateClipTrim,
+ splitClip,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
undo,
redo,
} = useTimelineStore();
@@ -72,19 +78,14 @@ export function Timeline() {
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
+ const [progress, setProgress] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
// Unified context menu state
- const [contextMenu, setContextMenu] = useState<{
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null>(null);
+ const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
// Marquee selection state
const [marquee, setMarquee] = useState<{
@@ -239,7 +240,7 @@ 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; clipId: string }[] = [];
tracks.forEach((track, trackIdx) => {
track.clips.forEach((clip) => {
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
@@ -359,8 +360,12 @@ export function Timeline() {
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
+ setProgress(0);
try {
- const processedItems = await processMediaFiles(e.dataTransfer.files);
+ const processedItems = await processMediaFiles(
+ e.dataTransfer.files,
+ (p) => setProgress(p)
+ );
for (const processedItem of processedItems) {
addMediaItem(processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
@@ -388,6 +393,7 @@ export function Timeline() {
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
+ setProgress(0);
}
}
};
@@ -475,33 +481,26 @@ export function Timeline() {
toast.error("No clips selected");
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 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,
- });
+
+ if (currentTime > effectiveStart && currentTime < effectiveEnd) {
+ const newClipId = splitClip(trackId, clipId, currentTime);
+ if (newClipId) splitCount++;
}
}
});
- toast.success("Split selected clip(s)");
+ if (splitCount > 0) {
+ toast.success(`Split ${splitCount} clip(s) at playhead`);
+ } else {
+ toast.error("Playhead must be within selected clips to split");
+ }
};
const handleDuplicateSelected = () => {
@@ -551,7 +550,86 @@ export function Timeline() {
});
toast.success("Freeze frame added for selected clip(s)");
};
+ const handleSplitAndKeepLeft = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleSplitAndKeepRight = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleSeparateAudio = () => {
+ if (selectedClips.length === 0) {
+ toast.error("No clips selected");
+ 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 handleDeleteSelected = () => {
if (selectedClips.length === 0) {
toast.error("No clips selected");
@@ -670,8 +748,9 @@ export function Timeline() {
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
- <div className="text-xs text-muted-foreground font-mono px-2"
- style={{ minWidth: '18ch', textAlign: 'center' }}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
@@ -714,34 +793,34 @@ export function Timeline() {
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split clip (S)</TooltipContent>
+ <TooltipContent>Split clip (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepLeft}>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split and keep left (A)</TooltipContent>
+ <TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSplitAndKeepRight}>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Split and keep right (D)</TooltipContent>
+ <TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
- <Button variant="text" size="icon">
+ <Button variant="text" size="icon" onClick={handleSeparateAudio}>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
- <TooltipContent>Separate audio (E)</TooltipContent>
+ <TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
@@ -854,17 +933,19 @@ export function Timeline() {
return (
<div
key={i}
- className={`absolute top-0 bottom-0 ${isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
style={{ left: `${time * 50 * zoomLevel}px` }}
>
<span
- className={`absolute top-1 left-1 text-xs ${isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
+ className={`absolute top-1 left-1 text-xs ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
>
{(() => {
const formatTime = (seconds: number) => {
@@ -925,12 +1006,13 @@ export function Timeline() {
>
<div className="flex items-center flex-1 min-w-0">
<div
- className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === "video"
- ? "bg-blue-500"
- : track.type === "audio"
- ? "bg-green-500"
- : "bg-purple-500"
- }`}
+ className={`w-3 h-3 rounded-full flex-shrink-0 ${
+ track.type === "video"
+ ? "bg-blue-500"
+ : track.type === "audio"
+ ? "bg-green-500"
+ : "bg-purple-500"
+ }`}
/>
<span className="ml-2 text-sm font-medium truncate">
{track.name}
@@ -990,6 +1072,16 @@ export function Timeline() {
y: e.clientY,
});
}}
+ onClick={(e) => {
+ // If clicking empty area (not on a clip), deselect all clips
+ if (
+ !(e.target as HTMLElement).closest(".timeline-clip")
+ ) {
+ const { clearSelectedClips } =
+ useTimelineStore.getState();
+ clearSelectedClips();
+ }
+ }}
>
<TimelineTrackContent
track={track}
@@ -1014,14 +1106,12 @@ export function Timeline() {
</>
)}
{isDragOver && (
- <div
- 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",
- }}
- >
- <div>Drop media here to add a new track</div>
+ <div className="absolute inset-0 z-20 flex items-center justify-center pointer-events-none backdrop-blur-lg">
+ <div>
+ {isProcessing
+ ? `Processing ${progress}%`
+ : "Drop media here to add to timeline"}
+ </div>
</div>
)}
</div>
@@ -1050,22 +1140,17 @@ export function Timeline() {
setContextMenu(null);
}}
>
- {(() => {
- const track = tracks.find(
- (t) => t.id === contextMenu.trackId
- );
- return track?.muted ? (
- <>
- <Volume2 className="h-4 w-4 mr-2" />
- Unmute Track
- </>
- ) : (
- <>
- <VolumeX className="h-4 w-4 mr-2" />
- Mute Track
- </>
- );
- })()}
+ {contextMenu.trackId ? (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <Volume2 className="h-4 w-4 mr-2" />
+ Unmute Track
+ </div>
+ ) : (
+ <div className="flex items-center w-full px-3 py-2 hover:bg-accent hover:text-accent-foreground transition-colors text-left">
+ <VolumeX className="h-4 w-4 mr-2" />
+ Mute Track
+ </div>
+ )}
</button>
<div className="h-px bg-border mx-1 my-1" />
<button
@@ -1195,134 +1280,191 @@ function TimelineTrackContent({
}: {
track: TimelineTrack;
zoomLevel: number;
- setContextMenu: (
- menu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null
- ) => void;
- contextMenu: {
- type: "track" | "clip";
- trackId: string;
- clipId?: string;
- x: number;
- y: number;
- } | null;
+ setContextMenu: (menu: ContextMenuState | null) => void;
+ contextMenu: ContextMenuState | null;
}) {
const { mediaItems } = useMediaStore();
const {
tracks,
moveClipToTrack,
- updateClipTrim,
updateClipStartTime,
addClipToTrack,
- removeClipFromTrack,
- toggleTrackMute,
selectedClips,
selectClip,
deselectClip,
+ dragState,
+ startDrag: startDragAction,
+ updateDragTime,
+ endDrag: endDragAction,
} = useTimelineStore();
- const { currentTime } = usePlaybackStore();
+
+ const timelineRef = useRef<HTMLDivElement>(null);
const [isDropping, setIsDropping] = useState(false);
const [dropPosition, setDropPosition] = useState<number | null>(null);
- const [isDraggedOver, setIsDraggedOver] = useState(false);
const [wouldOverlap, setWouldOverlap] = useState(false);
- const [resizing, setResizing] = useState<{
- clipId: string;
- side: "left" | "right";
- startX: number;
- initialTrimStart: number;
- initialTrimEnd: number;
- } | null>(null);
const dragCounterRef = useRef(0);
- const [clipMenuOpen, setClipMenuOpen] = useState<string | null>(null);
-
- // Handle clip deletion
- const handleDeleteClip = (clipId: string) => {
- removeClipFromTrack(track.id, clipId);
- };
+ const [mouseDownLocation, setMouseDownLocation] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
- const handleResizeStart = (
- e: React.MouseEvent,
- clipId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
+ // Set up mouse event listeners for drag
+ useEffect(() => {
+ if (!dragState.isDragging) return;
- const clip = track.clips.find((c) => c.id === clipId);
- if (!clip) return;
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!timelineRef.current) return;
- setResizing({
- clipId,
- side,
- startX: e.clientX,
- initialTrimStart: clip.trimStart,
- initialTrimEnd: clip.trimEnd,
- });
- };
+ 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;
- const updateTrimFromMouseMove = (e: { clientX: number; }) => {
- if (!resizing) return;
+ updateDragTime(snappedTime);
+ };
- const clip = track.clips.find((c) => c.id === resizing.clipId);
- if (!clip) return;
+ const handleMouseUp = () => {
+ if (!dragState.clipId || !dragState.trackId) return;
- const deltaX = e.clientX - resizing.startX;
- const deltaTime = deltaX / (50 * zoomLevel);
+ const finalTime = dragState.currentTime;
- if (resizing.side === "left") {
- const newTrimStart = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimEnd - 0.1,
- resizing.initialTrimStart + deltaTime
- )
- );
- updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
- } else {
- const newTrimEnd = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimStart - 0.1,
- resizing.initialTrimEnd - deltaTime
- )
+ // 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
);
- updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
+
+ 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 });
+ // Handle multi-selection only in mousedown
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ selectClip(track.id, clip.id, true);
}
- };
- const handleResizeMove = (e: React.MouseEvent) => {
- updateTrimFromMouseMove(e);
- };
+ // 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);
- const handleResizeEnd = () => {
- setResizing(null);
+ startDragAction(
+ clip.id,
+ track.id,
+ e.clientX,
+ clip.startTime,
+ clickOffsetTime
+ );
};
- const handleClipDragStart = (e: React.DragEvent, clip: any) => {
- const dragData = { clipId: clip.id, trackId: track.id, name: clip.name };
+ 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;
+ }
+ }
+
+ // Close context menu if it's open
+ if (contextMenu) {
+ setContextMenu(null);
+ return; // Don't handle selection when closing context menu
+ }
- e.dataTransfer.setData(
- "application/x-timeline-clip",
- JSON.stringify(dragData)
+ // Skip selection logic for multi-selection (handled in mousedown)
+ if (e.metaKey || e.ctrlKey || e.shiftKey) {
+ return;
+ }
+
+ // Handle single selection/deselection
+ const isSelected = selectedClips.some(
+ (c) => c.trackId === track.id && c.clipId === clip.id
);
- e.dataTransfer.effectAllowed = "move";
- // Add visual feedback to the dragged element
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "0.5";
- target.style.transform = "scale(0.95)";
+ if (isSelected) {
+ // If clip is selected, deselect it
+ deselectClip(track.id, clip.id);
+ } else {
+ // If clip is not selected, select it (replacing other selections)
+ selectClip(track.id, clip.id, false);
+ }
};
- const handleClipDragEnd = (e: React.DragEvent) => {
- // Reset visual feedback
- const target = e.currentTarget.parentElement as HTMLElement;
- target.style.opacity = "";
- target.style.transform = "";
+ const handleClipContextMenu = (e: React.MouseEvent, clipId: string) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setContextMenu({
+ type: "clip",
+ trackId: track.id,
+ clipId: clipId,
+ x: e.clientX,
+ y: e.clientY,
+ });
};
const handleTrackDragOver = (e: React.DragEvent) => {
@@ -1442,14 +1584,12 @@ function TimelineTrackContent({
if (wouldOverlap) {
e.dataTransfer.dropEffect = "none";
- setIsDraggedOver(true);
setWouldOverlap(true);
setDropPosition(Math.round(dropTime * 10) / 10);
return;
}
e.dataTransfer.dropEffect = hasTimelineClip ? "move" : "copy";
- setIsDraggedOver(true);
setWouldOverlap(false);
setDropPosition(Math.round(dropTime * 10) / 10);
};
@@ -1468,7 +1608,6 @@ function TimelineTrackContent({
dragCounterRef.current++;
setIsDropping(true);
- setIsDraggedOver(true);
};
const handleTrackDragLeave = (e: React.DragEvent) => {
@@ -1487,7 +1626,6 @@ function TimelineTrackContent({
if (dragCounterRef.current === 0) {
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
setDropPosition(null);
}
@@ -1500,7 +1638,6 @@ function TimelineTrackContent({
// Reset all drag states
dragCounterRef.current = 0;
setIsDropping(false);
- setIsDraggedOver(false);
setWouldOverlap(false);
const currentDropPosition = dropPosition;
setDropPosition(null);
@@ -1532,23 +1669,36 @@ function TimelineTrackContent({
);
if (!timelineClipData) return;
- const { clipId, trackId: fromTrackId } = JSON.parse(timelineClipData);
+ 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: any) => c.id === clipId);
+ 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 = snappedTime + movingClipDuration;
+ const movingClipEnd = finalStartTime + movingClipDuration;
const hasOverlap = track.clips.some((existingClip) => {
// Skip the clip being moved if it's on the same track
@@ -1563,7 +1713,7 @@ function TimelineTrackContent({
existingClip.trimEnd);
// Check if clips overlap
- return snappedTime < existingEnd && movingClipEnd > existingStart;
+ return finalStartTime < existingEnd && movingClipEnd > existingStart;
});
if (hasOverlap) {
@@ -1575,12 +1725,12 @@ function TimelineTrackContent({
if (fromTrackId === track.id) {
// Moving within same track
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
} else {
// Moving to different track
moveClipToTrack(fromTrackId, track.id, clipId);
requestAnimationFrame(() => {
- updateClipStartTime(track.id, clipId, snappedTime);
+ updateClipStartTime(track.id, clipId, finalStartTime);
});
}
} else if (hasMediaItem) {
@@ -1648,113 +1798,9 @@ function TimelineTrackContent({
}
};
- const getTrackColor = (type: string) => {
- switch (type) {
- case "video":
- return "bg-blue-500/20 border-blue-500/30";
- case "audio":
- return "bg-green-500/20 border-green-500/30";
- case "effects":
- return "bg-purple-500/20 border-purple-500/30";
- default:
- return "bg-gray-500/20 border-gray-500/30";
- }
- };
-
- const renderClipContent = (clip: any) => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
-
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- }
-
- if (mediaItem.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- />
- </div>
- );
- }
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="w-8 h-8 flex-shrink-0">
- <img
- src={mediaItem.thumbnailUrl}
- alt={mediaItem.name}
- className="w-full h-full object-cover rounded-sm"
- />
- </div>
- <span className="text-xs text-foreground/80 truncate flex-1">
- {clip.name}
- </span>
- </div>
- );
- }
-
- if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url}
- height={24}
- className="w-full"
- />
- </div>
- </div>
- );
- }
-
- // Fallback for videos without thumbnails
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- };
-
- const handleSplitClip = (clip: any) => {
- // Use current playback time as split point
- const splitTime = currentTime;
- // Only split if splitTime is within the clip's effective range
- const effectiveStart = clip.startTime;
- const effectiveEnd =
- clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd);
- if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return;
- const firstDuration = splitTime - effectiveStart;
- const secondDuration = effectiveEnd - splitTime;
- // First part: adjust original clip
- updateClipTrim(
- track.id,
- clip.id,
- clip.trimStart,
- clip.trimEnd + secondDuration
- );
- // Second part: add new clip after split
- addClipToTrack(track.id, {
- mediaId: clip.mediaId,
- name: clip.name + " (cut)",
- duration: clip.duration,
- startTime: splitTime,
- trimStart: clip.trimStart + firstDuration,
- trimEnd: clip.trimEnd,
- });
- };
-
return (
<div
- className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
- ? wouldOverlap
- ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
- : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
- : "hover:bg-muted/20"
- }`}
+ className="w-full h-full hover:bg-muted/20"
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
@@ -1767,23 +1813,31 @@ function TimelineTrackContent({
});
}
}}
+ onClick={(e) => {
+ // 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}
- onMouseMove={handleResizeMove}
- onMouseUp={handleResizeEnd}
- onMouseLeave={handleResizeEnd}
>
- <div className="h-full relative track-clips-container min-w-full">
+ <div
+ ref={timelineRef}
+ className="h-full relative track-clips-container min-w-full"
+ >
{track.clips.length === 0 ? (
<div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
>
{isDropping
? wouldOverlap
@@ -1794,138 +1848,26 @@ function TimelineTrackContent({
) : (
<>
{track.clips.map((clip) => {
- const effectiveDuration =
- clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(
- 80,
- effectiveDuration * 50 * zoomLevel
- );
- const clipLeft = clip.startTime * 50 * zoomLevel;
const isSelected = selectedClips.some(
(c) => c.trackId === track.id && c.clipId === clip.id
);
+
return (
- <div
+ <TimelineClip
key={clip.id}
- className={`timeline-clip absolute h-full border transition-all-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg ${isSelected ? "ring-2 ring-blue-500 z-10" : ""}`}
- style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
- onClick={(e) => {
- e.stopPropagation();
-
- // Close context menu if it's open
- if (contextMenu) {
- setContextMenu(null);
- return; // Don't handle selection when closing context menu
- }
-
- const isSelected = selectedClips.some(
- (c) => c.trackId === track.id && c.clipId === clip.id
- );
-
- if (e.metaKey || e.ctrlKey || e.shiftKey) {
- // Multi-selection mode: toggle the clip
- selectClip(track.id, clip.id, true);
- } else if (isSelected) {
- // If clip is already selected, deselect it
- deselectClip(track.id, clip.id);
- } else {
- // If clip is not selected, select it (replacing other selections)
- selectClip(track.id, clip.id, false);
- }
- }}
- tabIndex={0}
- onContextMenu={(e) => {
- e.preventDefault();
- e.stopPropagation();
- setContextMenu({
- type: "clip",
- trackId: track.id,
- clipId: clip.id,
- x: e.clientX,
- y: e.clientY,
- });
- }}
- >
- {/* Left trim handle */}
- <div
- className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
- {/* Clip content */}
- <div
- className="flex-1 cursor-grab active:cursor-grabbing relative"
- draggable={true}
- onDragStart={(e) => handleClipDragStart(e, clip)}
- onDragEnd={handleClipDragEnd}
- >
- {renderClipContent(clip)}
- {/* Clip options menu */}
- <div className="absolute top-1 right-1 z-10">
- <Button
- variant="text"
- size="icon"
- className="opacity-0 group-hover:opacity-100 transition-opacity"
- onClick={() => setClipMenuOpen(clip.id)}
- >
- <MoreVertical className="h-4 w-4" />
- </Button>
- {clipMenuOpen === clip.id && (
- <div className="absolute right-0 mt-2 w-32 bg-white text-black border rounded shadow z-50">
- <button
- className="flex items-center w-full px-3 py-2 text-sm hover:bg-muted/30"
- onClick={() => {
- handleSplitClip(clip);
- setClipMenuOpen(null);
- }}
- >
- <Scissors className="h-4 w-4 mr-2" /> Split
- </button>
- <button
- className="flex items-center w-full px-3 py-2 text-sm text-red-600 hover:bg-red-50"
- onClick={() => handleDeleteClip(clip.id)}
- >
- <Trash2 className="h-4 w-4 mr-2" /> Delete
- </button>
- </div>
- )}
- </div>
- </div>
- {/* Right trim handle */}
- <div
- className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
- </div>
+ clip={clip}
+ track={track}
+ zoomLevel={zoomLevel}
+ isSelected={isSelected}
+ onContextMenu={handleClipContextMenu}
+ onClipMouseDown={handleClipMouseDown}
+ onClipClick={handleClipClick}
+ />
);
})}
-
- {/* Drop position indicator */}
- {isDraggedOver && dropPosition !== null && (
- <div
- className={`absolute top-0 bottom-0 w-1 pointer-events-none z-30 transition-all duration-75 ease-out ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- style={{
- left: `${dropPosition * 50 * zoomLevel}px`,
- transform: "translateX(-50%)",
- }}
- >
- <div
- className={`absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 border-white shadow-md ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- />
- <div
- className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white px-1 py-0.5 rounded whitespace-nowrap ${wouldOverlap ? "bg-red-500" : "bg-blue-500"}`}
- >
- {wouldOverlap ? "⚠️" : ""}
- {dropPosition.toFixed(1)}s
- </div>
- </div>
- )}
</>
)}
</div>
</div>
);
}
-