@joewinke/jatui 0.8.5 → 0.9.0

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.
@@ -1,3 +1,16 @@
1
+ <script module lang="ts">
2
+ // Public type surface — unchanged (the interfaces moved to video/types.ts in
3
+ // the mos-5lzns.28 internal split; both import sites keep working).
4
+ export type {
5
+ VideoChapter,
6
+ VideoSpeakerTurn,
7
+ VideoTranscriptSegment,
8
+ VideoBookmark,
9
+ VideoWaveform,
10
+ VideoSpeakerTalkTime
11
+ } from '../video/types';
12
+ </script>
13
+
1
14
  <script lang="ts">
2
15
  /**
3
16
  * VideoPlayer — one video surface for everything, from a bare mp4 to a
@@ -21,8 +34,9 @@
21
34
  *
22
35
  * ── Design notes carried over from player.js ────────────────────────────
23
36
  * · 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.
37
+ * the speaker lane, click/hover-to-seek — maps through the band window's
38
+ * secToFrac/fracToSec (video/band.ts), so scroll-to-zoom is one piece of
39
+ * state everything else respects for free.
26
40
  * · ONE speaker surface. With a waveform, the envelope's own bars wear the
27
41
  * turn colours; the slim lane under it paints only as the no-waveform
28
42
  * fallback. Never two stacked time-parallel strips.
@@ -31,7 +45,15 @@
31
45
  * yours), so the two families are never confused at a glance.
32
46
  * · The wheel means different things in different regions, and each region is
33
47
  * its own hover target so nothing is ambiguous: over the video it's volume
34
- * (shift = seek); over the band it's zoom.
48
+ * (shift = seek, alt = speed); over the band it's zoom.
49
+ * · The keyboard map (video/keys.ts) is the single source the keydown
50
+ * handler and the `?` overlay both read — a key that exists is documented,
51
+ * a documented key exists.
52
+ *
53
+ * Internals live in `src/lib/video/` (band / keys / rate / deepLink /
54
+ * settings + types/time/model; audio, night and presence are contracts for
55
+ * mos-5lzns.31/.33/.34 — see that directory's README for the ownership map
56
+ * and the advisor doctrine).
35
57
  *
36
58
  * Deliberately NOT ported: player.js's trim region (in/out handles). That's
37
59
  * clip-editing, not playback, and belongs in an editor component.
@@ -44,74 +66,49 @@
44
66
 
45
67
  import { onMount, untrack } from 'svelte';
46
68
  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__';
69
+ import type {
70
+ VideoBookmark,
71
+ VideoChapter,
72
+ VideoSpeakerTalkTime,
73
+ VideoSpeakerTurn,
74
+ VideoTranscriptSegment,
75
+ VideoWaveform
76
+ } from '../video/types';
77
+ import { aspectRatioCss, formatDuration, formatTime, truncate } from '../video/time';
78
+ import {
79
+ ALL_KEY,
80
+ buildSpeakerColors,
81
+ buildSpeakerIndex,
82
+ buildTalkTime,
83
+ filterDims,
84
+ sortBookmarks,
85
+ sortChapters,
86
+ sortSegments,
87
+ sortTurns,
88
+ speakerKeyOf,
89
+ SPEAKER_PALETTE,
90
+ UNKNOWN_KEY
91
+ } from '../video/model';
92
+ import {
93
+ clampCardX,
94
+ followPlayhead,
95
+ fracToSec,
96
+ isZoomed,
97
+ nearestBookmark,
98
+ paintBand,
99
+ prepareCanvas,
100
+ resetWindow,
101
+ secToFrac,
102
+ biasToSegmentStart,
103
+ segmentAt,
104
+ chapterAt,
105
+ toBandTurns,
106
+ zoomAt,
107
+ type BandWindow
108
+ } from '../video/band';
109
+ import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
110
+ import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
111
+ import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
115
112
 
116
113
  let {
117
114
  src,
@@ -132,6 +129,27 @@
132
129
  startAt = null,
133
130
  transcriptPosition = 'below',
134
131
  transcriptMaxHeight = '18rem',
132
+ /**
133
+ * CORS mode for the <video> element — passed straight through as the
134
+ * `crossorigin` attribute. `'anonymous'` asks the server for a
135
+ * CORS-readable load, which is what frame-reading features (night
136
+ * dimming's canvas sampling, mos-5lzns.33) require. Omitted (default)
137
+ * keeps today's behaviour exactly. extension-library parity: it
138
+ * re-requests with `crossOrigin = "anonymous"` and remembers success —
139
+ * here the host opts in explicitly, because a *component* can't own a
140
+ * localStorage memory the host page also depends on.
141
+ */
142
+ crossorigin = null,
143
+ /**
144
+ * Deep-link the timestamp: when true, a `?t=` param on the current URL
145
+ * seeds the start position (an explicit `startAt` prop still wins) and
146
+ * playback keeps the param in step via history.replaceState — throttled,
147
+ * whole-second precision, every other query param preserved. Opt-in
148
+ * because a component library must not rewrite the host page's URL
149
+ * uninvited. The param persists after unmount: it IS the deep link —
150
+ * refresh/copy/share picks up where playback left off.
151
+ */
152
+ syncUrlTime = false,
135
153
  onTimeUpdate = undefined,
136
154
  onAddBookmark = undefined,
137
155
  onBookmarkContext = undefined,
@@ -185,6 +203,8 @@
185
203
  startAt?: number | null;
186
204
  transcriptPosition?: 'below' | 'side';
187
205
  transcriptMaxHeight?: string;
206
+ crossorigin?: 'anonymous' | 'use-credentials' | null;
207
+ syncUrlTime?: boolean;
188
208
  onTimeUpdate?: (video: HTMLVideoElement) => void;
189
209
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
190
210
  onAddBookmark?: (playbackSec: number, label: string) => void;
@@ -198,28 +218,6 @@
198
218
  class?: string;
199
219
  } = $props();
200
220
 
201
- /**
202
- * `aspect` → a CSS `aspect-ratio` value. Accepts `"W:H"`, a bare ratio number,
203
- * or null/"auto" (⇒ no lock; the video's intrinsic ratio drives the box). Shared
204
- * with <Flythrough>/<LessonEmbed> conceptually, kept inline so the primitive has
205
- * no cross-file coupling for one small pure function.
206
- */
207
- const aspectRatioCss = $derived.by(() => {
208
- const a = aspect;
209
- if (a == null) return null;
210
- const trimmed = String(a).trim();
211
- if (trimmed === '' || trimmed === 'auto') return null;
212
- const colon = trimmed.match(/^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/);
213
- if (colon) {
214
- const w = parseFloat(colon[1]);
215
- const h = parseFloat(colon[2]);
216
- if (w > 0 && h > 0) return `${w} / ${h}`;
217
- }
218
- const num = Number(trimmed);
219
- if (Number.isFinite(num) && num > 0) return String(num);
220
- return null;
221
- });
222
-
223
221
  // prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
