@joewinke/jatui 0.8.6 → 0.9.1
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.
- package/CLAUDE.md +1 -1
- package/dist/components/VideoPlayer.svelte +453 -496
- package/dist/components/VideoPlayer.svelte.d.ts +4 -46
- package/dist/video/README.md +53 -0
- package/dist/video/audio.d.ts +29 -0
- package/dist/video/audio.js +29 -0
- package/dist/video/band.d.ts +116 -0
- package/dist/video/band.js +311 -0
- package/dist/video/deepLink.d.ts +40 -0
- package/dist/video/deepLink.js +111 -0
- package/dist/video/keys.d.ts +43 -0
- package/dist/video/keys.js +62 -0
- package/dist/video/model.d.ts +57 -0
- package/dist/video/model.js +109 -0
- package/dist/video/night.d.ts +26 -0
- package/dist/video/night.js +26 -0
- package/dist/video/presence.d.ts +25 -0
- package/dist/video/presence.js +25 -0
- package/dist/video/rate.d.ts +69 -0
- package/dist/video/rate.js +88 -0
- package/dist/video/settings.d.ts +50 -0
- package/dist/video/settings.js +62 -0
- package/dist/video/time.d.ts +19 -0
- package/dist/video/time.js +55 -0
- package/dist/video/types.d.ts +59 -0
- package/dist/video/types.js +14 -0
- package/package.json +2 -2
|
@@ -1,49 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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';
|
|
25
|
+
import type { VideoBookmark, VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from './types';
|
|
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';
|
|
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;
|