"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, onContextMenu, onClipMouseDown, onClipClick, }: TimelineClipProps) { const { mediaItems } = useMediaStore(); const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState, splitClip, splitAndKeepLeft, splitAndKeepRight, separateAudio, } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [resizing, setResizing] = useState(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 ( {clip.name} ); } if (mediaItem.type === "image") { return (
{mediaItem.name}
); } if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { return (
{mediaItem.name}
{clip.name}
); } if (mediaItem.type === "audio") { return (
); } return ( {clip.name} ); }; const handleClipMouseDown = (e: React.MouseEvent) => { if (onClipMouseDown) { onClipMouseDown(e, clip); } }; return (
onClipClick && onClipClick(e, clip)} onMouseDown={handleClipMouseDown} onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} >
{renderClipContent()}
handleResizeStart(e, clip.id, "left")} />
handleResizeStart(e, clip.id, "right")} />
{/* Split operations - only available when playhead is within clip */} Split Split at Playhead Split and Keep Left Split and Keep Right {/* Audio separation - only available for video clips */} {canSeparateAudio() && ( <> Separate Audio )} Delete Clip
); }