224
222
  // under the reduce preference the video holds on its poster instead. Tracked
225
223
  // live (media query listener) so a mid-session preference flip is honoured, and
@@ -255,7 +253,7 @@
255
253
  } | null = $state(null);
256
254
  let zoomLabel = $state('');
257
255
  let zoomLabelOn = $state(false);
258
- let osd = $state(''); // transient wheel readout (volume / seek)
256
+ let osd = $state(''); // transient wheel/keyboard readout (volume / seek / speed)
259
257
  let osdOn = $state(false);
260
258
 
261
259
  // ── Speaker filter state ────────────────────────────────────────────────
@@ -264,6 +262,17 @@
264
262
  let highlightKey: string | null = $state(null);
265
263
  let bookmarkLabel = $state('');
266
264
 
265
+ // ── Speed state (rate.ts owns the staircase; see the composer contract) ──
266
+ // `base` is the only rate a human chose, and therefore the only one the UI
267
+ // reports. Advisors (mos-5lzns.32) multiply into the applied rate without
268
+ // ever touching this — the label must keep saying 1× while a gap is taken
269
+ // at 2.2×, or the readout becomes a strobe.
270
+ let baseRate = $state(1);
271
+
272
+ // ── Transcript + help overlays ──────────────────────────────────────────
273
+ let transcriptVisible = $state(true);
274
+ let keysOpen = $state(false);
275
+
267
276
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
268
277
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
269
278
 
@@ -277,32 +286,10 @@
277
286
  );
278
287
  const useHours = $derived(effectiveDuration >= 3600);
279
288
 
280
- const sortedChapters = $derived(
281
- [...chapters]
282
- .filter((c) => c && Number.isFinite(c.startSec) && c.startSec >= 0)
283
- .sort((a, b) => a.startSec - b.startSec)
284
- );
285
- const sortedTurns = $derived(
286
- [...speakers]
287
- .filter(
288
- (t) =>
289
- t &&
290
- Number.isFinite(t.startSec) &&
291
- Number.isFinite(t.endSec) &&
292
- t.endSec > t.startSec
293
- )
294
- .sort((a, b) => a.startSec - b.startSec)
295
- );
296
- const sortedSegments = $derived(
297
- [...transcript]
298
- .filter((s) => s && Number.isFinite(s.startSec))
299
- .sort((a, b) => a.startSec - b.startSec)
300
- );
301
- const sortedBookmarks = $derived(
302
- [...bookmarks]
303
- .filter((b) => b && Number.isFinite(b.playbackSec) && b.playbackSec >= 0)
304
- .sort((a, b) => a.playbackSec - b.playbackSec)
305
- );
289
+ const sortedChapters = $derived(sortChapters(chapters));
290
+ const sortedTurns = $derived(sortTurns(speakers));
291
+ const sortedSegments = $derived(sortSegments(transcript));
292
+ const sortedBookmarks = $derived(sortBookmarks(bookmarks));
306
293
 
307
294
  /** Peaks normalised to 0–1 against their own max, so 0–1, 0–99 and 0–255 inputs all work. */
