diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 6973501..3fd0a5a 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -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 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 TimeScale = React.lazy(() => import('@/components/timeline/TimeScale').then(m => ({ default: m.TimeScale }))); // Lazy load analysis components (shown conditionally based on analyzerView) 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 const lastRecordedValuesRef = React.useRef>(new Map()); + // Time scale scroll synchronization + const timeScaleScrollRef = React.useRef(null); + const timeScaleScrollHandlerRef = React.useRef<(() => void) | null>(null); + + const handleTimeScaleScroll = React.useCallback(() => { + if (timeScaleScrollHandlerRef.current) { + timeScaleScrollHandlerRef.current(); + } + }, []); + // Automation recording callback const handleAutomationRecording = React.useCallback(( trackId: string, @@ -2056,23 +2067,22 @@ export function AudioEditor() { {/* Main content area */} -
- {/* Main canvas area */} -
- {/* Multi-Track View */} -
- {/* Marker Timeline */} - - - +
+ {/* Time Scale - Full width */} + + + +
+ {/* Main canvas area */} +
-
-
+ - {/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */} - +
{/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */} diff --git a/components/timeline/TimeScale.tsx b/components/timeline/TimeScale.tsx new file mode 100644 index 0000000..366ad1a --- /dev/null +++ b/components/timeline/TimeScale.tsx @@ -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; + onScroll?: () => void; +} + +export function TimeScale({ + duration, + zoom, + currentTime, + onSeek, + className, + height = 40, + controlsWidth = 240, + scrollRef: externalScrollRef, + onScroll, +}: TimeScaleProps) { + const localScrollRef = React.useRef(null); + const scrollRef = externalScrollRef || localScrollRef; + const canvasRef = React.useRef(null); + const [viewportWidth, setViewportWidth] = React.useState(800); + const [scrollLeft, setScrollLeft] = React.useState(0); + const [hoverTime, setHoverTime] = React.useState(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) => { + 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) => { + 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 ( +
+
+ {/* Spacer to create scrollable width */} +
+ +
+
+ + {/* Hover tooltip */} + {hoverTime !== null && ( +
+ {formatTimeLabel(hoverTime, true)} +
+ )} +
+ ); +} diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 8eaceca..2453737 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -773,8 +773,8 @@ export function Track({ className="relative h-full" style={{ minWidth: - track.audioBuffer && zoom > 1 - ? `${duration * zoom * 100}px` + track.audioBuffer && zoom >= 1 + ? `${duration * zoom * 5}px` : "100%", }} > diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index 9e34d8e..c447ccf 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -36,6 +36,9 @@ export interface TrackListProps { trackLevels?: Record; onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void; isPlaying?: boolean; + timeScaleScrollRef?: React.MutableRefObject; + onTimeScaleScroll?: () => void; + timeScaleScrollHandlerRef?: React.MutableRefObject<(() => void) | null>; } export function TrackList({ @@ -57,6 +60,9 @@ export function TrackList({ trackLevels = {}, onParameterTouched, isPlaying = false, + timeScaleScrollRef: externalTimeScaleScrollRef, + onTimeScaleScroll, + timeScaleScrollHandlerRef, }: TrackListProps) { const [importDialogOpen, setImportDialogOpen] = React.useState(false); const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState(null); @@ -66,6 +72,8 @@ export function TrackList({ // Refs for horizontal scroll synchronization (per track) const waveformHScrollRefs = React.useRef>(new Map()); const automationHScrollRefs = React.useRef>(new Map()); + const localTimeScaleScrollRef = React.useRef(null); + const timeScaleScrollRef = externalTimeScaleScrollRef || localTimeScaleScrollRef; const [syncingScroll, setSyncingScroll] = React.useState(false); // Synchronize vertical scroll between controls and waveforms @@ -100,6 +108,11 @@ export function TrackList({ el.scrollLeft = scrollLeft; }); + // Sync time scale + if (timeScaleScrollRef.current) { + timeScaleScrollRef.current.scrollLeft = scrollLeft; + } + setSyncingScroll(false); }, [syncingScroll]); @@ -127,9 +140,50 @@ export function TrackList({ } }); + // Sync time scale + if (timeScaleScrollRef.current) { + timeScaleScrollRef.current.scrollLeft = scrollLeft; + } + setSyncingScroll(false); }, [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) => { if (onImportTrack) { onImportTrack(buffer, name); @@ -490,7 +544,7 @@ export function TrackList({
1 ? `${duration * zoom * 100}px` : '100%', + minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%', }} > {track.automation.lanes @@ -799,7 +853,7 @@ export function TrackList({
1 ? `${duration * zoom * 100}px` : '100%', + minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%', }} >
1 ? `${duration * zoom * 100}px` : '100%', + minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%', }} > {track.automation.lanes diff --git a/lib/utils/timeline.ts b/lib/utils/timeline.ts new file mode 100644 index 0000000..4c6426f --- /dev/null +++ b/lib/utils/timeline.ts @@ -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), + }; +}