'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 // Always ensure minimum width is at least viewport width for full coverage const PIXELS_PER_SECOND_BASE = 5; const totalWidth = React.useMemo(() => { if (zoom >= 1) { const calculatedWidth = duration * zoom * PIXELS_PER_SECOND_BASE; // Ensure it's at least viewport width so timeline always fills return Math.max(calculatedWidth, viewportWidth); } return viewportWidth; }, [duration, zoom, viewportWidth]); // 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)}
)}
); }