summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoriza <59828082+izadoesdev@users.noreply.github.com>2025-06-27 07:02:28 +0300
committerGitHub <noreply@github.com>2025-06-27 07:02:28 +0300
commitf53f2802ebeda4e4ae71f42e6f92b1a50dee8515 (patch)
tree4277024b083849cb55d824596d2e4564c463b982
parent87e90a5e24b6fbfe37be7d057617411071b56abf (diff)
parentc32daa4f2ec967902f2ce12d291f8034ca8bce38 (diff)
downloadopencut-f53f2802ebeda4e4ae71f42e6f92b1a50dee8515.tar.gz
Merge pull request #135 from DevloperAmanSingh/feature/player-controls
Feature: More Players Controls are added now .
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx266
-rw-r--r--apps/web/src/components/editor/timeline.tsx124
-rw-r--r--apps/web/src/hooks/use-playback-controls.ts182
-rw-r--r--apps/web/src/stores/timeline-store.ts234
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;