import { useEffect, useRef, useState, type PointerEvent as RPointerEvent } from 'react'; import type { Segment } from '../../shared/types.ts'; import { locate, segDuration, segmentStarts, timelineDuration } from '../../shared/timeline.ts'; import { fmtDuration, fmtTimecode } from '../lib/format.ts'; import { usePlayerTime, type TimelinePlayer } from '../lib/player.ts'; export interface PeaksData { rate: number; values: Uint8Array; } interface Props { segments: Segment[]; takeNumber: (takeId: string) => number; player: TimelinePlayer; selectedId: string | null; onSelect: (id: string | null) => void; marks: { in: number | null; out: number | null }; peaks: Record; zoom: number; } export const takeHue = (n: number) => (200 + n * 67) % 360; export function Timeline({ segments, takeNumber, player, selectedId, onSelect, marks, peaks, zoom }: Props) { const duration = timelineDuration(segments); const starts = segmentStarts(segments); const scrollRef = useRef(null); const trackRef = useRef(null); const dragging = useRef(false); const pct = (t: number) => `${duration > 0 ? (t / duration) * 100 : 0}%`; const timeAt = (clientX: number) => { const r = trackRef.current!.getBoundingClientRect(); return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration; }; const onPointerDown = (e: RPointerEvent) => { if (duration <= 0) return; dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); const t = timeAt(e.clientX); player.seek(t); const loc = locate(segments, t); onSelect(loc ? segments[loc.index].id : null); }; const onPointerMove = (e: RPointerEvent) => { if (dragging.current) player.seek(timeAt(e.clientX)); }; const onPointerUp = () => { dragging.current = false; }; const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in; return (
{segments.map((s, i) => { const n = takeNumber(s.takeId); return (
Take {n}
); })}
{hasRange &&
} {marks.in !== null &&
} {marks.out !== null &&
}
); } function Playhead({ player, duration, scrollRef }: { player: TimelinePlayer; duration: number; scrollRef: React.RefObject }) { const time = usePlayerTime(player); const ref = useRef(null); const frac = duration > 0 ? time / duration : 0; useEffect(() => { // Keep the playhead in view when zoomed in. const sc = scrollRef.current; const el = ref.current; if (!sc || !el) return; const x = el.offsetLeft; if (x < sc.scrollLeft + 20 || x > sc.scrollLeft + sc.clientWidth - 20) sc.scrollLeft = x - sc.clientWidth * 0.3; }, [frac, scrollRef]); return
; } function Ruler({ duration, zoom }: { duration: number; zoom: number }) { if (duration <= 0) return
; const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200]; const step = steps.find((s) => duration / s <= 10 * zoom) ?? 1800; const ticks: number[] = []; for (let t = 0; t <= duration + 1e-6; t += step) ticks.push(t); return (
{ticks.map((t) => ( {step < 1 ? fmtTimecode(t) : fmtDuration(t)} ))}
); } function Waveform({ peaks, start, end }: { peaks?: PeaksData; start: number; end: number }) { const ref = useRef(null); const [size, setSize] = useState({ w: 0, h: 0 }); useEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })); ro.observe(el); return () => ro.disconnect(); }, []); useEffect(() => { const c = ref.current; if (!c || !peaks || c.clientWidth === 0) return; const dpr = window.devicePixelRatio || 1; c.width = Math.round(c.clientWidth * dpr); c.height = Math.round(c.clientHeight * dpr); const ctx = c.getContext('2d')!; ctx.clearRect(0, 0, c.width, c.height); ctx.fillStyle = 'rgba(255,255,255,0.5)'; const { rate, values } = peaks; const i0 = start * rate; const perPx = ((end - start) * rate) / c.width; const mid = c.height / 2; for (let x = 0; x < c.width; x++) { const a = Math.floor(i0 + x * perPx); const b = Math.max(a + 1, Math.floor(i0 + (x + 1) * perPx)); let m = 0; for (let i = a; i < b && i < values.length; i++) if (values[i] > m) m = values[i]; const h = (m / 255) * c.height * 0.9; ctx.fillRect(x, mid - h / 2, 1, Math.max(1, h)); } }, [peaks, start, end, size]); return ; }