308
295
  const peaks = $derived.by(() => {
@@ -319,142 +306,83 @@
319
306
  * Ordered speaker registry, built from turns first then transcript, so the
320
307
  * palette index is stable across both panes and the legend.
321
308
  */
322
- const speakerIndex = $derived.by(() => {
323
- const map = new Map<string, { key: string; name: string; hue: number | null }>();
324
- const add = (
325
- identity: string | null | undefined,
326
- name: string | null | undefined,
327
- hue: number | null | undefined
328
- ) => {
329
- const key = identity ?? name ?? UNKNOWN_KEY;
330
- const existing = map.get(key);
331
- if (existing) {
332
- if (existing.hue == null && hue != null) existing.hue = hue;
333
- return;
334
- }
335
- map.set(key, { key, name: name ?? 'Unknown speaker', hue: hue ?? null });
336
- };
337
- for (const t of sortedTurns) {
338
- if (t.ad) continue;
339
- add(t.speakerIdentity, t.speakerName, t.hue ?? null);
340
- }
341
- for (const s of sortedSegments) add(s.speakerIdentity, s.speakerName, null);
342
- return map;
343
- });
309
+ const speakerIndex = $derived(buildSpeakerIndex(sortedTurns, sortedSegments));
344
310
 
345
311
  const hasSpeakers = $derived(speakerIndex.size > 0);
346
312
 
347
- /**
348
- * key → identity colour, resolved ONCE per data change. draw() asks for a
349
- * colour per rendered column and reruns on every playhead tick, so this must
350
- * never be an indexOf scan at call time.
351
- */
352
- const speakerColors = $derived.by(() => {
353
- const map = new Map<string, string>();
354
- let i = 0;
355
- for (const [key, entry] of speakerIndex) {
356
- map.set(
357
- key,
358
- entry.hue != null
359
- ? `oklch(0.72 0.14 ${entry.hue})`
360
- : SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]
361
- );
362
- i++;
363
- }
364
- return map;
365
- });
313
+ /** key → identity colour, resolved ONCE per data change (see video/model.ts). */
314
+ const speakerColors = $derived(buildSpeakerColors(speakerIndex));
366
315
 
367
316
  /** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
368
317
  const laneCarriesSpeakers = $derived(!hasWave && sortedTurns.length > 0);
369
318
 
370
- const effectiveTalkTime = $derived.by(() => {
371
- if (talkTime.length > 0) {
372
- return [...talkTime].sort((a, b) => b.seconds - a.seconds);
373
- }
374
- const totals = new Map<string, VideoSpeakerTalkTime & { key: string }>();
375
- const bump = (
376
- identity: string | null | undefined,
377
- name: string | null | undefined,
378
- secs: number
379
- ) => {
380
- const key = identity ?? name ?? UNKNOWN_KEY;
381
- const existing = totals.get(key);
382
- if (existing) existing.seconds += secs;
383
- else totals.set(key, { key, speakerIdentity: identity ?? null, speakerName: name ?? null, seconds: secs });
384
- };
385
- const source = sortedTurns.length > 0 ? sortedTurns : sortedSegments;
386
- for (const s of source) {
387
- if ('ad' in s && s.ad) continue;
388
- bump(s.speakerIdentity, s.speakerName, Math.max(0, s.endSec - s.startSec));
389
- }
390
- return [...totals.values()].sort((a, b) => b.seconds - a.seconds);
391
- });
319
+ const effectiveTalkTime = $derived(buildTalkTime(talkTime, sortedTurns, sortedSegments));
392
320
  const talkTimeTotal = $derived(effectiveTalkTime.reduce((n, t) => n + t.seconds, 0) || 1);
393
321
 
394
322
  const activeSegment = $derived(
395
323
  sortedSegments.find((s) => currentTime >= s.startSec && currentTime < s.endSec) ?? null
396
324
  );
397
325
 
326
+ /** The zoom window, as the band module sees it (plain data — band.ts is DOM-free). */
327
+ const bandWindow = $derived<BandWindow>({ start: viewStart, end: viewEnd });
328
+
329
+ /** Everything paintBand reads, resolved in one place (see video/band.ts). */
330
+ const paintState = $derived.by(() => {
331
+ void themeTick; // repaint on theme swap
332
+ return {
333
+ currentTime,
334
+ duration: effectiveDuration,
335
+ peaks,
336
+ chapters: sortedChapters,
337
+ bookmarks: sortedBookmarks,
338
+ turns: toBandTurns(sortedTurns),
339
+ speakerColors,
340
+ laneCarriesSpeakers,
341
+ highlightKey,
342
+ soloKey,
343
+ mutedKeys,
344
+ window: bandWindow,
345
+ hoverChapter,
346
+ colors: {
347
+ accent: getDaisyUIColor('primary'),
348
+ neutral: getDaisyUIColor('base-content'),
349
+ marker: getDaisyUIColor('warning')
350
+ }
351
+ };
352
+ });
353
+
354
+ /** Help-overlay rows: only keys whose feature's data exists are advertised. */
355
+ const availableKeys = $derived(
356
+ PLAYER_KEYS.filter((row) => {
357
+ if (!row.when) return true;
358
+ if (row.when === 'chapters') return sortedChapters.length > 0;
359
+ if (row.when === 'bookmarks') return onAddBookmark != null;
360
+ if (row.when === 'transcript') return sortedSegments.length > 0;
361
+ return true;
362
+ })
363
+ );
364
+
398
365
  // ── Helpers ─────────────────────────────────────────────────────────────
399
- function speakerKeyOf(s: {
400
- speakerIdentity?: string | null;
401
- speakerName?: string | null;
402
- }): string {
403
- return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
404
- }
405
366
 
406
367
  /** Explicit hue wins (host-supplied identity colour); otherwise the cycled palette. */
407
368
  function speakerColor(key: string): string {
408
369
  return speakerColors.get(key) ?? SPEAKER_PALETTE[0];
409
370
  }
410
371
 
411
- function formatTime(totalSeconds: number, withHours: boolean): string {
412
- const t = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : 0;
413
- const pad = (n: number) => String(n).padStart(2, '0');
414
- const s = Math.floor(t % 60);
415
- if (withHours) {
416
- return `${Math.floor(t / 3600)}:${pad(Math.floor((t % 3600) / 60))}:${pad(s)}`;
417
- }
418
- return `${Math.floor(t / 60)}:${pad(s)}`;
419
- }
420
-
421
- function formatDuration(totalSeconds: number): string {
422
- const s = Math.round(Math.max(0, totalSeconds));
423
- const h = Math.floor(s / 3600);
424
- const m = Math.floor((s % 3600) / 60);
425
- if (h > 0) return `${h}h ${m}m`;
426
- if (m > 0) return `${m}m ${s % 60}s`;
427
- return `${s}s`;
428
- }
429
-
430
- function truncate(s: string, n: number): string {
431
- return s.length > n ? s.slice(0, n - 1).trimEnd() + '…' : s;
432
- }
433
-
434
372
  // ── Zoom window: one mapping every position computation goes through ────
435
- /**
436
- * The visible window in seconds. Falls back to the whole video until the
437
- * duration effect has seeded viewEnd, so a first paint that races metadata
438
- * still maps positions correctly instead of collapsing to zero.
439
- */
440
373
  function spanSec(): number {
441
- return viewEnd > viewStart ? viewEnd - viewStart : effectiveDuration;
374
+ return bandWindow.end > bandWindow.start
375
+ ? bandWindow.end - bandWindow.start
376
+ : effectiveDuration;
442
377
  }
443
- function isZoomed(): boolean {
444
- return viewEnd > 0 && spanSec() < effectiveDuration - 0.01;
378
+ function isZoomedNow(): boolean {
379
+ return isZoomed(bandWindow, effectiveDuration);
445
380
  }
446
381
  function resetZoom() {
447
- viewStart = 0;
448
- viewEnd = effectiveDuration;
449
- }
450
- function secToFrac(sec: number): number {
451
- const span = spanSec();
452
- return span > 0 ? (sec - viewStart) / span : 0;
382
+ const w = resetWindow(effectiveDuration);
383
+ viewStart = w.start;
384
+ viewEnd = w.end;
453
385
  }
454
- function fracToSec(frac: number): number {
455
- return viewStart + frac * spanSec();
456
- }
457
-
458
386
  function flashZoomLabel() {
459
387
  const d = effectiveDuration;
460
388
  const span = spanSec();
@@ -480,238 +408,44 @@
480
408
  if (videoEl) videoEl.currentTime = t;
481
409
  }
482
410
 
411
+ // ── Speed ───────────────────────────────────────────────────────────────
483
412
  /**
484
- * Click-to-seek biases toward the START of the spoken line under the click —
485
- * nobody wants to land mid-sentence. Only when the transcript is dense right
486
- * there, though: on a sparse transcript the containing line could have begun
487
- * minutes earlier, and biasing to it would fling the seek somewhere the user
488
- * never clicked.
413
+ * Apply the base rate. No advisors exist yet (mos-5lzns.32 fills the
414
+ * speaker/silence ones) — composeRate collapses them to 1, so today this is
415
+ * exactly `base`. When they land, the composer multiplies and this keeps
416
+ * reporting `base` on the OSD — the contract in video/rate.ts.
489
417
  */
490
- const DENSITY_WINDOW_SEC = 60;
491
- function biasToSegmentStart(sec: number): number {
492
- if (sortedSegments.length === 0) return sec;
493
- let nearby = 0;
494
- for (const s of sortedSegments) {
495
- if (Math.abs(s.startSec - sec) <= DENSITY_WINDOW_SEC / 2) nearby++;
496
- }
497
- if (nearby <= 1) return sec;
498
- return segmentAt(sec)?.startSec ?? sec;
418
+ function applyRate() {
419
+ const v = videoEl;
420
+ if (!v) return;
421
+ const { rate } = composeRate(baseRate, 1, 1);
422
+ // Only real changes are written — playbackRate is a live audio parameter.
423
+ if (v.playbackRate !== rate) v.playbackRate = rate;
499
424
  }
500
-
501
- /** Last line starting at or before `sec` — segments are ascending. */
502
- function segmentAt(sec: number): VideoTranscriptSegment | null {
503
- let best: VideoTranscriptSegment | null = null;
504
- for (const s of sortedSegments) {
505
- if (s.startSec <= sec) best = s;
506
- else break;
507
- }
508
- return best;
509
- }
510
-
511
- function chapterAt(sec: number): number {
512
- let idx = -1;
513
- for (let i = 0; i < sortedChapters.length; i++) {
514
- if (sortedChapters[i].startSec <= sec) idx = i;
515
- else break;
516
- }
517
- return idx;
518
- }
519
-
520
- const BOOKMARK_HIT_PX = 6;
521
- function nearestBookmark(xCss: number, width: number): VideoBookmark | null {
522
- if (sortedBookmarks.length === 0 || !width) return null;
523
- let best: VideoBookmark | null = null;
524
- let bestD = BOOKMARK_HIT_PX;
525
- for (const b of sortedBookmarks) {
526
- const d = Math.abs(secToFrac(b.playbackSec) * width - xCss);
527
- if (d < bestD) {
528
- bestD = d;
529
- best = b;
530
- }
531
- }
532
- return best;
425
+ function stepSpeed(direction: 1 | -1) {
426
+ baseRate = stepRate(baseRate, direction);
427
+ applyRate();
428
+ flashOsd(formatSpeedLabel(baseRate));
533
429
  }
534
430
 
535
431
  // ── Canvas ──────────────────────────────────────────────────────────────
536
- const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
537
-
538
- /** Does the active solo/mute filter recede this turn's paint? */
539
- function filterDims(key: string | null, ad: boolean): boolean {
540
- if (soloKey !== ALL_KEY) return !(key != null && !ad && key === soloKey);
541
- return !!(key != null && !ad && mutedKeys.has(key));
542
- }
543
-
544
- function draw() {
545
- const canvas = canvasEl;
546
- if (!canvas) return;
547
- const W = canvas.clientWidth;
548
- const H = canvas.clientHeight;
549
- if (!W || !H) return;
550
- const dpr = window.devicePixelRatio || 1;
551
- if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
552
- canvas.width = Math.round(W * dpr);
553
- canvas.height = Math.round(H * dpr);
554
- }
555
- const ctx = canvas.getContext('2d');
556
- if (!ctx) return;
557
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
558
- ctx.clearRect(0, 0, W, H);
559
-
560
- const dur = effectiveDuration;
561
- const accent = getDaisyUIColor('primary');
562
- const neutral = getDaisyUIColor('base-content');
563
- const marker = getDaisyUIColor('warning');
564
-
565
- if (dur <= 0) {
566
- // No duration yet — paint the empty rail so the band owns its space
567
- // from the first frame and metadata arriving is never a layout shove.
568
- ctx.globalAlpha = 0.2;
569
- ctx.fillStyle = neutral;
570
- ctx.fillRect(0, (H - RAIL_H) / 2, W, RAIL_H);
571
- ctx.globalAlpha = 1;
572
- return;
573
- }
574
- const p = peaks;
575
- const withWave = !!p;
576
- const n = p?.length ?? 0;
577
- // viewEnd is seeded by the duration effect; until then the window is the
578
- // whole video, so a first paint that races metadata still lands correctly.
579
- const span = viewEnd - viewStart || dur;
580
- // Chapter boundaries in px — a column straddling one is skipped (the seam).
581
- const bounds = sortedChapters.slice(1).map((c) => secToFrac(c.startSec) * W);
582
- const GAP = 4;
583
- const nowChapter = chapterAt(currentTime);
584
-
585
- const visiblePeaks = withWave ? Math.max(1, Math.round((span / dur) * n)) : 0;
586
- const step = withWave ? Math.max(1.5, W / visiblePeaks) : 2;
587
- const cols = Math.max(1, Math.ceil(W / step));
588
- const bw = Math.max(1, step - 1);
589
-
590
- // Speaker sweep: columns advance monotonically in time and turns are
591
- // sorted by start, so ONE pointer walks the turn list across the whole
592
- // draw — never a per-column find(). Turns can overlap (an ad read inside
593
- // a speaker's turn), so the sweep keeps a stack of open turns and the
594
- // latest start wins, matching the lane's paint order.
595
- let ti = 0;
596
- const open: VideoSpeakerTurn[] = [];
597
-
598
- for (let ci = 0; ci < cols; ci++) {
599
- const x = ci * step;
600
- if (bounds.some((b) => Math.abs(x + bw / 2 - b) < GAP)) continue;
601
- const sec = fracToSec((x + step / 2) / W);
602
- while (ti < sortedTurns.length && sortedTurns[ti].startSec <= sec) open.push(sortedTurns[ti++]);
603
- while (open.length && open[open.length - 1].endSec <= sec) open.pop();
604
- const turn = laneCarriesSpeakers ? null : (open[open.length - 1] ?? null);
605
- const turnKey = turn && !turn.ad ? speakerKeyOf(turn) : null;
606
-
607
- const ch = sortedChapters.length ? chapterAt(sec) : -1;
608
- const played = sec <= currentTime;
609
- const hovered = ch >= 0 && ch === hoverChapter;
610
-
611
- // Unplayed floor is 0.38 rather than player.js's 0.3: that value was
612
- // tuned against the theater's near-black backdrop, and on a light
613
- // theme's base-200 the envelope washed out at it.
614
- let alpha: number;
615
- if (played) alpha = hovered ? 1 : 0.85;
616
- else if (hovered) alpha = 0.6;
617
- else if (ch >= 0 && ch === nowChapter) alpha = 0.52;
618
- else alpha = 0.38;
619
-
620
- // Legend hover extends onto the envelope: the named speaker at full
621
- // strength, everything else at 40% of its normal alpha — the
622
- // played/unplayed ladder survives inside both halves.
623
- if (highlightKey != null) {
624
- if (turnKey === highlightKey) alpha = played ? 1 : 0.8;
625
- else alpha *= 0.4;
626
- } else if (filterDims(turnKey, !!turn?.ad)) {
627
- alpha *= 0.4;
628
- }
629
-
630
- ctx.globalAlpha = alpha;
631
- if (turn?.ad) ctx.fillStyle = marker;
632
- else if (turnKey) ctx.fillStyle = speakerColor(turnKey);
633
- else ctx.fillStyle = played ? accent : neutral;
634
-
635
- const h = withWave ? Math.max(2, (p as number[])[peakIdx(sec, dur, n)] * (H - 4)) : RAIL_H;
636
- ctx.fillRect(x, withWave ? H - h : (H - RAIL_H) / 2, bw, h);
637
- }
638
- ctx.globalAlpha = 1;
639
-
640
- // Boundary notches: a hairline in each seam plus a small accent flag on
641
- // top — the flag reads as a UI marker even when the envelope is dense.
642
- // Flags within a few px of each other collapse to one so a long video at
643
- // full zoom-out never smears them into a solid bar.
644
- ctx.globalAlpha = 0.4;
645
- ctx.fillStyle = neutral;
646
- for (const b of bounds) ctx.fillRect(b - 1, 2, 2, H - 4);
647
- ctx.globalAlpha = 0.95;
648
- ctx.fillStyle = accent;
649
- const FLAG_MERGE_PX = 6;
650
- let lastFlagX = -Infinity;
651
- for (const b of bounds) {
652
- if (b - lastFlagX < FLAG_MERGE_PX) continue;
653
- lastFlagX = b;
654
- ctx.beginPath();
655
- ctx.moveTo(b - 3, 0);
656
- ctx.lineTo(b + 3, 0);
657
- ctx.lineTo(b, 4);
658
- ctx.closePath();
659
- ctx.fill();
660
- }
661
-
662
- // Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
663
- // of the chapter flags on top, so yours and the model's never blur together.
664
- ctx.fillStyle = marker;
665
- for (const b of sortedBookmarks) {
666
- const x = secToFrac(b.playbackSec) * W;
667
- if (x < -4 || x > W + 4) continue;
668
- ctx.beginPath();
669
- ctx.moveTo(x, H);
670
- ctx.lineTo(x - 3, H - 4);
671
- ctx.lineTo(x, H - 8);
672
- ctx.lineTo(x + 3, H - 4);
673
- ctx.closePath();
674
- ctx.fill();
675
- }
676
-
677
- // Playhead
678
- ctx.globalAlpha = 0.9;
679
- ctx.fillStyle = neutral;
680
- ctx.fillRect(Math.max(0, Math.min(1, secToFrac(currentTime))) * W - 0.5, 0, 1.5, H);
681
- ctx.globalAlpha = 1;
682
- }
683
-
684
- function peakIdx(sec: number, dur: number, n: number): number {
685
- return Math.max(0, Math.min(n - 1, Math.floor((sec / dur) * n)));
686
- }
687
-
688
432
  // Repaint on any input the canvas reads. Deps are listed explicitly and the
689
- // body is untracked — draw() touches a lot of state, and letting it register
690
- // its own reads is exactly the effect-dep leak that has bitten this library
691
- // before (PreJoinLobby's acquire loop).
433
+ // body is untracked — letting paintState's own reads register as deps is
434
+ // exactly the effect-dep leak that has bitten this library before
435
+ // (PreJoinLobby's acquire loop). paintState itself is a $derived, so it is
436
+ // one dep; canvasEl/canvasW/canvasH are the box deps.
692
437
  $effect(() => {
693
- // canvasEl is a dep, not just a read inside draw(): bind:this lands after
694
- // the first effect run, so without it the very first paint is skipped and
695
- // nothing ever re-triggers one.
696
438
  void canvasEl;
697
- void currentTime;
698
- void viewStart;
699
- void viewEnd;
700
- void hoverChapter;
701
- void highlightKey;
702
- void soloKey;
703
- void mutedKeys;
704
- void sortedChapters;
705
- void sortedTurns;
706
- void sortedBookmarks;
707
- void speakerColors;
708
- void laneCarriesSpeakers;
709
- void peaks;
710
- void effectiveDuration;
439
+ void paintState;
711
440
  void canvasW;
712
441
  void canvasH;
713
- void themeTick;
714
- untrack(() => draw());
442
+ untrack(() => {
443
+ const canvas = canvasEl;
444
+ if (!canvas) return;
445
+ const ctx = prepareCanvas(canvas);
446
+ if (!ctx) return;
447
+ paintBand(ctx, canvas.clientWidth, canvas.clientHeight, paintState);
448
+ });
715
449
  });
716
450
 
717
451
  // A new duration means the old zoom window may no longer make sense.
@@ -720,6 +454,17 @@
720
454
  untrack(() => resetZoom());
721
455
  });
722
456
 
457
+ // A new source shouldn't silently inherit the last video's speed.
458
+ $effect(() => {
459
+ void src;
460
+ untrack(() => {
461
+ if (baseRate !== 1) {
462
+ baseRate = 1;
463
+ }
464
+ applyRate();
465
+ });
466
+ });
467
+
723
468
  // The band gets resized by things this component can't see (a panel toggling,
724
469
  // the window, the side-transcript breakpoint), so the canvas tracks its own
725
470
  // box. Set up from an $effect keyed on canvasEl rather than onMount: bind:this
@@ -738,12 +483,20 @@
738
483
  return () => ro.disconnect();
739
484
  });
