f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// Plays the timeline (segments of takes) using one pair of <video> elements per take.
2// The element carrying the mic audio is the clock; the other track is kept in sync with it.
4import { useSyncExternalStore } from 'react';
5import type { Segment, Take, TrackKind } from '../../shared/types.ts';
6import { locate, segmentStarts, timelineDuration } from '../../shared/timeline.ts';
8export interface PlayerSnapshot {
9 time: number;
10 playing: boolean;
11 activeTakeId: string | null;
12}
14// Each take has up to three elements: the screen, the camera, and the camera with its background removed.
15export type ElKey = TrackKind | 'cutout';
16type Els = Partial<Record<ElKey, HTMLVideoElement>>;
18export class TimelinePlayer {
19 private segments: Segment[] = [];
20 private takes = new Map<string, Take>();
21 private starts: number[] = [];
22 private els = new Map<string, Els>();
23 private refCallbacks = new Map<string, (el: HTMLVideoElement | null) => void>();
24 private segIndex = 0;
25 private raf = 0;
26 private listeners = new Set<() => void>();
27 private snap: PlayerSnapshot = { time: 0, playing: false, activeTakeId: null };
28 duration = 0;
30 subscribe = (fn: () => void) => {
31 this.listeners.add(fn);
32 return () => {
33 this.listeners.delete(fn);
34 };
35 };
37 getSnapshot = () => this.snap;
39 private emit(patch: Partial<PlayerSnapshot>) {
40 this.snap = { ...this.snap, ...patch };
41 this.listeners.forEach((f) => f());
42 }
44 setData(segments: Segment[], takes: Take[]) {
45 this.segments = segments;
46 this.takes = new Map(takes.map((t) => [t.id, t]));
47 this.starts = segmentStarts(segments);
48 this.duration = timelineDuration(segments);
49 this.seek(Math.min(this.snap.time, this.duration));
50 }
52 // Stable ref callback for a take's <video> element.
53 refFor(takeId: string, track: ElKey) {
54 const key = `${takeId}/${track}`;
55 let cb = this.refCallbacks.get(key);
56 if (!cb) {
57 cb = (el) => this.register(takeId, track, el);
58 this.refCallbacks.set(key, cb);
59 }
60 return cb;
61 }
63 private register(takeId: string, track: ElKey, el: HTMLVideoElement | null) {
64 const e = this.els.get(takeId) ?? {};
65 if (el === (e[track] ?? null)) return;
66 if (el) e[track] = el;
67 else delete e[track];
68 this.els.set(takeId, e);
69 if (el && this.snap.activeTakeId === takeId) {
70 const loc = locate(this.segments, this.snap.time);
71 if (loc) el.currentTime = loc.takeTime;
72 if (this.snap.playing) el.play().catch(() => {});
73 }
74 }
76 elementsFor(takeId: string): Els {
77 return this.els.get(takeId) ?? {};
78 }
80 private list(takeId: string): HTMLVideoElement[] {
81 const e = this.els.get(takeId);
82 return e ? ([e.screen, e.camera, e.cutout].filter(Boolean) as HTMLVideoElement[]) : [];
83 }
85 private master(takeId: string): HTMLVideoElement | undefined {
86 const e = this.els.get(takeId);
87 const take = this.takes.get(takeId);
88 if (!e || !take) return undefined;
89 return (take.audio && e[take.audio]) || e.screen || e.camera;
90 }
92 private activate(takeId: string, takeTime: number, play: boolean) {
93 const prev = this.snap.activeTakeId;
94 if (prev && prev !== takeId) this.list(prev).forEach((el) => el.pause());
95 for (const el of this.list(takeId)) {
96 if (Math.abs(el.currentTime - takeTime) > 0.01) el.currentTime = takeTime;
97 if (play) el.play().catch(() => {});
98 else el.pause();
99 }
100 if (prev !== takeId) this.emit({ activeTakeId: takeId });
101 }
103 seek(t: number) {
104 t = Math.max(0, Math.min(t, this.duration));
105 const loc = locate(this.segments, t);
106 if (!loc) {
107 const prev = this.snap.activeTakeId;
108 if (prev) this.list(prev).forEach((el) => el.pause());
109 cancelAnimationFrame(this.raf);
110 this.emit({ time: 0, playing: false, activeTakeId: null });
111 return;
112 }
113 this.segIndex = loc.index;
114 this.activate(this.segments[loc.index].takeId, loc.takeTime, this.snap.playing);
115 this.emit({ time: t });
116 }
118 play() {
119 if (this.segments.length === 0) return;
120 if (this.snap.time >= this.duration - 0.03) this.emit({ time: 0 });
121 const loc = locate(this.segments, this.snap.time)!;
122 this.segIndex = loc.index;
123 this.emit({ playing: true });
124 this.activate(this.segments[loc.index].takeId, loc.takeTime, true);
125 cancelAnimationFrame(this.raf);
126 this.raf = requestAnimationFrame(this.tick);
127 }
129 pause() {
130 cancelAnimationFrame(this.raf);
131 const a = this.snap.activeTakeId;
132 if (a) this.list(a).forEach((el) => el.pause());
133 this.emit({ playing: false });
134 }
136 toggle() {
137 if (this.snap.playing) this.pause();
138 else this.play();
139 }
141 private tick = () => {
142 if (!this.snap.playing) return;
143 const seg = this.segments[this.segIndex];
144 if (!seg) return this.pause();
145 const m = this.master(seg.takeId);
146 if (!m) {
147 this.raf = requestAnimationFrame(this.tick);
148 return;
149 }
150 let tt = m.currentTime;
151 if (tt >= seg.end - 0.01 || m.ended) {
152 const next = this.segIndex + 1;
153 if (next >= this.segments.length) {
154 this.pause();
155 this.emit({ time: this.duration });
156 return;
157 }
158 const ns = this.segments[next];
159 this.segIndex = next;
160 const continuous = ns.takeId === seg.takeId && Math.abs(ns.start - seg.end) < 0.05;
161 if (!continuous) this.activate(ns.takeId, ns.start, true);
162 tt = ns.start;
163 } else {
164 for (const el of this.list(seg.takeId)) if (el !== m && Math.abs(el.currentTime - tt) > 0.15) el.currentTime = tt;
165 }
166 const cur = this.segments[this.segIndex];
167 this.emit({ time: this.starts[this.segIndex] + Math.max(0, Math.min(tt, cur.end) - cur.start) });
168 this.raf = requestAnimationFrame(this.tick);
169 };
171 destroy() {
172 this.pause();
173 }
174}
176export function usePlayerTime(player: TimelinePlayer) {
177 return useSyncExternalStore(player.subscribe, () => player.getSnapshot().time);
178}
180export function usePlayerPlaying(player: TimelinePlayer) {
181 return useSyncExternalStore(player.subscribe, () => player.getSnapshot().playing);
182}
184export function usePlayerActiveTake(player: TimelinePlayer) {
185 return useSyncExternalStore(player.subscribe, () => player.getSnapshot().activeTakeId);
186}