diff options
| author | iza <59828082+izadoesdev@users.noreply.github.com> | 2025-06-27 07:02:28 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-27 07:02:28 +0300 |
| commit | f53f2802ebeda4e4ae71f42e6f92b1a50dee8515 (patch) | |
| tree | 4277024b083849cb55d824596d2e4564c463b982 /apps/web | |
| parent | 87e90a5e24b6fbfe37be7d057617411071b56abf (diff) | |
| parent | c32daa4f2ec967902f2ce12d291f8034ca8bce38 (diff) | |
| download | opencut-f53f2802ebeda4e4ae71f42e6f92b1a50dee8515.tar.gz | |
Merge pull request #135 from DevloperAmanSingh/feature/player-controls
Feature: More Players Controls are added now .
Diffstat (limited to 'apps/web')
| -rw-r--r-- | apps/web/src/components/editor/timeline-clip.tsx | 266 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 124 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-playback-controls.ts | 182 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 234 |
4 files changed, 681 insertions, 125 deletions
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.tsx b/apps/web/src/components/editor/timeline.tsx index 93f722b7..f4c2473c 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -58,6 +58,10 @@ export function Timeline() { clearSelectedClips,
setSelectedClips,
updateClipTrim,
+ splitClip,
+ splitAndKeepLeft,
+ splitAndKeepRight,
+ separateAudio,
undo,
redo,
} = useTimelineStore();
@@ -455,33 +459,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 = () => {
@@ -531,7 +528,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");
@@ -644,34 +720,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>
diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index 5742e272..2c689470 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -1,18 +1,174 @@ -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { toast } from "sonner"; -export function usePlaybackControls() { - const { toggle } = usePlaybackStore(); +export const usePlaybackControls = () => { + const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "Space" && e.target === document.body) { - e.preventDefault(); - toggle(); + const { + selectedClips, + tracks, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); + + const handleSplitSelectedClip = useCallback(() => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip to split"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + 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 selected clip"); + return; + } + + splitClip(trackId, clipId, currentTime); + toast.success("Clip split at playhead"); + }, [selectedClips, tracks, currentTime, splitClip]); + + const handleSplitAndKeepLeftCallback = useCallback(() => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + 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 selected clip"); + return; + } + + splitAndKeepLeft(trackId, clipId, currentTime); + toast.success("Split and kept left portion"); + }, [selectedClips, tracks, currentTime, splitAndKeepLeft]); + + const handleSplitAndKeepRightCallback = useCallback(() => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + 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 selected clip"); + return; + } + + splitAndKeepRight(trackId, clipId, currentTime); + toast.success("Split and kept right portion"); + }, [selectedClips, tracks, currentTime, splitAndKeepRight]); + + const handleSeparateAudioCallback = useCallback(() => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one video clip to separate audio"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + + if (!track || track.type !== "video") { + toast.error("Select a video clip to separate audio"); + return; + } + + separateAudio(trackId, clipId); + toast.success("Audio separated to audio track"); + }, [selectedClips, tracks, separateAudio]); + + const handleKeyPress = useCallback( + (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [toggle]); -}
\ No newline at end of file + switch (e.key) { + case " ": + e.preventDefault(); + if (isPlaying) { + pause(); + } else { + play(); + } + break; + + case "s": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitSelectedClip(); + } + break; + + case "q": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitAndKeepLeftCallback(); + } + break; + + case "w": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitAndKeepRightCallback(); + } + break; + + case "d": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSeparateAudioCallback(); + } + break; + } + }, + [ + isPlaying, + play, + pause, + handleSplitSelectedClip, + handleSplitAndKeepLeftCallback, + handleSplitAndKeepRightCallback, + handleSeparateAudioCallback, + ] + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyPress); + return () => document.removeEventListener("keydown", handleKeyPress); + }, [handleKeyPress]); +}; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 4018c301..73628497 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -1,6 +1,21 @@ import { create } from "zustand"; import type { TrackType } from "@/types/timeline"; +// Helper function to manage clip naming with suffixes +const getClipNameWithSuffix = ( + originalName: string, + suffix: string +): string => { + // Remove existing suffixes to prevent accumulation + const baseName = originalName + .replace(/ \(left\)$/, "") + .replace(/ \(right\)$/, "") + .replace(/ \(audio\)$/, "") + .replace(/ \(split \d+\)$/, ""); + + return `${baseName} (${suffix})`; +}; + export interface TimelineClip { id: string; mediaId: string; @@ -75,10 +90,28 @@ interface TimelineStore { ) => void; toggleTrackMute: (trackId: string) => void; + // Split operations for clips + splitClip: ( + trackId: string, + clipId: string, + splitTime: number + ) => string | null; + splitAndKeepLeft: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + splitAndKeepRight: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + separateAudio: (trackId: string, clipId: string) => string | null; + // Computed values getTotalDuration: () => number; - // New actions + // History actions undo: () => void; redo: () => void; pushHistory: () => void; @@ -92,10 +125,9 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ pushHistory: () => { const { tracks, history, redoStack } = get(); - // Deep copy tracks set({ history: [...history, JSON.parse(JSON.stringify(tracks))], - redoStack: [], // Clear redo stack when new action is performed + redoStack: [], }); }, @@ -106,7 +138,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ set({ tracks: prev, history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], }); }, @@ -116,7 +148,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ (c) => c.trackId === trackId && c.clipId === clipId ); if (multi) { - // Toggle selection return exists ? { selectedClips: state.selectedClips.filter( @@ -129,6 +160,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } }); }, + deselectClip: (trackId, clipId) => { set((state) => ({ selectedClips: state.selectedClips.filter( @@ -136,6 +168,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ ), })); }, + clearSelectedClips: () => { set({ selectedClips: [] }); }, @@ -195,7 +228,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } : track ) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), })); }, @@ -224,7 +256,6 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ } return track; }) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), }; }); @@ -271,6 +302,195 @@ export const useTimelineStore = create<TimelineStore>((set, get) => ({ })); }, + splitClip: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return null; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const firstDuration = relativeTime; + const secondDuration = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + const secondClipId = crypto.randomUUID(); + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.flatMap((c) => + c.id === clipId + ? [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: getClipNameWithSuffix(c.name, "left"), + }, + { + ...c, + id: secondClipId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: getClipNameWithSuffix(c.name, "right"), + }, + ] + : [c] + ), + } + : track + ), + })); + + return secondClipId; + }, + + // Split clip and keep only the left portion + splitAndKeepLeft: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const durationToRemove = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + trimEnd: c.trimEnd + durationToRemove, + name: getClipNameWithSuffix(c.name, "left"), + } + : c + ), + } + : track + ), + })); + }, + + // Split clip and keep only the right portion + splitAndKeepRight: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + startTime: splitTime, + trimStart: c.trimStart + relativeTime, + name: getClipNameWithSuffix(c.name, "right"), + } + : c + ), + } + : track + ), + })); + }, + + // Extract audio from video clip to an audio track + separateAudio: (trackId, clipId) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip || track?.type !== "video") return null; + + get().pushHistory(); + + // Find existing audio track or prepare to create one + const existingAudioTrack = tracks.find((t) => t.type === "audio"); + const audioClipId = crypto.randomUUID(); + + if (existingAudioTrack) { + // Add audio clip to existing audio track + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === existingAudioTrack.id + ? { + ...track, + clips: [ + ...track.clips, + { + ...clip, + id: audioClipId, + name: getClipNameWithSuffix(clip.name, "audio"), + }, + ], + } + : track + ), + })); + } else { + // Create new audio track with the audio clip in a single atomic update + const newAudioTrack: TimelineTrack = { + id: crypto.randomUUID(), + name: "Audio Track", + type: "audio", + clips: [ + { + ...clip, + id: audioClipId, + name: getClipNameWithSuffix(clip.name, "audio"), + }, + ], + muted: false, + }; + + set((state) => ({ + tracks: [...state.tracks, newAudioTrack], + })); + } + + return audioClipId; + }, + getTotalDuration: () => { const { tracks } = get(); if (tracks.length === 0) return 0; |
