diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 71b2cd26..8cccd0e4 100644 --- a/apps/web/src/components/editor/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline-element.tsx @@ -1,470 +1,470 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "../ui/button"; -import { - MoreVertical, - Scissors, - Trash2, - SplitSquareHorizontal, - Music, - ChevronRight, - ChevronLeft, - Type, - Copy, - RefreshCw, -} 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 { TimelineElementProps, TrackType } from "@/types/timeline"; -import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; -import { - getTrackElementClasses, - TIMELINE_CONSTANTS, - getTrackHeight, -} from "@/constants/timeline-constants"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, -} from "../ui/dropdown-menu"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "../ui/context-menu"; - -export function TimelineElement({ - element, - track, - zoomLevel, - isSelected, - onElementMouseDown, - onElementClick, -}: TimelineElementProps) { - const { mediaItems } = useMediaStore(); - const { - updateElementTrim, - updateElementDuration, - removeElementFromTrack, - dragState, - splitElement, - splitAndKeepLeft, - splitAndKeepRight, - separateAudio, - addElementToTrack, - replaceElementMedia, - } = useTimelineStore(); - const { currentTime } = usePlaybackStore(); - - const [elementMenuOpen, setElementMenuOpen] = useState(false); - - const { - resizing, - isResizing, - handleResizeStart, - handleResizeMove, - handleResizeEnd, - } = useTimelineElementResize({ - element, - track, - zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, - }); - - const effectiveDuration = - element.duration - element.trimStart - element.trimEnd; - const elementWidth = Math.max( - TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, - effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel - ); - - // Use real-time position during drag, otherwise use stored position - const isBeingDragged = dragState.elementId === element.id; - const elementStartTime = - isBeingDragged && dragState.isDragging - ? dragState.currentTime - : element.startTime; - const elementLeft = elementStartTime * 50 * zoomLevel; - - const handleDeleteElement = () => { - removeElementFromTrack(track.id, element.id); - setElementMenuOpen(false); - }; - - const handleSplitElement = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within element to split"); - return; - } - - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - setElementMenuOpen(false); - }; - - const handleSplitAndKeepLeft = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within element"); - return; - } - - splitAndKeepLeft(track.id, element.id, currentTime); - setElementMenuOpen(false); - }; - - const handleSplitAndKeepRight = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { - toast.error("Playhead must be within element"); - return; - } - - splitAndKeepRight(track.id, element.id, currentTime); - setElementMenuOpen(false); - }; - - const handleSeparateAudio = () => { - if (element.type !== "media") { - toast.error("Audio separation only available for media elements"); - return; - } - - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem || mediaItem.type !== "video") { - toast.error("Audio separation only available for video elements"); - return; - } - - const audioElementId = separateAudio(track.id, element.id); - if (!audioElementId) { - toast.error("Failed to separate audio"); - } - setElementMenuOpen(false); - }; - - const canSplitAtPlayhead = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - return currentTime > effectiveStart && currentTime < effectiveEnd; - }; - - const canSeparateAudio = () => { - if (element.type !== "media") return false; - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - return mediaItem?.type === "video" && track.type === "media"; - }; - - const handleElementSplitContext = () => { - const effectiveStart = element.startTime; - const effectiveEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - - if (currentTime > effectiveStart && currentTime < effectiveEnd) { - const secondElementId = splitElement(track.id, element.id, currentTime); - if (!secondElementId) { - toast.error("Failed to split element"); - } - } else { - toast.error("Playhead must be within element to split"); - } - }; - - const handleElementDuplicateContext = () => { - const { id, ...elementWithoutId } = element; - addElementToTrack(track.id, { - ...elementWithoutId, - name: element.name + " (copy)", - startTime: - element.startTime + - (element.duration - element.trimStart - element.trimEnd) + - 0.1, - }); - }; - - const handleElementDeleteContext = () => { - removeElementFromTrack(track.id, element.id); - }; - - const handleReplaceClip = () => { - if (element.type !== "media") { - toast.error("Replace is only available for media clips"); - return; - } - - // Create a file input to select replacement media - const input = document.createElement("input"); - input.type = "file"; - input.accept = "video/*,audio/*,image/*"; - input.onchange = async (e) => { - const file = (e.target as HTMLInputElement).files?.[0]; - if (!file) return; - - try { - const success = await replaceElementMedia(track.id, element.id, file); - if (success) { - toast.success("Clip replaced successfully"); - } else { - toast.error("Failed to replace clip"); - } - } catch (error) { - toast.error("Failed to replace clip"); - console.log( - JSON.stringify({ error: "Failed to replace clip", details: error }) - ); - } - }; - input.click(); - }; - - const renderElementContent = () => { - if (element.type === "text") { - return ( -
- - {element.content} - -
- ); - } - - // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) { - return ( - - {element.name} - - ); - } - - const TILE_ASPECT_RATIO = 16 / 9; - - if (mediaItem.type === "image") { - // Calculate tile size based on 16:9 aspect ratio - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - 8; // Account for padding - const tileWidth = tileHeight * TILE_ASPECT_RATIO; - - return ( -
-
- {/* Background with tiled images */} -
- {/* Overlay with vertical borders */} -
-
-
- ); - } - - const VIDEO_TILE_PADDING = 16; - const OVERLAY_SPACE_MULTIPLIER = 1.5; - - if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { - const trackHeight = getTrackHeight(track.type); - const tileHeight = trackHeight - VIDEO_TILE_PADDING; - const tileWidth = tileHeight * TILE_ASPECT_RATIO; - - return ( -
-
- {/* Background with tiled thumbnails */} -
- {/* Overlay with vertical borders */} -
-
- {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( -
- {element.name} -
- ) : ( - - {element.name} - - )} -
- ); - } - - // Render audio element -> - if (mediaItem.type === "audio") { - return ( -
-
- -
-
- ); - } - - return ( - - {element.name} - - ); - }; - - const handleElementMouseDown = (e: React.MouseEvent) => { - if (onElementMouseDown) { - onElementMouseDown(e, element); - } - }; - - return ( - - -
-
onElementClick && onElementClick(e, element)} - onMouseDown={handleElementMouseDown} - onContextMenu={(e) => - onElementMouseDown && onElementMouseDown(e, element) - } - > -
- {renderElementContent()} -
- - {isSelected && ( - <> -
handleResizeStart(e, element.id, "left")} - /> -
handleResizeStart(e, element.id, "right")} - /> - - )} -
-
- - - - - Split at playhead - - - - Duplicate {element.type === "text" ? "text" : "clip"} - - {element.type === "media" && ( - - - Replace clip - - )} - - - - Delete {element.type === "text" ? "text" : "clip"} - - - - ); -} +"use client"; + +import { useState } from "react"; +import { Button } from "../ui/button"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, + Type, + Copy, + RefreshCw, +} 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 { TimelineElementProps, TrackType } from "@/types/timeline"; +import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; +import { + getTrackElementClasses, + TIMELINE_CONSTANTS, + getTrackHeight, +} from "@/constants/timeline-constants"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "../ui/context-menu"; + +export function TimelineElement({ + element, + track, + zoomLevel, + isSelected, + onElementMouseDown, + onElementClick, +}: TimelineElementProps) { + const { mediaItems } = useMediaStore(); + const { + updateElementTrim, + updateElementDuration, + removeElementFromTrack, + dragState, + splitElement, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + addElementToTrack, + replaceElementMedia, + } = useTimelineStore(); + const { currentTime } = usePlaybackStore(); + + const [elementMenuOpen, setElementMenuOpen] = useState(false); + + const { + resizing, + isResizing, + handleResizeStart, + handleResizeMove, + handleResizeEnd, + } = useTimelineElementResize({ + element, + track, + zoomLevel, + onUpdateTrim: updateElementTrim, + onUpdateDuration: updateElementDuration, + }); + + const effectiveDuration = + element.duration - element.trimStart - element.trimEnd; + const elementWidth = Math.max( + TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, + effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel + ); + + // Use real-time position during drag, otherwise use stored position + const isBeingDragged = dragState.elementId === element.id; + const elementStartTime = + isBeingDragged && dragState.isDragging + ? dragState.currentTime + : element.startTime; + const elementLeft = elementStartTime * 50 * zoomLevel; + + const handleDeleteElement = () => { + removeElementFromTrack(track.id, element.id); + setElementMenuOpen(false); + }; + + const handleSplitElement = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within element to split"); + return; + } + + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + setElementMenuOpen(false); + }; + + const handleSplitAndKeepLeft = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within element"); + return; + } + + splitAndKeepLeft(track.id, element.id, currentTime); + setElementMenuOpen(false); + }; + + const handleSplitAndKeepRight = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within element"); + return; + } + + splitAndKeepRight(track.id, element.id, currentTime); + setElementMenuOpen(false); + }; + + const handleSeparateAudio = () => { + if (element.type !== "media") { + toast.error("Audio separation only available for media elements"); + return; + } + + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video elements"); + return; + } + + const audioElementId = separateAudio(track.id, element.id); + if (!audioElementId) { + toast.error("Failed to separate audio"); + } + setElementMenuOpen(false); + }; + + const canSplitAtPlayhead = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + }; + + const canSeparateAudio = () => { + if (element.type !== "media") return false; + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + return mediaItem?.type === "video" && track.type === "media"; + }; + + const handleElementSplitContext = () => { + const effectiveStart = element.startTime; + const effectiveEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const secondElementId = splitElement(track.id, element.id, currentTime); + if (!secondElementId) { + toast.error("Failed to split element"); + } + } else { + toast.error("Playhead must be within element to split"); + } + }; + + const handleElementDuplicateContext = () => { + const { id, ...elementWithoutId } = element; + addElementToTrack(track.id, { + ...elementWithoutId, + name: element.name + " (copy)", + startTime: + element.startTime + + (element.duration - element.trimStart - element.trimEnd) + + 0.1, + }); + }; + + const handleElementDeleteContext = () => { + removeElementFromTrack(track.id, element.id); + }; + + const handleReplaceClip = () => { + if (element.type !== "media") { + toast.error("Replace is only available for media clips"); + return; + } + + // Create a file input to select replacement media + const input = document.createElement("input"); + input.type = "file"; + input.accept = "video/*,audio/*,image/*"; + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + + try { + const success = await replaceElementMedia(track.id, element.id, file); + if (success) { + toast.success("Clip replaced successfully"); + } else { + toast.error("Failed to replace clip"); + } + } catch (error) { + toast.error("Failed to replace clip"); + console.log( + JSON.stringify({ error: "Failed to replace clip", details: error }) + ); + } + }; + input.click(); + }; + + const renderElementContent = () => { + if (element.type === "text") { + return ( +
+ + {element.content} + +
+ ); + } + + // Render media element -> + const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + if (!mediaItem) { + return ( + + {element.name} + + ); + } + + const TILE_ASPECT_RATIO = 16 / 9; + + if (mediaItem.type === "image") { + // Calculate tile size based on 16:9 aspect ratio + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - 8; // Account for padding + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( +
+
+ {/* Background with tiled images */} +
+ {/* Overlay with vertical borders */} +
+
+
+ ); + } + + const VIDEO_TILE_PADDING = 16; + const OVERLAY_SPACE_MULTIPLIER = 1.5; + + if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { + const trackHeight = getTrackHeight(track.type); + const tileHeight = trackHeight - VIDEO_TILE_PADDING; + const tileWidth = tileHeight * TILE_ASPECT_RATIO; + + return ( +
+
+ {/* Background with tiled thumbnails */} +
+ {/* Overlay with vertical borders */} +
+
+ {elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( +
+ {element.name} +
+ ) : ( + + {element.name} + + )} +
+ ); + } + + // Render audio element -> + if (mediaItem.type === "audio") { + return ( +
+
+ +
+
+ ); + } + + return ( + + {element.name} + + ); + }; + + const handleElementMouseDown = (e: React.MouseEvent) => { + if (onElementMouseDown) { + onElementMouseDown(e, element); + } + }; + + return ( + + +
+
onElementClick && onElementClick(e, element)} + onMouseDown={handleElementMouseDown} + onContextMenu={(e) => + onElementMouseDown && onElementMouseDown(e, element) + } + > +
+ {renderElementContent()} +
+ + {isSelected && ( + <> +
handleResizeStart(e, element.id, "left")} + /> +
handleResizeStart(e, element.id, "right")} + /> + + )} +
+
+ + + + + Split at playhead + + + + Duplicate {element.type === "text" ? "text" : "clip"} + + {element.type === "media" && ( + + + Replace clip + + )} + + + + Delete {element.type === "text" ? "text" : "clip"} + + + + ); +}