@joewinke/jatui 0.8.6 → 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,49 +1,5 @@
1
- /** A model-authored marker: the video's structure. */
2
- export interface VideoChapter {
3
- startSec: number;
4
- title: string;
5
- /** Optional captured still, shown in the band's hover card. */
6
- thumbUrl?: string | null;
7
- }
8
- /** A diarized turn (or an ad read, `ad: true`). Structurally accepts meeting data. */
9
- export interface VideoSpeakerTurn {
10
- startSec: number;
11
- endSec: number;
12
- speakerIdentity?: string | null;
13
- speakerName?: string | null;
14
- /** Explicit oklch hue (0–360). Omit to take the next palette colour. */
15
- hue?: number | null;
16
- /** Ad read — shares the lane but reads as not-a-person. */
17
- ad?: boolean;
18
- }
19
- /** Transcript line. Structurally compatible with TranscriptPlayer's `TranscriptSegment`. */
20
- export interface VideoTranscriptSegment {
21
- id: string;
22
- startSec: number;
23
- endSec: number;
24
- speakerIdentity?: string | null;
25
- speakerName?: string | null;
26
- text: string;
27
- }
28
- /** A user-authored marker. Structurally compatible with `TranscriptBookmark`. */
29
- export interface VideoBookmark {
30
- playbackSec: number;
31
- label?: string | null;
32
- note?: string | null;
33
- participantName?: string | null;
34
- }
35
- /** Precomputed audio envelope. `peaks` are normalised against their own max. */
36
- export interface VideoWaveform {
37
- peaks: number[];
38
- /** Seconds the peaks span. Defaults to the video's duration. */
39
- durationSec?: number;
40
- }
41
- /** Per-speaker totals. Structurally compatible with `TranscriptSpeakerTalkTime`. */
42
- export interface VideoSpeakerTalkTime {
43
- speakerIdentity?: string | null;
44
- speakerName?: string | null;
45
- seconds: number;
46
- }
1
+ export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime } from '../video/types';
2
+ import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
47
3
  type $$ComponentProps = {
48
4
  src: string;
49
5
  poster?: string | null;
@@ -93,6 +49,8 @@ type $$ComponentProps = {
93
49
  startAt?: number | null;
94
50
  transcriptPosition?: 'below' | 'side';
95
51
  transcriptMaxHeight?: string;
52
+ crossorigin?: 'anonymous' | 'use-credentials' | null;
53
+ syncUrlTime?: boolean;
96
54
  onTimeUpdate?: (video: HTMLVideoElement) => void;
97
55
  /** Present ⇒ the band grows an "add bookmark at current time" control. */
98
56
  onAddBookmark?: (playbackSec: number, label: string) => void;
@@ -0,0 +1,53 @@
1
+ # VideoPlayer internals — `src/lib/video/`
2
+
3
+ The internal modules behind `components/VideoPlayer.svelte` (mos-5lzns.28 split).
4
+ The component's **public props are frozen** — MOS consumes it, and the split
5
+ exists precisely so the feature tasks below can land in parallel without
6
+ colliding: **each module file is owned by exactly one task**; nobody edits a
7
+ file they don't own, and everything converges through the component's existing
8
+ props + callback surfaces.
9
+
10
+ ## Layout
11
+
12
+ | File | Owner | Contents |
13
+ |------|-------|----------|
14
+ | `types.ts` | .28 (done) | The public data contracts — `VideoChapter`, `VideoSpeakerTurn`, `VideoTranscriptSegment`, `VideoBookmark`, `VideoWaveform`, `VideoSpeakerTalkTime`. Re-exported by the component, so `import type … from 'jatui'` is unchanged. |
15
+ | `time.ts` | .28 (done) | Pure formatting: `formatTime`, `formatDuration`, `truncate`, `aspectRatioCss`. |
16
+ | `model.ts` | .28 (done) | Speaker registry + identity colours + talk-time derivation + the sort normalisations. `ALL_KEY`/`UNKNOWN_KEY` live here. |
17
+ | `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
18
+ | `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
19
+ | `rate.ts` | .28 (done) — filled by **.32** | The rate composer contract (`composeRate`, deadband, clamp), the `SPEED_STEPS` staircase, `formatSpeedLabel`. .32 adds the speaker-pace and silence-map advisors — they never write `playbackRate`; they advise. |
20
+ | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
21
+ | `settings.ts` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
22
+ | `audio.ts` | **.31** | Loudness matching, quiet listening, limiter — audio-graph contract is in the file header. |
23
+ | `night.ts` | **.33** | Night dimming, bias light, predictive dimming (luma track), strobe guard. |
24
+ | `presence.ts` | **.34** | Wind-down (Idle Detection), resume-at-sentence, FLIP open. |
25
+
26
+ Sub-task map: **.30** scrubbing/one-axis/ribbon/markers → `band.ts` (+ its own
27
+ paint layers); **.31** audio graph → `audio.ts`; **.32** rate composer →
28
+ `rate.ts` advisors; **.33** night → `night.ts`; **.34** presence/wind-down →
29
+ `presence.ts`.
30
+
31
+ ## The advisor doctrine (from extension-library FEATURES.md — keep it)
32
+
33
+ 1. **A disabled feature leaves no trace in the signal.** Not a residual
34
+ filter, not a half-faded gain ramp, not a stale CSS variable. Off means
35
+ the pixels and samples are exactly what they would have been.
36
+ 2. **Nothing acts without something the user can see.** Every automatic
37
+ correction has a visible surface (OSD, ribbon, meter, the fade that IS
38
+ the prompt).
39
+ 3. **Measuring code is an advisor; the component decides and applies.**
40
+ Modules under `src/lib/video/` read no settings and touch no `<video>`
41
+ (the audio graph builder is the one exception, and it takes a
42
+ MediaElementAudioSourceNode, never the element). Knobs arrive as an
43
+ `opts` argument; a bad argument returns a safe neutral value rather than
44
+ throwing. This is what keeps every module node-testable under
45
+ `npm run test:unit`.
46
+ 4. **The rate composer is the shape to follow** (`rate.ts`): `rate = base ×
47
+ speaker × silence`, advisors collapse to 1, the UI reports only `base`.
48
+
49
+ ## Testing
50
+
51
+ Every pure module gets a `tests/unit/` node:test file (Node ≥ 22.18 strips
52
+ types natively). Canvas/WAAPI work stays out of unit tests by construction —
53
+ that's the point of the split.
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Audio module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.31
3
+ * ("audio graph — loudness matching, quiet listening, limiter").
4
+ *
5
+ * What lands here (extension-library FEATURES.md §Loudness matching,
6
+ * §Quiet listening), as ADVISORS + an audio graph builder:
7
+ *
8
+ * · loudness matching — a gated RMS meter drives a slow gain rider toward a
9
+ * target (~−20 dB). The gate forgets slowly so a new video doesn't reset
10
+ * the meter mid-lesson; the correction is measured, not a strength slider.
11
+ * · quiet listening — loudness-compensated tone (two shelves + presence
12
+ * peak + gentle compressor), amount rising as volume drops, a floor after
13
+ * dark. Amount 0 rebuilds nothing: splicing a live graph mid-playback
14
+ * clicks — a constant few ms of silence is the trade.
15
+ * · limiter — threshold −3 dBFS, catching what the slow rider cannot.
16
+ *
17
+ * Module rules (README): no <video> access, no settings reads, no DOM. The
18
+ * graph builder is the one exception that may touch AudioContext — it takes
19
+ * a MediaElementAudioSourceNode the component creates, never the element.
20
+ * Disabled ⇒ the graph is bypassed entirely: no residual filter, no half
21
+ * ramp, no stale param (cancelScheduledValues before restoring). Off must be
22
+ * indistinguishable from never having run.
23
+ */
24
+ /**
25
+ * Neutral advisor product — the multiplicative identity every composer and
26
+ * caller must get when audio correction is off or unmeasured. Exported so
27
+ * .31's rate-relevant advisors (if any) share the exact collapse value.
28
+ */
29
+ export declare const AUDIO_NEUTRAL = 1;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Audio module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.31
3
+ * ("audio graph — loudness matching, quiet listening, limiter").
4
+ *
5
+ * What lands here (extension-library FEATURES.md §Loudness matching,
6
+ * §Quiet listening), as ADVISORS + an audio graph builder:
7
+ *
8
+ * · loudness matching — a gated RMS meter drives a slow gain rider toward a
9
+ * target (~−20 dB). The gate forgets slowly so a new video doesn't reset
10
+ * the meter mid-lesson; the correction is measured, not a strength slider.
11
+ * · quiet listening — loudness-compensated tone (two shelves + presence
12
+ * peak + gentle compressor), amount rising as volume drops, a floor after
13
+ * dark. Amount 0 rebuilds nothing: splicing a live graph mid-playback
14
+ * clicks — a constant few ms of silence is the trade.
15
+ * · limiter — threshold −3 dBFS, catching what the slow rider cannot.
16
+ *
17
+ * Module rules (README): no <video> access, no settings reads, no DOM. The
18
+ * graph builder is the one exception that may touch AudioContext — it takes
19
+ * a MediaElementAudioSourceNode the component creates, never the element.
20
+ * Disabled ⇒ the graph is bypassed entirely: no residual filter, no half
21
+ * ramp, no stale param (cancelScheduledValues before restoring). Off must be
22
+ * indistinguishable from never having run.
23
+ */
24
+ /**
25
+ * Neutral advisor product — the multiplicative identity every composer and
26
+ * caller must get when audio correction is off or unmeasured. Exported so
27
+ * .31's rate-relevant advisors (if any) share the exact collapse value.
28
+ */
29
+ export const AUDIO_NEUTRAL = 1;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The timeline band (mos-5lzns.28 internal split) — one time axis for the
3
+ * waveform envelope, chapters, speakers and bookmarks.
4
+ *
5
+ * Ported from the VideoPlayer component's draw()/zoom window, which itself
6
+ * came from extension-library's player.js band. Design notes carried over:
7
+ *
8
+ * · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
9
+ * the speaker lane, click/hover-to-seek — maps through the window's
10
+ * secToFrac/fracToSec, so scroll-to-zoom is one piece of state everything
11
+ * else respects for free.
12
+ * · ONE speaker surface. With a waveform, the envelope's own bars wear the
13
+ * turn colours; the slim lane under it paints only as the no-waveform
14
+ * fallback. Never two stacked time-parallel strips.
15
+ * · Chapter flags sit on the TOP edge, bookmark diamonds on the BOTTOM edge —
16
+ * same idea (a marker on the timeline), different origin (the model's vs.
17
+ * yours), so the two families are never confused at a glance.
18
+ *
19
+ * Everything here is DOM-free except `paintBand`/`prepareCanvas`, which take a
20
+ * canvas 2d context — no getDaisyUIColor calls inside; the component resolves
21
+ * theme colours and passes them in, so theme changes are a repaint, not a
22
+ * re-render of this module.
23
+ */
24
+ import { ALL_KEY } from './model.ts';
25
+ import type { VideoBookmark, VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from './types.ts';
26
+ export interface BandWindow {
27
+ start: number;
28
+ end: number;
29
+ }
30
+ /**
31
+ * The visible window in seconds. Falls back to the whole video until the
32
+ * duration effect has seeded `end`, so a first paint that races metadata
33
+ * still maps positions correctly instead of collapsing to zero.
34
+ */
35
+ export declare function spanSec(w: BandWindow, duration: number): number;
36
+ export declare function isZoomed(w: BandWindow, duration: number): boolean;
37
+ export declare function resetWindow(duration: number): BandWindow;
38
+ export declare function secToFrac(w: BandWindow, duration: number, sec: number): number;
39
+ export declare function fracToSec(w: BandWindow, duration: number, frac: number): number;
40
+ /**
41
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
42
+ * under the pointer stays fixed while the window resizes around it, so
43
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
44
+ * at. `deltaY < 0` zooms in.
45
+ */
46
+ export declare const ZOOM_FACTOR = 1.3;
47
+ export declare const MIN_WINDOW_SEC = 3;
48
+ export declare function zoomAt(w: BandWindow, duration: number, cursorSec: number, deltaY: number): BandWindow;
49
+ /**
50
+ * Zoomed in and playback walked past the edge of the window — follow it, or
51
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
52
+ * null when no follow is needed.
53
+ */
54
+ export declare function followPlayhead(w: BandWindow, duration: number, t: number): BandWindow | null;
55
+ /**
56
+ * Click-to-seek biases toward the START of the spoken line under the click —
57
+ * nobody wants to land mid-sentence. Only when the transcript is dense right
58
+ * there, though: on a sparse transcript the containing line could have begun
59
+ * minutes earlier, and biasing to it would fling the seek somewhere the user
60
+ * never clicked.
61
+ */
62
+ export declare const DENSITY_WINDOW_SEC = 60;
63
+ export declare function biasToSegmentStart(sec: number, segments: VideoTranscriptSegment[]): number;
64
+ /** Last line starting at or before `sec` — segments are ascending. */
65
+ export declare function segmentAt(sec: number, segments: VideoTranscriptSegment[]): VideoTranscriptSegment | null;
66
+ export declare function chapterAt(sec: number, chapters: VideoChapter[]): number;
67
+ export declare const BOOKMARK_HIT_PX = 6;
68
+ export declare function nearestBookmark(xCss: number, width: number, bookmarks: VideoBookmark[], w: BandWindow, duration: number): VideoBookmark | null;
69
+ /** Keep the (centre-anchored) hover card inside the band's own width. */
70
+ export declare const CARD_HALF_PX = 90;
71
+ export declare function clampCardX(x: number, width: number): number;
72
+ export declare const RAIL_H = 10;
73
+ export interface BandTurn {
74
+ startSec: number;
75
+ endSec: number;
76
+ key: string | null;
77
+ ad: boolean;
78
+ }
79
+ export interface BandPaintState {
80
+ currentTime: number;
81
+ duration: number;
82
+ /** Normalised 0–1 envelope; null ⇒ the flat rail fallback. */
83
+ peaks: number[] | null;
84
+ chapters: VideoChapter[];
85
+ bookmarks: VideoBookmark[];
86
+ turns: BandTurn[];
87
+ speakerColors: Map<string, string>;
88
+ /** With an envelope the bars carry the hues; the lane is the no-waveform fallback only. */
89
+ laneCarriesSpeakers: boolean;
90
+ highlightKey: string | null;
91
+ soloKey: string;
92
+ mutedKeys: Set<string>;
93
+ window: BandWindow;
94
+ hoverChapter: number;
95
+ /** Theme colours, resolved by the component via getDaisyUIColor. */
96
+ colors: {
97
+ accent: string;
98
+ neutral: string;
99
+ marker: string;
100
+ };
101
+ }
102
+ /**
103
+ * Size the canvas backing store for devicePixelRatio and return its 2d
104
+ * context with the transform applied — or null when there is nothing honest
105
+ * to paint into (no canvas, zero box, no context).
106
+ */
107
+ export declare function prepareCanvas(canvas: HTMLCanvasElement): CanvasRenderingContext2D | null;
108
+ /**
109
+ * Paint the band. Ported verbatim from the component's draw() — the alpha
110
+ * ladder, the chapter seam skip, the top-edge flags vs bottom-edge diamonds,
111
+ * the playhead. `W`/`H` are CSS pixels of the canvas box.
112
+ */
113
+ export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: number, s: BandPaintState): void;
114
+ /** Turn → BandTurn for the paint state (resolved key, ad flag). */
115
+ export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
116
+ export { ALL_KEY };
@@ -0,0 +1,311 @@
1
+ /**
2
+ * The timeline band (mos-5lzns.28 internal split) — one time axis for the
3
+ * waveform envelope, chapters, speakers and bookmarks.
4
+ *
5
+ * Ported from the VideoPlayer component's draw()/zoom window, which itself
6
+ * came from extension-library's player.js band. Design notes carried over:
7
+ *
8
+ * · ONE time axis. Every position — waveform bars, chapter flags, bookmarks,
9
+ * the speaker lane, click/hover-to-seek — maps through the window's
10
+ * secToFrac/fracToSec, so scroll-to-zoom is one piece of state everything
11
+ * else respects for free.
12
+ * · ONE speaker surface. With a waveform, the envelope's own bars wear the
13
+ * turn colours; the slim lane under it paints only as the no-waveform
14
+ * fallback. Never two stacked time-parallel strips.
15
+ * · Chapter flags sit on the TOP edge, bookmark diamonds on the BOTTOM edge —
16
+ * same idea (a marker on the timeline), different origin (the model's vs.
17
+ * yours), so the two families are never confused at a glance.
18
+ *
19
+ * Everything here is DOM-free except `paintBand`/`prepareCanvas`, which take a
20
+ * canvas 2d context — no getDaisyUIColor calls inside; the component resolves
21
+ * theme colours and passes them in, so theme changes are a repaint, not a
22
+ * re-render of this module.
23
+ */
24
+ import { ALL_KEY, filterDims, speakerKeyOf } from './model.ts';
25
+ /**
26
+ * The visible window in seconds. Falls back to the whole video until the
27
+ * duration effect has seeded `end`, so a first paint that races metadata
28
+ * still maps positions correctly instead of collapsing to zero.
29
+ */
30
+ export function spanSec(w, duration) {
31
+ return w.end > w.start ? w.end - w.start : duration;
32
+ }
33
+ export function isZoomed(w, duration) {
34
+ return w.end > 0 && spanSec(w, duration) < duration - 0.01;
35
+ }
36
+ export function resetWindow(duration) {
37
+ return { start: 0, end: duration };
38
+ }
39
+ export function secToFrac(w, duration, sec) {
40
+ const span = spanSec(w, duration);
41
+ return span > 0 ? (sec - w.start) / span : 0;
42
+ }
43
+ export function fracToSec(w, duration, frac) {
44
+ return w.start + frac * spanSec(w, duration);
45
+ }
46
+ /**
47
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
48
+ * under the pointer stays fixed while the window resizes around it, so
49
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
50
+ * at. `deltaY < 0` zooms in.
51
+ */
52
+ export const ZOOM_FACTOR = 1.3;
53
+ export const MIN_WINDOW_SEC = 3;
54
+ export function zoomAt(w, duration, cursorSec, deltaY) {
55
+ if (!duration)
56
+ return w;
57
+ if (w.end <= 0)
58
+ w = resetWindow(duration);
59
+ const span = spanSec(w, duration);
60
+ let newSpan = deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
61
+ newSpan = Math.min(duration, Math.max(MIN_WINDOW_SEC, newSpan));
62
+ const frac = span > 0 ? (cursorSec - w.start) / span : 0.5;
63
+ const start = Math.max(0, Math.min(cursorSec - frac * newSpan, duration - newSpan));
64
+ return { start, end: start + newSpan };
65
+ }
66
+ /**
67
+ * Zoomed in and playback walked past the edge of the window — follow it, or
68
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
69
+ * null when no follow is needed.
70
+ */
71
+ export function followPlayhead(w, duration, t) {
72
+ if (!isZoomed(w, duration))
73
+ return null;
74
+ if (t >= w.start && t <= w.end)
75
+ return null;
76
+ const span = spanSec(w, duration);
77
+ const start = Math.max(0, Math.min(t - span / 2, Math.max(0, duration - span)));
78
+ return { start, end: start + span };
79
+ }
80
+ // ── Hit tests / position helpers ────────────────────────────────────────────
81
+ /**
82
+ * Click-to-seek biases toward the START of the spoken line under the click —
83
+ * nobody wants to land mid-sentence. Only when the transcript is dense right
84
+ * there, though: on a sparse transcript the containing line could have begun
85
+ * minutes earlier, and biasing to it would fling the seek somewhere the user
86
+ * never clicked.
87
+ */
88
+ export const DENSITY_WINDOW_SEC = 60;
89
+ export function biasToSegmentStart(sec, segments) {
90
+ if (segments.length === 0)
91
+ return sec;
92
+ let nearby = 0;
93
+ for (const s of segments) {
94
+ if (Math.abs(s.startSec - sec) <= DENSITY_WINDOW_SEC / 2)
95
+ nearby++;
96
+ }
97
+ if (nearby <= 1)
98
+ return sec;
99
+ return segmentAt(sec, segments)?.startSec ?? sec;
100
+ }
101
+ /** Last line starting at or before `sec` — segments are ascending. */
102
+ export function segmentAt(sec, segments) {
103
+ let best = null;
104
+ for (const s of segments) {
105
+ if (s.startSec <= sec)
106
+ best = s;
107
+ else
108
+ break;
109
+ }
110
+ return best;
111
+ }
112
+ export function chapterAt(sec, chapters) {
113
+ let idx = -1;
114
+ for (let i = 0; i < chapters.length; i++) {
115
+ if (chapters[i].startSec <= sec)
116
+ idx = i;
117
+ else
118
+ break;
119
+ }
120
+ return idx;
121
+ }
122
+ export const BOOKMARK_HIT_PX = 6;
123
+ export function nearestBookmark(xCss, width, bookmarks, w, duration) {
124
+ if (bookmarks.length === 0 || !width)
125
+ return null;
126
+ let best = null;
127
+ let bestD = BOOKMARK_HIT_PX;
128
+ for (const b of bookmarks) {
129
+ const d = Math.abs(secToFrac(w, duration, b.playbackSec) * width - xCss);
130
+ if (d < bestD) {
131
+ bestD = d;
132
+ best = b;
133
+ }
134
+ }
135
+ return best;
136
+ }
137
+ /** Keep the (centre-anchored) hover card inside the band's own width. */
138
+ export const CARD_HALF_PX = 90;
139
+ export function clampCardX(x, width) {
140
+ return Math.max(Math.min(CARD_HALF_PX, width / 2), Math.min(x, width - CARD_HALF_PX));
141
+ }
142
+ // ── Canvas paint ────────────────────────────────────────────────────────────
143
+ export const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
144
+ /**
145
+ * Size the canvas backing store for devicePixelRatio and return its 2d
146
+ * context with the transform applied — or null when there is nothing honest
147
+ * to paint into (no canvas, zero box, no context).
148
+ */
149
+ export function prepareCanvas(canvas) {
150
+ const W = canvas.clientWidth;
151
+ const H = canvas.clientHeight;
152
+ if (!W || !H)
153
+ return null;
154
+ const dpr = window.devicePixelRatio || 1;
155
+ if (canvas.width !== Math.round(W * dpr) || canvas.height !== Math.round(H * dpr)) {
156
+ canvas.width = Math.round(W * dpr);
157
+ canvas.height = Math.round(H * dpr);
158
+ }
159
+ const ctx = canvas.getContext('2d');
160
+ if (!ctx)
161
+ return null;
162
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
163
+ return ctx;
164
+ }
165
+ function peakIdx(sec, dur, n) {
166
+ return Math.max(0, Math.min(n - 1, Math.floor((sec / dur) * n)));
167
+ }
168
+ /**
169
+ * Paint the band. Ported verbatim from the component's draw() — the alpha
170
+ * ladder, the chapter seam skip, the top-edge flags vs bottom-edge diamonds,
171
+ * the playhead. `W`/`H` are CSS pixels of the canvas box.
172
+ */
173
+ export function paintBand(ctx, W, H, s) {
174
+ ctx.clearRect(0, 0, W, H);
175
+ const dur = s.duration;
176
+ const { accent, neutral, marker } = s.colors;
177
+ if (dur <= 0) {
178
+ // No duration yet — paint the empty rail so the band owns its space
179
+ // from the first frame and metadata arriving is never a layout shove.
180
+ ctx.globalAlpha = 0.2;
181
+ ctx.fillStyle = neutral;
182
+ ctx.fillRect(0, (H - RAIL_H) / 2, W, RAIL_H);
183
+ ctx.globalAlpha = 1;
184
+ return;
185
+ }
186
+ const p = s.peaks;
187
+ const withWave = !!p;
188
+ const n = p?.length ?? 0;
189
+ // The window is seeded by the duration effect; until then it is the whole
190
+ // video, so a first paint that races metadata still lands correctly.
191
+ const w = s.window;
192
+ const span = w.end - w.start || dur;
193
+ // Chapter boundaries in px — a column straddling one is skipped (the seam).
194
+ const bounds = s.chapters.slice(1).map((c) => secToFrac(w, dur, c.startSec) * W);
195
+ const GAP = 4;
196
+ const nowChapter = chapterAt(s.currentTime, s.chapters);
197
+ const visiblePeaks = withWave ? Math.max(1, Math.round((span / dur) * n)) : 0;
198
+ const step = withWave ? Math.max(1.5, W / visiblePeaks) : 2;
199
+ const cols = Math.max(1, Math.ceil(W / step));
200
+ const bw = Math.max(1, step - 1);
201
+ // Speaker sweep: columns advance monotonically in time and turns are
202
+ // sorted by start, so ONE pointer walks the turn list across the whole
203
+ // draw — never a per-column find(). Turns can overlap (an ad read inside
204
+ // a speaker's turn), so the sweep keeps a stack of open turns and the
205
+ // latest start wins, matching the lane's paint order.
206
+ let ti = 0;
207
+ const open = [];
208
+ for (let ci = 0; ci < cols; ci++) {
209
+ const x = ci * step;
210
+ if (bounds.some((b) => Math.abs(x + bw / 2 - b) < GAP))
211
+ continue;
212
+ const sec = fracToSec(w, dur, (x + step / 2) / W);
213
+ while (ti < s.turns.length && s.turns[ti].startSec <= sec)
214
+ open.push(s.turns[ti++]);
215
+ while (open.length && open[open.length - 1].endSec <= sec)
216
+ open.pop();
217
+ const turn = s.laneCarriesSpeakers ? null : (open[open.length - 1] ?? null);
218
+ const turnKey = turn && !turn.ad ? turn.key : null;
219
+ const ch = s.chapters.length ? chapterAt(sec, s.chapters) : -1;
220
+ const played = sec <= s.currentTime;
221
+ const hovered = ch >= 0 && ch === s.hoverChapter;
222
+ // Unplayed floor is 0.38 rather than player.js's 0.3: that value was
223
+ // tuned against the theater's near-black backdrop, and on a light
224
+ // theme's base-200 the envelope washed out at it.
225
+ let alpha;
226
+ if (played)
227
+ alpha = hovered ? 1 : 0.85;
228
+ else if (hovered)
229
+ alpha = 0.6;
230
+ else if (ch >= 0 && ch === nowChapter)
231
+ alpha = 0.52;
232
+ else
233
+ alpha = 0.38;
234
+ // Legend hover extends onto the envelope: the named speaker at full
235
+ // strength, everything else at 40% of its normal alpha — the
236
+ // played/unplayed ladder survives inside both halves.
237
+ if (s.highlightKey != null) {
238
+ if (turnKey === s.highlightKey)
239
+ alpha = played ? 1 : 0.8;
240
+ else
241
+ alpha *= 0.4;
242
+ }
243
+ else if (filterDims(turnKey, !!turn?.ad, s.soloKey, s.mutedKeys)) {
244
+ alpha *= 0.4;
245
+ }
246
+ ctx.globalAlpha = alpha;
247
+ if (turn?.ad)
248
+ ctx.fillStyle = marker;
249
+ else if (turnKey)
250
+ ctx.fillStyle = s.speakerColors.get(turnKey) ?? accent;
251
+ else
252
+ ctx.fillStyle = played ? accent : neutral;
253
+ const h = withWave ? Math.max(2, p[peakIdx(sec, dur, n)] * (H - 4)) : RAIL_H;
254
+ ctx.fillRect(x, withWave ? H - h : (H - RAIL_H) / 2, bw, h);
255
+ }
256
+ ctx.globalAlpha = 1;
257
+ // Boundary notches: a hairline in each seam plus a small accent flag on
258
+ // top — the flag reads as a UI marker even when the envelope is dense.
259
+ // Flags within a few px of each other collapse to one so a long video at
260
+ // full zoom-out never smears them into a solid bar.
261
+ ctx.globalAlpha = 0.4;
262
+ ctx.fillStyle = neutral;
263
+ for (const b of bounds)
264
+ ctx.fillRect(b - 1, 2, 2, H - 4);
265
+ ctx.globalAlpha = 0.95;
266
+ ctx.fillStyle = accent;
267
+ const FLAG_MERGE_PX = 6;
268
+ let lastFlagX = -Infinity;
269
+ for (const b of bounds) {
270
+ if (b - lastFlagX < FLAG_MERGE_PX)
271
+ continue;
272
+ lastFlagX = b;
273
+ ctx.beginPath();
274
+ ctx.moveTo(b - 3, 0);
275
+ ctx.lineTo(b + 3, 0);
276
+ ctx.lineTo(b, 4);
277
+ ctx.closePath();
278
+ ctx.fill();
279
+ }
280
+ // Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
281
+ // of the chapter flags on top, so yours and the model's never blur together.
282
+ ctx.fillStyle = marker;
283
+ for (const b of s.bookmarks) {
284
+ const x = secToFrac(w, dur, b.playbackSec) * W;
285
+ if (x < -4 || x > W + 4)
286
+ continue;
287
+ ctx.beginPath();
288
+ ctx.moveTo(x, H);
289
+ ctx.lineTo(x - 3, H - 4);
290
+ ctx.lineTo(x, H - 8);
291
+ ctx.lineTo(x + 3, H - 4);
292
+ ctx.closePath();
293
+ ctx.fill();
294
+ }
295
+ // Playhead
296
+ ctx.globalAlpha = 0.9;
297
+ ctx.fillStyle = neutral;
298
+ ctx.fillRect(Math.max(0, Math.min(1, secToFrac(w, dur, s.currentTime))) * W - 0.5, 0, 1.5, H);
299
+ ctx.globalAlpha = 1;
300
+ }
301
+ /** Turn → BandTurn for the paint state (resolved key, ad flag). */
302
+ export function toBandTurns(turns) {
303
+ return turns.map((t) => ({
304
+ startSec: t.startSec,
305
+ endSec: t.endSec,
306
+ key: t.ad ? null : speakerKeyOf(t),
307
+ ad: !!t.ad
308
+ }));
309
+ }
310
+ // Re-exports so the component (and sibling modules) have one band import site.
311
+ export { ALL_KEY };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `?t=` deep-link support for VideoPlayer (mos-5lzns.28).
3
+ *
4
+ * extension-library parity: every video there is addressable to the second —
5
+ * `?play=<file>&t=<sec>` — and "Continue on phone" builds exactly that link.
6
+ * In jatui the FILE is the host page's job (a route, a modal, a share token);
7
+ * the player owns the TIMESTAMP: `?t=` on the current URL.
8
+ *
9
+ * Opt-in via the `syncUrlTime` prop (default false — a component library must
10
+ * not rewrite the host page's URL history uninvited). When on:
11
+ *
12
+ * · READ — on mount, a `t` param seeds the start position (an explicit
13
+ * `startAt` prop still wins: the host knows its own intent).
14
+ * · WRITE — during playback the URL is kept in step via
15
+ * `history.replaceState` (never pushState — a timestamp is a position, not
16
+ * a page; Back must mean "previous page", not "10 seconds ago"). Other
17
+ * query params are preserved verbatim. Writes are throttled (TIMEUPDATE is
18
+ * ~4Hz; rewriting the URL on every tick churns both the history machinery
19
+ * and any devtools watching it) and floored to whole seconds — the link's
20
+ * promise is "picks up at 1:23", not "1:22.87".
21
+ *
22
+ * Format accepts both `?t=90` and `?t=90s` (the extension writes the `s`
23
+ * suffix, YouTube-style). Garbage degrades to "no time", never a throw.
24
+ */
25
+ /** Parse a `t` param value → seconds, or null. Accepts `90`, `90s`, `1:30`, `1:02:30`. */
26
+ export declare function parseTimeParam(raw: string | null | undefined): number | null;
27
+ /** Floor to whole seconds — the deep link promises second precision. */
28
+ export declare function formatTimeParam(sec: number): string;
29
+ /** Read the current `?t=` from a URL (or the document location). */
30
+ export declare function readTimeParam(url?: string | URL | null): number | null;
31
+ /**
32
+ * Rewrite the current URL's `t` param in place, preserving every other
33
+ * query param. Returns false when there was nothing to change (SSR, no
34
+ * history API, same second) so the caller can skip the replaceState.
35
+ */
36
+ export declare function writeTimeParam(sec: number, url?: string | URL | null): boolean;
37
+ /** Strip the `t` param (e.g. when the host unmounts the player opt-in). */
38
+ export declare function clearTimeParam(url?: string | URL | null): boolean;
39
+ /** Throttle for URL writes: at most one replaceState per WRITE_THROTTLE_MS. */
40
+ export declare const WRITE_THROTTLE_MS = 2000;