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>
</div>
{/* Tracks Area */}
<ScrollArea className="flex-1">
<div
ref={timelineRef}
className="min-w-[800px] relative cursor-pointer select-none"
onClick={handleTimelineClick}
onWheel={handleWheel}
>
{/* 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">
{/* Timeline Container */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Timeline Header with Ruler */}
<div className="flex border-b bg-background sticky top-0 z-10">
{/* Track Labels Header */}
<div className="w-48 flex-shrink-0 bg-muted/30 border-r flex items-center justify-between px-3 py-2">
<span className="text-sm font-medium text-muted-foreground">Tracks</span>
<div className="text-xs text-muted-foreground">
{zoomLevel.toFixed(1)}x
</div>
</div>
{/* Timeline Tracks */}
<div className="relative">
{tracks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
<SplitSquareHorizontal className="h-8 w-8 text-muted-foreground" />
</div>
<p className="text-sm text-muted-foreground">
No tracks in timeline
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Add a video or audio track to get started
</p>
</div>
) : (
<div className="flex flex-col gap-2.5">
{tracks.map((track) => (
<TimelineTrackComponent key={track.id} track={track} zoomLevel={zoomLevel} />
))}
</div>
)}
{/* Playhead for tracks area */}
{tracks.length > 0 && (
{/* Timeline Ruler */}
<div className="flex-1 relative overflow-hidden">
<ScrollArea className="w-full">
<div
className="absolute top-0 bottom-0 w-0.5 bg-red-500 pointer-events-none z-20"
style={{
left: `${currentTime * 50 * zoomLevel + 128}px`,
}}
className="relative h-12 bg-muted/30"
style={{ width: `${Math.max(1000, duration * 50 * zoomLevel)}px` }}
>
<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" />
{/* 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>
</ScrollArea>
</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">
Drop media to create tracks
</p>
</div>
) : (
<div className="flex flex-col">
{tracks.map((track) => (
<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>
{/* Timeline Tracks Content */}
<div className="flex-1 relative">
<ScrollArea className="h-full w-full">
<div
ref={timelineRef}
className="relative cursor-pointer select-none"
style={{
width: `${Math.max(1000, duration * 50 * zoomLevel)}px`,
minHeight: tracks.length > 0 ? `${tracks.length * 60}px` : '200px'
}}
onClick={handleTimelineClick}
onWheel={handleWheel}
>
{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>
</ScrollArea>
</div>
</div>
</div>
</div>
);
}
function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
function TimelineTrackContent({ track, zoomLevel }: { track: TimelineTrack, zoomLevel: number }) {
const { mediaItems } = useMediaStore();
const { moveClipToTrack, updateClipTrim, updateClipStartTime } = useTimelineStore();
const [isDropping, setIsDropping] = useState(false);
@ -546,15 +670,10 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
};
return (
<div className="flex items-center px-2">
<div className="w-24 text-xs text-muted-foreground flex-shrink-0 mr-2">
{track.name}
</div>
<div
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"
className={`w-full h-full transition-all duration-150 ease-out ${isDraggedOver
? "bg-blue-500/15 border-2 border-dashed border-blue-400 shadow-lg"
: "hover:bg-muted/20"
}`}
onDragOver={handleTrackDragOver}
onDragEnter={handleTrackDragEnter}
@ -622,6 +741,5 @@ function TimelineTrackComponent({ track, zoomLevel }: { track: TimelineTrack, zo
)}
</div>
</div>
</div>
);
}