feat: cleanup timeline further, easier to view durations / view larger videos, design makes more sense, more responsive

This commit is contained in:
Hyteq
2025-06-23 09:44:07 +03:00
parent 50931c487b
commit e57618a08f

View File

@ -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>
); );