stable timeline

This commit is contained in:
Maze Winther
2025-07-17 19:46:23 +02:00
parent 0b919d4df8
commit 648f0b8529
16 changed files with 2696 additions and 2845 deletions
@@ -40,7 +40,7 @@ export function SnapIndicator({
return ( return (
<div <div
className="absolute pointer-events-none z-[90] ml-48" className="absolute pointer-events-none z-[90]"
style={{ style={{
left: `${leftPosition}px`, left: `${leftPosition}px`,
top: 0, top: 0,
@@ -1,351 +1,470 @@
"use client"; "use client";
import { Scissors, Trash2, Copy, RefreshCw } from "lucide-react"; import { useState } from "react";
import { useMediaStore } from "@/stores/media-store"; import { Button } from "../ui/button";
import { useTimelineStore } from "@/stores/timeline-store"; import {
import { usePlaybackStore } from "@/stores/playback-store"; MoreVertical,
import AudioWaveform from "@/components/editor/audio-waveform"; Scissors,
import { toast } from "sonner"; Trash2,
import { TimelineElementProps } from "@/types/timeline"; SplitSquareHorizontal,
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize"; Music,
import { ChevronRight,
getTrackElementClasses, ChevronLeft,
TIMELINE_CONSTANTS, Type,
getTrackHeight, Copy,
} from "@/constants/timeline-constants"; RefreshCw,
import { } from "lucide-react";
ContextMenu, import { useMediaStore } from "@/stores/media-store";
ContextMenuContent, import { useTimelineStore } from "@/stores/timeline-store";
ContextMenuItem, import { usePlaybackStore } from "@/stores/playback-store";
ContextMenuSeparator, import AudioWaveform from "./audio-waveform";
ContextMenuTrigger, import { toast } from "sonner";
} from "@/components/ui/context-menu"; import { TimelineElementProps, TrackType } from "@/types/timeline";
import { useTimelineElementResize } from "@/hooks/use-timeline-element-resize";
export function TimelineElement({ import {
element, getTrackElementClasses,
track, TIMELINE_CONSTANTS,
zoomLevel, getTrackHeight,
isSelected, } from "@/constants/timeline-constants";
onElementMouseDown, import {
onElementClick, DropdownMenu,
}: TimelineElementProps) { DropdownMenuContent,
const { mediaItems } = useMediaStore(); DropdownMenuItem,
const { DropdownMenuSeparator,
updateElementTrim, DropdownMenuTrigger,
updateElementDuration, DropdownMenuSub,
removeElementFromTrack, DropdownMenuSubContent,
dragState, DropdownMenuSubTrigger,
splitElement, } from "../ui/dropdown-menu";
addElementToTrack, import {
replaceElementMedia, ContextMenu,
} = useTimelineStore(); ContextMenuContent,
const { currentTime } = usePlaybackStore(); ContextMenuItem,
ContextMenuSeparator,
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } = ContextMenuTrigger,
useTimelineElementResize({ } from "../ui/context-menu";
element,
track, export function TimelineElement({
zoomLevel, element,
onUpdateTrim: updateElementTrim, track,
onUpdateDuration: updateElementDuration, zoomLevel,
}); isSelected,
onElementMouseDown,
const effectiveDuration = onElementClick,
element.duration - element.trimStart - element.trimEnd; }: TimelineElementProps) {
const elementWidth = Math.max( const { mediaItems } = useMediaStore();
TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH, const {
effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel updateElementTrim,
); updateElementDuration,
removeElementFromTrack,
// Use real-time position during drag, otherwise use stored position dragState,
const isBeingDragged = dragState.elementId === element.id; splitElement,
const elementStartTime = splitAndKeepLeft,
isBeingDragged && dragState.isDragging splitAndKeepRight,
? dragState.currentTime separateAudio,
: element.startTime; addElementToTrack,
const elementLeft = elementStartTime * 50 * zoomLevel; replaceElementMedia,
} = useTimelineStore();
const handleElementSplitContext = () => { const { currentTime } = usePlaybackStore();
const effectiveStart = element.startTime;
const effectiveEnd = const [elementMenuOpen, setElementMenuOpen] = useState(false);
element.startTime +
(element.duration - element.trimStart - element.trimEnd); const {
resizing,
if (currentTime > effectiveStart && currentTime < effectiveEnd) { isResizing,
const secondElementId = splitElement(track.id, element.id, currentTime); handleResizeStart,
if (!secondElementId) { handleResizeMove,
toast.error("Failed to split element"); handleResizeEnd,
} } = useTimelineElementResize({
} else { element,
toast.error("Playhead must be within element to split"); track,
} zoomLevel,
}; onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
const handleElementDuplicateContext = () => { });
const { id, ...elementWithoutId } = element;
addElementToTrack(track.id, { const effectiveDuration =
...elementWithoutId, element.duration - element.trimStart - element.trimEnd;
name: element.name + " (copy)", const elementWidth = Math.max(
startTime: TIMELINE_CONSTANTS.ELEMENT_MIN_WIDTH,
element.startTime + effectiveDuration * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel
(element.duration - element.trimStart - element.trimEnd) + );
0.1,
}); // Use real-time position during drag, otherwise use stored position
}; const isBeingDragged = dragState.elementId === element.id;
const elementStartTime =
const handleElementDeleteContext = () => { isBeingDragged && dragState.isDragging
removeElementFromTrack(track.id, element.id); ? dragState.currentTime
}; : element.startTime;
const elementLeft = elementStartTime * 50 * zoomLevel;
const handleReplaceClip = () => {
if (element.type !== "media") { const handleDeleteElement = () => {
toast.error("Replace is only available for media clips"); removeElementFromTrack(track.id, element.id);
return; setElementMenuOpen(false);
} };
// Create a file input to select replacement media const handleSplitElement = () => {
const input = document.createElement("input"); const effectiveStart = element.startTime;
input.type = "file"; const effectiveEnd =
input.accept = "video/*,audio/*,image/*"; element.startTime +
input.onchange = async (e) => { (element.duration - element.trimStart - element.trimEnd);
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return; if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
toast.error("Playhead must be within element to split");
try { return;
const success = await replaceElementMedia(track.id, element.id, file); }
if (success) {
toast.success("Clip replaced successfully"); const secondElementId = splitElement(track.id, element.id, currentTime);
} else { if (!secondElementId) {
toast.error("Failed to replace clip"); toast.error("Failed to split element");
} }
} catch (error) { setElementMenuOpen(false);
toast.error("Failed to replace clip"); };
console.log(
JSON.stringify({ error: "Failed to replace clip", details: error }) const handleSplitAndKeepLeft = () => {
); const effectiveStart = element.startTime;
} const effectiveEnd =
}; element.startTime +
input.click(); (element.duration - element.trimStart - element.trimEnd);
};
if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
const renderElementContent = () => { toast.error("Playhead must be within element");
if (element.type === "text") { return;
return ( }
<div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate"> splitAndKeepLeft(track.id, element.id, currentTime);
{element.content} setElementMenuOpen(false);
</span> };
</div>
); const handleSplitAndKeepRight = () => {
} const effectiveStart = element.startTime;
const effectiveEnd =
// Render media element -> element.startTime +
const mediaItem = mediaItems.find((item) => item.id === element.mediaId); (element.duration - element.trimStart - element.trimEnd);
if (!mediaItem) {
return ( if (currentTime <= effectiveStart || currentTime >= effectiveEnd) {
<span className="text-xs text-foreground/80 truncate"> toast.error("Playhead must be within element");
{element.name} return;
</span> }
);
} splitAndKeepRight(track.id, element.id, currentTime);
setElementMenuOpen(false);
const TILE_ASPECT_RATIO = 16 / 9; };
if (mediaItem.type === "image") { const handleSeparateAudio = () => {
// Calculate tile size based on 16:9 aspect ratio if (element.type !== "media") {
const trackHeight = getTrackHeight(track.type); toast.error("Audio separation only available for media elements");
const tileHeight = trackHeight - 8; // Account for padding return;
const tileWidth = tileHeight * TILE_ASPECT_RATIO; }
return ( const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
<div className="w-full h-full flex items-center justify-center"> if (!mediaItem || mediaItem.type !== "video") {
<div className="bg-[#004D52] py-3 w-full h-full relative"> toast.error("Audio separation only available for video elements");
{/* Background with tiled images */} return;
<div }
className="absolute top-3 bottom-3 left-0 right-0"
style={{ const audioElementId = separateAudio(track.id, element.id);
backgroundImage: mediaItem.url if (!audioElementId) {
? `url(${mediaItem.url})` toast.error("Failed to separate audio");
: "none", }
backgroundRepeat: "repeat-x", setElementMenuOpen(false);
backgroundSize: `${tileWidth}px ${tileHeight}px`, };
backgroundPosition: "left center",
pointerEvents: "none", const canSplitAtPlayhead = () => {
}} const effectiveStart = element.startTime;
aria-label={`Tiled background of ${mediaItem.name}`} const effectiveEnd =
/> element.startTime +
{/* Overlay with vertical borders */} (element.duration - element.trimStart - element.trimEnd);
<div return currentTime > effectiveStart && currentTime < effectiveEnd;
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none" };
style={{
backgroundImage: `repeating-linear-gradient( const canSeparateAudio = () => {
to right, if (element.type !== "media") return false;
transparent 0px, const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
transparent ${tileWidth - 1}px, return mediaItem?.type === "video" && track.type === "media";
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, };
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`, const handleElementSplitContext = () => {
backgroundPosition: "left center", const effectiveStart = element.startTime;
}} const effectiveEnd =
/> element.startTime +
</div> (element.duration - element.trimStart - element.trimEnd);
</div>
); if (currentTime > effectiveStart && currentTime < effectiveEnd) {
} const secondElementId = splitElement(track.id, element.id, currentTime);
if (!secondElementId) {
const VIDEO_TILE_PADDING = 16; toast.error("Failed to split element");
const OVERLAY_SPACE_MULTIPLIER = 1.5; }
} else {
if (mediaItem.type === "video" && mediaItem.thumbnailUrl) { toast.error("Playhead must be within element to split");
const trackHeight = getTrackHeight(track.type); }
const tileHeight = trackHeight - VIDEO_TILE_PADDING; };
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
const handleElementDuplicateContext = () => {
return ( const { id, ...elementWithoutId } = element;
<div className="w-full h-full flex items-center gap-2"> addElementToTrack(track.id, {
<div className="flex-1 h-full relative overflow-hidden"> ...elementWithoutId,
{/* Background with tiled thumbnails */} name: element.name + " (copy)",
<div startTime:
className="absolute inset-0" element.startTime +
style={{ (element.duration - element.trimStart - element.trimEnd) +
backgroundImage: mediaItem.thumbnailUrl 0.1,
? `url(${mediaItem.thumbnailUrl})` });
: "none", };
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`, const handleElementDeleteContext = () => {
backgroundPosition: "left center", removeElementFromTrack(track.id, element.id);
pointerEvents: "none", };
}}
aria-label={`Tiled thumbnail of ${mediaItem.name}`} const handleReplaceClip = () => {
/> if (element.type !== "media") {
{/* Overlay with vertical borders */} toast.error("Replace is only available for media clips");
<div return;
className="absolute inset-0 pointer-events-none" }
style={{
backgroundImage: `repeating-linear-gradient( // Create a file input to select replacement media
to right, const input = document.createElement("input");
transparent 0px, input.type = "file";
transparent ${tileWidth - 1}px, input.accept = "video/*,audio/*,image/*";
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px, input.onchange = async (e) => {
rgba(255, 255, 255, 0.6) ${tileWidth}px const file = (e.target as HTMLInputElement).files?.[0];
)`, if (!file) return;
backgroundPosition: "left center",
}} try {
/> const success = await replaceElementMedia(track.id, element.id, file);
</div> if (success) {
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? ( toast.success("Clip replaced successfully");
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate"> } else {
{element.name} toast.error("Failed to replace clip");
</div> }
) : ( } catch (error) {
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]"> toast.error("Failed to replace clip");
{element.name} console.log(
</span> JSON.stringify({ error: "Failed to replace clip", details: error })
)} );
</div> }
); };
} input.click();
};
// Render audio element ->
if (mediaItem.type === "audio") { const renderElementContent = () => {
return ( if (element.type === "text") {
<div className="w-full h-full flex items-center gap-2"> return (
<div className="flex-1 min-w-0"> <div className="w-full h-full flex items-center justify-start pl-2">
<AudioWaveform <span className="text-xs text-foreground/80 truncate">
audioUrl={mediaItem.url || ""} {element.content}
height={24} </span>
className="w-full" </div>
/> );
</div> }
</div>
); // Render media element ->
} const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
if (!mediaItem) {
return ( return (
<span className="text-xs text-foreground/80 truncate"> <span className="text-xs text-foreground/80 truncate">
{element.name} {element.name}
</span> </span>
); );
}; }
const handleElementMouseDown = (e: React.MouseEvent) => { const TILE_ASPECT_RATIO = 16 / 9;
if (onElementMouseDown) {
onElementMouseDown(e, element); 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
return ( const tileWidth = tileHeight * TILE_ASPECT_RATIO;
<ContextMenu>
<ContextMenuTrigger asChild> return (
<div <div className="w-full h-full flex items-center justify-center">
className={`absolute top-0 h-full select-none timeline-element ${ <div className="bg-[#004D52] py-3 w-full h-full relative">
isBeingDragged ? "z-50" : "z-10" {/* Background with tiled images */}
}`} <div
style={{ className="absolute top-3 bottom-3 left-0 right-0"
left: `${elementLeft}px`, style={{
width: `${elementWidth}px`, backgroundImage: mediaItem.url
}} ? `url(${mediaItem.url})`
data-element-id={element.id} : "none",
data-track-id={track.id} backgroundRepeat: "repeat-x",
onMouseMove={resizing ? handleResizeMove : undefined} backgroundSize: `${tileWidth}px ${tileHeight}px`,
onMouseUp={resizing ? handleResizeEnd : undefined} backgroundPosition: "left center",
onMouseLeave={resizing ? handleResizeEnd : undefined} pointerEvents: "none",
> }}
<div aria-label={`Tiled background of ${mediaItem.name}`}
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses( />
track.type {/* Overlay with vertical borders */}
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${ <div
isBeingDragged ? "z-50" : "z-10" className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
}`} style={{
onClick={(e) => onElementClick && onElementClick(e, element)} backgroundImage: `repeating-linear-gradient(
onMouseDown={handleElementMouseDown} to right,
onContextMenu={(e) => transparent 0px,
onElementMouseDown && onElementMouseDown(e, element) transparent ${tileWidth - 1}px,
} rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
> rgba(255, 255, 255, 0.6) ${tileWidth}px
<div className="absolute inset-0 flex items-center h-full"> )`,
{renderElementContent()} backgroundPosition: "left center",
</div> }}
/>
{isSelected && ( </div>
<> </div>
<div );
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50" }
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/> const VIDEO_TILE_PADDING = 16;
<div const OVERLAY_SPACE_MULTIPLIER = 1.5;
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")} if (mediaItem.type === "video" && mediaItem.thumbnailUrl) {
/> const trackHeight = getTrackHeight(track.type);
</> const tileHeight = trackHeight - VIDEO_TILE_PADDING;
)} const tileWidth = tileHeight * TILE_ASPECT_RATIO;
</div>
</div> return (
</ContextMenuTrigger> <div className="w-full h-full flex items-center gap-2">
<ContextMenuContent> <div className="flex-1 h-full relative overflow-hidden">
<ContextMenuItem onClick={handleElementSplitContext}> {/* Background with tiled thumbnails */}
<Scissors className="h-4 w-4 mr-2" /> <div
Split at playhead className="absolute inset-0"
</ContextMenuItem> style={{
<ContextMenuItem onClick={handleElementDuplicateContext}> backgroundImage: mediaItem.thumbnailUrl
<Copy className="h-4 w-4 mr-2" /> ? `url(${mediaItem.thumbnailUrl})`
Duplicate {element.type === "text" ? "text" : "clip"} : "none",
</ContextMenuItem> backgroundRepeat: "repeat-x",
{element.type === "media" && ( backgroundSize: `${tileWidth}px ${tileHeight}px`,
<ContextMenuItem onClick={handleReplaceClip}> backgroundPosition: "left center",
<RefreshCw className="h-4 w-4 mr-2" /> pointerEvents: "none",
Replace clip }}
</ContextMenuItem> aria-label={`Tiled thumbnail of ${mediaItem.name}`}
)} />
<ContextMenuSeparator /> {/* Overlay with vertical borders */}
<ContextMenuItem <div
onClick={handleElementDeleteContext} className="absolute inset-0 pointer-events-none"
className="text-destructive focus:text-destructive" style={{
> backgroundImage: `repeating-linear-gradient(
<Trash2 className="h-4 w-4 mr-2" /> to right,
Delete {element.type === "text" ? "text" : "clip"} transparent 0px,
</ContextMenuItem> transparent ${tileWidth - 1}px,
</ContextMenuContent> rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
</ContextMenu> rgba(255, 255, 255, 0.6) ${tileWidth}px
); )`,
} backgroundPosition: "left center",
}}
/>
</div>
{elementWidth > tileWidth * OVERLAY_SPACE_MULTIPLIER ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded pointer-events-none max-w-[40%] truncate">
{element.name}
</div>
) : (
<span className="text-xs text-foreground/80 truncate flex-shrink-0 max-w-[120px]">
{element.name}
</span>
)}
</div>
);
}
// Render audio element ->
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">
{element.name}
</span>
);
};
const handleElementMouseDown = (e: React.MouseEvent) => {
if (onElementMouseDown) {
onElementMouseDown(e, element);
}
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={`absolute top-0 h-full select-none timeline-element ${
isBeingDragged ? "z-50" : "z-10"
}`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
}}
data-element-id={element.id}
data-track-id={track.id}
onMouseMove={resizing ? handleResizeMove : undefined}
onMouseUp={resizing ? handleResizeEnd : undefined}
onMouseLeave={resizing ? handleResizeEnd : undefined}
>
<div
className={`relative h-full rounded-[0.15rem] cursor-pointer overflow-hidden ${getTrackElementClasses(
track.type
)} ${isSelected ? "border-b-[0.5px] border-t-[0.5px] border-foreground" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
onElementMouseDown && onElementMouseDown(e, element)
}
>
<div className="absolute inset-0 flex items-center h-full">
{renderElementContent()}
</div>
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "left")}
/>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-e-resize bg-foreground z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
{element.type === "media" && (
<ContextMenuItem onClick={handleReplaceClip}>
<RefreshCw className="h-4 w-4 mr-2" />
Replace clip
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4 mr-2" />
Delete {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -1,115 +1,116 @@
"use client"; "use client";
import { useRef } from "react"; import { useRef } from "react";
import { TimelineTrack } from "@/types/timeline"; import { TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import {
import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; TIMELINE_CONSTANTS,
getTotalTracksHeight,
interface TimelinePlayheadProps { } from "@/constants/timeline-constants";
currentTime: number; import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead";
duration: number;
zoomLevel: number; interface TimelinePlayheadProps {
tracks: TimelineTrack[]; currentTime: number;
seek: (time: number) => void; duration: number;
rulerRef: React.RefObject<HTMLDivElement>; zoomLevel: number;
rulerScrollRef: React.RefObject<HTMLDivElement>; tracks: TimelineTrack[];
tracksScrollRef: React.RefObject<HTMLDivElement>; seek: (time: number) => void;
trackLabelsRef?: React.RefObject<HTMLDivElement>; rulerRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>; rulerScrollRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>; tracksScrollRef: React.RefObject<HTMLDivElement>;
isSnappingToPlayhead?: boolean; trackLabelsRef?: React.RefObject<HTMLDivElement>;
} timelineRef: React.RefObject<HTMLDivElement>;
playheadRef?: React.RefObject<HTMLDivElement>;
export function TimelinePlayhead({ isSnappingToPlayhead?: boolean;
currentTime, }
duration,
zoomLevel, export function TimelinePlayhead({
tracks, currentTime,
seek, duration,
rulerRef, zoomLevel,
rulerScrollRef, tracks,
tracksScrollRef, seek,
trackLabelsRef, rulerRef,
timelineRef, rulerScrollRef,
playheadRef: externalPlayheadRef, tracksScrollRef,
isSnappingToPlayhead = false, trackLabelsRef,
}: TimelinePlayheadProps) { timelineRef,
const internalPlayheadRef = useRef<HTMLDivElement>(null); playheadRef: externalPlayheadRef,
const playheadRef = externalPlayheadRef || internalPlayheadRef; isSnappingToPlayhead = false,
const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({ }: TimelinePlayheadProps) {
currentTime, const internalPlayheadRef = useRef<HTMLDivElement>(null);
duration, const playheadRef = externalPlayheadRef || internalPlayheadRef;
zoomLevel, const { playheadPosition, handlePlayheadMouseDown } = useTimelinePlayhead({
seek, currentTime,
rulerRef, duration,
rulerScrollRef, zoomLevel,
tracksScrollRef, seek,
playheadRef, rulerRef,
}); rulerScrollRef,
tracksScrollRef,
// Get dynamic track labels width, fallback to 192px (ml-48) if no tracks or no ref playheadRef,
const trackLabelsWidth = 192; // Fixed width from grid layout });
const leftPosition =
playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; // Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
return ( const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
<>
{/* Playhead line container */} // Get dynamic track labels width, fallback to 0 if no tracks or no ref
<div const trackLabelsWidth =
ref={playheadRef} tracks.length > 0 && trackLabelsRef?.current
className="absolute pointer-events-auto z-[95]" ? trackLabelsRef.current.offsetWidth
style={{ : 0;
left: `${trackLabelsWidth + leftPosition}px`, const leftPosition =
top: 0, trackLabelsWidth +
bottom: 0, playheadPosition * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
width: "2px", // Slightly wider for better click target
}} return (
onMouseDown={handlePlayheadMouseDown} <div
> ref={playheadRef}
{/* The playhead line spanning full height */} className="absolute pointer-events-auto z-[100]"
<div style={{
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`} left: `${leftPosition}px`,
/> top: 0,
</div> height: `${totalHeight}px`,
width: "2px", // Slightly wider for better click target
{/* Playhead dot indicator - separate container with highest z-index */} }}
<div onMouseDown={handlePlayheadMouseDown}
className="absolute pointer-events-none z-[100]" >
style={{ {/* The playhead line spanning full height */}
left: `${trackLabelsWidth + leftPosition}px`, <div
top: 0, className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
bottom: 0, />
}}
> {/* Playhead dot indicator at the top (in ruler area) */}
<div <div
className={`sticky top-1 left-1 -translate-x-[40%] transform w-3 h-3 rounded-full shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`} className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-sm ${isSnappingToPlayhead ? "bg-primary border-primary" : "bg-foreground border-foreground"}`}
/> />
</div> </div>
</> );
); }
}
// Also export a hook for getting ruler handlers
// Also export a hook for getting ruler handlers export function useTimelinePlayheadRuler({
export function useTimelinePlayheadRuler({ currentTime,
currentTime, duration,
duration, zoomLevel,
zoomLevel, seek,
seek, rulerRef,
rulerRef, rulerScrollRef,
rulerScrollRef, tracksScrollRef,
tracksScrollRef, playheadRef,
playheadRef, }: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) {
}: Omit<TimelinePlayheadProps, "tracks" | "trackLabelsRef" | "timelineRef">) { const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({
const { handleRulerMouseDown, isDraggingRuler } = useTimelinePlayhead({ currentTime,
currentTime, duration,
duration, zoomLevel,
zoomLevel, seek,
seek, rulerRef,
rulerRef, rulerScrollRef,
rulerScrollRef, tracksScrollRef,
tracksScrollRef, playheadRef,
playheadRef, });
});
return { handleRulerMouseDown, isDraggingRuler };
return { handleRulerMouseDown, isDraggingRuler }; }
}
export { TimelinePlayhead as default };
File diff suppressed because it is too large Load Diff
@@ -1,193 +0,0 @@
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
import { useSelectionBox } from "@/hooks/use-selection-box";
import { SnapPoint } from "@/hooks/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { TimelineToolbar } from "./timeline-toolbar";
import { TimelineContent } from "./timeline-content";
import { useTimelineDragHandlers } from "./timeline-drag-handlers";
import { useTimelineActionHandlers } from "./timeline-action-handlers";
import { useTimelineScrollSync } from "./timeline-scroll-sync";
import { useTimelineContentClick } from "./timeline-content-click";
import { useTimelineWheelHandler } from "./timeline-wheel-handler";
export function Timeline() {
// Timeline shows all tracks (video, audio, effects) and their elements.
// You can drag media here to add it to your project.
// elements can be trimmed, deleted, and moved.
const {
tracks,
getTotalDuration,
selectedElements,
clearSelectedElements,
setSelectedElements,
dragState,
snappingEnabled,
} = useTimelineStore();
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null);
const [isInTimeline, setIsInTimeline] = useState(false);
// Timeline zoom functionality
const { zoomLevel, setZoomLevel, handleWheel } = useTimelineZoom({
containerRef: timelineRef,
isInTimeline,
});
// Dynamic timeline width calculation based on playhead position and duration
const dynamicTimelineWidth = Math.max(
(duration || 0) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Base width from duration
(currentTime + 30) * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel, // Width to show current time + 30 seconds buffer
timelineRef.current?.clientWidth || 1000 // Minimum width
);
// Scroll synchronization and auto-scroll to playhead
const rulerScrollRef = useRef<HTMLDivElement>(null);
const tracksScrollRef = useRef<HTMLDivElement>(null);
const trackLabelsRef = useRef<HTMLDivElement>(null);
const playheadRef = useRef<HTMLDivElement>(null);
const trackLabelsScrollRef = useRef<HTMLDivElement>(null);
// Selection box functionality
const tracksContainerRef = useRef<HTMLDivElement>(null);
const {
selectionBox,
handleMouseDown: handleSelectionMouseDown,
isSelecting,
justFinishedSelecting,
} = useSelectionBox({
containerRef: tracksContainerRef,
playheadRef,
onSelectionComplete: (elements) => {
console.log(JSON.stringify({ onSelectionComplete: elements.length }));
setSelectedElements(elements);
},
});
// Calculate snap indicator state
const [currentSnapPoint, setCurrentSnapPoint] = useState<SnapPoint | null>(
null
);
const showSnapIndicator =
dragState.isDragging && snappingEnabled && currentSnapPoint !== null;
// Callback to handle snap point changes from TimelineTrackContent
const handleSnapPointChange = useCallback((snapPoint: SnapPoint | null) => {
setCurrentSnapPoint(snapPoint);
}, []);
// Timeline content click handler
const { handleTimelineContentClick } = useTimelineContentClick({
duration,
zoomLevel,
seek,
rulerScrollRef,
tracksScrollRef,
clearSelectedElements,
isSelecting,
justFinishedSelecting,
playheadRef,
});
// Update timeline duration when tracks change
useEffect(() => {
const totalDuration = getTotalDuration();
setDuration(Math.max(totalDuration, 10)); // Minimum 10 seconds for empty timeline
}, [tracks, setDuration, getTotalDuration]);
// Drag handlers
const { dragProps } = useTimelineDragHandlers({
isDragOver,
setIsDragOver,
isProcessing,
setIsProcessing,
progress,
setProgress,
});
// Action handlers
const {
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleSplitAndKeepLeft,
handleSplitAndKeepRight,
handleSeparateAudio,
handleDeleteSelected,
} = useTimelineActionHandlers();
// Scroll synchronization
useTimelineScrollSync({
rulerScrollRef,
tracksScrollRef,
});
// Wheel event handling
useTimelineWheelHandler({
timelineRef,
isInTimeline,
handleWheel,
});
return (
<div
className={`h-full flex flex-col transition-colors duration-200 relative bg-panel rounded-sm overflow-hidden`}
{...dragProps}
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
>
{/* Toolbar */}
<TimelineToolbar
handleSplitSelected={handleSplitSelected}
handleSplitAndKeepLeft={handleSplitAndKeepLeft}
handleSplitAndKeepRight={handleSplitAndKeepRight}
handleSeparateAudio={handleSeparateAudio}
handleDuplicateSelected={handleDuplicateSelected}
handleFreezeSelected={handleFreezeSelected}
handleDeleteSelected={handleDeleteSelected}
/>
{/* Timeline Container */}
<div
ref={timelineRef}
className="relative h-full w-full overflow-auto border"
onMouseEnter={() => setIsInTimeline(true)}
onMouseLeave={() => setIsInTimeline(false)}
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
>
<TimelineContent
dynamicTimelineWidth={dynamicTimelineWidth}
tracks={tracks}
duration={duration}
zoomLevel={zoomLevel}
currentTime={currentTime}
seek={seek}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
playheadRef={playheadRef}
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
handleSelectionMouseDown={handleSelectionMouseDown}
handleTimelineContentClick={handleTimelineContentClick}
handleSnapPointChange={handleSnapPointChange}
clearSelectedElements={clearSelectedElements}
selectionBox={selectionBox}
tracksContainerRef={tracksContainerRef}
currentSnapPoint={currentSnapPoint}
showSnapIndicator={showSnapIndicator}
/>
</div>
</div>
);
}
@@ -1,178 +0,0 @@
import { useMemo } from "react";
import { toast } from "sonner";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
export function useTimelineActionHandlers() {
const {
tracks,
addElementToTrack,
removeElementFromTrack,
selectedElements,
clearSelectedElements,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
// Create optimized lookup maps for O(1) access instead of O(n) find operations
const trackMap = useMemo(() => {
const map = new Map();
tracks.forEach((track) => {
map.set(track.id, track);
// Also create element lookup for this track
const elementMap = new Map();
track.elements.forEach((element) => {
elementMap.set(element.id, element);
});
map.set(`${track.id}_elements`, elementMap);
});
return map;
}, [tracks]);
// Helper function for O(1) track/element lookup
const findTrackAndElement = (trackId: string, elementId: string) => {
const track = trackMap.get(trackId);
const elementMap = trackMap.get(`${trackId}_elements`);
const element = elementMap?.get(elementId);
return { track, element };
};
// Action handlers for toolbar
const handleSplitSelected = () => {
if (selectedElements.length === 0) {
toast.error("No elements selected");
return;
}
let splitCount = 0;
selectedElements.forEach(({ trackId, elementId }) => {
const { track, element } = findTrackAndElement(trackId, elementId);
if (element && track) {
const effectiveStart = element.startTime;
const effectiveEnd =
element.startTime +
(element.duration - element.trimStart - element.trimEnd);
if (currentTime > effectiveStart && currentTime < effectiveEnd) {
const newElementId = splitElement(trackId, elementId, currentTime);
if (newElementId) splitCount++;
}
}
});
if (splitCount === 0) {
toast.error("Playhead must be within selected elements to split");
}
};
const handleDuplicateSelected = () => {
if (selectedElements.length === 0) {
toast.error("No elements selected");
return;
}
const canDuplicate = selectedElements.length === 1;
if (!canDuplicate) return;
selectedElements.forEach(({ trackId, elementId }) => {
const { element } = findTrackAndElement(trackId, elementId);
if (element) {
const newStartTime =
element.startTime +
(element.duration - element.trimStart - element.trimEnd) +
0.1;
// Create element without id (will be generated by store)
const { id, ...elementWithoutId } = element;
addElementToTrack(trackId, {
...elementWithoutId,
startTime: newStartTime,
});
// We can't predict the new id, so just clear selection for now
// TODO: addElementToTrack could return the new element id
}
});
clearSelectedElements();
};
const handleFreezeSelected = () => {
toast.info("Freeze frame functionality coming soon!");
};
const handleSplitAndKeepLeft = () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const { element } = findTrackAndElement(trackId, elementId);
if (!element) return;
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 selected element");
return;
}
splitAndKeepLeft(trackId, elementId, currentTime);
};
const handleSplitAndKeepRight = () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one element");
return;
}
const { trackId, elementId } = selectedElements[0];
const { element } = findTrackAndElement(trackId, elementId);
if (!element) return;
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 selected element");
return;
}
splitAndKeepRight(trackId, elementId, currentTime);
};
const handleSeparateAudio = () => {
if (selectedElements.length !== 1) {
toast.error("Select exactly one media element to separate audio");
return;
}
const { trackId, elementId } = selectedElements[0];
const { track } = findTrackAndElement(trackId, elementId);
if (!track || track.type !== "media") {
toast.error("Select a media element to separate audio");
return;
}
separateAudio(trackId, elementId);
};
const handleDeleteSelected = () => {
if (selectedElements.length === 0) {
toast.error("No elements selected");
return;
}
selectedElements.forEach(({ trackId, elementId }) => {
removeElementFromTrack(trackId, elementId);
});
clearSelectedElements();
};
return {
handleSplitSelected,
handleDuplicateSelected,
handleFreezeSelected,
handleSplitAndKeepLeft,
handleSplitAndKeepRight,
handleSeparateAudio,
handleDeleteSelected,
};
}
@@ -1,118 +0,0 @@
import { useCallback } from "react";
import { useProjectStore } from "@/stores/project-store";
import {
TIMELINE_CONSTANTS,
snapTimeToFrame,
} from "@/constants/timeline-constants";
export interface TimelineContentClickProps {
duration: number;
zoomLevel: number;
seek: (time: number) => void;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
clearSelectedElements: () => void;
isSelecting: boolean;
justFinishedSelecting: boolean;
playheadRef: React.RefObject<HTMLDivElement>;
}
export function useTimelineContentClick({
duration,
zoomLevel,
seek,
rulerScrollRef,
tracksScrollRef,
clearSelectedElements,
isSelecting,
justFinishedSelecting,
playheadRef,
}: TimelineContentClickProps) {
const { activeProject } = useProjectStore();
// Timeline content click to seek handler
const handleTimelineContentClick = useCallback(
(e: React.MouseEvent) => {
// Don't seek if this was a selection box operation
if (isSelecting || justFinishedSelecting) {
return;
}
// Don't seek if clicking on timeline elements, but still deselect
if ((e.target as HTMLElement).closest(".timeline-element")) {
return;
}
// Don't seek if clicking on playhead
if (playheadRef.current?.contains(e.target as Node)) {
return;
}
// Don't seek if clicking on track labels
if ((e.target as HTMLElement).closest("[data-track-labels]")) {
clearSelectedElements();
return;
}
// Clear selected elements when clicking empty timeline area
clearSelectedElements();
// Determine if we're clicking in ruler or tracks area
const isRulerClick = (e.target as HTMLElement).closest(
"[data-ruler-area]"
);
let mouseX: number;
let scrollLeft = 0;
if (isRulerClick) {
// Calculate based on ruler position
const rulerContent = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerContent) return;
const rect = rulerContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = rulerContent.scrollLeft;
} else {
// Calculate based on tracks content position
const tracksContent = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!tracksContent) return;
const rect = tracksContent.getBoundingClientRect();
mouseX = e.clientX - rect.left;
scrollLeft = tracksContent.scrollLeft;
}
const rawTime = Math.max(
0,
Math.min(
duration,
(mouseX + scrollLeft) /
(TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel)
)
);
// Use frame snapping for timeline clicking
const projectFps = activeProject?.fps || 30;
const time = snapTimeToFrame(rawTime, projectFps);
seek(time);
},
[
duration,
zoomLevel,
seek,
rulerScrollRef,
tracksScrollRef,
clearSelectedElements,
isSelecting,
justFinishedSelecting,
playheadRef,
activeProject?.fps,
]
);
return { handleTimelineContentClick };
}
@@ -1,126 +0,0 @@
import { SelectionBox } from "../selection-box";
import { TimelinePlayhead } from "./timeline-playhead";
import { SnapIndicator } from "../snap-indicator";
import { TimelineRuler } from "./timeline-ruler";
import { TimelineTracksArea } from "./timeline-tracks-area";
import type { TimelineTrack } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/use-timeline-snapping";
export interface TimelineContentProps {
dynamicTimelineWidth: number;
tracks: TimelineTrack[];
duration: number;
zoomLevel: number;
currentTime: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef: React.RefObject<HTMLDivElement>;
trackLabelsRef: React.RefObject<HTMLDivElement>;
timelineRef: React.RefObject<HTMLDivElement>;
handleSelectionMouseDown: (e: React.MouseEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
handleSnapPointChange: (snapPoint: SnapPoint | null) => void;
clearSelectedElements: () => void;
selectionBox: {
startPos: { x: number; y: number } | null;
currentPos: { x: number; y: number } | null;
isActive: boolean;
} | null;
tracksContainerRef: React.RefObject<HTMLDivElement>;
currentSnapPoint: SnapPoint | null;
showSnapIndicator: boolean;
}
export function TimelineContent({
dynamicTimelineWidth,
tracks,
duration,
zoomLevel,
currentTime,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
trackLabelsRef,
timelineRef,
handleSelectionMouseDown,
handleTimelineContentClick,
handleSnapPointChange,
clearSelectedElements,
selectionBox,
tracksContainerRef,
currentSnapPoint,
showSnapIndicator,
}: TimelineContentProps) {
return (
<div
className="relative min-w-max min-h-max h-full"
style={{ minWidth: `${dynamicTimelineWidth}px` }}
>
<div
className={`h-full grid grid-cols-[192px_1fr] ${tracks.length > 0 ? `grid-rows-[20px_repeat(${tracks.length},minmax(0,max-content))]` : "grid-rows-[20px_1fr]"}`}
>
{/* Top Row (Sticky Ruler Header) */}
<TimelineRuler
duration={duration}
zoomLevel={zoomLevel}
currentTime={currentTime}
seek={seek}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
playheadRef={playheadRef}
handleSelectionMouseDown={handleSelectionMouseDown}
handleTimelineContentClick={handleTimelineContentClick}
/>
{/* Track Rows */}
<TimelineTracksArea
tracks={tracks}
zoomLevel={zoomLevel}
handleSnapPointChange={handleSnapPointChange}
clearSelectedElements={clearSelectedElements}
/>
</div>
{/* Overlay Components - positioned absolutely relative to the timeline container */}
<SelectionBox
startPos={selectionBox?.startPos || null}
currentPos={selectionBox?.currentPos || null}
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<TimelinePlayhead
currentTime={currentTime}
duration={duration}
zoomLevel={zoomLevel}
tracks={tracks}
seek={seek}
rulerRef={rulerRef}
rulerScrollRef={rulerScrollRef}
tracksScrollRef={tracksScrollRef}
trackLabelsRef={trackLabelsRef}
timelineRef={timelineRef}
playheadRef={playheadRef}
isSnappingToPlayhead={
showSnapIndicator && currentSnapPoint?.type === "playhead"
}
/>
<SnapIndicator
snapPoint={currentSnapPoint}
zoomLevel={zoomLevel}
tracks={tracks}
timelineRef={timelineRef}
trackLabelsRef={trackLabelsRef}
isVisible={showSnapIndicator}
/>
</div>
);
}
@@ -1,143 +0,0 @@
import { useRef } from "react";
import { toast } from "sonner";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";
import { processMediaFiles } from "@/lib/media-processing";
import type { DragData } from "@/types/timeline";
export interface TimelineDragHandlersProps {
isDragOver: boolean;
setIsDragOver: (isDragOver: boolean) => void;
isProcessing: boolean;
setIsProcessing: (isProcessing: boolean) => void;
progress: number;
setProgress: (progress: number) => void;
}
export function useTimelineDragHandlers({
isDragOver,
setIsDragOver,
isProcessing,
setIsProcessing,
progress,
setProgress,
}: TimelineDragHandlersProps) {
const { mediaItems, addMediaItem } = useMediaStore();
const { activeProject } = useProjectStore();
const dragCounterRef = useRef(0);
const handleDragEnter = (e: React.DragEvent) => {
// When something is dragged over the timeline, show overlay
e.preventDefault();
// Don't show overlay for timeline elements - they're handled by tracks
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current += 1;
if (!isDragOver) {
setIsDragOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
// Don't update state for timeline elements - they're handled by tracks
if (e.dataTransfer.types.includes("application/x-timeline-element")) {
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) {
setIsDragOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
// When media is dropped, add it as a new track/element
e.preventDefault();
setIsDragOver(false);
dragCounterRef.current = 0;
// Ignore timeline element drags - they're handled by track-specific handlers
const hasTimelineElement = e.dataTransfer.types.includes(
"application/x-timeline-element"
);
if (hasTimelineElement) {
return;
}
const itemData = e.dataTransfer.getData("application/x-media-item");
if (itemData) {
try {
const dragData: DragData = JSON.parse(itemData);
if (dragData.type === "text") {
// Always create new text track to avoid overlaps
useTimelineStore.getState().addTextToNewTrack(dragData);
} else {
// Handle media items
const mediaItem = mediaItems.find(
(item: any) => item.id === dragData.id
);
if (!mediaItem) {
toast.error("Media item not found");
return;
}
useTimelineStore.getState().addMediaToNewTrack(mediaItem);
}
} catch (error) {
console.error("Error parsing dropped item data:", error);
toast.error("Failed to add item to timeline");
}
} else if (e.dataTransfer.files?.length > 0) {
// Handle file drops by creating new tracks
if (!activeProject) {
toast.error("No active project");
return;
}
setIsProcessing(true);
setProgress(0);
try {
const processedItems = await processMediaFiles(
e.dataTransfer.files,
(p) => setProgress(p)
);
for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems;
const addedItem = currentMediaItems.find(
(item) =>
item.name === processedItem.name && item.url === processedItem.url
);
if (addedItem) {
useTimelineStore.getState().addMediaToNewTrack(addedItem);
}
}
} catch (error) {
// Show error if file processing fails
console.error("Error processing external files:", error);
toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
setProgress(0);
}
}
};
const dragProps = {
onDragEnter: handleDragEnter,
onDragOver: handleDragOver,
onDragLeave: handleDragLeave,
onDrop: handleDrop,
};
return { dragProps };
}
@@ -1,127 +0,0 @@
import { useMemo } from "react";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelinePlayheadRuler } from "./timeline-playhead";
export interface TimelineRulerProps {
duration: number;
zoomLevel: number;
currentTime: number;
seek: (time: number) => void;
rulerRef: React.RefObject<HTMLDivElement>;
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
playheadRef: React.RefObject<HTMLDivElement>;
handleSelectionMouseDown: (e: React.MouseEvent) => void;
handleTimelineContentClick: (e: React.MouseEvent) => void;
}
export function TimelineRuler({
duration,
zoomLevel,
currentTime,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
handleSelectionMouseDown,
handleTimelineContentClick,
}: TimelineRulerProps) {
// Timeline playhead ruler handlers
const { handleRulerMouseDown } = useTimelinePlayheadRuler({
currentTime,
duration,
zoomLevel,
seek,
rulerRef,
rulerScrollRef,
tracksScrollRef,
playheadRef,
});
// Memoize the expensive ruler markers calculation
const rulerMarkers = useMemo(() => {
// Calculate appropriate time interval based on zoom level
const getTimeInterval = (zoom: number) => {
const pixelsPerSecond = TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoom;
if (pixelsPerSecond >= 200) return 0.1; // Every 0.1s when very zoomed in
if (pixelsPerSecond >= 100) return 0.5; // Every 0.5s when zoomed in
if (pixelsPerSecond >= 50) return 1; // Every 1s at normal zoom
if (pixelsPerSecond >= 25) return 2; // Every 2s when zoomed out
if (pixelsPerSecond >= 12) return 5; // Every 5s when more zoomed out
if (pixelsPerSecond >= 6) return 10; // Every 10s when very zoomed out
return 30; // Every 30s when extremely zoomed out
};
const formatTime = (seconds: number, interval: number) => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (minutes > 0) {
return `${minutes}:${Math.floor(secs).toString().padStart(2, "0")}`;
} else if (interval >= 1) {
return `${Math.floor(secs)}s`;
} else {
return `${secs.toFixed(1)}s`;
}
};
const interval = getTimeInterval(zoomLevel);
const markerCount = Math.ceil(duration / interval) + 1;
const markers = [];
for (let i = 0; i < markerCount; i++) {
const time = i * interval;
if (time > duration) break;
const isMainMarker =
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
const leftPosition =
time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel;
markers.push(
<div
key={i}
className={`absolute top-0 bottom-0 z-[99] ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
style={{ left: `${leftPosition}px` }}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] z-[99] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
>
{formatTime(time, interval)}
</span>
</div>
);
}
return markers;
}, [duration, zoomLevel]); // Only recalculate when duration or zoomLevel changes
return (
<div
className="sticky top-0 border-b border-muted/30 z-[99] w-24"
onMouseDown={handleSelectionMouseDown}
onClick={handleTimelineContentClick}
data-ruler-area
>
<div
ref={rulerRef}
className="relative h-5 select-none cursor-default pb-1 z-[99]"
onMouseDown={handleRulerMouseDown}
>
{rulerMarkers}
</div>
</div>
);
}
@@ -1,61 +0,0 @@
import { useEffect, useRef } from "react";
export interface TimelineScrollSyncProps {
rulerScrollRef: React.RefObject<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLDivElement>;
}
export function useTimelineScrollSync({
rulerScrollRef,
tracksScrollRef,
}: TimelineScrollSyncProps) {
const isUpdatingRef = useRef(false);
const lastSyncRef = useRef(0);
// --- Horizontal scroll synchronization between ruler and tracks ---
useEffect(() => {
const rulerViewport = rulerScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
const tracksViewport = tracksScrollRef.current?.querySelector(
"[data-radix-scroll-area-viewport]"
) as HTMLElement;
if (!rulerViewport || !tracksViewport) return;
// Throttled scroll handlers for better performance
const handleRulerScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling
lastSyncRef.current = now;
isUpdatingRef.current = true;
tracksViewport.scrollLeft = rulerViewport.scrollLeft;
isUpdatingRef.current = false;
};
const handleTracksScroll = () => {
const now = Date.now();
if (isUpdatingRef.current || now - lastSyncRef.current < 16) return; // 60fps throttling
lastSyncRef.current = now;
isUpdatingRef.current = true;
rulerViewport.scrollLeft = tracksViewport.scrollLeft;
isUpdatingRef.current = false;
};
rulerViewport.addEventListener("scroll", handleRulerScroll, {
passive: true,
});
tracksViewport.addEventListener("scroll", handleTracksScroll, {
passive: true,
});
return () => {
rulerViewport.removeEventListener("scroll", handleRulerScroll);
tracksViewport.removeEventListener("scroll", handleTracksScroll);
};
}, [rulerScrollRef, tracksScrollRef]);
return null;
}
@@ -1,201 +0,0 @@
import { Button } from "../../ui/button";
import {
Scissors,
ArrowLeftToLine,
ArrowRightToLine,
Trash2,
Snowflake,
Copy,
SplitSquareHorizontal,
Pause,
Play,
Lock,
LockOpen,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "../../ui/tooltip";
import { useTimelineStore } from "@/stores/timeline-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
export interface TimelineToolbarProps {
handleSplitSelected: () => void;
handleSplitAndKeepLeft: () => void;
handleSplitAndKeepRight: () => void;
handleSeparateAudio: () => void;
handleDuplicateSelected: () => void;
handleFreezeSelected: () => void;
handleDeleteSelected: () => void;
}
export function TimelineToolbar({
handleSplitSelected,
handleSplitAndKeepLeft,
handleSplitAndKeepRight,
handleSeparateAudio,
handleDuplicateSelected,
handleFreezeSelected,
handleDeleteSelected,
}: TimelineToolbarProps) {
const {
tracks,
addTrack,
addElementToTrack,
snappingEnabled,
toggleSnapping,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
return (
<div className="border-b flex items-center justify-between px-2 py-1 bg-card z-90">
<div className="flex items-center gap-1 w-full">
<TooltipProvider delayDuration={500}>
<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" />
<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>
{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("media");
addElementToTrack(trackId, {
type: "media",
mediaId: "test",
name: "Test Clip",
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
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 element (Ctrl+S)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepLeft}
>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep left (Ctrl+Q)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleSplitAndKeepRight}
>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Split and keep right (Ctrl+W)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleSeparateAudio}>
<SplitSquareHorizontal className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Separate audio (Ctrl+D)</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="text"
size="icon"
onClick={handleDuplicateSelected}
>
<Copy className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate element (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 element (Delete)</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={toggleSnapping}>
{snappingEnabled ? (
<Lock className="h-4 w-4" />
) : (
<LockOpen className="h-4 w-4 text-primary" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Auto snapping</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}
@@ -1,84 +0,0 @@
import { Plus } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../../ui/context-menu";
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineTrackContent } from "./timeline-track";
import { TrackIcon } from "./track-icon";
import { getTrackHeight } from "@/constants/timeline-constants";
import type { TimelineTrack } from "@/types/timeline";
import type { SnapPoint } from "@/hooks/use-timeline-snapping";
export interface TimelineTracksAreaProps {
tracks: TimelineTrack[];
zoomLevel: number;
handleSnapPointChange: (snapPoint: SnapPoint | null) => void;
clearSelectedElements: () => void;
}
export function TimelineTracksArea({
tracks,
zoomLevel,
handleSnapPointChange,
clearSelectedElements,
}: TimelineTracksAreaProps) {
const { toggleTrackMute } = useTimelineStore();
return (
<>
<div className="h-full flex flex-col absolute left-0 top-0 w-24 bg-panel border-r border-black z-[100]">
<div className="sticky left-0 h-5 border-inset z-[100]"></div>
{tracks.map((track, index) => (
<div
key={track.id}
className="sticky left-0 flex items-center border-b border-panel border-inset group z-[100]"
style={{ height: `${getTrackHeight(track.type)}px` }}
>
<div className="flex items-center gap-2 px-2">
<TrackIcon track={track} />
</div>
{track.muted && (
<span className="text-xs text-red-500 font-semibold">Muted</span>
)}
</div>
))}
</div>
<div className="h-full flex flex-col pt-5 ml-24">
{tracks.map((track, index) => (
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
className="h-full"
style={{ height: `${getTrackHeight(track.type)}px` }}
onClick={(e) => {
if (!(e.target as HTMLElement).closest(".timeline-element")) {
clearSelectedElements();
}
}}
>
<TimelineTrackContent
track={track}
zoomLevel={zoomLevel}
onSnapPointChange={handleSnapPointChange}
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => toggleTrackMute(track.id)}>
{track.muted ? "Unmute Track" : "Mute Track"}
</ContextMenuItem>
<ContextMenuItem>Track settings (soon)</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
))}
<div style={{ height: `${getTrackHeight("media")}px` }}></div>
</div>
</>
);
}
@@ -1,32 +0,0 @@
import { useEffect } from "react";
export interface TimelineWheelHandlerProps {
timelineRef: React.RefObject<HTMLDivElement>;
isInTimeline: boolean;
handleWheel: (e: React.WheelEvent) => void;
}
export function useTimelineWheelHandler({
timelineRef,
isInTimeline,
handleWheel,
}: TimelineWheelHandlerProps) {
useEffect(() => {
const timelineContainer = timelineRef.current;
if (!timelineContainer || !isInTimeline) return;
const handleWheelCapture = (e: WheelEvent) => {
handleWheel(e as any);
};
timelineContainer.addEventListener("wheel", handleWheelCapture, {
passive: false,
});
return () => {
timelineContainer.removeEventListener("wheel", handleWheelCapture);
};
}, [handleWheel, isInTimeline, timelineRef]);
return null;
}
@@ -1,18 +0,0 @@
import { Video, Music, TypeIcon } from "lucide-react";
import type { TimelineTrack } from "@/types/timeline";
export function TrackIcon({ track }: { track: TimelineTrack }) {
return (
<>
{track.type === "media" && (
<Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
)}
{track.type === "text" && (
<TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
)}
{track.type === "audio" && (
<Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
)}
</>
);
}