@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.
- package/CLAUDE.md +1 -1
- package/dist/components/ContextMenu.svelte +13 -1
- package/dist/components/ContextMenu.svelte.d.ts +6 -0
- 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 +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
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 function parseTimeParam(raw) {
|
|
27
|
+
if (raw == null)
|
|
28
|
+
return null;
|
|
29
|
+
const s = String(raw).trim().replace(/s$/i, '');
|
|
30
|
+
if (s === '')
|
|
31
|
+
return null;
|
|
32
|
+
// h:mm:ss / m:ss clock form
|
|
33
|
+
if (s.includes(':')) {
|
|
34
|
+
const parts = s.split(':');
|
|
35
|
+
if (parts.length > 3)
|
|
36
|
+
return null;
|
|
37
|
+
let total = 0;
|
|
38
|
+
for (const part of parts) {
|
|
39
|
+
const n = Number(part);
|
|
40
|
+
if (!Number.isFinite(n) || n < 0)
|
|
41
|
+
return null;
|
|
42
|
+
total = total * 60 + n;
|
|
43
|
+
}
|
|
44
|
+
return total;
|
|
45
|
+
}
|
|
46
|
+
const n = Number(s);
|
|
47
|
+
if (!Number.isFinite(n) || n < 0)
|
|
48
|
+
return null;
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
/** Floor to whole seconds — the deep link promises second precision. */
|
|
52
|
+
export function formatTimeParam(sec) {
|
|
53
|
+
return `${Math.max(0, Math.floor(sec))}s`;
|
|
54
|
+
}
|
|
55
|
+
/** Read the current `?t=` from a URL (or the document location). */
|
|
56
|
+
export function readTimeParam(url = typeof location !== 'undefined' ? location.href : null) {
|
|
57
|
+
if (!url)
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(String(url), typeof location !== 'undefined' ? location.href : undefined);
|
|
61
|
+
return parseTimeParam(u.searchParams.get('t'));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Rewrite the current URL's `t` param in place, preserving every other
|
|
69
|
+
* query param. Returns false when there was nothing to change (SSR, no
|
|
70
|
+
* history API, same second) so the caller can skip the replaceState.
|
|
71
|
+
*/
|
|
72
|
+
export function writeTimeParam(sec, url = typeof location !== 'undefined' ? location.href : null) {
|
|
73
|
+
if (typeof history === 'undefined' || typeof history.replaceState !== 'function')
|
|
74
|
+
return false;
|
|
75
|
+
if (!url)
|
|
76
|
+
return false;
|
|
77
|
+
let u;
|
|
78
|
+
try {
|
|
79
|
+
u = new URL(String(url), typeof location !== 'undefined' ? location.href : undefined);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
const next = formatTimeParam(sec);
|
|
85
|
+
if (u.searchParams.get('t') === next)
|
|
86
|
+
return false;
|
|
87
|
+
u.searchParams.set('t', next);
|
|
88
|
+
history.replaceState(history.state, '', u);
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
/** Strip the `t` param (e.g. when the host unmounts the player opt-in). */
|
|
92
|
+
export function clearTimeParam(url = typeof location !== 'undefined' ? location.href : null) {
|
|
93
|
+
if (typeof history === 'undefined' || typeof history.replaceState !== 'function')
|
|
94
|
+
return false;
|
|
95
|
+
if (!url)
|
|
96
|
+
return false;
|
|
97
|
+
let u;
|
|
98
|
+
try {
|
|
99
|
+
u = new URL(String(url), typeof location !== 'undefined' ? location.href : undefined);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
if (!u.searchParams.has('t'))
|
|
105
|
+
return false;
|
|
106
|
+
u.searchParams.delete('t');
|
|
107
|
+
history.replaceState(history.state, '', u);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
/** Throttle for URL writes: at most one replaceState per WRITE_THROTTLE_MS. */
|
|
111
|
+
export const WRITE_THROTTLE_MS = 2000;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayer keyboard map (mos-5lzns.28) — the single source the keydown
|
|
3
|
+
* handler and the `?` help overlay both read, so a key that exists is always
|
|
4
|
+
* documented and a documented key always exists.
|
|
5
|
+
*
|
|
6
|
+
* Map parity is with extension-library's theater handler (FEATURES.md):
|
|
7
|
+
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
|
+
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
|
+
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
+
* library-app verbs, not player verbs), N (night cycle — no night module
|
|
11
|
+
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
12
|
+
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
+
* one), `/` (transcript search — the pane has none yet).
|
|
14
|
+
*/
|
|
15
|
+
export interface PlayerKeyRow {
|
|
16
|
+
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
|
17
|
+
keys: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/**
|
|
20
|
+
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
|
+
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
|
+
*/
|
|
23
|
+
when?: 'chapters' | 'bookmarks' | 'transcript';
|
|
24
|
+
}
|
|
25
|
+
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
|
+
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
|
27
|
+
/** Band-local keys (band focused): advertised by the band's aria, not the overlay. */
|
|
28
|
+
export declare const BAND_KEYS: PlayerKeyRow[];
|
|
29
|
+
/** mpv-faithful frame step — pause first; the stepped frame is visible in the video itself. */
|
|
30
|
+
export declare const FRAME_STEP_SEC: number;
|
|
31
|
+
/**
|
|
32
|
+
* Previous / next chapter boundary relative to the playhead. `]` jumps past a
|
|
33
|
+
* 0.25s grace so pressing it on a chapter's first frames doesn't stick; `[`
|
|
34
|
+
* uses a 0.75s grace so it lands on the PREVIOUS chapter rather than snapping
|
|
35
|
+
* to the current one's start. Returns null when there is nowhere to go.
|
|
36
|
+
*/
|
|
37
|
+
export declare function chapterJump(current: number, chapters: {
|
|
38
|
+
startSec: number;
|
|
39
|
+
title: string;
|
|
40
|
+
}[], direction: 1 | -1): {
|
|
41
|
+
startSec: number;
|
|
42
|
+
title: string;
|
|
43
|
+
} | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayer keyboard map (mos-5lzns.28) — the single source the keydown
|
|
3
|
+
* handler and the `?` help overlay both read, so a key that exists is always
|
|
4
|
+
* documented and a documented key always exists.
|
|
5
|
+
*
|
|
6
|
+
* Map parity is with extension-library's theater handler (FEATURES.md):
|
|
7
|
+
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
|
+
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
|
+
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
+
* library-app verbs, not player verbs), N (night cycle — no night module
|
|
11
|
+
* until mos-5lzns.33; add it THERE, with its own row here), `,` (settings —
|
|
12
|
+
* jatui is prop-driven; a settings module arrives with the features that need
|
|
13
|
+
* one), `/` (transcript search — the pane has none yet).
|
|
14
|
+
*/
|
|
15
|
+
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
16
|
+
export const PLAYER_KEYS = [
|
|
17
|
+
{ keys: 'Space / K', description: 'Play / pause' },
|
|
18
|
+
{ keys: '← / →', description: 'Seek ±5s (Shift for ±10s)' },
|
|
19
|
+
{ keys: 'J / L', description: 'Seek ±5s (Shift for ±10s)' },
|
|
20
|
+
{ keys: '↑ / ↓', description: 'Volume ±5%' },
|
|
21
|
+
{ keys: 'M', description: 'Mute / unmute' },
|
|
22
|
+
{ keys: '− / =', description: 'Speed down / up one step' },
|
|
23
|
+
{ keys: 'Backspace', description: 'Reset speed to 1×' },
|
|
24
|
+
{ keys: '< / >', description: 'Frame step (pauses; ~1/30s per press)' },
|
|
25
|
+
{ keys: '[ / ]', description: 'Previous / next chapter', when: 'chapters' },
|
|
26
|
+
{ keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
|
|
27
|
+
{ keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
28
|
+
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
29
|
+
{ keys: '?', description: 'Toggle this help' }
|
|
30
|
+
];
|
|
31
|
+
/** Band-local keys (band focused): advertised by the band's aria, not the overlay. */
|
|
32
|
+
export const BAND_KEYS = [
|
|
33
|
+
{ keys: '← / →', description: 'Seek ±5s (Shift for ±10s)' },
|
|
34
|
+
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
35
|
+
{ keys: 'Esc', description: 'Reset zoom' }
|
|
36
|
+
];
|
|
37
|
+
/** mpv-faithful frame step — pause first; the stepped frame is visible in the video itself. */
|
|
38
|
+
export const FRAME_STEP_SEC = 1 / 30;
|
|
39
|
+
/**
|
|
40
|
+
* Previous / next chapter boundary relative to the playhead. `]` jumps past a
|
|
41
|
+
* 0.25s grace so pressing it on a chapter's first frames doesn't stick; `[`
|
|
42
|
+
* uses a 0.75s grace so it lands on the PREVIOUS chapter rather than snapping
|
|
43
|
+
* to the current one's start. Returns null when there is nowhere to go.
|
|
44
|
+
*/
|
|
45
|
+
export function chapterJump(current, chapters, direction) {
|
|
46
|
+
if (chapters.length === 0)
|
|
47
|
+
return null;
|
|
48
|
+
if (direction === 1) {
|
|
49
|
+
return chapters.find((c) => c.startSec > current + 0.25) ?? null;
|
|
50
|
+
}
|
|
51
|
+
let target = 0;
|
|
52
|
+
let hit = null;
|
|
53
|
+
for (const c of chapters) {
|
|
54
|
+
if (c.startSec < current - 0.75) {
|
|
55
|
+
target = c.startSec;
|
|
56
|
+
hit = c;
|
|
57
|
+
}
|
|
58
|
+
else
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return hit ?? { startSec: target, title: 'Start' };
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker/model derivation for VideoPlayer (mos-5lzns.28 internal split).
|
|
3
|
+
*
|
|
4
|
+
* Pure, DOM-free transforms over the optional intelligence props: the ordered
|
|
5
|
+
* speaker registry (palette-stable across band, lane, legend and transcript),
|
|
6
|
+
* the identity→colour map, and talk-time totals. The component binds these
|
|
7
|
+
* with `$derived`; the band paint reads the results.
|
|
8
|
+
*/
|
|
9
|
+
import type { VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment } from './types.ts';
|
|
10
|
+
export declare const ALL_KEY = "__all__";
|
|
11
|
+
export declare const UNKNOWN_KEY = "__unknown__";
|
|
12
|
+
/**
|
|
13
|
+
* Speaker identity palette — the same small distinct oklch set TranscriptPlayer
|
|
14
|
+
* cycles, so a speaker reads as the same colour in both components.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SPEAKER_PALETTE: string[];
|
|
17
|
+
export type SpeakerLike = {
|
|
18
|
+
speakerIdentity?: string | null;
|
|
19
|
+
speakerName?: string | null;
|
|
20
|
+
};
|
|
21
|
+
export declare function speakerKeyOf(s: SpeakerLike): string;
|
|
22
|
+
/**
|
|
23
|
+
* Ordered speaker registry, built from turns first then transcript, so the
|
|
24
|
+
* palette index is stable across both panes and the legend.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildSpeakerIndex(turns: VideoSpeakerTurn[], segments: VideoTranscriptSegment[]): Map<string, {
|
|
27
|
+
key: string;
|
|
28
|
+
name: string;
|
|
29
|
+
hue: number | null;
|
|
30
|
+
}>;
|
|
31
|
+
/**
|
|
32
|
+
* key → identity colour, resolved ONCE per data change. draw() asks for a
|
|
33
|
+
* colour per rendered column and reruns on every playhead tick, so this must
|
|
34
|
+
* never be an indexOf scan at call time.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildSpeakerColors(speakerIndex: Map<string, {
|
|
37
|
+
key: string;
|
|
38
|
+
name: string;
|
|
39
|
+
hue: number | null;
|
|
40
|
+
}>): Map<string, string>;
|
|
41
|
+
/** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
|
|
42
|
+
export declare function sortChapters<T extends {
|
|
43
|
+
startSec: number;
|
|
44
|
+
}>(chapters: T[]): T[];
|
|
45
|
+
export declare function sortTurns<T extends VideoSpeakerTurn>(turns: T[]): T[];
|
|
46
|
+
export declare function sortSegments<T extends {
|
|
47
|
+
startSec: number;
|
|
48
|
+
}>(segments: T[]): T[];
|
|
49
|
+
export declare function sortBookmarks<T extends {
|
|
50
|
+
playbackSec: number;
|
|
51
|
+
}>(bookmarks: T[]): T[];
|
|
52
|
+
/** Does the active solo/mute filter recede this turn's paint? */
|
|
53
|
+
export declare function filterDims(key: string | null, ad: boolean, soloKey: string, mutedKeys: Set<string>): boolean;
|
|
54
|
+
/** Talk-time totals: host-supplied, else derived from turns (then segments). */
|
|
55
|
+
export declare function buildTalkTime(talkTime: VideoSpeakerTalkTime[], turns: VideoSpeakerTurn[], segments: VideoTranscriptSegment[]): (VideoSpeakerTalkTime & {
|
|
56
|
+
key: string;
|
|
57
|
+
})[];
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const ALL_KEY = '__all__';
|
|
2
|
+
export const UNKNOWN_KEY = '__unknown__';
|
|
3
|
+
/**
|
|
4
|
+
* Speaker identity palette — the same small distinct oklch set TranscriptPlayer
|
|
5
|
+
* cycles, so a speaker reads as the same colour in both components.
|
|
6
|
+
*/
|
|
7
|
+
export const SPEAKER_PALETTE = [
|
|
8
|
+
'oklch(0.70 0.16 250)', // blue
|
|
9
|
+
'oklch(0.68 0.18 145)', // green
|
|
10
|
+
'oklch(0.78 0.16 85)', // amber
|
|
11
|
+
'oklch(0.68 0.19 25)', // red
|
|
12
|
+
'oklch(0.68 0.16 300)', // violet
|
|
13
|
+
'oklch(0.72 0.14 200)' // cyan
|
|
14
|
+
];
|
|
15
|
+
export function speakerKeyOf(s) {
|
|
16
|
+
return s.speakerIdentity ?? s.speakerName ?? UNKNOWN_KEY;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Ordered speaker registry, built from turns first then transcript, so the
|
|
20
|
+
* palette index is stable across both panes and the legend.
|
|
21
|
+
*/
|
|
22
|
+
export function buildSpeakerIndex(turns, segments) {
|
|
23
|
+
const map = new Map();
|
|
24
|
+
const add = (identity, name, hue) => {
|
|
25
|
+
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
26
|
+
const existing = map.get(key);
|
|
27
|
+
if (existing) {
|
|
28
|
+
if (existing.hue == null && hue != null)
|
|
29
|
+
existing.hue = hue;
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
map.set(key, { key, name: name ?? 'Unknown speaker', hue: hue ?? null });
|
|
33
|
+
};
|
|
34
|
+
for (const t of turns) {
|
|
35
|
+
if (t.ad)
|
|
36
|
+
continue;
|
|
37
|
+
add(t.speakerIdentity, t.speakerName, t.hue ?? null);
|
|
38
|
+
}
|
|
39
|
+
for (const s of segments)
|
|
40
|
+
add(s.speakerIdentity, s.speakerName, null);
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* key → identity colour, resolved ONCE per data change. draw() asks for a
|
|
45
|
+
* colour per rendered column and reruns on every playhead tick, so this must
|
|
46
|
+
* never be an indexOf scan at call time.
|
|
47
|
+
*/
|
|
48
|
+
export function buildSpeakerColors(speakerIndex) {
|
|
49
|
+
const map = new Map();
|
|
50
|
+
let i = 0;
|
|
51
|
+
for (const [key, entry] of speakerIndex) {
|
|
52
|
+
map.set(key, entry.hue != null
|
|
53
|
+
? `oklch(0.72 0.14 ${entry.hue})`
|
|
54
|
+
: SPEAKER_PALETTE[i % SPEAKER_PALETTE.length]);
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
/** Ascending sort of the optional data arrays — the only normalization every consumer agrees on. */
|
|
60
|
+
export function sortChapters(chapters) {
|
|
61
|
+
return [...chapters]
|
|
62
|
+
.filter((c) => c && Number.isFinite(c.startSec) && c.startSec >= 0)
|
|
63
|
+
.sort((a, b) => a.startSec - b.startSec);
|
|
64
|
+
}
|
|
65
|
+
export function sortTurns(turns) {
|
|
66
|
+
return [...turns]
|
|
67
|
+
.filter((t) => t && Number.isFinite(t.startSec) && Number.isFinite(t.endSec) && t.endSec > t.startSec)
|
|
68
|
+
.sort((a, b) => a.startSec - b.startSec);
|
|
69
|
+
}
|
|
70
|
+
export function sortSegments(segments) {
|
|
71
|
+
return [...segments]
|
|
72
|
+
.filter((s) => s && Number.isFinite(s.startSec))
|
|
73
|
+
.sort((a, b) => a.startSec - b.startSec);
|
|
74
|
+
}
|
|
75
|
+
export function sortBookmarks(bookmarks) {
|
|
76
|
+
return [...bookmarks]
|
|
77
|
+
.filter((b) => b && Number.isFinite(b.playbackSec) && b.playbackSec >= 0)
|
|
78
|
+
.sort((a, b) => a.playbackSec - b.playbackSec);
|
|
79
|
+
}
|
|
80
|
+
/** Does the active solo/mute filter recede this turn's paint? */
|
|
81
|
+
export function filterDims(key, ad, soloKey, mutedKeys) {
|
|
82
|
+
if (soloKey !== ALL_KEY)
|
|
83
|
+
return !(key != null && !ad && key === soloKey);
|
|
84
|
+
return !!(key != null && !ad && mutedKeys.has(key));
|
|
85
|
+
}
|
|
86
|
+
/** Talk-time totals: host-supplied, else derived from turns (then segments). */
|
|
87
|
+
export function buildTalkTime(talkTime, turns, segments) {
|
|
88
|
+
if (talkTime.length > 0) {
|
|
89
|
+
return [...talkTime]
|
|
90
|
+
.map((t) => ({ ...t, key: speakerKeyOf(t) }))
|
|
91
|
+
.sort((a, b) => b.seconds - a.seconds);
|
|
92
|
+
}
|
|
93
|
+
const totals = new Map();
|
|
94
|
+
const bump = (identity, name, secs) => {
|
|
95
|
+
const key = identity ?? name ?? UNKNOWN_KEY;
|
|
96
|
+
const existing = totals.get(key);
|
|
97
|
+
if (existing)
|
|
98
|
+
existing.seconds += secs;
|
|
99
|
+
else
|
|
100
|
+
totals.set(key, { key, speakerIdentity: identity ?? null, speakerName: name ?? null, seconds: secs });
|
|
101
|
+
};
|
|
102
|
+
const source = turns.length > 0 ? turns : segments;
|
|
103
|
+
for (const s of source) {
|
|
104
|
+
if ('ad' in s && s.ad)
|
|
105
|
+
continue;
|
|
106
|
+
bump(s.speakerIdentity, s.speakerName, Math.max(0, s.endSec - s.startSec));
|
|
107
|
+
}
|
|
108
|
+
return [...totals.values()].sort((a, b) => b.seconds - a.seconds);
|
|
109
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Night module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.33
|
|
3
|
+
* ("night dimming, bias light, predictive dimming, strobe guard").
|
|
4
|
+
*
|
|
5
|
+
* What lands here (extension-library FEATURES.md §Night dimming, §Bias light,
|
|
6
|
+
* §The light track), as ADVISORS + paint helpers:
|
|
7
|
+
*
|
|
8
|
+
* · night dimming — every ~900ms while playing, sample the frame's actual
|
|
9
|
+
* brightness (downscaled canvas read), drive filter: brightness() toward
|
|
10
|
+
* `1 − 0.5·depth·excess`. Attack fast, release slow, so a dark cut inside a
|
|
11
|
+
* bright video doesn't flash. `N` cycles auto/on/off — add its row to
|
|
12
|
+
* keys.ts PLAYER_KEYS when the reader lands, not before.
|
|
13
|
+
* · bias light — the frame's own colours, blurred, behind the player
|
|
14
|
+
* (radial-gradient cells at z-index −1).
|
|
15
|
+
* · predictive dimming — the pre-scanned luma track read ~1s ahead so
|
|
16
|
+
* dimming starts BEFORE a bright cut; the light ribbon rides the same
|
|
17
|
+
* track on whichever axis owns scrubbing (mos-5lzns.30's one-axis).
|
|
18
|
+
* · strobe guard — hold the level down across detected flash intervals.
|
|
19
|
+
*
|
|
20
|
+
* The measuring code is an advisor: it reads no settings, touches no
|
|
21
|
+
* <video>, and returns safe neutral values on garbage input (CORS-tainted
|
|
22
|
+
* canvas ⇒ escalate to the caller, never silently guess brightness). The
|
|
23
|
+
* component decides and applies. Respect prefers-reduced-motion where it
|
|
24
|
+
* applies: the correction is the feature, the ramp is only how it arrives.
|
|
25
|
+
*/
|
|
26
|
+
export declare const NIGHT_NEUTRAL = 1;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Night module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.33
|
|
3
|
+
* ("night dimming, bias light, predictive dimming, strobe guard").
|
|
4
|
+
*
|
|
5
|
+
* What lands here (extension-library FEATURES.md §Night dimming, §Bias light,
|
|
6
|
+
* §The light track), as ADVISORS + paint helpers:
|
|
7
|
+
*
|
|
8
|
+
* · night dimming — every ~900ms while playing, sample the frame's actual
|
|
9
|
+
* brightness (downscaled canvas read), drive filter: brightness() toward
|
|
10
|
+
* `1 − 0.5·depth·excess`. Attack fast, release slow, so a dark cut inside a
|
|
11
|
+
* bright video doesn't flash. `N` cycles auto/on/off — add its row to
|
|
12
|
+
* keys.ts PLAYER_KEYS when the reader lands, not before.
|
|
13
|
+
* · bias light — the frame's own colours, blurred, behind the player
|
|
14
|
+
* (radial-gradient cells at z-index −1).
|
|
15
|
+
* · predictive dimming — the pre-scanned luma track read ~1s ahead so
|
|
16
|
+
* dimming starts BEFORE a bright cut; the light ribbon rides the same
|
|
17
|
+
* track on whichever axis owns scrubbing (mos-5lzns.30's one-axis).
|
|
18
|
+
* · strobe guard — hold the level down across detected flash intervals.
|
|
19
|
+
*
|
|
20
|
+
* The measuring code is an advisor: it reads no settings, touches no
|
|
21
|
+
* <video>, and returns safe neutral values on garbage input (CORS-tainted
|
|
22
|
+
* canvas ⇒ escalate to the caller, never silently guess brightness). The
|
|
23
|
+
* component decides and applies. Respect prefers-reduced-motion where it
|
|
24
|
+
* applies: the correction is the feature, the ramp is only how it arrives.
|
|
25
|
+
*/
|
|
26
|
+
export const NIGHT_NEUTRAL = 1;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.34
|
|
3
|
+
* ("wind-down (Idle Detection API), resume-at-sentence, FLIP open").
|
|
4
|
+
*
|
|
5
|
+
* What lands here (extension-library FEATURES.md §Wind-down,
|
|
6
|
+
* §Resume at a sentence, §Open like an object), as ADVISORS + pure helpers:
|
|
7
|
+
*
|
|
8
|
+
* · wind-down — the desktop says nobody is there (Idle Detection API / user
|
|
9
|
+
* activity), the player fades picture and sound over ~20s and pauses. The
|
|
10
|
+
* fade IS the prompt — cancelling it is how you come back.
|
|
11
|
+
* · resume-at-sentence — snap a resume point back to the nearest sentence /
|
|
12
|
+
* speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
|
|
13
|
+
* quote the line, never paraphrase; stand down if the user has taken the
|
|
14
|
+
* wheel (>2.5s from the stored point). Runs when the transcript lands, not
|
|
15
|
+
* at open.
|
|
16
|
+
* · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
|
|
17
|
+
* keyframes, transform-origin top-left, poster painted under the video on
|
|
18
|
+
* EVERY path (a black rectangle is a bug at any motion setting); no-op
|
|
19
|
+
* under prefers-reduced-motion except the poster.
|
|
20
|
+
*
|
|
21
|
+
* All three are advisors/mechanics: they read no settings, touch no
|
|
22
|
+
* <video> outside the callbacks the component passes, and fail toward doing
|
|
23
|
+
* nothing.
|
|
24
|
+
*/
|
|
25
|
+
export declare const PRESENCE_NEUTRAL = 1;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence module — CONTRACT ONLY (mos-5lzns.28). Implemented by mos-5lzns.34
|
|
3
|
+
* ("wind-down (Idle Detection API), resume-at-sentence, FLIP open").
|
|
4
|
+
*
|
|
5
|
+
* What lands here (extension-library FEATURES.md §Wind-down,
|
|
6
|
+
* §Resume at a sentence, §Open like an object), as ADVISORS + pure helpers:
|
|
7
|
+
*
|
|
8
|
+
* · wind-down — the desktop says nobody is there (Idle Detection API / user
|
|
9
|
+
* activity), the player fades picture and sound over ~20s and pauses. The
|
|
10
|
+
* fade IS the prompt — cancelling it is how you come back.
|
|
11
|
+
* · resume-at-sentence — snap a resume point back to the nearest sentence /
|
|
12
|
+
* speaker turn / chapter within 15s (CLOSEST wins, not most-specific);
|
|
13
|
+
* quote the line, never paraphrase; stand down if the user has taken the
|
|
14
|
+
* wheel (>2.5s from the stored point). Runs when the transcript lands, not
|
|
15
|
+
* at open.
|
|
16
|
+
* · FLIP open — the clicked card's rectangle → the player shell, WAAPI, two
|
|
17
|
+
* keyframes, transform-origin top-left, poster painted under the video on
|
|
18
|
+
* EVERY path (a black rectangle is a bug at any motion setting); no-op
|
|
19
|
+
* under prefers-reduced-motion except the poster.
|
|
20
|
+
*
|
|
21
|
+
* All three are advisors/mechanics: they read no settings, touch no
|
|
22
|
+
* <video> outside the callbacks the component passes, and fail toward doing
|
|
23
|
+
* nothing.
|
|
24
|
+
*/
|
|
25
|
+
export const PRESENCE_NEUTRAL = 1;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split).
|
|
3
|
+
*
|
|
4
|
+
* THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
|
|
5
|
+
* composer") — this is the one non-obvious rule, stated up front because it
|
|
6
|
+
* looks like a bug from the outside:
|
|
7
|
+
*
|
|
8
|
+
* Three features want `playbackRate`, and NONE of them writes it:
|
|
9
|
+
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
10
|
+
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
11
|
+
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
12
|
+
*
|
|
13
|
+
* They contribute to a product: rate = base × speaker × silence
|
|
14
|
+
*
|
|
15
|
+
* `base` is the only one a human chose, and therefore the only one the UI
|
|
16
|
+
* reports. The speed readout must keep saying `1×` while a gap is being taken
|
|
17
|
+
* at 2.2×, or the label becomes a strobe.
|
|
18
|
+
*
|
|
19
|
+
* 1. Both advisors collapse to `1` the moment their setting is off or their
|
|
20
|
+
* data has not arrived — `1` is the multiplicative identity, so the
|
|
21
|
+
* composer runs identically on a video with no transcript, no waveform
|
|
22
|
+
* and no speakers. There is no "feature absent" branch anywhere.
|
|
23
|
+
* 2. `base === 0` is a state, not a level (the frozen-frame end of the
|
|
24
|
+
* staircase) — it returns early, before any multiplication, because no
|
|
25
|
+
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
26
|
+
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
27
|
+
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
28
|
+
* Hence the 0.001 deadband.
|
|
29
|
+
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
30
|
+
* support. A product of three multipliers can leave it; a single one
|
|
31
|
+
* cannot.
|
|
32
|
+
*
|
|
33
|
+
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
34
|
+
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
35
|
+
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
36
|
+
*/
|
|
37
|
+
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
38
|
+
export declare const SPEED_STEPS: number[];
|
|
39
|
+
/**
|
|
40
|
+
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
41
|
+
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
42
|
+
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
43
|
+
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formatSpeedLabel(rate: number): string;
|
|
46
|
+
/**
|
|
47
|
+
* One step along the staircase. Unknown current rate snaps to 1 first (a
|
|
48
|
+
* hand-set rate off the staircase still steps from where the user expects).
|
|
49
|
+
*/
|
|
50
|
+
export declare function stepRate(current: number, direction: 1 | -1): number;
|
|
51
|
+
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
52
|
+
export declare const RATE_MIN = 0.0625;
|
|
53
|
+
export declare const RATE_MAX = 16;
|
|
54
|
+
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
55
|
+
export declare const RATE_DEADBAND = 0.001;
|
|
56
|
+
export interface ComposedRate {
|
|
57
|
+
rate: number;
|
|
58
|
+
/** False when the deadband swallowed the write (keep the previous rate). */
|
|
59
|
+
changed: boolean;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
63
|
+
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
64
|
+
* not arrived — mos-5lzns.32 fills both; until then they are always 1 and
|
|
65
|
+
* this is exactly `base`.
|
|
66
|
+
*
|
|
67
|
+
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
68
|
+
*/
|
|
69
|
+
export declare function composeRate(base: number, speaker: number, silence: number): ComposedRate;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playback-rate arbitration for VideoPlayer (mos-5lzns.28 internal split).
|
|
3
|
+
*
|
|
4
|
+
* THE RATE COMPOSER CONTRACT (extension-library FEATURES.md — "The rate
|
|
5
|
+
* composer") — this is the one non-obvious rule, stated up front because it
|
|
6
|
+
* looks like a bug from the outside:
|
|
7
|
+
*
|
|
8
|
+
* Three features want `playbackRate`, and NONE of them writes it:
|
|
9
|
+
* · you — the speed keys (−/=/Backspace, alt+wheel)
|
|
10
|
+
* · speaker — "this person talks fast" (mos-5lzns.32 fills)
|
|
11
|
+
* · silence — "nothing is being said here" (mos-5lzns.32 fills)
|
|
12
|
+
*
|
|
13
|
+
* They contribute to a product: rate = base × speaker × silence
|
|
14
|
+
*
|
|
15
|
+
* `base` is the only one a human chose, and therefore the only one the UI
|
|
16
|
+
* reports. The speed readout must keep saying `1×` while a gap is being taken
|
|
17
|
+
* at 2.2×, or the label becomes a strobe.
|
|
18
|
+
*
|
|
19
|
+
* 1. Both advisors collapse to `1` the moment their setting is off or their
|
|
20
|
+
* data has not arrived — `1` is the multiplicative identity, so the
|
|
21
|
+
* composer runs identically on a video with no transcript, no waveform
|
|
22
|
+
* and no speakers. There is no "feature absent" branch anywhere.
|
|
23
|
+
* 2. `base === 0` is a state, not a level (the frozen-frame end of the
|
|
24
|
+
* staircase) — it returns early, before any multiplication, because no
|
|
25
|
+
* advisor gets to un-pause the video by multiplying its way out of zero.
|
|
26
|
+
* 3. Only real changes are written. `playbackRate` is a live audio
|
|
27
|
+
* parameter: writing a *slightly* different value is audible as a wobble.
|
|
28
|
+
* Hence the 0.001 deadband.
|
|
29
|
+
* 4. The clamp is [0.0625, 16] — the range HTML media elements actually
|
|
30
|
+
* support. A product of three multipliers can leave it; a single one
|
|
31
|
+
* cannot.
|
|
32
|
+
*
|
|
33
|
+
* The SPEED_STEPS staircase and formatSpeedLabel are ported from the
|
|
34
|
+
* extension's theater (linear 0.1 steps below 1×, all the way down to 0 —
|
|
35
|
+
* playbackRate=0 is a genuine frozen frame; above 1× the familiar jumps).
|
|
36
|
+
*/
|
|
37
|
+
/** The speed staircase — one step per press of −/=, Backspace resets to 1. */
|
|
38
|
+
export const SPEED_STEPS = [
|
|
39
|
+
0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.75, 2, 2.5, 5, 10
|
|
40
|
+
];
|
|
41
|
+
/**
|
|
42
|
+
* "A quarter speed" reads faster as "−.75" than "0.25×" — below 1× the label
|
|
43
|
+
* is the delta, not the multiplier. The 0 step is the frozen-frame end of the
|
|
44
|
+
* staircase — a state, not a level — and the label says so ("0×"), because a
|
|
45
|
+
* frozen frame labeled "1×" lies. Garbage degrades to `1×`.
|
|
46
|
+
*/
|
|
47
|
+
export function formatSpeedLabel(rate) {
|
|
48
|
+
if (!Number.isFinite(rate) || rate < 0)
|
|
49
|
+
return '1×';
|
|
50
|
+
if (rate === 0)
|
|
51
|
+
return '0×';
|
|
52
|
+
if (rate < 1) {
|
|
53
|
+
return (rate - 1).toFixed(2).replace(/\.?0+$/, '').replace(/^-0\./, '-.') + '×';
|
|
54
|
+
}
|
|
55
|
+
return rate.toFixed(2).replace(/\.?0+$/, '') + '×';
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One step along the staircase. Unknown current rate snaps to 1 first (a
|
|
59
|
+
* hand-set rate off the staircase still steps from where the user expects).
|
|
60
|
+
*/
|
|
61
|
+
export function stepRate(current, direction) {
|
|
62
|
+
const i = SPEED_STEPS.indexOf(current);
|
|
63
|
+
const cur = i === -1 ? SPEED_STEPS.indexOf(1) : i;
|
|
64
|
+
return SPEED_STEPS[Math.min(SPEED_STEPS.length - 1, Math.max(0, cur + direction))];
|
|
65
|
+
}
|
|
66
|
+
/** The clamp the composer applies — the range HTML media elements actually support. */
|
|
67
|
+
export const RATE_MIN = 0.0625;
|
|
68
|
+
export const RATE_MAX = 16;
|
|
69
|
+
/** Writes only on real change — a slightly-different write is audible as a wobble. */
|
|
70
|
+
export const RATE_DEADBAND = 0.001;
|
|
71
|
+
/**
|
|
72
|
+
* The whole arbitration. `speaker` and `silence` are advisor products in
|
|
73
|
+
* [0.0625, 16] that COLLAPSE TO 1 when their feature is off or its data has
|
|
74
|
+
* not arrived — mos-5lzns.32 fills both; until then they are always 1 and
|
|
75
|
+
* this is exactly `base`.
|
|
76
|
+
*
|
|
77
|
+
* `base === 0` returns early: a frozen frame is a state, not a level.
|
|
78
|
+
*/
|
|
79
|
+
export function composeRate(base, speaker, silence) {
|
|
80
|
+
if (!Number.isFinite(base) || base <= 0) {
|
|
81
|
+
return { rate: Math.max(0, Number.isFinite(base) ? base : 0), changed: false };
|
|
82
|
+
}
|
|
83
|
+
const sp = Number.isFinite(speaker) && speaker > 0 ? speaker : 1;
|
|
84
|
+
const si = Number.isFinite(silence) && silence > 0 ? silence : 1;
|
|
85
|
+
const rate = Math.min(RATE_MAX, Math.max(RATE_MIN, base * sp * si));
|
|
86
|
+
const changed = Math.abs(rate - base) > RATE_DEADBAND;
|
|
87
|
+
return { rate: changed ? rate : base, changed };
|
|
88
|
+
}
|