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:
@@ -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 */}
|
||||||
|
|||||||
259
components/timeline/TimeScale.tsx
Normal file
259
components/timeline/TimeScale.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
93
lib/utils/timeline.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user