summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/src/components/editor/timeline-toolbar.tsx219
-rw-r--r--apps/web/src/components/editor/timeline.tsx281
-rw-r--r--apps/web/src/stores/timeline-store.ts5
-rw-r--r--apps/web/src/types/timeline.ts2
4 files changed, 284 insertions, 223 deletions
diff --git a/apps/web/src/components/editor/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline-toolbar.tsx
new file mode 100644
index 00000000..51464794
--- /dev/null
+++ b/apps/web/src/components/editor/timeline-toolbar.tsx
@@ -0,0 +1,219 @@
+"use client";
+
+import type { TrackType } from "@/types/timeline";
+import {
+ ArrowLeftToLine,
+ ArrowRightToLine,
+ Copy,
+ Pause,
+ Play,
+ Scissors,
+ Snowflake,
+ SplitSquareHorizontal,
+ Trash2,
+} from "lucide-react";
+import { Button } from "../ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "../ui/tooltip";
+
+interface TimelineToolbarProps {
+ isPlaying: boolean;
+ currentTime: number;
+ duration: number;
+ speed: number;
+ tracks: any[];
+ toggle: () => void;
+ setSpeed: (speed: number) => void;
+ addTrack: (type: TrackType) => string;
+ addClipToTrack: (trackId: string, clip: any) => void;
+ handleSplitSelected: () => void;
+ handleDuplicateSelected: () => void;
+ handleFreezeSelected: () => void;
+ handleDeleteSelected: () => void;
+}
+
+export function TimelineToolbar({
+ isPlaying,
+ currentTime,
+ duration,
+ speed,
+ tracks,
+ toggle,
+ setSpeed,
+ addTrack,
+ addClipToTrack,
+ handleSplitSelected,
+ handleDuplicateSelected,
+ handleFreezeSelected,
+ handleDeleteSelected,
+}: TimelineToolbarProps) {
+ return (
+ <div className="border-b flex items-center px-2 py-1 gap-1">
+ <TooltipProvider delayDuration={500}>
+ {/* Play/Pause Button */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={toggle}
+ className="mr-2"
+ >
+ {isPlaying ? (
+ <Pause className="h-4 w-4" />
+ ) : (
+ <Play className="h-4 w-4" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ {isPlaying ? "Pause (Space)" : "Play (Space)"}
+ </TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Time Display */}
+ <div
+ className="text-xs text-muted-foreground font-mono px-2"
+ style={{ minWidth: "18ch", textAlign: "center" }}
+ >
+ {currentTime.toFixed(1)}s / {duration.toFixed(1)}s
+ </div>
+
+ {/* Test Clip Button - for debugging */}
+ {tracks.length === 0 && (
+ <>
+ <div className="w-px h-6 bg-border mx-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ const trackId = addTrack("video");
+ addClipToTrack(trackId, {
+ mediaId: "test",
+ name: "Test Clip",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ });
+ }}
+ className="text-xs"
+ >
+ Add Test Clip
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Add a test clip to try playback</TooltipContent>
+ </Tooltip>
+ </>
+ )}
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleSplitSelected}>
+ <Scissors className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split clip (S)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowLeftToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep left (A)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <ArrowRightToLine className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Split and keep right (D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon">
+ <SplitSquareHorizontal className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Separate audio (E)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="text"
+ size="icon"
+ onClick={handleDuplicateSelected}
+ >
+ <Copy className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Duplicate clip (Ctrl+D)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleFreezeSelected}>
+ <Snowflake className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Freeze frame (F)</TooltipContent>
+ </Tooltip>
+
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button variant="text" size="icon" onClick={handleDeleteSelected}>
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>Delete clip (Delete)</TooltipContent>
+ </Tooltip>
+
+ <div className="w-px h-6 bg-border mx-1" />
+
+ {/* Speed Control */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Select
+ value={speed.toFixed(1)}
+ onValueChange={(value) => setSpeed(parseFloat(value))}
+ >
+ <SelectTrigger className="w-[90px] h-8">
+ <SelectValue placeholder="1.0x" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="0.5">0.5x</SelectItem>
+ <SelectItem value="1.0">1.0x</SelectItem>
+ <SelectItem value="1.5">1.5x</SelectItem>
+ <SelectItem value="2.0">2.0x</SelectItem>
+ </SelectContent>
+ </Select>
+ </TooltipTrigger>
+ <TooltipContent>Playback Speed</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx
index ae6b98d3..4de290a7 100644
--- a/apps/web/src/components/editor/timeline.tsx
+++ b/apps/web/src/components/editor/timeline.tsx
@@ -5,14 +5,9 @@ import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineStore, type TimelineTrack } from "@/stores/timeline-store";
import {
- ArrowLeftToLine,
- ArrowRightToLine,
Copy,
MoreVertical,
- Pause,
- Play,
Scissors,
- Snowflake,
SplitSquareHorizontal,
Trash2,
Volume2,
@@ -22,23 +17,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "../ui/button";
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 { TimelineToolbar } from "./timeline-toolbar";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their clips.
@@ -71,7 +52,6 @@ export function Timeline() {
speed,
} = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
- const [isProcessing, setIsProcessing] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null);
@@ -217,7 +197,7 @@ export function Timeline() {
const bx2 = clamp(x2, 0, rect.width);
const by1 = clamp(y1, 0, rect.height);
const by2 = clamp(y2, 0, rect.height);
- let newSelection: { trackId: string; clipId: string; }[] = [];
+ let newSelection: { trackId: string; clipId: string }[] = [];
tracks.forEach((track, trackIdx) => {
track.clips.forEach((clip) => {
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
@@ -335,8 +315,6 @@ export function Timeline() {
toast.error("Failed to add media to timeline");
}
} else if (e.dataTransfer.files?.length > 0) {
- // Handle file drops by creating new tracks
- setIsProcessing(true);
try {
const processedItems = await processMediaFiles(e.dataTransfer.files);
for (const processedItem of processedItems) {
@@ -364,8 +342,6 @@ export function Timeline() {
// Show error if file processing fails
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
- } finally {
- setIsProcessing(false);
}
}
};
@@ -570,162 +546,21 @@ export function Timeline() {
onMouseLeave={() => setIsInTimeline(false)}
onWheel={handleWheel}
>
- {/* Toolbar */}
- <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>
+ <TimelineToolbar
+ isPlaying={isPlaying}
+ currentTime={currentTime}
+ duration={duration}
+ speed={speed}
+ tracks={tracks}
+ toggle={toggle}
+ setSpeed={setSpeed}
+ addTrack={addTrack}
+ addClipToTrack={addClipToTrack}
+ handleSplitSelected={handleSplitSelected}
+ handleDuplicateSelected={handleDuplicateSelected}
+ handleFreezeSelected={handleFreezeSelected}
+ handleDeleteSelected={handleDeleteSelected}
+ />
{/* Timeline Container */}
<div className="flex-1 flex flex-col overflow-hidden">
@@ -781,17 +616,19 @@ export function Timeline() {
return (
<div
key={i}
- className={`absolute top-0 bottom-0 ${isMainMarker
- ? "border-l border-muted-foreground/40"
- : "border-l border-muted-foreground/20"
- }`}
+ className={`absolute top-0 bottom-0 ${
+ isMainMarker
+ ? "border-l border-muted-foreground/40"
+ : "border-l border-muted-foreground/20"
+ }`}
style={{ left: `${time * 50 * zoomLevel}px` }}
>
<span
- className={`absolute top-1 left-1 text-xs ${isMainMarker
- ? "text-muted-foreground font-medium"
- : "text-muted-foreground/70"
- }`}
+ className={`absolute top-1 left-1 text-xs ${
+ isMainMarker
+ ? "text-muted-foreground font-medium"
+ : "text-muted-foreground/70"
+ }`}
>
{(() => {
const formatTime = (seconds: number) => {
@@ -852,12 +689,13 @@ export function Timeline() {
>
<div className="flex items-center flex-1 min-w-0">
<div
- className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === "video"
- ? "bg-blue-500"
- : track.type === "audio"
- ? "bg-green-500"
- : "bg-purple-500"
- }`}
+ className={`w-3 h-3 rounded-full flex-shrink-0 ${
+ track.type === "video"
+ ? "bg-blue-500"
+ : track.type === "audio"
+ ? "bg-green-500"
+ : "bg-purple-500"
+ }`}
/>
<span className="ml-2 text-sm font-medium truncate">
{track.name}
@@ -1197,7 +1035,7 @@ function TimelineTrackContent({
});
};
- const updateTrimFromMouseMove = (e: { clientX: number; }) => {
+ const updateTrimFromMouseMove = (e: { clientX: number }) => {
if (!resizing) return;
const clip = track.clips.find((c) => c.id === resizing.clipId);
@@ -1632,18 +1470,18 @@ function TimelineTrackContent({
}
if (mediaItem.type === "audio") {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="flex-1 min-w-0">
- <AudioWaveform
- audioUrl={mediaItem.url}
- height={24}
- className="w-full"
- />
+ return (
+ <div className="w-full h-full flex items-center gap-2">
+ <div className="flex-1 min-w-0">
+ <AudioWaveform
+ audioUrl={mediaItem.url}
+ height={24}
+ className="w-full"
+ />
+ </div>
</div>
- </div>
- );
- }
+ );
+ }
// Fallback for videos without thumbnails
return (
@@ -1681,12 +1519,13 @@ function TimelineTrackContent({
return (
<div
- className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
- ? wouldOverlap
- ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
- : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
- : "hover:bg-muted/20"
- }`}
+ className={`w-full h-full transition-all duration-150 ease-out ${
+ isDraggedOver
+ ? wouldOverlap
+ ? "bg-red-500/15 border-2 border-dashed border-red-400 shadow-lg"
+ : "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
+ : "hover:bg-muted/20"
+ }`}
onContextMenu={(e) => {
e.preventDefault();
// Only show track menu if we didn't click on a clip
@@ -1710,12 +1549,13 @@ function TimelineTrackContent({
<div className="h-full relative track-clips-container min-w-full">
{track.clips.length === 0 ? (
<div
- className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping
- ? wouldOverlap
- ? "border-red-500 bg-red-500/10 text-red-600"
- : "border-blue-500 bg-blue-500/10 text-blue-600"
- : "border-muted/30"
- }`}
+ className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${
+ isDropping
+ ? wouldOverlap
+ ? "border-red-500 bg-red-500/10 text-red-600"
+ : "border-blue-500 bg-blue-500/10 text-blue-600"
+ : "border-muted/30"
+ }`}
>
{isDropping
? wouldOverlap
@@ -1860,4 +1700,3 @@ function TimelineTrackContent({
</div>
);
}
-
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 3b068d7c..4018c301 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -1,4 +1,5 @@
import { create } from "zustand";
+import type { TrackType } from "@/types/timeline";
export interface TimelineClip {
id: string;
@@ -13,7 +14,7 @@ export interface TimelineClip {
export interface TimelineTrack {
id: string;
name: string;
- type: "video" | "audio" | "effects";
+ type: TrackType;
clips: TimelineClip[];
muted?: boolean;
}
@@ -52,7 +53,7 @@ interface TimelineStore {
endDrag: () => void;
// Actions
- addTrack: (type: "video" | "audio" | "effects") => string;
+ addTrack: (type: TrackType) => string;
removeTrack: (trackId: string) => void;
addClipToTrack: (trackId: string, clip: Omit<TimelineClip, "id">) => void;
removeClipFromTrack: (trackId: string, clipId: string) => void;
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 21be520e..7fafc187 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,5 +1,7 @@
import { TimelineTrack, TimelineClip } from "@/stores/timeline-store";
+export type TrackType = "video" | "audio" | "effects";
+
export interface TimelineClipProps {
clip: TimelineClip;
track: TimelineTrack;