summaryrefslogtreecommitdiff
path: root/apps/web/src/components/editor/timeline-clip.tsx
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-06 20:45:29 +0200
commit40c7fbb4f86130c83fbd9029e83c5e7550e3219d (patch)
tree054d918d282358c084f4fc08462913833de644a5 /apps/web/src/components/editor/timeline-clip.tsx
parent0e32c732ddc335d35e6a7f8547f4af68bfd9c369 (diff)
downloadopencut-40c7fbb4f86130c83fbd9029e83c5e7550e3219d.tar.gz
refactor: move to a typed-tracks system and add support for text
Diffstat (limited to 'apps/web/src/components/editor/timeline-clip.tsx')
-rw-r--r--apps/web/src/components/editor/timeline-clip.tsx385
1 files changed, 0 insertions, 385 deletions
diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx
deleted file mode 100644
index ddc58f8d..00000000
--- a/apps/web/src/components/editor/timeline-clip.tsx
+++ /dev/null
@@ -1,385 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { Button } from "../ui/button";
-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 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,
- track,
- zoomLevel,
- isSelected,
- onClipMouseDown,
- onClipClick,
-}: TimelineClipProps) {
- const { mediaItems } = useMediaStore();
- const {
- updateClipTrim,
- addClipToTrack,
- removeClipFromTrack,
- dragState,
- splitClip,
- splitAndKeepLeft,
- splitAndKeepRight,
- separateAudio,
- } = useTimelineStore();
- const { currentTime } = usePlaybackStore();
-
- const [resizing, setResizing] = useState<ResizeState | null>(null);
- const [clipMenuOpen, setClipMenuOpen] = useState(false);
-
- const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
- const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
-
- // Use real-time position during drag, otherwise use stored position
- const isBeingDragged = dragState.clipId === clip.id;
- const clipStartTime =
- isBeingDragged && dragState.isDragging
- ? dragState.currentTime
- : clip.startTime;
- const clipLeft = clipStartTime * 50 * zoomLevel;
-
- const getTrackColor = (type: string) => {
- switch (type) {
- case "video":
- return "bg-blue-500/20 border-blue-500/30";
- case "audio":
- return "bg-green-500/20 border-green-500/30";
- case "effects":
- return "bg-purple-500/20 border-purple-500/30";
- default:
- return "bg-gray-500/20 border-gray-500/30";
- }
- };
-
- // Resize handles for trimming clips
- const handleResizeStart = (
- e: React.MouseEvent,
- clipId: string,
- side: "left" | "right"
- ) => {
- e.stopPropagation();
- e.preventDefault();
-
- setResizing({
- clipId,
- side,
- startX: e.clientX,
- initialTrimStart: clip.trimStart,
- initialTrimEnd: clip.trimEnd,
- });
- };
-
- const updateTrimFromMouseMove = (e: { clientX: number }) => {
- if (!resizing) return;
-
- const deltaX = e.clientX - resizing.startX;
- const deltaTime = deltaX / (50 * zoomLevel);
-
- if (resizing.side === "left") {
- const newTrimStart = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimEnd - 0.1,
- resizing.initialTrimStart + deltaTime
- )
- );
- updateClipTrim(track.id, clip.id, newTrimStart, clip.trimEnd);
- } else {
- const newTrimEnd = Math.max(
- 0,
- Math.min(
- clip.duration - clip.trimStart - 0.1,
- resizing.initialTrimEnd - deltaTime
- )
- );
- updateClipTrim(track.id, clip.id, clip.trimStart, newTrimEnd);
- }
- };
-
- const handleResizeMove = (e: React.MouseEvent) => {
- updateTrimFromMouseMove(e);
- };
-
- const handleResizeEnd = () => {
- setResizing(null);
- };
-
- const handleDeleteClip = () => {
- removeClipFromTrack(track.id, clip.id);
- setClipMenuOpen(false);
- toast.success("Clip deleted");
- };
-
- const handleSplitClip = () => {
- 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 to split");
- return;
- }
-
- const secondClipId = splitClip(track.id, clip.id, currentTime);
- if (secondClipId) {
- toast.success("Clip split successfully");
- } else {
- toast.error("Failed to split clip");
- }
- setClipMenuOpen(false);
- };
-
- 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 = () => {
- const mediaItem = mediaItems.find((item) => item.id === clip.mediaId);
-
- if (!mediaItem) {
- return (
- <span className="text-xs text-foreground/80 truncate">{clip.name}</span>
- );
- }
-
- if (mediaItem.type === "image") {
- return (
- <div className="w-full h-full flex items-center justify-center">
- <img
- src={mediaItem.url}
- alt={mediaItem.name}
- className="w-full h-full object-cover"
- draggable={false}
- />
- </div>
- );
- }
-
- if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
- return (
- <div className="w-full h-full flex items-center gap-2">
- <div className="w-8 h-8 flex-shrink-0">
- <img
- src={mediaItem.thumbnailUrl}
- alt={mediaItem.name}
- className="w-full h-full object-cover rounded-sm"
- draggable={false}
- />
- </div>
- <span className="text-xs text-foreground/80 truncate flex-1">
- {clip.name}
- </span>
- </div>
- );
- }
-
- 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"
- />
- </div>
- </div>
- );
- }
-
- 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={`absolute top-0 h-full select-none transition-all duration-75${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- style={{
- left: `${clipLeft}px`,
- width: `${clipWidth}px`,
- }}
- onMouseMove={resizing ? handleResizeMove : undefined}
- onMouseUp={resizing ? handleResizeEnd : undefined}
- onMouseLeave={resizing ? handleResizeEnd : undefined}
- >
- <div
- className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackColor(
- track.type
- )} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
- isBeingDragged ? "z-50" : "z-10"
- }`}
- onClick={(e) => onClipClick && onClipClick(e, clip)}
- onMouseDown={handleClipMouseDown}
- onContextMenu={(e) => onClipMouseDown && onClipMouseDown(e, clip)}
- >
- <div className="absolute inset-1 flex items-center p-1">
- {renderClipContent()}
- </div>
-
- {isSelected && (
- <>
- <div
- className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "left")}
- />
- <div
- className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
- onMouseDown={(e) => handleResizeStart(e, clip.id, "right")}
- />
- </>
- )}
-
- <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);
- }}
- >
- <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="mr-2 h-4 w-4" />
- Delete Clip
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- </div>
- );
-}