refactor: separate timeline toolbar into new component and add new TrackType type

This commit is contained in:
Maze Winther
2025-06-27 01:02:01 +02:00
parent 679ebc02b5
commit dfde7592bb
4 changed files with 284 additions and 223 deletions

View File

@ -0,0 +1,219 @@
"use client";
import type { TrackType } from "@/types/timeline";
import {
ArrowLeftToLine,
ArrowRightToLine,
Copy,
Pause,
Play,
Scissors,
Snowflake,
SplitSquareHorizontal,
Trash2,
} from "lucide-react";
import { Button } from "../ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
interface TimelineToolbarProps {
isPlaying: boolean;
currentTime: number;
duration: number;
speed: number;
tracks: any[];
toggle: () => void;
setSpeed: (speed: number) => void;
addTrack: (type: TrackType) => string;
addClipToTrack: (trackId: string, clip: any) => void;
handleSplitSelected: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
isPlaying,
currentTime,
duration,
speed,
tracks,
toggle,
setSpeed,
addTrack,
addClipToTrack,
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
return (
<div className="border-b flex items-center px-2 py-1 gap-1">
<TooltipProvider delayDuration={500}>
{/* Play/Pause Button */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={toggle}
className="mr-2"
>
{isPlaying ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div
className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: "18ch", textAlign: "center" }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}

View File

@ -5,14 +5,9 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store"; import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import { import {
ArrowLeftToLine,
ArrowRightToLine,
Copy, Copy,
MoreVertical, MoreVertical,
Pause,
Play,
Scissors, Scissors,
Snowflake,
SplitSquareHorizontal, SplitSquareHorizontal,
Trash2, Trash2,
Volume2, Volume2,
@ -22,23 +17,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "../ui/button"; import { Button } from "../ui/button";
import { ScrollArea } from "../ui/scroll-area"; import { ScrollArea } from "../ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import AudioWaveform from "./audio-waveform"; import AudioWaveform from "./audio-waveform";
import { TimelineToolbar } from "./timeline-toolbar";
export function Timeline() { export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips. // Timeline shows all tracks (video, audio, effects) and their clips.
@ -71,7 +52,6 @@ export function Timeline() {
speed, speed,
} = usePlaybackStore(); } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1); const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
@ -217,7 +197,7 @@ export function Timeline() {
const bx2 = clamp(x2, 0, rect.width); const bx2 = clamp(x2, 0, rect.width);
const by1 = clamp(y1, 0, rect.height); const by1 = clamp(y1, 0, rect.height);
const by2 = clamp(y2, 0, rect.height); const by2 = clamp(y2, 0, rect.height);
let newSelection: { trackId: string; clipId: string; }[] = []; let newSelection: { trackId: string; clipId: string }[] = [];
tracks.forEach((track, trackIdx) => { tracks.forEach((track, trackIdx) => {
track.clips.forEach((clip) => { track.clips.forEach((clip) => {
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
@ -335,8 +315,6 @@ export function Timeline() {
toast.error("Failed to add media to timeline"); toast.error("Failed to add media to timeline");
} }
} else if (e.dataTransfer.files?.length > 0) { } else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
setIsProcessing(true);
try { try {
const processedItems = await processMediaFiles(e.dataTransfer.files); const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) { for (const processedItem of processedItems) {
@ -364,8 +342,6 @@ export function Timeline() {
// Show error if file processing fails // Show error if file processing fails
console.error("Error processing external files:", error); console.error("Error processing external files:", error);
toast.error("Failed to process dropped files"); toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
} }
} }
}; };
@ -570,162 +546,21 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)} onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel} onWheel={handleWheel}
> >
{/* Toolbar */} <TimelineToolbar
<div className="border-b flex items-center px-2 py-1 gap-1"> isPlaying={isPlaying}
<TooltipProvider delayDuration={500}> currentTime={currentTime}
{/* Play/Pause Button */} duration={duration}
<Tooltip> speed={speed}
<TooltipTrigger asChild> tracks={tracks}
<Button toggle={toggle}
variant="text" setSpeed={setSpeed}
size="icon" addTrack={addTrack}
onClick={toggle} addClipToTrack={addClipToTrack}
className="mr-2" handleSplitSelected={handleSplitSelected}
> handleDuplicateSelected={handleDuplicateSelected}
{isPlaying ? ( handleFreezeSelected={handleFreezeSelected}
<Pause className="h-4 w-4" /> handleDeleteSelected={handleDeleteSelected}
) : ( />
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{isPlaying ? "Pause (Space)" : "Play (Space)"}
</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Time Display */}
<div className="text-xs text-muted-foreground font-mono px-2"
style={{ minWidth: '18ch', textAlign: 'center' }}
>
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
</div>
{/* Test Clip Button - for debugging */}
{tracks.length === 0 && (
<>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => {
const trackId = addTrack("video");
addClipToTrack(trackId, {
mediaId: "test",
name: "Test Clip",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
});
}}
className="text-xs"
>
Add Test Clip
</Button>
</TooltipTrigger>
<TooltipContent>Add a test clip to try playback</TooltipContent>
</Tooltip>
</>
)}
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSplitSelected}>
<Scissors className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split clip (S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (A)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon">
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (E)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleFreezeSelected}>
<Snowflake className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Freeze frame (F)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete clip (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
{/* Speed Control */}
<Tooltip>
<TooltipTrigger asChild>
<Select
value={speed.toFixed(1)}
onValueChange={(value) => setSpeed(parseFloat(value))}
>
<SelectTrigger className="w-[90px] h-8">
<SelectValue placeholder="1.0x" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.5">0.5x</SelectItem>
<SelectItem value="1.0">1.0x</SelectItem>
<SelectItem value="1.5">1.5x</SelectItem>
<SelectItem value="2.0">2.0x</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>Playback Speed</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Timeline Container */} {/* Timeline Container */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex-1 flex flex-col overflow-hidden">
@ -781,14 +616,16 @@ export function Timeline() {
return ( return (
<div <div
key={i} key={i}
className={`absolute top-0 bottom-0 ${isMainMarker className={`absolute top-0 bottom-0 ${
isMainMarker
? "border-l border-muted-foreground/40" ? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20" : "border-l border-muted-foreground/20"
}`} }`}
style={{ left: `${time * 50 * zoomLevel}px` }} style={{ left: `${time * 50 * zoomLevel}px` }}
> >
<span <span
className={`absolute top-1 left-1 text-xs ${isMainMarker className={`absolute top-1 left-1 text-xs ${
isMainMarker
? "text-muted-foreground font-medium" ? "text-muted-foreground font-medium"
: "text-muted-foreground/70" : "text-muted-foreground/70"
}`} }`}
@ -852,7 +689,8 @@ export function Timeline() {
> >
<div className="flex items-center flex-1 min-w-0"> <div className="flex items-center flex-1 min-w-0">
<div <div
className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === "video" className={`w-3 h-3 rounded-full flex-shrink-0 ${
track.type === "video"
? "bg-blue-500" ? "bg-blue-500"
: track.type === "audio" : track.type === "audio"
? "bg-green-500" ? "bg-green-500"
@ -1197,7 +1035,7 @@ function TimelineTrackContent({
}); });
}; };
const updateTrimFromMouseMove = (e: { clientX: number; }) => { const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return; if (!resizing) return;
const clip = track.clips.find((c) => c.id === resizing.clipId); const clip = track.clips.find((c) => c.id === resizing.clipId);
@ -1681,7 +1519,8 @@ function TimelineTrackContent({
return ( return (
<div <div
className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver className={`w-full h-full transition-all duration-150 ease-out ${
isDraggedOver
? wouldOverlap ? wouldOverlap
? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg" ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
: "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg" : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
@ -1710,7 +1549,8 @@ function TimelineTrackContent({
<div className="h-full relative track-clips-container min-w-full"> <div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? ( {track.clips.length === 0 ? (
<div <div
className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
isDropping
? wouldOverlap ? wouldOverlap
? "border-red-500 bg-red-500/10 text-red-600" ? "border-red-500 bg-red-500/10 text-red-600"
: "border-blue-500 bg-blue-500/10 text-blue-600" : "border-blue-500 bg-blue-500/10 text-blue-600"
@ -1860,4 +1700,3 @@ function TimelineTrackContent({
</div> </div>
); );
} }

View File

@ -1,4 +1,5 @@
import { create } from "zustand"; import { create } from "zustand";
import type { TrackType } from "@/types/timeline";
export interface TimelineClip { export interface TimelineClip {
id: string; id: string;
@ -13,7 +14,7 @@ export interface TimelineClip {
export interface TimelineTrack { export interface TimelineTrack {
id: string; id: string;
name: string; name: string;
type: "video" | "audio" | "effects"; type: TrackType;
clips: TimelineClip[]; clips: TimelineClip[];
muted?: boolean; muted?: boolean;
} }
@ -52,7 +53,7 @@ interface TimelineStore {
endDrag: () => void; endDrag: () => void;
// Actions // Actions
addTrack: (type: "video" | "audio" | "effects") => string; addTrack: (type: TrackType) => string;
removeTrack: (trackId: string) => void; removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void; addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void; removeClipFromTrack: (trackId: string, clipId: string) => void;

View File

@ -1,5 +1,7 @@
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store"; import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
export type TrackType = "video" | "audio" | "effects";
export interface TimelineClipProps { export interface TimelineClipProps {
clip: TimelineClip; clip: TimelineClip;
track: TimelineTrack; track: TimelineTrack;