feat: implement TimeScale component with proper zoom calculation

- Add TimeScale component with canvas-based rendering
- Use 5 pixels per second base scale (duration * zoom * 5)
- Implement viewport-based rendering for performance
- Add scroll synchronization with waveforms
- Add 240px padding for alignment with track controls and master area
- Apply custom scrollbar styling
- Update all waveform width calculations to match timeline

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 10:12:13 +01:00
parent 119c8c2942
commit 477a444c78
5 changed files with 446 additions and 27 deletions

View File

@@ -24,6 +24,7 @@ const ImportTrackDialog = React.lazy(() => import('@/components/tracks/ImportTra
const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog }))); const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog })));
const MarkerTimeline = React.lazy(() => import('@/components/markers/MarkerTimeline').then(m => ({ default: m.MarkerTimeline }))); const MarkerTimeline = React.lazy(() => import('@/components/markers/MarkerTimeline').then(m => ({ default: m.MarkerTimeline })));
const MarkerDialog = React.lazy(() => import('@/components/markers/MarkerDialog').then(m => ({ default: m.MarkerDialog }))); const MarkerDialog = React.lazy(() => import('@/components/markers/MarkerDialog').then(m => ({ default: m.MarkerDialog })));
const TimeScale = React.lazy(() => import('@/components/timeline/TimeScale').then(m => ({ default: m.TimeScale })));
// Lazy load analysis components (shown conditionally based on analyzerView) // Lazy load analysis components (shown conditionally based on analyzerView)
const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer }))); const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer })));
@@ -197,6 +198,16 @@ export function AudioEditor() {
// Track last recorded values to detect changes // Track last recorded values to detect changes
const lastRecordedValuesRef = React.useRef<Map<string, { value: number; time: number }>>(new Map()); const lastRecordedValuesRef = React.useRef<Map<string, { value: number; time: number }>>(new Map());
// Time scale scroll synchronization
const timeScaleScrollRef = React.useRef<HTMLDivElement | null>(null);
const timeScaleScrollHandlerRef = React.useRef<(() => void) | null>(null);
const handleTimeScaleScroll = React.useCallback(() => {
if (timeScaleScrollHandlerRef.current) {
timeScaleScrollHandlerRef.current();
}
}, []);
// Automation recording callback // Automation recording callback
const handleAutomationRecording = React.useCallback(( const handleAutomationRecording = React.useCallback((
trackId: string, trackId: string,
@@ -2056,23 +2067,22 @@ export function AudioEditor() {
</header> </header>
{/* Main content area */} {/* Main content area */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* Main canvas area */} {/* Time Scale - Full width */}
<main className="flex-1 flex flex-col overflow-hidden bg-background"> <React.Suspense fallback={null}>
{/* Multi-Track View */} <TimeScale
<div className="flex-1 flex flex-col overflow-hidden"> duration={duration}
{/* Marker Timeline */} zoom={zoom}
<React.Suspense fallback={null}> currentTime={currentTime}
<MarkerTimeline onSeek={seek}
markers={markers} scrollRef={timeScaleScrollRef}
duration={duration} onScroll={handleTimeScaleScroll}
currentTime={currentTime} />
onMarkerEdit={handleEditMarker} </React.Suspense>
onMarkerDelete={handleDeleteMarker}
onSeek={seek}
/>
</React.Suspense>
<div className="flex flex-1 overflow-hidden">
{/* Main canvas area */}
<main className="flex-1 flex flex-col overflow-hidden bg-background">
<TrackList <TrackList
tracks={tracks} tracks={tracks}
zoom={zoom} zoom={zoom}
@@ -2087,18 +2097,20 @@ export function AudioEditor() {
onSeek={seek} onSeek={seek}
onSelectionChange={handleSelectionChange} onSelectionChange={handleSelectionChange}
onToggleRecordEnable={handleToggleRecordEnable} onToggleRecordEnable={handleToggleRecordEnable}
timeScaleScrollRef={timeScaleScrollRef}
onTimeScaleScroll={handleTimeScaleScroll}
timeScaleScrollHandlerRef={timeScaleScrollHandlerRef}
recordingTrackId={recordingTrackId} recordingTrackId={recordingTrackId}
recordingLevel={recordingState.inputLevel} recordingLevel={recordingState.inputLevel}
trackLevels={trackLevels} trackLevels={trackLevels}
onParameterTouched={setParameterTouched} onParameterTouched={setParameterTouched}
isPlaying={isPlaying} isPlaying={isPlaying}
/> />
</div> </main>
</main>
{/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */} {/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */}
<aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60"> <aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Master Controls */} {/* Master Controls */}
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<MasterControls <MasterControls
volume={masterVolume} volume={masterVolume}
@@ -2215,7 +2227,8 @@ export function AudioEditor() {
)} )}
</div> </div>
</div> </div>
</aside> </aside>
</div>
</div> </div>
{/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */} {/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */}

View File

@@ -0,0 +1,259 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import {
timeToPixel,
pixelToTime,
calculateTickInterval,
formatTimeLabel,
getVisibleTimeRange,
} from '@/lib/utils/timeline';
export interface TimeScaleProps {
duration: number;
zoom: number;
currentTime: number;
onSeek?: (time: number) => void;
className?: string;
height?: number;
controlsWidth?: number;
scrollRef?: React.MutableRefObject<HTMLDivElement | null>;
onScroll?: () => void;
}
export function TimeScale({
duration,
zoom,
currentTime,
onSeek,
className,
height = 40,
controlsWidth = 240,
scrollRef: externalScrollRef,
onScroll,
}: TimeScaleProps) {
const localScrollRef = React.useRef<HTMLDivElement>(null);
const scrollRef = externalScrollRef || localScrollRef;
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [viewportWidth, setViewportWidth] = React.useState(800);
const [scrollLeft, setScrollLeft] = React.useState(0);
const [hoverTime, setHoverTime] = React.useState<number | null>(null);
// Calculate total timeline width (match waveform calculation)
// Uses 5 pixels per second as base scale, multiplied by zoom
const PIXELS_PER_SECOND_BASE = 5;
const totalWidth = React.useMemo(() => {
return duration * zoom * PIXELS_PER_SECOND_BASE;
}, [duration, zoom]);
// Update viewport width on resize
React.useEffect(() => {
const scroller = scrollRef.current;
if (!scroller) return;
const updateWidth = () => {
setViewportWidth(scroller.clientWidth);
};
updateWidth();
const resizeObserver = new ResizeObserver(updateWidth);
resizeObserver.observe(scroller);
return () => resizeObserver.disconnect();
}, [scrollRef]);
// Handle scroll - update scrollLeft and trigger onScroll callback
const handleScroll = React.useCallback(() => {
if (scrollRef.current) {
setScrollLeft(scrollRef.current.scrollLeft);
}
if (onScroll) {
onScroll();
}
}, [onScroll, scrollRef]);
// Draw time scale - redraws on scroll and zoom
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || duration === 0) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Set canvas size to viewport width
const dpr = window.devicePixelRatio || 1;
canvas.width = viewportWidth * dpr;
canvas.height = height * dpr;
canvas.style.width = `${viewportWidth}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
// Clear canvas
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || '#ffffff';
ctx.fillRect(0, 0, viewportWidth, height);
// Calculate visible time range
const visibleRange = getVisibleTimeRange(scrollLeft, viewportWidth, duration, zoom);
const visibleDuration = visibleRange.end - visibleRange.start;
// Calculate tick intervals based on visible duration
const { major, minor } = calculateTickInterval(visibleDuration);
// Calculate which ticks to draw (only visible ones)
const startTick = Math.floor(visibleRange.start / minor) * minor;
const endTick = Math.ceil(visibleRange.end / minor) * minor;
// Set up text style for labels
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Draw ticks and labels
for (let time = startTick; time <= endTick; time += minor) {
if (time < 0 || time > duration) continue;
// Calculate x position using the actual totalWidth (not timeToPixel which recalculates)
const x = (time / duration) * totalWidth - scrollLeft;
if (x < 0 || x > viewportWidth) continue;
const isMajor = Math.abs(time % major) < 0.001;
if (isMajor) {
// Major ticks - tall and prominent
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, height - 20);
ctx.lineTo(x, height);
ctx.stroke();
// Major tick label
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
const label = formatTimeLabel(time, visibleDuration < 10);
ctx.fillText(label, x, 6);
} else {
// Minor ticks - shorter and lighter
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, height - 10);
ctx.lineTo(x, height);
ctx.stroke();
// Minor tick label (smaller and lighter)
if (x > 20 && x < viewportWidth - 20) {
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
const label = formatTimeLabel(time, visibleDuration < 10);
ctx.fillText(label, x, 8);
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
}
}
}
// Draw playhead indicator
const playheadX = (currentTime / duration) * totalWidth - scrollLeft;
if (playheadX >= 0 && playheadX <= viewportWidth) {
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, height);
ctx.stroke();
}
// Draw hover indicator
if (hoverTime !== null) {
const hoverX = (hoverTime / duration) * totalWidth - scrollLeft;
if (hoverX >= 0 && hoverX <= viewportWidth) {
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
ctx.lineWidth = 1;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(hoverX, 0);
ctx.lineTo(hoverX, height);
ctx.stroke();
ctx.setLineDash([]);
}
}
}, [duration, zoom, currentTime, viewportWidth, scrollLeft, height, hoverTime, totalWidth]);
// Handle click to seek
const handleClick = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (!onSeek) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const pixelPos = x + scrollLeft;
const time = (pixelPos / totalWidth) * duration;
onSeek(Math.max(0, Math.min(duration, time)));
},
[onSeek, duration, totalWidth, scrollLeft]
);
// Handle mouse move for hover
const handleMouseMove = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const pixelPos = x + scrollLeft;
const time = (pixelPos / totalWidth) * duration;
setHoverTime(Math.max(0, Math.min(duration, time)));
},
[duration, totalWidth, scrollLeft]
);
const handleMouseLeave = React.useCallback(() => {
setHoverTime(null);
}, []);
return (
<div className={cn('relative bg-background', className)} style={{ paddingLeft: '240px', paddingRight: '240px' }}>
<div
ref={scrollRef}
className="w-full bg-background overflow-x-auto overflow-y-hidden custom-scrollbar"
style={{
height: `${height}px`,
}}
onScroll={handleScroll}
>
{/* Spacer to create scrollable width */}
<div style={{ width: `${totalWidth}px`, height: `${height}px`, position: 'relative' }}>
<canvas
ref={canvasRef}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
className="cursor-pointer"
style={{
position: 'sticky',
left: 0,
width: `${viewportWidth}px`,
height: `${height}px`,
}}
/>
</div>
</div>
{/* Hover tooltip */}
{hoverTime !== null && (
<div
className="absolute top-full mt-1 px-2 py-1 bg-popover border border-border rounded shadow-lg text-xs font-mono pointer-events-none z-10"
style={{
left: `${Math.min(
viewportWidth - 60,
Math.max(0, (hoverTime / duration) * totalWidth - scrollLeft - 30)
)}px`,
}}
>
{formatTimeLabel(hoverTime, true)}
</div>
)}
</div>
);
}

View File

@@ -773,8 +773,8 @@ export function Track({
className="relative h-full" className="relative h-full"
style={{ style={{
minWidth: minWidth:
track.audioBuffer && zoom > 1 track.audioBuffer && zoom >= 1
? `${duration * zoom * 100}px` ? `${duration * zoom * 5}px`
: "100%", : "100%",
}} }}
> >

View File

@@ -36,6 +36,9 @@ export interface TrackListProps {
trackLevels?: Record<string, number>; trackLevels?: Record<string, number>;
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void; onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
isPlaying?: boolean; isPlaying?: boolean;
timeScaleScrollRef?: React.MutableRefObject<HTMLDivElement | null>;
onTimeScaleScroll?: () => void;
timeScaleScrollHandlerRef?: React.MutableRefObject<(() => void) | null>;
} }
export function TrackList({ export function TrackList({
@@ -57,6 +60,9 @@ export function TrackList({
trackLevels = {}, trackLevels = {},
onParameterTouched, onParameterTouched,
isPlaying = false, isPlaying = false,
timeScaleScrollRef: externalTimeScaleScrollRef,
onTimeScaleScroll,
timeScaleScrollHandlerRef,
}: TrackListProps) { }: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false); const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null); const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null);
@@ -66,6 +72,8 @@ export function TrackList({
// Refs for horizontal scroll synchronization (per track) // Refs for horizontal scroll synchronization (per track)
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map()); const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map()); const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const localTimeScaleScrollRef = React.useRef<HTMLDivElement | null>(null);
const timeScaleScrollRef = externalTimeScaleScrollRef || localTimeScaleScrollRef;
const [syncingScroll, setSyncingScroll] = React.useState(false); const [syncingScroll, setSyncingScroll] = React.useState(false);
// Synchronize vertical scroll between controls and waveforms // Synchronize vertical scroll between controls and waveforms
@@ -100,6 +108,11 @@ export function TrackList({
el.scrollLeft = scrollLeft; el.scrollLeft = scrollLeft;
}); });
// Sync time scale
if (timeScaleScrollRef.current) {
timeScaleScrollRef.current.scrollLeft = scrollLeft;
}
setSyncingScroll(false); setSyncingScroll(false);
}, [syncingScroll]); }, [syncingScroll]);
@@ -127,9 +140,50 @@ export function TrackList({
} }
}); });
// Sync time scale
if (timeScaleScrollRef.current) {
timeScaleScrollRef.current.scrollLeft = scrollLeft;
}
setSyncingScroll(false); setSyncingScroll(false);
}, [syncingScroll]); }, [syncingScroll]);
const handleTimeScaleScrollInternal = React.useCallback(() => {
if (syncingScroll) return;
setSyncingScroll(true);
if (!timeScaleScrollRef.current) {
setSyncingScroll(false);
return;
}
const scrollLeft = timeScaleScrollRef.current.scrollLeft;
// Sync all waveforms
waveformHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
// Sync all automation lanes
automationHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
setSyncingScroll(false);
// Also call the external callback if provided
if (onTimeScaleScroll) {
onTimeScaleScroll();
}
}, [syncingScroll, onTimeScaleScroll]);
// Expose the scroll handler via ref so AudioEditor can call it
React.useEffect(() => {
if (timeScaleScrollHandlerRef) {
timeScaleScrollHandlerRef.current = handleTimeScaleScrollInternal;
}
}, [handleTimeScaleScrollInternal, timeScaleScrollHandlerRef]);
const handleImportTrack = (buffer: AudioBuffer, name: string) => { const handleImportTrack = (buffer: AudioBuffer, name: string) => {
if (onImportTrack) { if (onImportTrack) {
onImportTrack(buffer, name); onImportTrack(buffer, name);
@@ -490,7 +544,7 @@ export function TrackList({
<div className="overflow-x-auto custom-scrollbar"> <div className="overflow-x-auto custom-scrollbar">
<div <div
style={{ style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%', minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
}} }}
> >
{track.automation.lanes {track.automation.lanes
@@ -799,7 +853,7 @@ export function TrackList({
<div <div
className="h-full" className="h-full"
style={{ style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%', minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
}} }}
> >
<Track <Track
@@ -1072,7 +1126,7 @@ export function TrackList({
> >
<div <div
style={{ style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%', minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
}} }}
> >
{track.automation.lanes {track.automation.lanes

93
lib/utils/timeline.ts Normal file
View File

@@ -0,0 +1,93 @@
/**
* Timeline coordinate conversion and formatting utilities
*/
/**
* Base pixels per second at zoom level 1
* zoom=1: 5 pixels per second
* zoom=2: 10 pixels per second, etc.
*/
const PIXELS_PER_SECOND_BASE = 5;
/**
* Convert time (in seconds) to pixel position
*/
export function timeToPixel(time: number, duration: number, zoom: number): number {
if (duration === 0) return 0;
const totalWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
return (time / duration) * totalWidth;
}
/**
* Convert pixel position to time (in seconds)
*/
export function pixelToTime(pixel: number, duration: number, zoom: number): number {
if (duration === 0) return 0;
const totalWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
return (pixel / totalWidth) * duration;
}
/**
* Calculate appropriate tick interval based on visible duration
* Returns interval in seconds
*/
export function calculateTickInterval(visibleDuration: number): {
major: number;
minor: number;
} {
// Very zoomed in: show sub-second intervals
if (visibleDuration < 5) {
return { major: 1, minor: 0.5 };
}
// Zoomed in: show every second
if (visibleDuration < 20) {
return { major: 5, minor: 1 };
}
// Medium zoom: show every 5 seconds
if (visibleDuration < 60) {
return { major: 10, minor: 5 };
}
// Zoomed out: show every 10 seconds
if (visibleDuration < 300) {
return { major: 30, minor: 10 };
}
// Very zoomed out: show every minute
return { major: 60, minor: 30 };
}
/**
* Format time in seconds to display format
* Returns format like "0:00", "1:23", "12:34.5"
*/
export function formatTimeLabel(seconds: number, showMillis: boolean = false): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (showMillis) {
const wholeSecs = Math.floor(secs);
const decimalPart = Math.floor((secs - wholeSecs) * 10);
return `${mins}:${wholeSecs.toString().padStart(2, '0')}.${decimalPart}`;
}
return `${mins}:${Math.floor(secs).toString().padStart(2, '0')}`;
}
/**
* Calculate visible time range based on scroll position
*/
export function getVisibleTimeRange(
scrollLeft: number,
viewportWidth: number,
duration: number,
zoom: number
): { start: number; end: number } {
const totalWidth = duration * zoom * 100;
const start = pixelToTime(scrollLeft, duration, zoom);
const end = pixelToTime(scrollLeft + viewportWidth, duration, zoom);
return {
start: Math.max(0, start),
end: Math.min(duration, end),
};
}