740
485
 
486
+ // ── Deep link: read once on mount; the start position prefers startAt ───
487
+ // A queued seek never fires on a later source (same one-shot as startAt).
488
+ let urlStartAt: number | null = null;
489
+ let appliedStartAt = false;
490
+ let lastUrlWrite = 0;
491
+
741
492
  onMount(() => {
742
493
  const stopTheme = watchThemeChanges(() => {
743
494
  clearColorCache();
744
495
  themeTick++;
745
496
  });
746
497
 
498
+ if (syncUrlTime) urlStartAt = readTimeParam();
499
+
747
500
  // Live reduced-motion tracking — drives the autoplay gate. A flip toward
748
501
  // "reduce" mid-playback pauses the clip on its current frame; a flip away
749
502
  // lets a queued autoplay fire (the effect below owns actually starting it).
@@ -764,6 +517,22 @@
764
517
  };
765
518
  });
766
519
 
520
+ /** Keep the URL's ?t= in step with playback — throttled, whole seconds. */
521
+ function syncUrl() {
522
+ if (!syncUrlTime || !videoEl) return;
523
+ const now = Date.now();
524
+ if (now - lastUrlWrite < WRITE_THROTTLE_MS) return;
525
+ lastUrlWrite = now;
526
+ writeTimeParam(videoEl.currentTime);
527
+ }
528
+
529
+ /** Unthrottled: seek/pause are the moments the deep link must not be stale. */
530
+ function syncUrlNow() {
531
+ if (!syncUrlTime || !videoEl) return;
532
+ lastUrlWrite = Date.now();
533
+ writeTimeParam(videoEl.currentTime);
534
+ }
535
+
767
536
  // Autoplay is applied imperatively (a queued `.play()` that the reduced-motion
