Merge branch 'main' into feat/timeline-return-to-start

This commit is contained in:
ryu
2025-08-07 19:37:06 +05:00
committed by GitHub
105 changed files with 3702 additions and 1506 deletions
@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { ScrollArea } from "../../ui/scroll-area";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "../../ui/button";
import {
Scissors,
@@ -21,6 +21,10 @@ import {
Link,
ZoomIn,
ZoomOut,
Bookmark,
Eye,
MicOff,
Mic,
} from "lucide-react";
import {
Tooltip,
@@ -545,7 +549,7 @@ export function Timeline() {
{/* Timeline Header with Ruler */}
<div className="flex bg-panel sticky top-0 z-10">
{/* Track Labels Header */}
<div className="w-48 flex-shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
<div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
{/* Empty space */}
<span className="text-sm font-medium text-muted-foreground opacity-0">
.
@@ -653,6 +657,30 @@ export function Timeline() {
);
}).filter(Boolean);
})()}
{/* Bookmark markers */}
{(() => {
const { activeProject } = useProjectStore.getState();
if (!activeProject?.bookmarks?.length) return null;
return activeProject.bookmarks.map((bookmarkTime, i) => (
<div
key={`bookmark-${i}`}
className="absolute top-0 h-10 w-0.5 !bg-primary cursor-pointer"
style={{
left: `${bookmarkTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
onClick={(e) => {
e.stopPropagation();
usePlaybackStore.getState().seek(bookmarkTime);
}}
>
<div className="absolute top-[-1px] left-[-5px] text-primary">
<Bookmark className="h-3 w-3 fill-primary" />
</div>
</div>
));
})()}
</div>
</ScrollArea>
</div>
@@ -664,7 +692,7 @@ export function Timeline() {
{tracks.length > 0 && (
<div
ref={trackLabelsRef}
className="w-48 flex-shrink-0 border-r border-black overflow-y-auto z-[200] bg-panel"
className="w-28 shrink-0 border-r overflow-y-auto z-100 bg-panel"
data-track-labels
>
<ScrollArea className="w-full h-full" ref={trackLabelsScrollRef}>
@@ -672,17 +700,24 @@ export function Timeline() {
{tracks.map((track) => (
<div
key={track.id}
className="flex items-center px-3 border-b border-muted/30 group bg-foreground/5"
className="flex items-center px-3 group"
style={{ height: `${getTrackHeight(track.type)}px` }}
>
<div className="flex items-center flex-1 min-w-0">
<div className="flex items-center justify-end flex-1 min-w-0 gap-2">
{track.muted ? (
<MicOff
className="h-4 w-4 text-destructive cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
/>
) : (
<Mic
className="h-4 w-4 text-muted-foreground cursor-pointer"
onClick={() => toggleTrackMute(track.id)}
/>
)}
<Eye className="h-4 w-4 text-muted-foreground" />
<TrackIcon track={track} />
</div>
{track.muted && (
<span className="ml-2 text-xs text-red-500 font-semibold flex-shrink-0">
Muted
</span>
)}
</div>
))}
</div>
@@ -713,12 +748,7 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<ScrollArea
className="w-full h-full"
ref={tracksScrollRef}
type="scroll"
showHorizontalScrollbar
>
<ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<div
className="relative flex-1"
style={{
@@ -737,7 +767,7 @@ export function Timeline() {
<ContextMenu key={track.id}>
<ContextMenuTrigger asChild>
<div
className="absolute left-0 right-0 border-b border-muted/30 py-[0.05rem]"
className="absolute left-0 right-0"
style={{
top: `${getCumulativeHeightBefore(
tracks,
@@ -763,7 +793,7 @@ export function Timeline() {
/>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[200]">
<ContextMenuContent className="z-200">
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -793,13 +823,13 @@ function TrackIcon({ track }: { track: TimelineTrack }) {
return (
<>
{track.type === "media" && (
<Video className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
<Video className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{track.type === "text" && (
<TypeIcon className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
<TypeIcon className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
{track.type === "audio" && (
<Music className="w-4 h-4 flex-shrink-0 text-muted-foreground" />
<Music className="w-4 h-4 shrink-0 text-muted-foreground" />
)}
</>
);
@@ -832,6 +862,7 @@ function TimelineToolbar({
toggleRippleEditing,
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle } = usePlaybackStore();
const { toggleBookmark, isBookmarked } = useProjectStore();
// Action handlers
const handleSplitSelected = () => {
@@ -961,6 +992,13 @@ function TimelineToolbar({
const handleZoomSliderChange = (values: number[]) => {
setZoomLevel(values[0]);
};
const handleToggleBookmark = async () => {
await toggleBookmark(currentTime);
};
// Check if the current time is bookmarked
const currentBookmarked = isBookmarked(currentTime);
return (
<div className="border-b flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
@@ -1108,6 +1146,19 @@ function TimelineToolbar({
</TooltipTrigger>
<TooltipContent>Delete element (Delete)</TooltipContent>
</Tooltip>
<div className="w-px h-6 bg-border mx-1" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="text" size="icon" onClick={handleToggleBookmark}>
<Bookmark
className={`h-4 w-4 ${currentBookmarked ? "fill-primary text-primary" : ""}`}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{currentBookmarked ? "Remove bookmark" : "Add bookmark"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-1">
@@ -1141,6 +1192,8 @@ function TimelineToolbar({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="h-6 w-px bg-border mx-1" />
<div className="flex items-center gap-1">
<Button variant="text" size="icon" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
@@ -1,41 +1,27 @@
"use client";
import { useState } from "react";
import { Button } from "../../ui/button";
import {
MoreVertical,
Scissors,
Trash2,
SplitSquareHorizontal,
Music,
ChevronRight,
ChevronLeft,
Type,
Copy,
RefreshCw,
EyeOff,
Eye,
Volume2,
VolumeX,
} 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 { TimelineElementProps } 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,
@@ -60,30 +46,27 @@ export function TimelineElement({
removeElementFromTrackWithRipple,
dragState,
splitElement,
splitAndKeepLeft,
splitAndKeepRight,
separateAudio,
addElementToTrack,
replaceElementMedia,
rippleEditingEnabled,
toggleElementHidden,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
const [elementMenuOpen, setElementMenuOpen] = useState(false);
const mediaItem =
element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId)
: null;
const isAudio = mediaItem?.type === "audio";
const {
resizing,
isResizing,
handleResizeStart,
handleResizeMove,
handleResizeEnd,
} = useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const { resizing, handleResizeStart, handleResizeMove, handleResizeEnd } =
useTimelineElementResize({
element,
track,
zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
});
const effectiveDuration =
element.duration - element.trimStart - element.trimEnd;
@@ -141,6 +124,11 @@ export function TimelineElement({
}
};
const handleToggleElementHidden = (e: React.MouseEvent) => {
e.stopPropagation();
toggleElementHidden(track.id, element.id);
};
const handleReplaceClip = (e: React.MouseEvent) => {
e.stopPropagation();
if (element.type !== "media") {
@@ -177,9 +165,7 @@ export function TimelineElement({
if (element.type === "text") {
return (
<div className="w-full h-full flex items-center justify-start pl-2">
<span className="text-xs text-foreground/80 truncate">
{element.content}
</span>
<span className="text-xs text-white truncate">{element.content}</span>
</div>
);
}
@@ -196,86 +182,36 @@ export function TimelineElement({
const TILE_ASPECT_RATIO = 16 / 9;
if (mediaItem.type === "image") {
if (
mediaItem.type === "image" ||
(mediaItem.type === "video" && mediaItem.thumbnailUrl)
) {
// Calculate tile size based on 16:9 aspect ratio
const trackHeight = getTrackHeight(track.type);
const tileHeight = trackHeight - 8; // Account for padding
const tileHeight = trackHeight;
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
const imageUrl =
mediaItem.type === "image" ? mediaItem.url : mediaItem.thumbnailUrl;
const isImage = mediaItem.type === "image";
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled images */}
<div
className={`w-full h-full relative ${
isSelected ? "bg-primary" : "bg-transparent"
}`}
>
<div
className="absolute top-3 bottom-3 left-0 right-0"
className={`absolute top-[0.15rem] bottom-[0.15rem] left-0 right-0`}
style={{
backgroundImage: mediaItem.url
? `url(${mediaItem.url})`
: "none",
backgroundImage: imageUrl ? `url(${imageUrl})` : "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled background of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
/>
</div>
</div>
);
}
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 - 8; // Match image padding
const tileWidth = tileHeight * TILE_ASPECT_RATIO;
return (
<div className="w-full h-full flex items-center justify-center">
<div className="bg-[#004D52] py-3 w-full h-full relative">
{/* Background with tiled thumbnails */}
<div
className="absolute top-3 bottom-3 left-0 right-0"
style={{
backgroundImage: mediaItem.thumbnailUrl
? `url(${mediaItem.thumbnailUrl})`
: "none",
backgroundRepeat: "repeat-x",
backgroundSize: `${tileWidth}px ${tileHeight}px`,
backgroundPosition: "left center",
pointerEvents: "none",
}}
aria-label={`Tiled thumbnail of ${mediaItem.name}`}
/>
{/* Overlay with vertical borders */}
<div
className="absolute top-3 bottom-3 left-0 right-0 pointer-events-none"
style={{
backgroundImage: `repeating-linear-gradient(
to right,
transparent 0px,
transparent ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth - 1}px,
rgba(255, 255, 255, 0.6) ${tileWidth}px
)`,
backgroundPosition: "left center",
}}
aria-label={`Tiled ${isImage ? "background" : "thumbnail"} of ${mediaItem.name}`}
/>
</div>
</div>
@@ -330,9 +266,9 @@ export function TimelineElement({
<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" : ""} ${
)} ${isSelected ? "" : ""} ${
isBeingDragged ? "z-50" : "z-10"
}`}
} ${element.hidden ? "opacity-50" : ""}`}
onClick={(e) => onElementClick && onElementClick(e, element)}
onMouseDown={handleElementMouseDown}
onContextMenu={(e) =>
@@ -343,14 +279,24 @@ export function TimelineElement({
{renderElementContent()}
</div>
{element.hidden && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center pointer-events-none">
{isAudio ? (
<VolumeX className="h-6 w-6 text-white" />
) : (
<EyeOff className="h-6 w-6 text-white" />
)}
</div>
)}
{isSelected && (
<>
<div
className="absolute left-0 top-0 bottom-0 w-1 cursor-w-resize bg-foreground z-50"
className="absolute left-0 top-0 bottom-0 w-[0.2rem] cursor-w-resize bg-primary 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"
className="absolute right-0 top-0 bottom-0 w-[0.2rem] cursor-e-resize bg-primary z-50"
onMouseDown={(e) => handleResizeStart(e, element.id, "right")}
/>
</>
@@ -358,11 +304,34 @@ export function TimelineElement({
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[200]">
<ContextMenuContent className="z-200">
<ContextMenuItem onClick={handleElementSplitContext}>
<Scissors className="h-4 w-4 mr-2" />
Split at playhead
</ContextMenuItem>
<ContextMenuItem onClick={handleToggleElementHidden}>
{isAudio ? (
element.hidden ? (
<Volume2 className="h-4 w-4 mr-2" />
) : (
<VolumeX className="h-4 w-4 mr-2" />
)
) : element.hidden ? (
<Eye className="h-4 w-4 mr-2" />
) : (
<EyeOff className="h-4 w-4 mr-2" />
)}
<span>
{isAudio
? element.hidden
? "Unmute"
: "Mute"
: element.hidden
? "Show"
: "Hide"}{" "}
{element.type === "text" ? "text" : "clip"}
</span>
</ContextMenuItem>
<ContextMenuItem onClick={handleElementDuplicateContext}>
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
@@ -70,7 +70,7 @@ export function TimelinePlayhead({
// Use timeline container height minus a few pixels for breathing room
const timelineContainerHeight = timelineRef.current?.offsetHeight || 400;
const totalHeight = timelineContainerHeight - 8; // 8px padding from edges
const totalHeight = timelineContainerHeight - 4;
// Get dynamic track labels width, fallback to 0 if no tracks or no ref
const trackLabelsWidth =
@@ -126,7 +126,7 @@ export function TimelinePlayhead({
return (
<div
ref={playheadRef}
className="absolute pointer-events-auto z-[150]"
className="absolute pointer-events-auto z-150"
style={{
left: `${leftPosition}px`,
top: 0,
@@ -137,12 +137,12 @@ export function TimelinePlayhead({
>
{/* The playhead line spanning full height */}
<div
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-primary" : "bg-foreground"}`}
className={`absolute left-0 w-0.5 cursor-col-resize h-full ${isSnappingToPlayhead ? "bg-foreground" : "bg-foreground"}`}
/>
{/* Playhead dot indicator at the top (in ruler area) */}
<div
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"}`}
className={`absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
/>
</div>
);
@@ -68,45 +68,51 @@ export function TimelineTrackContent({
elementDuration: number,
excludeElementId?: string
) => {
if (!snappingEnabled) {
// Use frame snapping if project has FPS, otherwise use decimal snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
return snapTimeToFrame(dropTime, projectFps);
// Always apply frame snapping first
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
let finalTime = snapTimeToFrame(dropTime, projectFps);
// Additionally apply element snapping if enabled
if (snappingEnabled) {
// Try snapping both start and end edges for drops
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true // snap to start edge
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
}
}
// Try snapping both start and end edges for drops
const startSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
true // snap to start edge
);
const endSnapResult = snapElementEdge(
dropTime,
elementDuration,
tracks,
currentTime,
zoomLevel,
excludeElementId,
false // snap to end edge
);
// Choose the snap result with the smaller distance (closer snap)
let bestSnapResult = startSnapResult;
if (
endSnapResult.snapPoint &&
(!startSnapResult.snapPoint ||
endSnapResult.snapDistance < startSnapResult.snapDistance)
) {
bestSnapResult = endSnapResult;
}
return bestSnapResult.snappedTime;
return finalTime;
};
const timelineRef = useRef<HTMLDivElement>(null);
@@ -148,9 +154,13 @@ export function TimelineTrackContent({
);
const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime);
// Apply snapping if enabled
let finalTime = adjustedTime;
// Always apply frame snapping first
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
let finalTime = snapTimeToFrame(adjustedTime, projectFps);
let snapPoint = null;
// Additionally apply element snapping if enabled
if (snappingEnabled) {
// Find the element being dragged to get its duration
let elementDuration = 5; // fallback duration
@@ -196,18 +206,16 @@ export function TimelineTrackContent({
bestSnapResult = endSnapResult;
}
finalTime = bestSnapResult.snappedTime;
snapPoint = bestSnapResult.snapPoint;
// Only use element snapping if it found a snap point, otherwise keep frame-snapped time
if (bestSnapResult.snapPoint) {
finalTime = bestSnapResult.snappedTime;
snapPoint = bestSnapResult.snapPoint;
}
// Notify parent component about snap point change
onSnapPointChange?.(snapPoint);
} else {
// Use frame snapping if project has FPS, otherwise use decimal snapping
const projectStore = useProjectStore.getState();
const projectFps = projectStore.activeProject?.fps || 30;
finalTime = snapTimeToFrame(adjustedTime, projectFps);
// Clear snap point when not snapping
// Clear snap point when element snapping is disabled
onSnapPointChange?.(null);
}