Merge pull request #135 from DevloperAmanSingh/feature/player-controls
Feature: More Players Controls are added now .
This commit is contained in:
@ -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;
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
const secondClipId = splitClip(track.id, clip.id, currentTime);
|
||||
if (secondClipId) {
|
||||
toast.success("Clip split successfully");
|
||||
} else {
|
||||
toast.error("Failed to split clip");
|
||||
}
|
||||
setClipMenuOpen(false);
|
||||
toast.success("Clip split successfully");
|
||||
};
|
||||
|
||||
const handleSplitAndKeepLeft = () => {
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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")}
|
||||
/>
|
||||
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 content */}
|
||||
<div className="flex-1 relative">
|
||||
{renderClipContent()}
|
||||
<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")}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space" && e.target === document.body) {
|
||||
e.preventDefault();
|
||||
toggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggle]);
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyPress);
|
||||
return () => document.removeEventListener("keydown", handleKeyPress);
|
||||
}, [handleKeyPress]);
|
||||
};
|
||||
|
@ -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;
|
||||
|
Reference in New Issue
Block a user