@joewinke/jatui 0.2.28 → 0.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1592 @@
1
+ <script lang="ts">
2
+ /**
3
+ * VideoPlayer — one video surface for everything, from a bare mp4 to a
4
+ * fully-analysed recording.
5
+ *
6
+ * Marries two prior implementations rather than inventing a third:
7
+ *
8
+ * 1. extension-library's framework-agnostic `mountPlayer` (player.js) —
9
+ * the timeline band: waveform envelope as the scrubber, chapters as
10
+ * structure inside it, diarized speaker colouring, bookmark diamonds,
11
+ * scroll-to-zoom, hover card with "what's being said here".
12
+ * 2. jatui's TranscriptPlayer — the transcript pane: click a line to seek,
13
+ * active line auto-scroll, solo-speaker playback, talk-time bars.
14
+ *
15
+ * EVERY intelligence prop is optional. A bare `<VideoPlayer {src} />` still
16
+ * gets the band (a real scrubber with hover-time, click-to-seek, keyboard
17
+ * seek), wheel volume and the keyboard map; chapters, speakers, waveform,
18
+ * transcript, bookmarks and talk-time each light up their own pane as the
19
+ * data exists. Nothing shifts layout when data arrives late (diarization is
20
+ * slow) — the band's slots are reserved from the first frame.
21
+ *
22
+ * ── Design notes carried over from player.js ────────────────────────────
23
+ * · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
24
+ * the speaker lane, click/hover-to-seek — maps through secToFrac/fracToSec,
25
+ * so scroll-to-zoom is one piece of state everything else respects for free.
26
+ * · ONE speaker surface. With a waveform, the envelope's own bars wear the
27
+ * turn colours; the slim lane under it paints only as the no-waveform
28
+ * fallback. Never two stacked time-parallel strips.
29
+ * · Chapter flags sit on the TOP edge, bookmark diamonds on the BOTTOM edge —
30
+ * same idea (a marker on the timeline), different origin (the model's vs.
31
+ * yours), so the two families are never confused at a glance.
32
+ * · The wheel means different things in different regions, and each region is
33
+ * its own hover target so nothing is ambiguous: over the video it's volume
34
+ * (shift = seek); over the band it's zoom.
35
+ *
36
+ * Deliberately NOT ported: player.js's trim region (in/out handles). That's
37
+ * clip-editing, not playback, and belongs in an editor component.
38
+ *
39
+ * Theming is token-only — DaisyUI semantic vars for chrome, resolved at
40
+ * runtime via `getDaisyUIColor` for the canvas, repainted on theme change.
41
+ * Speaker hues follow TranscriptPlayer's oklch palette (data-driven identity
42
+ * colour, cycled per speaker — not brand colour).
43
+ */
44
+
45
+ import { onMount, untrack } from 'svelte';
46
+ import { getDaisyUIColor, watchThemeChanges, clearColorCache } from '../utils/daisyuiColors';
47
+
48
+ /** A model-authored marker: the video's structure. */
49
+ export interface VideoChapter {
50
+ startSec: number;
51
+ title: string;
52
+ /** Optional captured still, shown in the band's hover card. */
53
+ thumbUrl?: string | null;
54
+ }
55
+
56
+ /** A diarized turn (or an ad read, `ad: true`). Structurally accepts meeting data. */
57
+ export interface VideoSpeakerTurn {
58
+ startSec: number;
59
+ endSec: number;
60
+ speakerIdentity?: string | null;
61
+ speakerName?: string | null;
62
+ /** Explicit oklch hue (0–360). Omit to take the next palette colour. */
63
+ hue?: number | null;
64
+ /** Ad read — shares the lane but reads as not-a-person. */
65
+ ad?: boolean;
66
+ }
67
+
68
+ /** Transcript line. Structurally compatible with TranscriptPlayer's `TranscriptSegment`. */
69
+ export interface VideoTranscriptSegment {
70
+ id: string;
71
+ startSec: number;
72
+ endSec: number;
73
+ speakerIdentity?: string | null;
74
+ speakerName?: string | null;
75
+ text: string;
76
+ }
77
+
78
+ /** A user-authored marker. Structurally compatible with `TranscriptBookmark`. */
79
+ export interface VideoBookmark {
80
+ playbackSec: number;
81
+ label?: string | null;
82
+ note?: string | null;
83
+ participantName?: string | null;
84
+ }
85
+
86
+ /** Precomputed audio envelope. `peaks` are normalised against their own max. */
87
+ export interface VideoWaveform {
88
+ peaks: number[];
89
+ /** Seconds the peaks span. Defaults to the video's duration. */
90
+ durationSec?: number;
91
+ }
92
+
93
+ /** Per-speaker totals. Structurally compatible with `TranscriptSpeakerTalkTime`. */
94
+ export interface VideoSpeakerTalkTime {
95
+ speakerIdentity?: string | null;
96
+ speakerName?: string | null;
97
+ seconds: number;
98
+ }
99
+
100
+ /**
101
+ * Speaker identity palette — the same small distinct oklch set TranscriptPlayer
102
+ * cycles, so a speaker reads as the same colour in both components.
103
+ */
104
+ const SPEAKER_PALETTE = [
105
+ 'oklch(0.70 0.16 250)', // blue
106
+ 'oklch(0.68 0.18 145)', // green
107
+ 'oklch(0.78 0.16 85)', // amber
108
+ 'oklch(0.68 0.19 25)', // red
109
+ 'oklch(0.68 0.16 300)', // violet
110
+ 'oklch(0.72 0.14 200)' // cyan
111
+ ];
112
+
113
+ const ALL_KEY = '__all__';
114
+ const UNKNOWN_KEY = '__unknown__';
115
+
116
+ let {
117
+ src,
118
+ poster = null,
119
+ captionsSrc = null,
120
+ captionsLabel = 'Captions',
121
+ captionsLang = 'en',
122
+ chapters = [],
123
+ speakers = [],
124
+ transcript = [],
125
+ bookmarks = [],
126
+ waveform = null,
127
+ talkTime = [],
128
+ startAt = null,
129
+ transcriptPosition = 'below',
130
+ transcriptMaxHeight = '18rem',
131
+ onTimeUpdate = undefined,
132
+ onAddBookmark = undefined,
133
+ onBookmarkContext = undefined,
134
+ class: className = ''
135
+ }: {
136
+ src: string;
137
+ poster?: string | null;
138
+ captionsSrc?: string | null;
139
+ captionsLabel?: string;
140
+ captionsLang?: string;
141
+ chapters?: VideoChapter[];
142
+ speakers?: VideoSpeakerTurn[];
143
+ transcript?: VideoTranscriptSegment[];
144
+ bookmarks?: VideoBookmark[];
145
+ waveform?: VideoWaveform | null;
146
+ talkTime?: VideoSpeakerTalkTime[];
147
+ /** Seek here once metadata lands (a queued seek never fires on a later source). */
148
+ startAt?: number | null;
149
+ transcriptPosition?: 'below' | 'side';
150
+ transcriptMaxHeight?: string;
151
+ onTimeUpdate?: (video: HTMLVideoElement) => void;
152
+ /** Present ⇒ the band grows an "add bookmark at current time" control. */
153
+ onAddBookmark?: (playbackSec: number, label: string) => void;
154
+ /** Right-click on the band. The host owns the panel; this only hit-tests. */
155
+ onBookmarkContext?: (
156
+ sec: number,
157
+ existing: VideoBookmark | null,
158
+ clientX: number,
159
+ clientY: number
160
+ ) => void;
161
+ class?: string;
162
+ } = $props();
163
+
164
+ // ── Element refs + media state ──────────────────────────────────────────
165
+ let videoEl: HTMLVideoElement | undefined = $state();
166
+ let canvasEl: HTMLCanvasElement | undefined = $state();
167
+
168
+ let currentTime = $state(0);
169
+ let duration = $state(0);
170
+
171
+ // Canvas box, tracked so a resize (panel toggle, window resize) repaints.
172
+ let canvasW = $state(0);
173
+ let canvasH = $state(0);
174
+ // Bumped by the theme observer so the draw effect re-runs on a theme swap.
175
+ let themeTick = $state(0);
176
+
177
+ // ── Band interaction state ──────────────────────────────────────────────
178
+ let viewStart = $state(0);
179
+ let viewEnd = $state(0); // 0 until a duration is known
180
+ let hoverChapter = $state(-1);
181
+ let hover: {
182
+ x: number;
183
+ title: string;
184
+ text: string;
185
+ thumb: string | null;
186
+ isBookmark: boolean;
187
+ } | null = $state(null);
188
+ let zoomLabel = $state('');
189
+ let zoomLabelOn = $state(false);
190
+ let osd = $state(''); // transient wheel readout (volume / seek)
191
+ let osdOn = $state(false);
192
+
193
+ // ── Speaker filter state ────────────────────────────────────────────────
194
+ let soloKey = $state(ALL_KEY);
195
+ let mutedKeys = $state(new Set<string>());
196
+ let highlightKey: string | null = $state(null);
197
+ let bookmarkLabel = $state('');
198
+
199
+ let zoomTimer: ReturnType<typeof setTimeout> | null = null;
200
+ let osdTimer: ReturnType<typeof setTimeout> | null = null;
201
+
202
+ // Row refs for transcript auto-scroll. Plain object — refs are written on
203
+ // mount and only read from inside an effect, never used as a reactive source.
204
+ let rowRefs: Record<string, HTMLButtonElement> = {};
205
+
206
+ // ── Derived data ────────────────────────────────────────────────────────
207
+ const effectiveDuration = $derived(
208
+ Number.isFinite(duration) && duration > 0 ? duration : (waveform?.durationSec ?? 0)
209
+ );
210
+ const useHours = $derived(effectiveDuration >= 3600);
211
+
212
+ const sortedChapters = $derived(
213
+ [...chapters]
214
+ .filter((c) => c && Number.isFinite(c.startSec) && c.startSec >= 0)
215
+ .sort((a, b) => a.startSec - b.startSec)
216
+ );
217
+ const sortedTurns = $derived(
218
+ [...speakers]
219
+ .filter(
220
+ (t) =>
221
+ t &&
222
+ Number.isFinite(t.startSec) &&
223
+ Number.isFinite(t.endSec) &&
224
+ t.endSec > t.startSec
225
+ )
226
+ .sort((a, b) => a.startSec - b.startSec)
227
+ );
228
+ const sortedSegments = $derived(
229
+ [...transcript]
230
+ .filter((s) => s && Number.isFinite(s.startSec))
231
+ .sort((a, b) => a.startSec - b.startSec)
232
+ );
233
+ const sortedBookmarks = $derived(
234
+ [...bookmarks]
235
+ .filter((b) => b && Number.isFinite(b.playbackSec) && b.playbackSec >= 0)
236
+ .sort((a, b) => a.playbackSec - b.playbackSec)
237
+ );
238
+
239
+ /** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
240
+ const peaks = $derived.by(() => {
241
+ const raw = waveform?.peaks;
242
+ if (!Array.isArray(raw) || raw.length === 0) return null;
243
+ let max = 0;
244
+ for (const p of raw) if (Number.isFinite(p) && p > max) max = p;
245
+ if (max <= 0) return null;
246
+ return raw.map((p) => (Number.isFinite(p) ? Math.max(0, p) / max : 0));
247
+ });
248
+ const hasWave = $derived(!!peaks);
249
+
250
+ /**
251
+ * Ordered speaker registry, built from turns first then transcript, so the
252
+ * palette index is stable across both panes and the legend.
253
+ */
254
+ const speakerIndex = $derived.by(() => {
255
+ const map = new Map<string, { key: string; name: string; hue: number | null }>();
256
+ const add = (
257
+ identity: string | null | undefined,
258
+ name: string | null | undefined,
259
+ hue: number | null | undefined
260
+ ) => {
261
+ const key = identity ?? name ?? UNKNOWN_KEY;
262
+ const existing = map.get(key);
263
+ if (existing) {
264
+ if (existing.hue == null && hue != null) existing.hue = hue;
265
+ return;
266
+ }
267
+ map.set(key, { key, name: name ?? 'Unknown speaker', hue: hue ?? null });
268
+ };
269
+ for (const t of sortedTurns) {
270
+ if (t.ad) continue;
271
+ add(t.speakerIdentity, t.speakerName, t.hue ?? null);
272
+ }
273
+ for (const s of sortedSegments) add(s.speakerIdentity, s.speakerName, null);
274
+ return map;
275
+ });
276
+
277
+ const hasSpeakers = $derived(speakerIndex.size > 0);
278
+
279
+ /**
280
+ * key → identity colour, resolved ONCE per data change. draw() asks for a
281
+ * colour per rendered column and reruns on every playhead tick, so this must
282
+ * never be an indexOf scan at call time.
283
+ */
284
+ const speakerColors = $derived.by(() => {
285
+ const map = new Map<string, string>();
286
+ let i = 0;
287
+ for (const [key, entry] of speakerIndex) {
288
+ map.set(
289
+ key,
290
+ entry.hue != null
291
+ ? `oklch(0.72 0.14 ${entry.hue})`
292
+ : SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]
293
+ );
294
+ i++;
295
+ }
296
+ return map;
297
+ });
298
+
299
+ /** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
300
+ const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
301
+
302
+ const effectiveTalkTime = $derived.by(() => {
303
+ if (talkTime.length > 0) {
304
+ return [...talkTime].sort((a, b) => b.seconds - a.seconds);
305
+ }
306
+ const totals = new Map<string, VideoSpeakerTalkTime & { key: string }>();
307
+ const bump = (
308
+ identity: string | null | undefined,
309
+ name: string | null | undefined,
310
+ secs: number
311
+ ) => {
312
+ const key = identity ?? name ?? UNKNOWN_KEY;
313
+ const existing = totals.get(key);
314
+ if (existing) existing.seconds += secs;
315
+ else totals.set(key, { key, speakerIdentity: identity ?? null, speakerName: name ?? null, seconds: secs });
316
+ };
317
+ const source = sortedTurns.length > 0 ? sortedTurns : sortedSegments;
318
+ for (const s of source) {
319
+ if ('ad' in s && s.ad) continue;
320
+ bump(s.speakerIdentity, s.speakerName, Math.max(0, s.endSec - s.startSec));
321
+ }
322
+ return [...totals.values()].sort((a, b) => b.seconds - a.seconds);
323
+ });
324
+ const talkTimeTotal = $derived(effectiveTalkTime.reduce((n, t) => n + t.seconds, 0) || 1);
325
+
326
+ const activeSegment = $derived(
327
+ sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
328
+ );
329
+
330
+ // ── Helpers ─────────────────────────────────────────────────────────────
331
+ function speakerKeyOf(s: {
332
+ speakerIdentity?: string | null;
333
+ speakerName?: string | null;
334
+ }): string {
335
+ return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
336
+ }
337
+
338
+ /** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
339
+ function speakerColor(key: string): string {
340
+ return speakerColors.get(key) ?? SPEAKER_PALETTE[0];
341
+ }
342
+
343
+ function formatTime(totalSeconds: number, withHours: boolean): string {
344
+ const t = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
345
+ const pad = (n: number) => String(n).padStart(2, '0');
346
+ const s = Math.floor(t % 60);
347
+ if (withHours) {
348
+ return `${Math.floor(t / 3600)}:${pad(Math.floor((t % 3600) / 60))}:${pad(s)}`;
349
+ }
350
+ return `${Math.floor(t / 60)}:${pad(s)}`;
351
+ }
352
+
353
+ function formatDuration(totalSeconds: number): string {
354
+ const s = Math.round(Math.max(0, totalSeconds));
355
+ const h = Math.floor(s / 3600);
356
+ const m = Math.floor((s % 3600) / 60);
357
+ if (h > 0) return `${h}h ${m}m`;
358
+ if (m > 0) return `${m}m ${s % 60}s`;
359
+ return `${s}s`;
360
+ }
361
+
362
+ function truncate(s: string, n: number): string {
363
+ return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
364
+ }
365
+
366
+ // ── Zoom window: one mapping every position computation goes through ────
367
+ /**
368
+ * The visible window in seconds. Falls back to the whole video until the
369
+ * duration effect has seeded viewEnd, so a first paint that races metadata
370
+ * still maps positions correctly instead of collapsing to zero.
371
+ */
372
+ function spanSec(): number {
373
+ return viewEnd > viewStart ? viewEnd - viewStart : effectiveDuration;
374
+ }
375
+ function isZoomed(): boolean {
376
+ return viewEnd > 0 && spanSec() < effectiveDuration - 0.01;
377
+ }
378
+ function resetZoom() {
379
+ viewStart = 0;
380
+ viewEnd = effectiveDuration;
381
+ }
382
+ function secToFrac(sec: number): number {
383
+ const span = spanSec();
384
+ return span > 0 ? (sec - viewStart) / span : 0;
385
+ }
386
+ function fracToSec(frac: number): number {
387
+ return viewStart + frac * spanSec();
388
+ }
389
+
390
+ function flashZoomLabel() {
391
+ const d = effectiveDuration;
392
+ const span = spanSec();
393
+ const ratio = span > 0 ? d / span : 1;
394
+ zoomLabel = (ratio < 1.05 ? '1' : ratio.toFixed(1).replace(/\.0$/, '')) + '×';
395
+ zoomLabelOn = true;
396
+ if (zoomTimer) clearTimeout(zoomTimer);
397
+ zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
398
+ }
399
+
400
+ function flashOsd(text: string) {
401
+ osd = text;
402
+ osdOn = true;
403
+ if (osdTimer) clearTimeout(osdTimer);
404
+ osdTimer = setTimeout(() => (osdOn = false), 900);
405
+ }
406
+
407
+ // ── Seeking ─────────────────────────────────────────────────────────────
408
+ function seekTo(sec: number) {
409
+ const d = effectiveDuration;
410
+ const t = Math.max(0, d > 0 ? Math.min(sec, d) : sec);
411
+ currentTime = t; // bind:currentTime pushes this to the element
412
+ if (videoEl) videoEl.currentTime = t;
413
+ }
414
+
415
+ /**
416
+ * Click-to-seek biases toward the START of the spoken line under the click —
417
+ * nobody wants to land mid-sentence. Only when the transcript is dense right
418
+ * there, though: on a sparse transcript the containing line could have begun
419
+ * minutes earlier, and biasing to it would fling the seek somewhere the user
420
+ * never clicked.
421
+ */
422
+ const DENSITY_WINDOW_SEC = 60;
423
+ function biasToSegmentStart(sec: number): number {
424
+ if (sortedSegments.length === 0) return sec;
425
+ let nearby = 0;
426
+ for (const s of sortedSegments) {
427
+ if (Math.abs(s.startSec - sec) <= DENSITY_WINDOW_SEC / 2) nearby++;
428
+ }
429
+ if (nearby <= 1) return sec;
430
+ return segmentAt(sec)?.startSec ?? sec;
431
+ }
432
+
433
+ /** Last line starting at or before `sec` — segments are ascending. */
434
+ function segmentAt(sec: number): VideoTranscriptSegment | null {
435
+ let best: VideoTranscriptSegment | null = null;
436
+ for (const s of sortedSegments) {
437
+ if (s.startSec <= sec) best = s;
438
+ else break;
439
+ }
440
+ return best;
441
+ }
442
+
443
+ function chapterAt(sec: number): number {
444
+ let idx = -1;
445
+ for (let i = 0; i < sortedChapters.length; i++) {
446
+ if (sortedChapters[i].startSec <= sec) idx = i;
447
+ else break;
448
+ }
449
+ return idx;
450
+ }
451
+
452
+ const BOOKMARK_HIT_PX = 6;
453
+ function nearestBookmark(xCss: number, width: number): VideoBookmark | null {
454
+ if (sortedBookmarks.length === 0 || !width) return null;
455
+ let best: VideoBookmark | null = null;
456
+ let bestD = BOOKMARK_HIT_PX;
457
+ for (const b of sortedBookmarks) {
458
+ const d = Math.abs(secToFrac(b.playbackSec) * width - xCss);
459
+ if (d < bestD) {
460
+ bestD = d;
461
+ best = b;
462
+ }
463
+ }
464
+ return best;
465
+ }
466
+
467
+ // ── Canvas ──────────────────────────────────────────────────────────────
468
+ const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
469
+
470
+ /** Does the active solo/mute filter recede this turn's paint? */
471
+ function filterDims(key: string | null, ad: boolean): boolean {
472
+ if (soloKey !== ALL_KEY) return !(key != null && !ad && key === soloKey);
473
+ return !!(key != null && !ad && mutedKeys.has(key));
474
+ }
475
+
476
+ function draw() {
477
+ const canvas = canvasEl;
478
+ if (!canvas) return;
479
+ const W = canvas.clientWidth;
480
+ const H = canvas.clientHeight;
481
+ if (!W || !H) return;
482
+ const dpr = window.devicePixelRatio || 1;
483
+ if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
484
+ canvas.width = Math.round(W * dpr);
485
+ canvas.height = Math.round(H * dpr);
486
+ }
487
+ const ctx = canvas.getContext('2d');
488
+ if (!ctx) return;
489
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
490
+ ctx.clearRect(0, 0, W, H);
491
+
492
+ const dur = effectiveDuration;
493
+ const accent = getDaisyUIColor('primary');
494
+ const neutral = getDaisyUIColor('base-content');
495
+ const marker = getDaisyUIColor('warning');
496
+
497
+ if (dur <= 0) {
498
+ // No duration yet — paint the empty rail so the band owns its space
499
+ // from the first frame and metadata arriving is never a layout shove.
500
+ ctx.globalAlpha = 0.2;
501
+ ctx.fillStyle = neutral;
502
+ ctx.fillRect(0, (H - RAIL_H) / 2, W, RAIL_H);
503
+ ctx.globalAlpha = 1;
504
+ return;
505
+ }
506
+ const p = peaks;
507
+ const withWave = !!p;
508
+ const n = p?.length ?? 0;
509
+ // viewEnd is seeded by the duration effect; until then the window is the
510
+ // whole video, so a first paint that races metadata still lands correctly.
511
+ const span = viewEnd - viewStart || dur;
512
+ // Chapter boundaries in px — a column straddling one is skipped (the seam).
513
+ const bounds = sortedChapters.slice(1).map((c) => secToFrac(c.startSec) * W);
514
+ const GAP = 4;
515
+ const nowChapter = chapterAt(currentTime);
516
+
517
+ const visiblePeaks = withWave ? Math.max(1, Math.round((span / dur) * n)) : 0;
518
+ const step = withWave ? Math.max(1.5, W / visiblePeaks) : 2;
519
+ const cols = Math.max(1, Math.ceil(W / step));
520
+ const bw = Math.max(1, step - 1);
521
+
522
+ // Speaker sweep: columns advance monotonically in time and turns are
523
+ // sorted by start, so ONE pointer walks the turn list across the whole
524
+ // draw — never a per-column find(). Turns can overlap (an ad read inside
525
+ // a speaker's turn), so the sweep keeps a stack of open turns and the
526
+ // latest start wins, matching the lane's paint order.
527
+ let ti = 0;
528
+ const open: VideoSpeakerTurn[] = [];
529
+
530
+ for (let ci = 0; ci < cols; ci++) {
531
+ const x = ci * step;
532
+ if (bounds.some((b) => Math.abs(x + bw / 2 - b) < GAP)) continue;
533
+ const sec = fracToSec((x + step / 2) / W);
534
+ while (ti < sortedTurns.length && sortedTurns[ti].startSec <= sec) open.push(sortedTurns[ti++]);
535
+ while (open.length && open[open.length - 1].endSec <= sec) open.pop();
536
+ const turn = laneCarriesSpeakers ? null : (open[open.length - 1] ?? null);
537
+ const turnKey = turn && !turn.ad ? speakerKeyOf(turn) : null;
538
+
539
+ const ch = sortedChapters.length ? chapterAt(sec) : -1;
540
+ const played = sec <= currentTime;
541
+ const hovered = ch >= 0 && ch === hoverChapter;
542
+
543
+ // Unplayed floor is 0.38 rather than player.js's 0.3: that value was
544
+ // tuned against the theater's near-black backdrop, and on a light
545
+ // theme's base-200 the envelope washed out at it.
546
+ let alpha: number;
547
+ if (played) alpha = hovered ? 1 : 0.85;
548
+ else if (hovered) alpha = 0.6;
549
+ else if (ch >= 0 && ch === nowChapter) alpha = 0.52;
550
+ else alpha = 0.38;
551
+
552
+ // Legend hover extends onto the envelope: the named speaker at full
553
+ // strength, everything else at 40% of its normal alpha — the
554
+ // played/unplayed ladder survives inside both halves.
555
+ if (highlightKey != null) {
556
+ if (turnKey === highlightKey) alpha = played ? 1 : 0.8;
557
+ else alpha *= 0.4;
558
+ } else if (filterDims(turnKey, !!turn?.ad)) {
559
+ alpha *= 0.4;
560
+ }
561
+
562
+ ctx.globalAlpha = alpha;
563
+ if (turn?.ad) ctx.fillStyle = marker;
564
+ else if (turnKey) ctx.fillStyle = speakerColor(turnKey);
565
+ else ctx.fillStyle = played ? accent : neutral;
566
+
567
+ const h = withWave ? Math.max(2, (p as number[])[peakIdx(sec, dur, n)] * (H - 4)) : RAIL_H;
568
+ ctx.fillRect(x, withWave ? H - h : (H - RAIL_H) / 2, bw, h);
569
+ }
570
+ ctx.globalAlpha = 1;
571
+
572
+ // Boundary notches: a hairline in each seam plus a small accent flag on
573
+ // top — the flag reads as a UI marker even when the envelope is dense.
574
+ // Flags within a few px of each other collapse to one so a long video at
575
+ // full zoom-out never smears them into a solid bar.
576
+ ctx.globalAlpha = 0.4;
577
+ ctx.fillStyle = neutral;
578
+ for (const b of bounds) ctx.fillRect(b - 1, 2, 2, H - 4);
579
+ ctx.globalAlpha = 0.95;
580
+ ctx.fillStyle = accent;
581
+ const FLAG_MERGE_PX = 6;
582
+ let lastFlagX = -Infinity;
583
+ for (const b of bounds) {
584
+ if (b - lastFlagX < FLAG_MERGE_PX) continue;
585
+ lastFlagX = b;
586
+ ctx.beginPath();
587
+ ctx.moveTo(b - 3, 0);
588
+ ctx.lineTo(b + 3, 0);
589
+ ctx.lineTo(b, 4);
590
+ ctx.closePath();
591
+ ctx.fill();
592
+ }
593
+
594
+ // Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
595
+ // of the chapter flags on top, so yours and the model's never blur together.
596
+ ctx.fillStyle = marker;
597
+ for (const b of sortedBookmarks) {
598
+ const x = secToFrac(b.playbackSec) * W;
599
+ if (x < -4 || x > W + 4) continue;
600
+ ctx.beginPath();
601
+ ctx.moveTo(x, H);
602
+ ctx.lineTo(x - 3, H - 4);
603
+ ctx.lineTo(x, H - 8);
604
+ ctx.lineTo(x + 3, H - 4);
605
+ ctx.closePath();
606
+ ctx.fill();
607
+ }
608
+
609
+ // Playhead
610
+ ctx.globalAlpha = 0.9;
611
+ ctx.fillStyle = neutral;
612
+ ctx.fillRect(Math.max(0, Math.min(1, secToFrac(currentTime))) * W - 0.5, 0, 1.5, H);
613
+ ctx.globalAlpha = 1;
614
+ }
615
+
616
+ function peakIdx(sec: number, dur: number, n: number): number {
617
+ return Math.max(0, Math.min(n - 1, Math.floor((sec / dur) * n)));
618
+ }
619
+
620
+ // Repaint on any input the canvas reads. Deps are listed explicitly and the
621
+ // body is untracked — draw() touches a lot of state, and letting it register
622
+ // its own reads is exactly the effect-dep leak that has bitten this library
623
+ // before (PreJoinLobby's acquire loop).
624
+ $effect(() => {
625
+ // canvasEl is a dep, not just a read inside draw(): bind:this lands after
626
+ // the first effect run, so without it the very first paint is skipped and
627
+ // nothing ever re-triggers one.
628
+ void canvasEl;
629
+ void currentTime;
630
+ void viewStart;
631
+ void viewEnd;
632
+ void hoverChapter;
633
+ void highlightKey;
634
+ void soloKey;
635
+ void mutedKeys;
636
+ void sortedChapters;
637
+ void sortedTurns;
638
+ void sortedBookmarks;
639
+ void speakerColors;
640
+ void laneCarriesSpeakers;
641
+ void peaks;
642
+ void effectiveDuration;
643
+ void canvasW;
644
+ void canvasH;
645
+ void themeTick;
646
+ untrack(() => draw());
647
+ });
648
+
649
+ // A new duration means the old zoom window may no longer make sense.
650
+ $effect(() => {
651
+ void effectiveDuration;
652
+ untrack(() => resetZoom());
653
+ });
654
+
655
+ // The band gets resized by things this component can't see (a panel toggling,
656
+ // the window, the side-transcript breakpoint), so the canvas tracks its own
657
+ // box. Set up from an $effect keyed on canvasEl rather than onMount: bind:this
658
+ // isn't guaranteed to have landed by the time onMount runs, and a missed
659
+ // observer means a canvas that never paints at all.
660
+ $effect(() => {
661
+ const el = canvasEl;
662
+ if (!el || typeof ResizeObserver === 'undefined') return;
663
+ const ro = new ResizeObserver((entries) => {
664
+ const r = entries[0]?.contentRect;
665
+ if (!r) return;
666
+ canvasW = Math.round(r.width);
667
+ canvasH = Math.round(r.height);
668
+ });
669
+ ro.observe(el);
670
+ return () => ro.disconnect();
671
+ });
672
+
673
+ onMount(() => {
674
+ const stopTheme = watchThemeChanges(() => {
675
+ clearColorCache();
676
+ themeTick++;
677
+ });
678
+ return () => {
679
+ stopTheme();
680
+ if (zoomTimer) clearTimeout(zoomTimer);
681
+ if (osdTimer) clearTimeout(osdTimer);
682
+ };
683
+ });
684
+
685
+ // ── Band pointer handlers ───────────────────────────────────────────────
686
+ function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
687
+ const canvas = canvasEl;
688
+ if (!canvas) return null;
689
+ const r = canvas.getBoundingClientRect();
690
+ if (!r.width || spanSec() <= 0) return null;
691
+ const x = e.clientX - r.left;
692
+ return { sec: fracToSec(x / r.width), x, width: r.width };
693
+ }
694
+
695
+ function onBandClick(e: MouseEvent) {
696
+ const hit = canvasSec(e);
697
+ if (!hit) return;
698
+ seekTo(biasToSegmentStart(hit.sec));
699
+ }
700
+
701
+ function onBandDblClick() {
702
+ if (!isZoomed()) return;
703
+ resetZoom();
704
+ flashZoomLabel();
705
+ }
706
+
707
+ function onBandContextMenu(e: MouseEvent) {
708
+ if (!onBookmarkContext) return;
709
+ const hit = canvasSec(e);
710
+ if (!hit) return;
711
+ e.preventDefault();
712
+ const near = nearestBookmark(hit.x, hit.width);
713
+ onBookmarkContext(near ? near.playbackSec : hit.sec, near, e.clientX, e.clientY);
714
+ }
715
+
716
+ /** Keep the (centre-anchored) hover card inside the band's own width. */
717
+ const CARD_HALF_PX = 90;
718
+ function clampCardX(x: number, width: number): number {
719
+ return Math.max(Math.min(CARD_HALF_PX, width / 2), Math.min(x, width - CARD_HALF_PX));
720
+ }
721
+
722
+ function onBandMove(e: MouseEvent) {
723
+ const hit = canvasSec(e);
724
+ if (!hit) return;
725
+ const cardX = clampCardX(hit.x, hit.width);
726
+ const bm = nearestBookmark(hit.x, hit.width);
727
+ if (bm) {
728
+ // A bookmark under the pointer takes over the card entirely — it's
729
+ // yours, and it outranks the model's chapter guess at this exact spot.
730
+ hover = {
731
+ x: cardX,
732
+ title: bm.label || formatTime(bm.playbackSec, useHours),
733
+ text: bm.note ? truncate(bm.note, 90) : formatTime(bm.playbackSec, useHours),
734
+ thumb: null,
735
+ isBookmark: true
736
+ };
737
+ if (hoverChapter !== -1) hoverChapter = -1;
738
+ return;
739
+ }
740
+ const ch = chapterAt(hit.sec);
741
+ const title = ch >= 0 ? sortedChapters[ch]?.title : '';
742
+ const at = segmentAt(hit.sec);
743
+ hover = {
744
+ x: cardX,
745
+ title: title ? `${title} · ${formatTime(hit.sec, useHours)}` : formatTime(hit.sec, useHours),
746
+ text: at?.text ? truncate(at.text, 90) : '',
747
+ thumb: ch >= 0 ? (sortedChapters[ch]?.thumbUrl ?? null) : null,
748
+ isBookmark: false
749
+ };
750
+ if (ch !== hoverChapter) hoverChapter = ch;
751
+ }
752
+
753
+ function onBandLeave() {
754
+ hover = null;
755
+ if (hoverChapter !== -1) hoverChapter = -1;
756
+ }
757
+
758
+ /**
759
+ * Scroll over the band to zoom, centred on the cursor — the DAW/mpv
760
+ * convention: the point under the pointer stays fixed while the window
761
+ * resizes around it, so scrolling repeatedly walks you inward on exactly the
762
+ * spot you're pointing at. Plain scroll, no modifier: the band is its own
763
+ * hover region (distinct from the video, which owns volume), so nothing else
764
+ * here claims the wheel.
765
+ */
766
+ const ZOOM_FACTOR = 1.3;
767
+ const MIN_WINDOW_SEC = 3;
768
+ function onBandWheel(e: WheelEvent) {
769
+ const d = effectiveDuration;
770
+ const canvas = canvasEl;
771
+ if (!d || !canvas) return;
772
+ const r = canvas.getBoundingClientRect();
773
+ const w = r.width;
774
+ if (!w) return;
775
+ e.preventDefault();
776
+ if (viewEnd <= 0) resetZoom();
777
+ const cursorSec = fracToSec(Math.max(0, Math.min(1, (e.clientX - r.left) / w)));
778
+ const span = spanSec();
779
+ let newSpan = e.deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
780
+ newSpan = Math.min(d, Math.max(MIN_WINDOW_SEC, newSpan));
781
+ const frac = span > 0 ? (cursorSec - viewStart) / span : 0.5;
782
+ viewStart = Math.max(0, Math.min(cursorSec - frac * newSpan, d - newSpan));
783
+ viewEnd = viewStart + newSpan;
784
+ flashZoomLabel();
785
+ }
786
+
787
+ /** Keyboard seek on the band — it advertises `role="slider"`, so it must honour arrows. */
788
+ function onBandKeyDown(e: KeyboardEvent) {
789
+ const d = effectiveDuration;
790
+ if (!d) return;
791
+ const step = e.shiftKey ? 10 : 5;
792
+ if (e.key === 'ArrowLeft') seekTo(currentTime - step);
793
+ else if (e.key === 'ArrowRight') seekTo(currentTime + step);
794
+ else if (e.key === 'Home') seekTo(0);
795
+ else if (e.key === 'End') seekTo(d);
796
+ else if (e.key === 'Escape' && isZoomed()) resetZoom();
797
+ else return;
798
+ e.preventDefault();
799
+ }
800
+
801
+ // ── Video region: wheel = volume (shift = seek), plus a keyboard map ─────
802
+ function onStageWheel(e: WheelEvent) {
803
+ if (!videoEl) return;
804
+ e.preventDefault();
805
+ if (e.shiftKey) {
806
+ const delta = e.deltaY < 0 ? 5 : -5;
807
+ seekTo(currentTime + delta);
808
+ flashOsd(`${delta > 0 ? '+' : ''}${delta}s · ${formatTime(currentTime + delta, useHours)}`);
809
+ return;
810
+ }
811
+ const next = Math.min(1, Math.max(0, videoEl.volume - Math.sign(e.deltaY) * 0.05));
812
+ videoEl.volume = next;
813
+ if (next > 0) videoEl.muted = false;
814
+ flashOsd(`${Math.round(next * 100)}%`);
815
+ }
816
+
817
+ function onRootKeyDown(e: KeyboardEvent) {
818
+ const target = e.target as HTMLElement | null;
819
+ const tag = target?.tagName;
820
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
821
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
822
+ const v = videoEl;
823
+ if (!v) return;
824
+ switch (e.key) {
825
+ case ' ':
826
+ case 'k':
827
+ v.paused ? v.play() : v.pause();
828
+ break;
829
+ case 'ArrowLeft':
830
+ case 'j':
831
+ seekTo(currentTime - (e.shiftKey ? 10 : 5));
832
+ break;
833
+ case 'ArrowRight':
834
+ case 'l':
835
+ seekTo(currentTime + (e.shiftKey ? 10 : 5));
836
+ break;
837
+ case 'ArrowUp':
838
+ v.volume = Math.min(1, v.volume + 0.05);
839
+ flashOsd(`${Math.round(v.volume * 100)}%`);
840
+ break;
841
+ case 'ArrowDown':
842
+ v.volume = Math.max(0, v.volume - 0.05);
843
+ flashOsd(`${Math.round(v.volume * 100)}%`);
844
+ break;
845
+ case 'm':
846
+ v.muted = !v.muted;
847
+ flashOsd(v.muted ? 'Muted' : `${Math.round(v.volume * 100)}%`);
848
+ break;
849
+ case 'Home':
850
+ seekTo(0);
851
+ break;
852
+ case 'End':
853
+ seekTo(effectiveDuration);
854
+ break;
855
+ default:
856
+ return;
857
+ }
858
+ e.preventDefault();
859
+ }
860
+
861
+ // ── Playback: startAt + solo-speaker skipping ───────────────────────────
862
+ let appliedStartAt = false;
863
+ function onLoadedMetadata() {
864
+ if (!appliedStartAt && startAt != null && Number.isFinite(startAt)) {
865
+ appliedStartAt = true;
866
+ seekTo(startAt);
867
+ }
868
+ }
869
+
870
+ /**
871
+ * Solo playback: if we're not inside a turn belonging to the selected
872
+ * speaker, jump forward to their next one. With no later turn we pause
873
+ * rather than let playback run on into someone else's talk time unchecked.
874
+ */
875
+ function applySoloSkip() {
876
+ if (soloKey === ALL_KEY || !videoEl) return;
877
+ const t = videoEl.currentTime;
878
+ const source: { startSec: number; endSec: number; speakerIdentity?: string | null; speakerName?: string | null; ad?: boolean }[] =
879
+ sortedTurns.length > 0 ? sortedTurns : sortedSegments;
880
+ const mine = source.filter((s) => !('ad' in s && s.ad) && speakerKeyOf(s) === soloKey);
881
+ if (mine.length === 0) return;
882
+ if (mine.some((s) => t >= s.startSec && t < s.endSec)) return;
883
+ const next = mine.find((s) => s.startSec > t);
884
+ if (next) videoEl.currentTime = next.startSec;
885
+ else videoEl.pause();
886
+ }
887
+
888
+ function handleTimeUpdate() {
889
+ applySoloSkip();
890
+ // Zoomed in and playback walked past the edge of the window — follow it,
891
+ // or you'd watch a frozen slice of waveform while the video plays on.
892
+ if (isZoomed() && (currentTime < viewStart || currentTime > viewEnd)) {
893
+ const span = spanSec();
894
+ const d = effectiveDuration;
895
+ viewStart = Math.max(0, Math.min(currentTime - span / 2, Math.max(0, d - span)));
896
+ viewEnd = viewStart + span;
897
+ }
898
+ if (videoEl) onTimeUpdate?.(videoEl);
899
+ }
900
+
901
+ // Re-run the solo scan the moment the selection changes rather than waiting
902
+ // for the next native timeupdate tick.
903
+ $effect(() => {
904
+ void soloKey;
905
+ untrack(() => applySoloSkip());
906
+ });
907
+
908
+ // Auto-scroll the active transcript row into view as playback advances.
909
+ $effect(() => {
910
+ const seg = activeSegment;
911
+ if (!seg) return;
912
+ untrack(() => rowRefs[seg.id]?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
913
+ });
914
+
915
+ // ── Legend interactions ─────────────────────────────────────────────────
916
+ function toggleSolo(key: string) {
917
+ soloKey = soloKey === key ? ALL_KEY : key;
918
+ }
919
+ function toggleMute(key: string) {
920
+ const next = new Set(mutedKeys);
921
+ if (next.has(key)) next.delete(key);
922
+ else next.add(key);
923
+ mutedKeys = next;
924
+ }
925
+ function talkTimeKey(t: VideoSpeakerTalkTime): string {
926
+ return t.speakerIdentity ?? t.speakerName ?? UNKNOWN_KEY;
927
+ }
928
+
929
+ function submitBookmark() {
930
+ onAddBookmark?.(currentTime, bookmarkLabel.trim());
931
+ bookmarkLabel = '';
932
+ }
933
+ </script>
934
+
935
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
936
+ <div
937
+ class="vp-root {className}"
938
+ class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0}
939
+ role="region"
940
+ aria-label="Video player"
941
+ tabindex="-1"
942
+ onkeydown={onRootKeyDown}
943
+ >
944
+ <div class="vp-main">
945
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
946
+ <div class="vp-stage" onwheel={onStageWheel}>
947
+ <video
948
+ bind:this={videoEl}
949
+ bind:currentTime
950
+ bind:duration
951
+ ontimeupdate={handleTimeUpdate}
952
+ onloadedmetadata={onLoadedMetadata}
953
+ {src}
954
+ poster={poster ?? undefined}
955
+ controls
956
+ class="vp-video"
957
+ >
958
+ <!-- A captions track is always present for a11y; without a src it is
959
+ an empty, inert track rather than a missing affordance. -->
960
+ <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc} />
961
+ </video>
962
+ <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
963
+ </div>
964
+
965
+ <!-- The band: one time axis for the envelope, chapters, speakers and
966
+ bookmarks. Its height is reserved from the first frame, so data
967
+ arriving late fades into already-owned space. -->
968
+ <div
969
+ class="vp-band"
970
+ onwheel={onBandWheel}
971
+ onmousemove={onBandMove}
972
+ onmouseleave={onBandLeave}
973
+ role="presentation"
974
+ >
975
+ <canvas
976
+ bind:this={canvasEl}
977
+ class="vp-wave"
978
+ class:vp-wave-tall={hasWave}
979
+ role="slider"
980
+ tabindex="0"
981
+ aria-label="Seek"
982
+ aria-valuemin={0}
983
+ aria-valuemax={Math.round(effectiveDuration)}
984
+ aria-valuenow={Math.round(currentTime)}
985
+ aria-valuetext={formatTime(currentTime, useHours)}
986
+ onclick={onBandClick}
987
+ ondblclick={onBandDblClick}
988
+ oncontextmenu={onBandContextMenu}
989
+ onkeydown={onBandKeyDown}
990
+ ></canvas>
991
+
992
+ <!-- Speaker lane. Painted ONLY as the no-waveform fallback: with an
993
+ envelope its own bars wear the turn colours, and two stacked
994
+ time-parallel strips would just restate the same fact twice. The
995
+ empty div still renders so the slot is reserved from the first
996
+ frame — diarization is slow, and turns arriving minutes into a
997
+ session must fade into owned space, never shove the layout.
998
+ Purely informational: the band already owns seek/scrub/zoom, so
999
+ the lane takes no pointer events and grows no affordances. -->
1000
+ <div class="vp-lane" aria-hidden="true">
1001
+ {#if laneCarriesSpeakers}
1002
+ {#each sortedTurns as turn, i (turn.startSec + ':' + i)}
1003
+ {@const key = speakerKeyOf(turn)}
1004
+ {@const l = secToFrac(turn.startSec) * 100}
1005
+ {@const r = secToFrac(turn.endSec) * 100}
1006
+ {#if turn.endSec > viewStart && turn.startSec < (viewEnd || effectiveDuration)}
1007
+ <span
1008
+ class="vp-lane-span"
1009
+ class:vp-lane-ad={turn.ad}
1010
+ class:vp-dim={highlightKey != null
1011
+ ? highlightKey !== key
1012
+ : filterDims(turn.ad ? null : key, !!turn.ad)}
1013
+ style="left: {Math.max(0, Math.min(100, l))}%; width: max({Math.max(
1014
+ 0,
1015
+ Math.min(100, r) - Math.max(0, l)
1016
+ )}%, 0.35%); {turn.ad ? '' : `background: ${speakerColor(key)};`}"
1017
+ ></span>
1018
+ {/if}
1019
+ {/each}
1020
+ {/if}
1021
+ </div>
1022
+
1023
+ <div class="vp-zoom" class:on={zoomLabelOn} aria-hidden="true">{zoomLabel}</div>
1024
+
1025
+ {#if hover}
1026
+ <div
1027
+ class="vp-card"
1028
+ class:has-thumb={!!hover.thumb}
1029
+ style="left: {hover.x}px"
1030
+ aria-hidden="true"
1031
+ >
1032
+ {#if hover.thumb}
1033
+ <img class="vp-card-thumb" src={hover.thumb} alt="" />
1034
+ {/if}
1035
+ <div class="vp-card-title">
1036
+ {#if hover.isBookmark}
1037
+ <svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true" class="vp-bm-icon">
1038
+ <path d="M6 2a2 2 0 0 0-2 2v18l8-5 8 5V4a2 2 0 0 0-2-2H6z" fill="currentColor" />
1039
+ </svg>
1040
+ {/if}
1041
+ {hover.title}
1042
+ </div>
1043
+ {#if hover.text}<div class="vp-card-text">{hover.text}</div>{/if}
1044
+ </div>
1045
+ {/if}
1046
+ </div>
1047
+
1048
+ <div class="vp-times">
1049
+ <span>{formatTime(currentTime, useHours)}</span>
1050
+ {#if isZoomed()}
1051
+ <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1052
+ Reset zoom
1053
+ </button>
1054
+ {/if}
1055
+ <span>{formatTime(effectiveDuration, useHours)}</span>
1056
+ </div>
1057
+
1058
+ {#if hasSpeakers}
1059
+ <!-- Talk-time doubles as the speaker legend: hover a chip to raise that
1060
+ speaker on the timeline, click to solo, and use its mute toggle to
1061
+ skip them. Identification through brightness, never a glow. -->
1062
+ <div class="vp-talktime">
1063
+ <div class="vp-talktime-bar">
1064
+ {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
1065
+ <div
1066
+ class="vp-talktime-seg"
1067
+ style="width: {(tt.seconds / talkTimeTotal) * 100}%; background: {speakerColor(
1068
+ talkTimeKey(tt)
1069
+ )}"
1070
+ title="{tt.speakerName ?? 'Unknown speaker'} — {formatDuration(tt.seconds)}"
1071
+ ></div>
1072
+ {/each}
1073
+ </div>
1074
+ <div class="vp-legend">
1075
+ {#each effectiveTalkTime as tt, i (talkTimeKey(tt) + ':' + i)}
1076
+ {@const key = talkTimeKey(tt)}
1077
+ <span class="vp-legend-item">
1078
+ <button
1079
+ type="button"
1080
+ class="vp-chip"
1081
+ class:solo={soloKey === key}
1082
+ class:vp-dim={mutedKeys.has(key) || (soloKey !== ALL_KEY && soloKey !== key)}
1083
+ onmouseenter={() => (highlightKey = key)}
1084
+ onmouseleave={() => (highlightKey = null)}
1085
+ onfocus={() => (highlightKey = key)}
1086
+ onblur={() => (highlightKey = null)}
1087
+ onclick={() => toggleSolo(key)}
1088
+ aria-pressed={soloKey === key}
1089
+ title="Solo {tt.speakerName ?? 'Unknown speaker'}"
1090
+ >
1091
+ <span class="vp-chip-dot" style="background: {speakerColor(key)}"></span>
1092
+ {tt.speakerName ?? 'Unknown speaker'}
1093
+ <span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
1094
+ </button>
1095
+ <button
1096
+ type="button"
1097
+ class="vp-mute"
1098
+ class:on={mutedKeys.has(key)}
1099
+ onclick={() => toggleMute(key)}
1100
+ aria-pressed={mutedKeys.has(key)}
1101
+ aria-label="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} {tt.speakerName ??
1102
+ 'Unknown speaker'}"
1103
+ title="{mutedKeys.has(key) ? 'Unskip' : 'Skip'} this speaker"
1104
+ >
1105
+ {mutedKeys.has(key) ? '🔇' : '🔈'}
1106
+ </button>
1107
+ </span>
1108
+ {/each}
1109
+ {#if soloKey !== ALL_KEY || mutedKeys.size > 0}
1110
+ <button
1111
+ type="button"
1112
+ class="vp-chip vp-chip-clear"
1113
+ onclick={() => {
1114
+ soloKey = ALL_KEY;
1115
+ mutedKeys = new Set();
1116
+ }}
1117
+ >
1118
+ Clear filter
1119
+ </button>
1120
+ {/if}
1121
+ </div>
1122
+ </div>
1123
+ {/if}
1124
+
1125
+ {#if onAddBookmark}
1126
+ <div class="vp-bookmark-add">
1127
+ <label for="vp-bookmark-label">Bookmark label</label>
1128
+ <input
1129
+ id="vp-bookmark-label"
1130
+ type="text"
1131
+ placeholder="Bookmark label (optional)"
1132
+ bind:value={bookmarkLabel}
1133
+ />
1134
+ <button type="button" class="vp-add-btn" onclick={submitBookmark}>
1135
+ + Bookmark at {formatTime(currentTime, useHours)}
1136
+ </button>
1137
+ </div>
1138
+ {/if}
1139
+ </div>
1140
+
1141
+ {#if sortedSegments.length > 0}
1142
+ <div class="vp-transcript" style="max-height: {transcriptMaxHeight}">
1143
+ {#each sortedSegments as seg (seg.id)}
1144
+ <button
1145
+ type="button"
1146
+ class="vp-row"
1147
+ class:active={activeSegment?.id === seg.id}
1148
+ class:vp-dim={highlightKey != null
1149
+ ? highlightKey !== speakerKeyOf(seg)
1150
+ : filterDims(speakerKeyOf(seg), false)}
1151
+ bind:this={rowRefs[seg.id]}
1152
+ onclick={() => seekTo(seg.startSec)}
1153
+ >
1154
+ <span class="vp-row-time">{formatTime(seg.startSec, useHours)}</span>
1155
+ <span class="vp-row-speaker" style="color: {speakerColor(speakerKeyOf(seg))}">
1156
+ {seg.speakerName ?? 'Unknown speaker'}
1157
+ </span>
1158
+ <span class="vp-row-text">{seg.text}</span>
1159
+ </button>
1160
+ {/each}
1161
+ </div>
1162
+ {/if}
1163
+ </div>
1164
+
1165
+ <style>
1166
+ /* Chrome is token-only: DaisyUI semantic vars for colour, --jatui-* for
1167
+ radius/spacing, --motion-* for easing and duration. The canvas resolves
1168
+ the same semantic colours at runtime via getDaisyUIColor. */
1169
+ .vp-root {
1170
+ display: flex;
1171
+ flex-direction: column;
1172
+ gap: var(--jatui-space-md, 6px);
1173
+ background: var(--color-base-200);
1174
+ border: var(--jatui-border-w, 1px) solid var(--color-base-content, currentColor);
1175
+ border-color: color-mix(in oklch, var(--color-base-content) 12%, transparent);
1176
+ border-radius: var(--jatui-radius-box, 0.5rem);
1177
+ padding: var(--jatui-space-lg, 12px);
1178
+ }
1179
+
1180
+ .vp-root:focus {
1181
+ outline: none;
1182
+ }
1183
+
1184
+ .vp-main {
1185
+ display: flex;
1186
+ flex-direction: column;
1187
+ gap: var(--jatui-space-md, 6px);
1188
+ min-width: 0;
1189
+ }
1190
+
1191
+ /* Side layout: transcript beside the video once there's room for both. */
1192
+ @media (min-width: 64rem) {
1193
+ .vp-root.vp-side {
1194
+ display: grid;
1195
+ grid-template-columns: minmax(0, 3fr) minmax(16rem, 2fr);
1196
+ gap: var(--jatui-space-lg, 12px);
1197
+ align-items: start;
1198
+ }
1199
+ }
1200
+
1201
+ .vp-stage {
1202
+ position: relative;
1203
+ }
1204
+
1205
+ .vp-video {
1206
+ display: block;
1207
+ width: 100%;
1208
+ border-radius: var(--jatui-radius-field, 0.25rem);
1209
+ background: var(--color-neutral);
1210
+ }
1211
+
1212
+ /* Transient wheel readout (volume / seek) — one calm flash, then gone. */
1213
+ .vp-osd {
1214
+ position: absolute;
1215
+ top: var(--jatui-space-base, 8px);
1216
+ left: var(--jatui-space-base, 8px);
1217
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-base, 8px);
1218
+ border-radius: var(--jatui-radius-selector, 0.375rem);
1219
+ background: color-mix(in oklch, var(--color-base-100) 88%, transparent);
1220
+ color: var(--color-base-content);
1221
+ font-size: 0.7rem;
1222
+ font-weight: 600;
1223
+ font-variant-numeric: tabular-nums;
1224
+ pointer-events: none;
1225
+ opacity: 0;
1226
+ transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
1227
+ }
1228
+
1229
+ .vp-osd.on {
1230
+ opacity: 1;
1231
+ }
1232
+
1233
+ /* The band owns its height from the first frame so a waveform or speaker
1234
+ turns arriving minutes in fade into space that is already theirs. */
1235
+ .vp-band {
1236
+ position: relative;
1237
+ padding-top: var(--jatui-space-base, 8px);
1238
+ opacity: 0.55; /* ambient until the pointer arrives */
1239
+ transition: opacity var(--motion-move, 300ms) var(--motion-glide, ease-out);
1240
+ }
1241
+
1242
+ .vp-root:hover .vp-band,
1243
+ .vp-band:focus-within {
1244
+ opacity: 1;
1245
+ }
1246
+
1247
+ .vp-wave {
1248
+ display: block;
1249
+ width: 100%;
1250
+ height: 22px;
1251
+ cursor: pointer;
1252
+ border-radius: var(--jatui-radius-field, 0.25rem);
1253
+ }
1254
+
1255
+ .vp-wave:focus-visible {
1256
+ outline: 2px solid var(--color-primary);
1257
+ outline-offset: 2px;
1258
+ }
1259
+
1260
+ .vp-wave-tall {
1261
+ height: 44px;
1262
+ }
1263
+
1264
+ /* Speaker lane — the no-waveform fallback. Its slot is reserved whether or
1265
+ not turns exist, so arrival is a fade, never a layout shove. */
1266
+ .vp-lane {
1267
+ position: relative;
1268
+ height: 6px;
1269
+ margin-top: var(--jatui-space-xs, 2px);
1270
+ border-radius: 3px;
1271
+ overflow: hidden;
1272
+ pointer-events: none;
1273
+ }
1274
+
1275
+ .vp-lane-span {
1276
+ position: absolute;
1277
+ top: 0;
1278
+ bottom: 0;
1279
+ opacity: 0.85;
1280
+ transition: opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out);
1281
+ }
1282
+
1283
+ /* Ad reads share the lane but read as not-a-person: the warning token,
1284
+ quieter than any voice — never one of the speaker identity colours. */
1285
+ .vp-lane-ad {
1286
+ background: var(--color-warning);
1287
+ opacity: 0.5;
1288
+ }
1289
+
1290
+ .vp-dim {
1291
+ opacity: 0.4;
1292
+ }
1293
+
1294
+ .vp-lane-span.vp-dim {
1295
+ opacity: 0.3;
1296
+ }
1297
+
1298
+ .vp-zoom {
1299
+ position: absolute;
1300
+ top: var(--jatui-space-base, 8px);
1301
+ left: var(--jatui-space-sm, 4px);
1302
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
1303
+ border-radius: var(--jatui-radius-selector, 0.375rem);
1304
+ background: color-mix(in oklch, var(--color-base-100) 85%, transparent);
1305
+ color: var(--color-base-content);
1306
+ font-size: 0.68rem;
1307
+ font-weight: 600;
1308
+ font-variant-numeric: tabular-nums;
1309
+ pointer-events: none;
1310
+ opacity: 0;
1311
+ transition: opacity var(--motion-enter, 200ms) var(--motion-glide, ease-out);
1312
+ }
1313
+
1314
+ .vp-zoom.on {
1315
+ opacity: 1;
1316
+ }
1317
+
1318
+ /* Two-line hover card: what's here on top, what's being said beneath — a
1319
+ timestamp alone doesn't tell you whether a moment is worth a click. */
1320
+ .vp-card {
1321
+ position: absolute;
1322
+ bottom: calc(100% - var(--jatui-space-base, 8px));
1323
+ transform: translateX(-50%);
1324
+ max-width: 22rem;
1325
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
1326
+ background: var(--color-base-100);
1327
+ border: var(--jatui-border-w, 1px) solid
1328
+ color-mix(in oklch, var(--color-base-content) 18%, transparent);
1329
+ border-radius: var(--jatui-radius-box, 0.5rem);
1330
+ color: var(--color-base-content);
1331
+ font-size: 0.7rem;
1332
+ line-height: 1.4;
1333
+ pointer-events: none;
1334
+ z-index: 5;
1335
+ }
1336
+
1337
+ .vp-card.has-thumb {
1338
+ width: 16rem;
1339
+ }
1340
+
1341
+ .vp-card-thumb {
1342
+ display: block;
1343
+ width: 100%;
1344
+ border-radius: var(--jatui-radius-field, 0.25rem);
1345
+ margin-bottom: var(--jatui-space-sm, 4px);
1346
+ }
1347
+
1348
+ .vp-card-title {
1349
+ display: flex;
1350
+ align-items: center;
1351
+ gap: var(--jatui-space-sm, 4px);
1352
+ font-weight: 600;
1353
+ white-space: nowrap;
1354
+ overflow: hidden;
1355
+ text-overflow: ellipsis;
1356
+ }
1357
+
1358
+ .vp-bm-icon {
1359
+ color: var(--color-warning);
1360
+ flex-shrink: 0;
1361
+ }
1362
+
1363
+ .vp-card-text {
1364
+ margin-top: var(--jatui-space-xs, 2px);
1365
+ color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
1366
+ white-space: nowrap;
1367
+ overflow: hidden;
1368
+ text-overflow: ellipsis;
1369
+ }
1370
+
1371
+ .vp-times {
1372
+ display: flex;
1373
+ align-items: center;
1374
+ justify-content: space-between;
1375
+ gap: var(--jatui-space-base, 8px);
1376
+ font-size: 0.68rem;
1377
+ font-variant-numeric: tabular-nums;
1378
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
1379
+ }
1380
+
1381
+ .vp-reset-zoom {
1382
+ background: none;
1383
+ border: none;
1384
+ padding: 0;
1385
+ font-size: inherit;
1386
+ color: var(--color-primary);
1387
+ cursor: pointer;
1388
+ }
1389
+
1390
+ /* Talk-time + legend */
1391
+ .vp-talktime {
1392
+ display: flex;
1393
+ flex-direction: column;
1394
+ gap: var(--jatui-space-sm, 4px);
1395
+ }
1396
+
1397
+ .vp-talktime-bar {
1398
+ display: flex;
1399
+ height: 0.6rem;
1400
+ border-radius: 999px;
1401
+ overflow: hidden;
1402
+ background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
1403
+ }
1404
+
1405
+ .vp-talktime-seg {
1406
+ height: 100%;
1407
+ }
1408
+
1409
+ .vp-legend {
1410
+ display: flex;
1411
+ flex-wrap: wrap;
1412
+ gap: var(--jatui-space-md, 6px);
1413
+ font-size: 0.68rem;
1414
+ }
1415
+
1416
+ .vp-legend-item {
1417
+ display: inline-flex;
1418
+ align-items: center;
1419
+ gap: var(--jatui-space-xs, 2px);
1420
+ }
1421
+
1422
+ .vp-chip {
1423
+ display: inline-flex;
1424
+ align-items: center;
1425
+ gap: var(--jatui-space-sm, 4px);
1426
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
1427
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
1428
+ border: var(--jatui-border-w, 1px) solid transparent;
1429
+ border-radius: var(--jatui-radius-selector, 0.375rem);
1430
+ color: var(--color-base-content);
1431
+ font-size: inherit;
1432
+ cursor: pointer;
1433
+ transition:
1434
+ opacity var(--motion-fast, 150ms) var(--motion-glide, ease-out),
1435
+ border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
1436
+ }
1437
+
1438
+ .vp-chip.solo {
1439
+ border-color: var(--color-primary);
1440
+ }
1441
+
1442
+ .vp-chip-clear {
1443
+ color: var(--color-primary);
1444
+ }
1445
+
1446
+ .vp-chip-dot {
1447
+ width: 0.5rem;
1448
+ height: 0.5rem;
1449
+ border-radius: 999px;
1450
+ flex-shrink: 0;
1451
+ }
1452
+
1453
+ .vp-chip-time {
1454
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
1455
+ font-variant-numeric: tabular-nums;
1456
+ }
1457
+
1458
+ .vp-mute {
1459
+ background: none;
1460
+ border: none;
1461
+ padding: 0 var(--jatui-space-xs, 2px);
1462
+ font-size: 0.7rem;
1463
+ line-height: 1;
1464
+ cursor: pointer;
1465
+ opacity: 0.5;
1466
+ }
1467
+
1468
+ .vp-mute.on,
1469
+ .vp-mute:hover {
1470
+ opacity: 1;
1471
+ }
1472
+
1473
+ /* Add-bookmark row — only rendered when the host wired onAddBookmark. */
1474
+ .vp-bookmark-add {
1475
+ display: flex;
1476
+ align-items: center;
1477
+ gap: var(--jatui-space-sm, 4px);
1478
+ font-size: 0.72rem;
1479
+ }
1480
+
1481
+ .vp-bookmark-add label {
1482
+ position: absolute;
1483
+ width: 1px;
1484
+ height: 1px;
1485
+ padding: 0;
1486
+ margin: -1px;
1487
+ overflow: hidden;
1488
+ clip-path: inset(50%);
1489
+ white-space: nowrap;
1490
+ }
1491
+
1492
+ .vp-bookmark-add input {
1493
+ flex: 1;
1494
+ min-width: 8rem;
1495
+ max-width: 18rem;
1496
+ background: var(--color-base-100);
1497
+ border: var(--jatui-border-w, 1px) solid
1498
+ color-mix(in oklch, var(--color-base-content) 18%, transparent);
1499
+ border-radius: var(--jatui-radius-field, 0.25rem);
1500
+ color: var(--color-base-content);
1501
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-base, 8px);
1502
+ font-size: inherit;
1503
+ }
1504
+
1505
+ .vp-add-btn {
1506
+ background: color-mix(in oklch, var(--color-warning) 16%, transparent);
1507
+ border: var(--jatui-border-w, 1px) solid
1508
+ color-mix(in oklch, var(--color-warning) 45%, transparent);
1509
+ border-radius: var(--jatui-radius-field, 0.25rem);
1510
+ color: var(--color-base-content);
1511
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-lg, 12px);
1512
+ font-size: inherit;
1513
+ white-space: nowrap;
1514
+ cursor: pointer;
1515
+ transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
1516
+ }
1517
+
1518
+ .vp-add-btn:hover {
1519
+ background: color-mix(in oklch, var(--color-warning) 28%, transparent);
1520
+ }
1521
+
1522
+ /* Transcript pane */
1523
+ .vp-transcript {
1524
+ display: flex;
1525
+ flex-direction: column;
1526
+ gap: 1px;
1527
+ overflow-y: auto;
1528
+ background: var(--color-base-100);
1529
+ border: var(--jatui-border-w, 1px) solid
1530
+ color-mix(in oklch, var(--color-base-content) 12%, transparent);
1531
+ border-radius: var(--jatui-radius-box, 0.5rem);
1532
+ padding: var(--jatui-space-sm, 4px);
1533
+ min-width: 0;
1534
+ }
1535
+
1536
+ .vp-row {
1537
+ display: flex;
1538
+ align-items: baseline;
1539
+ flex-wrap: wrap;
1540
+ gap: var(--jatui-space-sm, 4px);
1541
+ background: none;
1542
+ border: none;
1543
+ border-radius: var(--jatui-radius-field, 0.25rem);
1544
+ padding: var(--jatui-space-sm, 4px) var(--jatui-space-md, 6px);
1545
+ text-align: left;
1546
+ cursor: pointer;
1547
+ transition: background var(--motion-fast, 150ms) var(--motion-glide, ease-out);
1548
+ }
1549
+
1550
+ .vp-row:hover {
1551
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
1552
+ }
1553
+
1554
+ .vp-row.active {
1555
+ background: color-mix(in oklch, var(--color-primary) 14%, transparent);
1556
+ box-shadow: inset 2px 0 0 var(--color-primary);
1557
+ }
1558
+
1559
+ .vp-row-time {
1560
+ font-family: ui-monospace, monospace;
1561
+ font-size: 0.66rem;
1562
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
1563
+ flex-shrink: 0;
1564
+ }
1565
+
1566
+ .vp-row-speaker {
1567
+ font-size: 0.72rem;
1568
+ font-weight: 600;
1569
+ flex-shrink: 0;
1570
+ }
1571
+
1572
+ .vp-row-text {
1573
+ font-size: 0.76rem;
1574
+ color: color-mix(in oklch, var(--color-base-content) 85%, transparent);
1575
+ flex: 1;
1576
+ min-width: 10rem;
1577
+ }
1578
+
1579
+ /* Motion is decoration here — every state also has a static read (colour,
1580
+ position, label), so collapsing it changes nothing functional. */
1581
+ @media (prefers-reduced-motion: reduce) {
1582
+ .vp-band,
1583
+ .vp-osd,
1584
+ .vp-zoom,
1585
+ .vp-lane-span,
1586
+ .vp-chip,
1587
+ .vp-add-btn,
1588
+ .vp-row {
1589
+ transition: none;
1590
+ }
1591
+ }
1592
+ </style>