/ proof-of-concept / takes
Sign in
proof-of-concept / takes
takes / src / components / Timeline.tsx
161 lines · 6.0 KBBlameHistoryRaw
1import { useEffect, useRef, useState, type PointerEvent as RPointerEvent } from 'react';
2import type { Segment } from '../../shared/types.ts';
3import { locate, segDuration, segmentStarts, timelineDuration } from '../../shared/timeline.ts';
4import { fmtDuration, fmtTimecode } from '../lib/format.ts';
5import { usePlayerTime, type TimelinePlayer } from '../lib/player.ts';
7export interface PeaksData {
8 rate: number;
9 values: Uint8Array;
12interface Props {
13 segments: Segment[];
14 takeNumber: (takeId: string) => number;
15 player: TimelinePlayer;
16 selectedId: string | null;
17 onSelect: (id: string | null) => void;
18 marks: { in: number | null; out: number | null };
19 peaks: Record<string, PeaksData>;
20 zoom: number;
23export const takeHue = (n: number) => (200 + n * 67) % 360;
25export function Timeline({ segments, takeNumber, player, selectedId, onSelect, marks, peaks, zoom }: Props) {
26 const duration = timelineDuration(segments);
27 const starts = segmentStarts(segments);
28 const scrollRef = useRef<HTMLDivElement>(null);
29 const trackRef = useRef<HTMLDivElement>(null);
30 const dragging = useRef(false);
31 const pct = (t: number) => `${duration > 0 ? (t / duration) * 100 : 0}%`;
33 const timeAt = (clientX: number) => {
34 const r = trackRef.current!.getBoundingClientRect();
35 return Math.max(0, Math.min(1, (clientX - r.left) / r.width)) * duration;
36 };
38 const onPointerDown = (e: RPointerEvent) => {
39 if (duration <= 0) return;
40 dragging.current = true;
41 e.currentTarget.setPointerCapture(e.pointerId);
42 const t = timeAt(e.clientX);
43 player.seek(t);
44 const loc = locate(segments, t);
45 onSelect(loc ? segments[loc.index].id : null);
46 };
47 const onPointerMove = (e: RPointerEvent) => {
48 if (dragging.current) player.seek(timeAt(e.clientX));
49 };
50 const onPointerUp = () => {
51 dragging.current = false;
52 };
54 const hasRange = marks.in !== null && marks.out !== null && marks.out > marks.in;
56 return (
57 <div className="timeline-scroll" ref={scrollRef}>
58 <div
59 className="timeline"
60 ref={trackRef}
61 style={{ width: `${zoom * 100}%` }}
62 onPointerDown={onPointerDown}
63 onPointerMove={onPointerMove}
64 onPointerUp={onPointerUp}
65 onPointerCancel={onPointerUp}
66 >
67 <Ruler duration={duration} zoom={zoom} />
68 <div className="tl-track">
69 {segments.map((s, i) => {
70 const n = takeNumber(s.takeId);
71 return (
72 <div
73 key={s.id}
74 className={`tl-seg${s.id === selectedId ? ' selected' : ''}`}
75 style={{ left: pct(starts[i]), width: pct(segDuration(s)), ['--hue' as string]: takeHue(n) }}
76 title={`Take ${n}: ${fmtDuration(s.start)}–${fmtDuration(s.end)}`}
77 >
78 <Waveform peaks={peaks[s.takeId]} start={s.start} end={s.end} />
79 <span className="tl-label">Take {n}</span>
80 </div>
81 );
82 })}
83 </div>
84 {hasRange && <div className="tl-range" style={{ left: pct(marks.in!), width: pct(marks.out! - marks.in!) }} />}
85 {marks.in !== null && <div className="tl-mark in" style={{ left: pct(marks.in) }} />}
86 {marks.out !== null && <div className="tl-mark out" style={{ left: pct(marks.out) }} />}
87 <Playhead player={player} duration={duration} scrollRef={scrollRef} />
88 </div>
89 </div>
90 );
93function Playhead({ player, duration, scrollRef }: { player: TimelinePlayer; duration: number; scrollRef: React.RefObject<HTMLDivElement | null> }) {
94 const time = usePlayerTime(player);
95 const ref = useRef<HTMLDivElement>(null);
96 const frac = duration > 0 ? time / duration : 0;
97 useEffect(() => {
98 // Keep the playhead in view when zoomed in.
99 const sc = scrollRef.current;
100 const el = ref.current;
101 if (!sc || !el) return;
102 const x = el.offsetLeft;
103 if (x < sc.scrollLeft + 20 || x > sc.scrollLeft + sc.clientWidth - 20) sc.scrollLeft = x - sc.clientWidth * 0.3;
104 }, [frac, scrollRef]);
105 return <div className="tl-playhead" ref={ref} style={{ left: `${frac * 100}%` }} />;
108function Ruler({ duration, zoom }: { duration: number; zoom: number }) {
109 if (duration <= 0) return <div className="tl-ruler" />;
110 const steps = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200];
111 const step = steps.find((s) => duration / s <= 10 * zoom) ?? 1800;
112 const ticks: number[] = [];
113 for (let t = 0; t <= duration + 1e-6; t += step) ticks.push(t);
114 return (
115 <div className="tl-ruler">
116 {ticks.map((t) => (
117 <span key={t} className="tl-tick" style={{ left: `${(t / duration) * 100}%` }}>
118 {step < 1 ? fmtTimecode(t) : fmtDuration(t)}
119 </span>
120 ))}
121 </div>
122 );
125function Waveform({ peaks, start, end }: { peaks?: PeaksData; start: number; end: number }) {
126 const ref = useRef<HTMLCanvasElement>(null);
127 const [size, setSize] = useState({ w: 0, h: 0 });
129 useEffect(() => {
130 const el = ref.current;
131 if (!el) return;
132 const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight }));
133 ro.observe(el);
134 return () => ro.disconnect();
135 }, []);
137 useEffect(() => {
138 const c = ref.current;
139 if (!c || !peaks || c.clientWidth === 0) return;
140 const dpr = window.devicePixelRatio || 1;
141 c.width = Math.round(c.clientWidth * dpr);
142 c.height = Math.round(c.clientHeight * dpr);
143 const ctx = c.getContext('2d')!;
144 ctx.clearRect(0, 0, c.width, c.height);
145 ctx.fillStyle = 'rgba(255,255,255,0.5)';
146 const { rate, values } = peaks;
147 const i0 = start * rate;
148 const perPx = ((end - start) * rate) / c.width;
149 const mid = c.height / 2;
150 for (let x = 0; x < c.width; x++) {
151 const a = Math.floor(i0 + x * perPx);
152 const b = Math.max(a + 1, Math.floor(i0 + (x + 1) * perPx));
153 let m = 0;
154 for (let i = a; i < b && i < values.length; i++) if (values[i] > m) m = values[i];
155 const h = (m / 255) * c.height * 0.9;
156 ctx.fillRect(x, mid - h / 2, 1, Math.max(1, h));
157 }
158 }, [peaks, start, end, size]);
160 return <canvas className="tl-wave" ref={ref} />;
↑↓ move↵openescclose