768
537
  // gate can veto) rather than via the native `autoplay` attribute, so a
769
538
  // mid-session preference flip is respected instead of being a mount-only
@@ -793,17 +562,17 @@
793
562
  const r = canvas.getBoundingClientRect();
794
563
  if (!r.width || spanSec() <= 0) return null;
795
564
  const x = e.clientX - r.left;
796
- return { sec: fracToSec(x / r.width), x, width: r.width };
565
+ return { sec: fracToSec(bandWindow, effectiveDuration, x / r.width), x, width: r.width };
797
566
  }
798
567
 
799
568
  function onBandClick(e: MouseEvent) {
800
569
  const hit = canvasSec(e);
801
570
  if (!hit) return;
802
- seekTo(biasToSegmentStart(hit.sec));
571
+ seekTo(biasToSegmentStart(hit.sec, sortedSegments));
803
572
  }
804
573
 
805
574
  function onBandDblClick() {
806
- if (!isZoomed()) return;
575
+ if (!isZoomedNow()) return;
807
576
  resetZoom();
808
577
  flashZoomLabel();
809
578
  }
@@ -813,21 +582,15 @@
813
582
  const hit = canvasSec(e);
814
583
  if (!hit) return;
815
584
  e.preventDefault();
816
- const near = nearestBookmark(hit.x, hit.width);
585
+ const near = nearestBookmark(hit.x, hit.width, sortedBookmarks, bandWindow, effectiveDuration);
817
586
  onBookmarkContext(near ? near.playbackSec : hit.sec, near, e.clientX, e.clientY);
