feat: cleanup timeline further, easier to view durations / view larger videos, design makes more sense, more responsive
This commit is contained in:
@ -249,63 +249,187 @@ export function Timeline() {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tracks Area */}
|
{/* Timeline Container */}
|
||||||
<ScrollArea className="flex-1">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div
|
{/* Timeline Header with Ruler */}
|
||||||
ref={timelineRef}
|
<div className="flex border-b bg-background sticky top-0 z-10">
|
||||||
className="min-w-[800px] relative cursor-pointer select-none"
|
{/* Track Labels Header */}
|
||||||
onClick={handleTimelineClick}
|
<div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
|
||||||
onWheel={handleWheel}
|
<span className="text-sm font-medium text-muted-foreground">Tracks</span>
|
||||||
>
|
<div className="text-xs text-muted-foreground">
|
||||||
{/* Timeline Header */}
|
|
||||||
<div className="py-3 relative bg-muted/30 border-b">
|
|
||||||
{/* Zoom indicator */}
|
|
||||||
<div className="absolute top-1 right-2 text-xs text-muted-foreground">
|
|
||||||
{zoomLevel.toFixed(1)}x
|
{zoomLevel.toFixed(1)}x
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline Tracks */}
|
{/* Timeline Ruler */}
|
||||||
<div className="relative">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
{tracks.length === 0 ? (
|
<ScrollArea className="w-full">
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div
|
||||||
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
|
className="relative h-12 bg-muted/30"
|
||||||
<SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
|
style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px` }}
|
||||||
|
>
|
||||||
|
{/* Time markers */}
|
||||||
|
{(() => {
|
||||||
|
// Calculate appropriate time interval based on zoom level
|
||||||
|
const getTimeInterval = (zoom: number) => {
|
||||||
|
const pixelsPerSecond = 50 * 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 interval = getTimeInterval(zoomLevel);
|
||||||
|
const markerCount = Math.ceil(duration / interval) + 1;
|
||||||
|
|
||||||
|
return Array.from({ length: markerCount }, (_, i) => {
|
||||||
|
const time = i * interval;
|
||||||
|
if (time > duration) return null;
|
||||||
|
|
||||||
|
const isMainMarker = time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`absolute top-0 bottom-0 ${isMainMarker
|
||||||
|
? 'border-l border-muted-foreground/40'
|
||||||
|
: 'border-l border-muted-foreground/20'
|
||||||
|
}`}
|
||||||
|
style={{ left: `${time * 50 * zoomLevel}px` }}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-1 left-1 text-xs ${isMainMarker
|
||||||
|
? 'text-muted-foreground font-medium'
|
||||||
|
: 'text-muted-foreground/70'
|
||||||
|
}`}>
|
||||||
|
{(() => {
|
||||||
|
const formatTime = (seconds: 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`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return formatTime(time);
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}).filter(Boolean);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Playhead in ruler */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-10"
|
||||||
|
style={{ left: `${currentTime * 50 * zoomLevel}px` }}
|
||||||
|
>
|
||||||
|
<div className="absolute top-1 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
</div>
|
||||||
No tracks in timeline
|
</ScrollArea>
|
||||||
</p>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tracks Area */}
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* Track Labels */}
|
||||||
|
<div className="w-48 flex-shrink-0 border-r bg-background overflow-y-auto">
|
||||||
|
{tracks.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full py-8 text-center px-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-muted/30 flex items-center justify-center mb-3">
|
||||||
|
<SplitSquareHorizontal className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">No tracks</p>
|
||||||
<p className="text-xs text-muted-foreground/70 mt-1">
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
Add a video or audio track to get started
|
Drop media to create tracks
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col">
|
||||||
{tracks.map((track) => (
|
{tracks.map((track) => (
|
||||||
<TimelineTrackComponent key={track.id} track={track} zoomLevel={zoomLevel} />
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="h-[60px] flex items-center px-3 border-b border-muted/30 bg-background"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<div className={`w-3 h-3 rounded-full flex-shrink-0 ${track.type === 'video' ? 'bg-blue-500' :
|
||||||
|
track.type === 'audio' ? 'bg-green-500' : 'bg-purple-500'
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm font-medium truncate">{track.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Playhead for tracks area */}
|
{/* Timeline Tracks Content */}
|
||||||
{tracks.length > 0 && (
|
<div className="flex-1 relative">
|
||||||
|
<ScrollArea className="h-full w-full">
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-20"
|
ref={timelineRef}
|
||||||
|
className="relative cursor-pointer select-none"
|
||||||
style={{
|
style={{
|
||||||
left: `${currentTime * 50 * zoomLevel + 128}px`,
|
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
|
||||||
|
minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : '200px'
|
||||||
}}
|
}}
|
||||||
|
onClick={handleTimelineClick}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-red-500 rounded-full border-2 border-white shadow-sm" />
|
{tracks.length === 0 ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4 mx-auto">
|
||||||
|
<SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Drop media here to start</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<div
|
||||||
|
key={track.id}
|
||||||
|
className="absolute left-0 right-0 border-b border-muted/30"
|
||||||
|
style={{
|
||||||
|
top: `${index * 60}px`,
|
||||||
|
height: '60px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineTrackContent track={track} zoomLevel={zoomLevel} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Playhead for tracks area */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 w-0.5 bg-red-500 pointer-events-none z-20"
|
||||||
|
style={{
|
||||||
|
left: `${currentTime * 50 * zoomLevel}px`,
|
||||||
|
height: `${tracks.length * 60}px`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
|
function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
|
||||||
const { mediaItems } = useMediaStore();
|
const { mediaItems } = useMediaStore();
|
||||||
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore();
|
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore();
|
||||||
const [isDropping, setIsDropping] = useState(false);
|
const [isDropping, setIsDropping] = useState(false);
|
||||||
@ -546,81 +670,75 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center px-2">
|
<div
|
||||||
<div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
|
className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
|
||||||
{track.name}
|
? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
|
||||||
</div>
|
: "hover:bg-muted/20"
|
||||||
|
}`}
|
||||||
|
onDragOver={handleTrackDragOver}
|
||||||
|
onDragEnter={handleTrackDragEnter}
|
||||||
|
onDragLeave={handleTrackDragLeave}
|
||||||
|
onDrop={handleTrackDrop}
|
||||||
|
onMouseMove={handleResizeMove}
|
||||||
|
onMouseUp={handleResizeEnd}
|
||||||
|
onMouseLeave={handleResizeEnd}
|
||||||
|
>
|
||||||
|
<div className="h-full relative track-clips-container min-w-full">
|
||||||
|
{track.clips.length === 0 ? (
|
||||||
|
<div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
|
||||||
|
}`}>
|
||||||
|
{isDropping ? "Drop clip here" : "Drop media here"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{track.clips.map((clip) => {
|
||||||
|
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
||||||
|
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
||||||
|
const clipLeft = clip.startTime * 50 * zoomLevel;
|
||||||
|
|
||||||
<div
|
return (
|
||||||
className={`flex-1 h-[60px] transition-all duration-150 ease-out ${isDraggedOver
|
|
||||||
? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg transform scale-[1.02]"
|
|
||||||
: "border border-transparent hover:bg-muted/30"
|
|
||||||
}`}
|
|
||||||
onDragOver={handleTrackDragOver}
|
|
||||||
onDragEnter={handleTrackDragEnter}
|
|
||||||
onDragLeave={handleTrackDragLeave}
|
|
||||||
onDrop={handleTrackDrop}
|
|
||||||
onMouseMove={handleResizeMove}
|
|
||||||
onMouseUp={handleResizeEnd}
|
|
||||||
onMouseLeave={handleResizeEnd}
|
|
||||||
>
|
|
||||||
<div className="h-full relative track-clips-container min-w-full">
|
|
||||||
{track.clips.length === 0 ? (
|
|
||||||
<div className={`h-full w-full rounded-sm border-2 border-dashed flex items-center justify-center text-xs text-muted-foreground transition-colors ${isDropping ? "border-blue-500 bg-blue-500/10 text-blue-600" : "border-muted/30"
|
|
||||||
}`}>
|
|
||||||
{isDropping ? "Drop clip here" : "Drop media here"}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{track.clips.map((clip) => {
|
|
||||||
const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd;
|
|
||||||
const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel);
|
|
||||||
const clipLeft = clip.startTime * 50 * zoomLevel;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={clip.id}
|
|
||||||
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
|
|
||||||
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-1 cursor-grab active:cursor-grabbing"
|
|
||||||
draggable={true}
|
|
||||||
onDragStart={(e) => handleClipDragStart(e, clip)}
|
|
||||||
onDragEnd={handleClipDragEnd}
|
|
||||||
>
|
|
||||||
{renderClipContent(clip)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
|
|
||||||
onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Drop position indicator */}
|
|
||||||
{isDraggedOver && dropPosition !== null && (
|
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out"
|
key={clip.id}
|
||||||
style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }}
|
className={`timeline-clip absolute h-full rounded-sm border transition-all duration-200 ${getTrackColor(track.type)} flex items-center py-3 min-w-[80px] overflow-hidden group hover:shadow-lg`}
|
||||||
|
style={{ width: `${clipWidth}px`, left: `${clipLeft}px` }}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
|
<div
|
||||||
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
|
className="absolute left-0 top-0 bottom-0 w-2 cursor-w-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
|
||||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap">
|
onMouseDown={(e) => handleResizeStart(e, clip.id, 'left')}
|
||||||
{dropPosition.toFixed(1)}s
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-grab active:cursor-grabbing"
|
||||||
|
draggable={true}
|
||||||
|
onDragStart={(e) => handleClipDragStart(e, clip)}
|
||||||
|
onDragEnd={handleClipDragEnd}
|
||||||
|
>
|
||||||
|
{renderClipContent(clip)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-0 bottom-0 w-2 cursor-e-resize opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/50 hover:bg-blue-500"
|
||||||
|
onMouseDown={(e) => handleResizeStart(e, clip.id, 'right')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</>
|
})}
|
||||||
)}
|
|
||||||
</div>
|
{/* Drop position indicator */}
|
||||||
|
{isDraggedOver && dropPosition !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-1 bg-blue-500 pointer-events-none z-30 transition-all duration-75 ease-out"
|
||||||
|
style={{ left: `${dropPosition * 50 * zoomLevel}px`, transform: 'translateX(-50%)' }}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
|
||||||
|
<div className="absolute -bottom-2 left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-500 rounded-full border-2 border-white shadow-md" />
|
||||||
|
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-xs text-white bg-blue-500 px-1 py-0.5 rounded whitespace-nowrap">
|
||||||
|
{dropPosition.toFixed(1)}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user