818
587
  }
819
588
 
820
- /** Keep the (centre-anchored) hover card inside the band's own width. */
821
- const CARD_HALF_PX = 90;
822
- function clampCardX(x: number, width: number): number {
823
- return Math.max(Math.min(CARD_HALF_PX, width / 2), Math.min(x, width - CARD_HALF_PX));
824
- }
825
-
826
589
  function onBandMove(e: MouseEvent) {
827
590
  const hit = canvasSec(e);
828
591
  if (!hit) return;
829
592
  const cardX = clampCardX(hit.x, hit.width);
830
- const bm = nearestBookmark(hit.x, hit.width);
593
+ const bm = nearestBookmark(hit.x, hit.width, sortedBookmarks, bandWindow, effectiveDuration);
831
594
  if (bm) {
832
595
  // A bookmark under the pointer takes over the card entirely — it's
833
596
  // yours, and it outranks the model's chapter guess at this exact spot.
@@ -841,9 +604,9 @@
841
604
  if (hoverChapter !== -1) hoverChapter = -1;
842
605
  return;
843
606
  }
844
- const ch = chapterAt(hit.sec);
607
+ const ch = chapterAt(hit.sec, sortedChapters);
845
608
  const title = ch >= 0 ? sortedChapters[ch]?.title : '';
846
- const at = segmentAt(hit.sec);
609
+ const at = segmentAt(hit.sec, sortedSegments);
847
610
  hover = {
848
611
  x: cardX,
849
612
  title: title ? `${title} · ${formatTime(hit.sec, useHours)}` : formatTime(hit.sec, useHours),
@@ -867,8 +630,6 @@
867
630
  * hover region (distinct from the video, which owns volume), so nothing else
868
631
  * here claims the wheel.
869
632
  */
870
- const ZOOM_FACTOR = 1.3;
871
- const MIN_WINDOW_SEC = 3;
872
633
  function onBandWheel(e: WheelEvent) {
873
634
  const d = effectiveDuration;
874
635
  const canvas = canvasEl;
@@ -877,14 +638,14 @@
877
638
  const w = r.width;
878
639
  if (!w) return;
879
640
  e.preventDefault();
880
- if (viewEnd <= 0) resetZoom();
881
- const cursorSec = fracToSec(Math.max(0, Math.min(1, (e.clientX - r.left) / w)));
882
- const span = spanSec();
883
- let newSpan = e.deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
884
- newSpan = Math.min(d, Math.max(MIN_WINDOW_SEC, newSpan));
885
- const frac = span > 0 ? (cursorSec - viewStart) / span : 0.5;
886
- viewStart = Math.max(0, Math.min(cursorSec - frac * newSpan, d - newSpan));
887
- viewEnd = viewStart + newSpan;
641
+ const cursorSec = fracToSec(
642
+ bandWindow.end > 0 ? bandWindow : resetWindow(d),
643
+ d,
644
+ Math.max(0, Math.min(1, (e.clientX - r.left) / w))
645
+ );
646
+ const next = zoomAt(bandWindow.end > 0 ? bandWindow : resetWindow(d), d, cursorSec, e.deltaY);
647
+ viewStart = next.start;
648
+ viewEnd = next.end;
888
649
  flashZoomLabel();
889
650
  }
890
651
 
@@ -897,15 +658,20 @@
897
658
  else if (e.key === 'ArrowRight') seekTo(currentTime + step);
898
659
  else if (e.key === 'Home') seekTo(0);
899
660
  else if (e.key === 'End') seekTo(d);
900
- else if (e.key === 'Escape' && isZoomed()) resetZoom();
661
+ else if (e.key === 'Escape' && isZoomedNow()) resetZoom();
901
662
  else return;
902
663
  e.preventDefault();
903
664
  }
904
665
 
905
- // ── Video region: wheel = volume (shift = seek), plus a keyboard map ─────
666
+ // ── Video region: wheel = volume (shift = seek, alt = speed), keyboard map ──
906
667
  function onStageWheel(e: WheelEvent) {
907
668
  if (!videoEl) return;
908
669
  e.preventDefault();
670
+ if (e.altKey) {
671
+ // alt+scroll — speed, the third wheel verb (extension parity)
672
+ stepSpeed(e.deltaY < 0 ? 1 : -1);
673
+ return;
674
+ }
909
675
  if (e.shiftKey) {
910
676
  const delta = e.deltaY < 0 ? 5 : -5;
911
677
  seekTo(currentTime + delta);
@@ -918,14 +684,38 @@
918
684
  flashOsd(`${Math.round(next * 100)}%`);
919
685
  }
920
686
 
687
+ /**
688
+ * The full keyboard map (video/keys.ts is the single source; `?` renders it).
689
+ * Keys whose feature has no data are bound anyway where harmless, but only
690
+ * ADVERTISED when the data exists — a documented key that does nothing is
691
+ * the one thing a keymap must never be.
692
+ */
921
693
  function onRootKeyDown(e: KeyboardEvent) {
694
+ // The band's slider handler (and the help overlay) own their keys and
695
+ // preventDefault them — without this guard an ArrowLeft with the band
696
+ // focused seeks TWICE (band ±5s, then root ±5s more).
697
+ if (e.defaultPrevented) return;
922
698
  const target = e.target as HTMLElement | null;
923
699
  const tag = target?.tagName;
924
700
  if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return;
925
701
  if (e.metaKey || e.ctrlKey || e.altKey) return;
926
702
  const v = videoEl;
927
703
  if (!v) return;
704
+
705
+ // The help overlay takes over Escape while it's open.
706
+ if (keysOpen) {
707
+ if (e.key === 'Escape' || e.key === '?') {
708
+ e.preventDefault();
709
+ keysOpen = false;
710
+ }
711
+ return;
712
+ }
713
+
928
714
  switch (e.key) {
715
+ case '?':
716
+ e.preventDefault();
717
+ keysOpen = true;
718
+ return;
929
719
  case ' ':
930
720
  case 'k':
931
721
  v.paused ? v.play() : v.pause();
@@ -940,6 +730,7 @@
940
730
  break;
941
731
  case 'ArrowUp':
942
732
  v.volume = Math.min(1, v.volume + 0.05);
733
+ if (v.volume > 0) v.muted = false;
943
734
  flashOsd(`${Math.round(v.volume * 100)}%`);
944
735
  break;
945
736
  case 'ArrowDown':
@@ -950,6 +741,62 @@
950
741
  v.muted = !v.muted;
951
742
  flashOsd(v.muted ? 'Muted' : `${Math.round(v.volume * 100)}%`);
952
743
  break;
744
+ case '-':
745
+ case '_':
746
+ stepSpeed(-1);
747
+ break;
748
+ case '=':
749
+ case '+':
750
+ stepSpeed(1);
751
+ break;
752
+ case 'Backspace':
753
+ e.preventDefault();
754
+ if (baseRate !== 1) {
755
+ baseRate = 1;
756
+ applyRate();
757
+ }
758
+ flashOsd(formatSpeedLabel(baseRate));
759
+ return;
760
+ case '<':
761
+ // Frame step: pause first; the stepped frame is visible in the video
762
+ // itself, so no OSD — the mpv binding, shifted one key over (,</> are
763
+ // shifted ,/. — the extension moved it there because `,` means settings).
764
+ e.preventDefault();
765
+ v.pause();
766
+ v.currentTime = Math.max(0, v.currentTime - FRAME_STEP_SEC);
767
+ return;
768
+ case '>':
769
+ e.preventDefault();
770
+ v.pause();
771
+ v.currentTime = Math.min(effectiveDuration || Infinity, v.currentTime + FRAME_STEP_SEC);
772
+ return;
773
+ case '[':
774
+ case ']': {
775
+ e.preventDefault();
776
+ const jump = chapterJump(currentTime, sortedChapters, e.key === ']' ? 1 : -1);
777
+ if (jump) {
778
+ seekTo(jump.startSec);
779
+ flashOsd(jump.title || 'Chapter');
780
+ } else {
781
+ flashOsd('No chapters');
782
+ }
783
+ return;
784
+ }
785
+ case 'b':
786
+ case 'B':
787
+ if (onAddBookmark) {
788
+ e.preventDefault();
789
+ submitBookmark();
790
+ }
791
+ return;
792
+ case 't':
793
+ case 'T':
794
+ if (sortedSegments.length > 0) {
795
+ e.preventDefault();
796
+ transcriptVisible = !transcriptVisible;
797
+ flashOsd(transcriptVisible ? 'Transcript shown' : 'Transcript hidden');
798
+ }
799
+ return;
953
800
  case 'Home':
954
801
  seekTo(0);
955
802
  break;
@@ -962,12 +809,16 @@
962
809
  e.preventDefault();
963
810
  }
964
811
 
965
- // ── Playback: startAt + solo-speaker skipping ───────────────────────────
966
- let appliedStartAt = false;
812
+ // ── Playback: startAt/deep-link seed + solo-speaker skipping ────────────
967
813
  function onLoadedMetadata() {
968
- if (!appliedStartAt && startAt != null && Number.isFinite(startAt)) {
969
- appliedStartAt = true;
970
- seekTo(startAt);
814
+ if (!appliedStartAt) {
815
+ // An explicit startAt prop wins over the URL's ?t= (the host knows its
816
+ // own intent); with syncUrlTime off this is exactly the old behaviour.
817
+ const start = startAt ?? urlStartAt;
818
+ if (start != null && Number.isFinite(start)) {
819
+ appliedStartAt = true;
820
+ seekTo(start);
821
+ }
971
822
  }
972
823
  }
973
824
 
@@ -991,13 +842,13 @@
991
842
 
992
843
  function handleTimeUpdate() {
993
844
  applySoloSkip();
845
+ syncUrl();
994
846
  // Zoomed in and playback walked past the edge of the window — follow it,
995
847
  // or you'd watch a frozen slice of waveform while the video plays on.
996
- if (isZoomed() && (currentTime < viewStart || currentTime > viewEnd)) {
997
- const span = spanSec();
998
- const d = effectiveDuration;
999
- viewStart = Math.max(0, Math.min(currentTime - span / 2, Math.max(0, d - span)));
1000
- viewEnd = viewStart + span;
848
+ const follow = followPlayhead(bandWindow, effectiveDuration, currentTime);
849
+ if (follow) {
850
+ viewStart = follow.start;
851
+ viewEnd = follow.end;
1001
852
  }
1002
853
  if (videoEl) onTimeUpdate?.(videoEl);
1003
854
  }
@@ -1039,7 +890,7 @@
1039
890
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1040
891
  <div
1041
892
  class="vp-root {className}"
1042
- class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0}
893
+ class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
1043
894
  role="region"
1044
895
  aria-label="Video player"
1045
896
  tabindex="-1"
@@ -1059,8 +910,11 @@
1059
910
  bind:duration
1060
911
  ontimeupdate={handleTimeUpdate}
1061
912
  onloadedmetadata={onLoadedMetadata}
913
+ onseeked={syncUrlNow}
914
+ onpause={syncUrlNow}
1062
915
  {src}
1063
916
  {loop}
917
+ crossorigin={crossorigin ?? undefined}
1064
918
  muted={effectiveAutoplay || undefined}
1065
919
  poster={poster ?? undefined}
1066
920
  controls
@@ -1117,15 +971,15 @@
1117
971
  {#if laneCarriesSpeakers}
1118
972
  {#each sortedTurns as turn, i (turn.startSec + ':' + i)}
1119
973
  {@const key = speakerKeyOf(turn)}
1120
- {@const l = secToFrac(turn.startSec) * 100}
1121
- {@const r = secToFrac(turn.endSec) * 100}
1122
- {#if turn.endSec > viewStart && turn.startSec < (viewEnd || effectiveDuration)}
974
+ {@const l = secToFrac(bandWindow, effectiveDuration, turn.startSec) * 100}
975
+ {@const r = secToFrac(bandWindow, effectiveDuration, turn.endSec) * 100}
976
+ {#if turn.endSec > bandWindow.start && turn.startSec < (bandWindow.end || effectiveDuration)}
1123
977
  <span
1124
978
  class="vp-lane-span"
1125
979
  class:vp-lane-ad={turn.ad}
1126
980
  class:vp-dim={highlightKey != null
1127
981
  ? highlightKey !== key
1128
- : filterDims(turn.ad ? null : key, !!turn.ad)}
982
+ : filterDims(turn.ad ? null : key, !!turn.ad, soloKey, mutedKeys)}
1129
983
  style="left: {Math.max(0, Math.min(100, l))}%; width: max({Math.max(
1130
984
  0,
1131
985
  Math.min(100, r) - Math.max(0, l)
@@ -1163,7 +1017,7 @@
1163
1017
 
1164
1018
  <div class="vp-times">
1165
1019
  <span>{formatTime(currentTime, useHours)}</span>
1166
- {#if isZoomed()}
1020
+ {#if isZoomedNow()}
1167
1021
  <button type="button" class="vp-reset-zoom" onclick={() => { resetZoom(); flashZoomLabel(); }}>
1168
1022
  Reset zoom
1169
1023
  </button>
@@ -1254,7 +1108,7 @@
1254
1108
  {/if}
1255
1109
  </div>
1256
1110
 
1257
- {#if sortedSegments.length > 0}
1111
+ {#if sortedSegments.length > 0 && transcriptVisible}
1258
1112
  <div class="vp-transcript" style="max-height: {transcriptMaxHeight}">
1259
1113
  {#each sortedSegments as seg (seg.id)}
1260
1114
  <button
@@ -1263,7 +1117,7 @@
1263
1117
  class:active={activeSegment?.id === seg.id}
1264
1118
  class:vp-dim={highlightKey != null
1265
1119
  ? highlightKey !== speakerKeyOf(seg)
1266
- : filterDims(speakerKeyOf(seg), false)}
1120
+ : filterDims(speakerKeyOf(seg), false, soloKey, mutedKeys)}
1267
1121
  bind:this={rowRefs[seg.id]}
1268
1122
  onclick={() => seekTo(seg.startSec)}
1269
1123
  >
@@ -1276,6 +1130,35 @@
1276
1130
  {/each}
1277
1131
  </div>
1278
1132
  {/if}
1133
+
1134
+ {#if keysOpen}
1135
+ <!-- Keyboard help — `?` toggles, Escape/`?` closes. The rows are the
1136
+ PLAYER_KEYS map filtered to keys whose feature's data exists, so the
1137
+ overlay never advertises a key that would do nothing here. -->
1138
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
1139
+ <div class="vp-keys-backdrop" onclick={(e) => { if (e.target === e.currentTarget) keysOpen = false; }}>
1140
+ <div class="vp-keys" role="dialog" aria-label="Keyboard shortcuts">
1141
+ <div class="vp-keys-title">Keyboard</div>
1142
+ <ul class="vp-keys-list">
1143
+ {#each availableKeys as row (row.keys)}
1144
+ <li class="vp-keys-row">
1145
+ <span class="vp-keys-kbd">{row.keys}</span>
1146
+ <span class="vp-keys-desc">{row.description}</span>
1147
+ </li>
1148
+ {/each}
1149
+ </ul>
1150
+ <div class="vp-keys-title">Band focused</div>
1151
+ <ul class="vp-keys-list">
1152
+ {#each BAND_KEYS as row (row.keys)}
1153
+ <li class="vp-keys-row">
1154
+ <span class="vp-keys-kbd">{row.keys}</span>
1155
+ <span class="vp-keys-desc">{row.description}</span>
1156
+ </li>
1157
+ {/each}
1158
+ </ul>
1159
+ </div>
1160
+ </div>
1161
+ {/if}
1279
1162
  </div>
1280
1163
 
1281
1164
  <style>
@@ -1291,6 +1174,7 @@
1291
1174
  border-color: color-mix(in oklch, var(--color-base-content) 12%, transparent);
1292
1175
  border-radius: var(--jatui-radius-box, 0.5rem);
1293
1176
  padding: var(--jatui-space-lg, 12px);
1177
+ position: relative;
1294
1178
  }
1295
1179
 
1296
1180
  .vp-root:focus {
@@ -1346,7 +1230,8 @@
1346
1230
  background: var(--color-neutral);
1347
1231
  }
1348
1232
 
1349
- /* Transient wheel readout (volume / seek) — one calm flash, then gone. */
1233
+ /* Transient wheel/keyboard readout (volume / seek / speed) — one calm flash,
1234
+ then gone. */
1350
1235
  .vp-osd {
1351
1236
  position: absolute;
1352
1237
  top: var(--jatui-space-base, 8px);
@@ -1713,6 +1598,78 @@
1713
1598
  min-width: 10rem;
1714
1599
  }
1715
1600
 
1601
+ /* Keyboard help overlay — the `?` surface. Keycaps first, description after;
1602
+ token-only colours, same chrome family as the hover card. */
1603
+ .vp-keys-backdrop {
1604
+ position: absolute;
1605
+ inset: 0;
1606
+ display: flex;
1607
+ align-items: center;
1608
+ justify-content: center;
1609
+ background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
1610
+ z-index: 10;
1611
+ border-radius: inherit;
1612
+ }
1613
+
1614
+ .vp-keys {
1615
+ max-width: 22rem;
1616
+ max-height: 90%;
1617
+ overflow-y: auto;
1618
+ padding: var(--jatui-space-lg, 12px);
1619
+ background: var(--color-base-100);
1620
+ border: var(--jatui-border-w, 1px) solid
1621
+ color-mix(in oklch, var(--color-base-content) 18%, transparent);
1622
+ border-radius: var(--jatui-radius-box, 0.5rem);
1623
+ color: var(--color-base-content);
1624
+ box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
1625
+ }
1626
+
1627
+ .vp-keys-title {
1628
+ font-size: 0.66rem;
1629
+ font-weight: 700;
1630
+ text-transform: uppercase;
1631
+ letter-spacing: 0.04em;
1632
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
1633
+ margin: var(--jatui-space-sm, 4px) 0;
1634
+ }
1635
+
1636
+ .vp-keys-title:first-child {
1637
+ margin-top: 0;
1638
+ }
1639
+
1640
+ .vp-keys-list {
1641
+ display: flex;
1642
+ flex-direction: column;
1643
+ gap: var(--jatui-space-xs, 2px);
1644
+ margin: 0 0 var(--jatui-space-sm, 4px);
1645
+ padding: 0;
1646
+ list-style: none;
1647
+ }
1648
+
1649
+ .vp-keys-row {
1650
+ display: flex;
1651
+ align-items: baseline;
1652
+ justify-content: space-between;
1653
+ gap: var(--jatui-space-md, 6px);
1654
+ font-size: 0.72rem;
1655
+ }
1656
+
1657
+ .vp-keys-kbd {
1658
+ font-family: ui-monospace, monospace;
1659
+ font-size: 0.66rem;
1660
+ white-space: nowrap;
1661
+ padding: 0 var(--jatui-space-xs, 2px);
1662
+ border: var(--jatui-border-w, 1px) solid
1663
+ color-mix(in oklch, var(--color-base-content) 25%, transparent);
1664
+ border-radius: var(--jatui-radius-selector, 0.375rem);
1665
+ background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
1666
+ }
1667
+
1668
+ .vp-keys-desc {
1669
+ color: color-mix(in oklch, var(--color-base-content) 75%, transparent);
1670
+ text-align: right;
1671
+ }
1672
+
1716
1673
  /* Motion is decoration here — every state also has a static read (colour,
1717
1674
  position, label), so collapsing it changes nothing functional. */
1718
1675
  @media (prefers-reduced-motion: reduce) {
@@ -1726,4 +1683,4 @@
1726
1683
  transition: none;
1727
1684
  }
1728
1685
  }
1729
- </style>
1686
+ </style>