@joewinke/jatui 0.34.2 → 0.36.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.
Files changed (41) hide show
  1. package/CLAUDE.md +9 -3
  2. package/dist/actions/reveal.d.ts +72 -11
  3. package/dist/actions/reveal.js +103 -14
  4. package/dist/components/AudioRecorder.svelte +23 -1
  5. package/dist/components/AudioRecorder.svelte.d.ts +13 -1
  6. package/dist/components/BulkActionBar.svelte +17 -2
  7. package/dist/components/BulkActionBar.svelte.d.ts +5 -0
  8. package/dist/components/Drawer.svelte +20 -1
  9. package/dist/components/Drawer.svelte.d.ts +11 -0
  10. package/dist/components/FloatingActionBar.svelte +26 -11
  11. package/dist/components/FloatingActionBar.svelte.d.ts +5 -0
  12. package/dist/components/KeyboardShortcutsOverlay.svelte +180 -7
  13. package/dist/components/KeyboardShortcutsOverlay.svelte.d.ts +21 -0
  14. package/dist/components/ListenControl.svelte +1064 -0
  15. package/dist/components/ListenControl.svelte.d.ts +87 -0
  16. package/dist/components/ThumbnailGrid.svelte +34 -7
  17. package/dist/components/ThumbnailGrid.svelte.d.ts +17 -1
  18. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  19. package/dist/index.d.ts +9 -3
  20. package/dist/index.js +9 -2
  21. package/dist/listen/keys.d.ts +36 -0
  22. package/dist/listen/keys.js +68 -0
  23. package/dist/listen/mediaSession.d.ts +90 -0
  24. package/dist/listen/mediaSession.js +108 -0
  25. package/dist/listen/prefs.d.ts +49 -0
  26. package/dist/listen/prefs.js +97 -0
  27. package/dist/listen/segments.d.ts +101 -0
  28. package/dist/listen/segments.js +224 -0
  29. package/dist/listen/types.d.ts +81 -0
  30. package/dist/listen/types.js +7 -0
  31. package/dist/motion/index.d.ts +1 -1
  32. package/dist/motion/index.js +1 -1
  33. package/dist/utils/audioRecorder.d.ts +6 -1
  34. package/dist/utils/audioRecorder.js +5 -0
  35. package/dist/utils/audioRecorderCore.d.ts +34 -0
  36. package/dist/utils/audioRecorderCore.js +43 -6
  37. package/dist/utils/keyboardShortcutHub.d.ts +73 -0
  38. package/dist/utils/keyboardShortcutHub.js +89 -0
  39. package/dist/utils/mentionParser.d.ts +44 -2
  40. package/dist/utils/mentionParser.js +84 -4
  41. package/package.json +2 -1
@@ -0,0 +1,49 @@
1
+ /**
2
+ * ListenControl preferences — speed and voice persistence (jatui-eev2n).
3
+ *
4
+ * The same contract as `video/settings/store.ts` and `columnStorage`: jatui
5
+ * persists to THIS browser's localStorage by default, a host keeping per-user
6
+ * state passes its own store. Every access is guarded — private mode, a full
7
+ * quota, or SSR degrade to "nothing stored", never to a thrown error.
8
+ */
9
+ /** The listening speeds people actually pick — click cycles, [ / ] step. */
10
+ export declare const LISTEN_SPEEDS: number[];
11
+ /** The task's range: 0.75–2×. */
12
+ export declare const SPEED_FLOOR = 0.75;
13
+ export declare const SPEED_CEIL = 2;
14
+ /**
15
+ * Clamp to the 0.75–2× range. Garbage (NaN, Infinity, hostile types) degrades
16
+ * to 1× — never a thrown error.
17
+ */
18
+ export declare function clampSpeed(rate: unknown): number;
19
+ /**
20
+ * One step along the speed staircase in `direction`. A rate OFF the staircase
21
+ * (1.1 from a host's own menu, say) steps to its nearest staircase neighbour
22
+ * in the pressed direction — never snapping back to 1×. Past either end the
23
+ * rate holds.
24
+ */
25
+ /**
26
+ * Plain multiplier label for listening speeds — `0.75×`, `1×`, `1.25×`,
27
+ * `1.5×`, `1.75×`, `2×`. (VideoPlayer's `formatSpeedLabel` writes sub-1×
28
+ * speeds as a delta — mpv style — which reads wrong on a reading player.)
29
+ */
30
+ export declare function formatListenSpeed(rate: number): string;
31
+ export declare function stepSpeed(current: number, direction: 1 | -1): number;
32
+ export interface ListenPrefs {
33
+ speed: number;
34
+ voiceId: string | null;
35
+ }
36
+ /** What persistence needs. Null/undefined ⇒ no storage (SSR, private mode). */
37
+ export type ListenPrefStore = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> | null | undefined;
38
+ /**
39
+ * Read prefs. Missing/corrupt storage or hostile JSON → null (use the
40
+ * defaults); a hostile `speed` degrades to 1× rather than poisoning the
41
+ * player, and `voiceId` must be a non-empty string to survive.
42
+ */
43
+ export declare function loadListenPrefs(store: ListenPrefStore, key: string): ListenPrefs | null;
44
+ /** Write prefs. Best-effort: a throwing/full store is swallowed. */
45
+ export declare function saveListenPrefs(store: ListenPrefStore, key: string, prefs: ListenPrefs): void;
46
+ /** The default store: this browser's localStorage, or null on SSR/private mode. */
47
+ export declare function localListenStore(): ListenPrefStore;
48
+ /** Default key prefix — hosts pass `storageKey` to namespace per-page/feature. */
49
+ export declare const LISTEN_PREFS_KEY = "jatui.listen:prefs";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * ListenControl preferences — speed and voice persistence (jatui-eev2n).
3
+ *
4
+ * The same contract as `video/settings/store.ts` and `columnStorage`: jatui
5
+ * persists to THIS browser's localStorage by default, a host keeping per-user
6
+ * state passes its own store. Every access is guarded — private mode, a full
7
+ * quota, or SSR degrade to "nothing stored", never to a thrown error.
8
+ */
9
+ /** The listening speeds people actually pick — click cycles, [ / ] step. */
10
+ export const LISTEN_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2];
11
+ /** The task's range: 0.75–2×. */
12
+ export const SPEED_FLOOR = 0.75;
13
+ export const SPEED_CEIL = 2;
14
+ /**
15
+ * Clamp to the 0.75–2× range. Garbage (NaN, Infinity, hostile types) degrades
16
+ * to 1× — never a thrown error.
17
+ */
18
+ export function clampSpeed(rate) {
19
+ const n = typeof rate === 'number' ? rate : Number(rate);
20
+ if (!Number.isFinite(n) || n <= 0)
21
+ return 1;
22
+ return Math.min(SPEED_CEIL, Math.max(SPEED_FLOOR, n));
23
+ }
24
+ /**
25
+ * One step along the speed staircase in `direction`. A rate OFF the staircase
26
+ * (1.1 from a host's own menu, say) steps to its nearest staircase neighbour
27
+ * in the pressed direction — never snapping back to 1×. Past either end the
28
+ * rate holds.
29
+ */
30
+ /**
31
+ * Plain multiplier label for listening speeds — `0.75×`, `1×`, `1.25×`,
32
+ * `1.5×`, `1.75×`, `2×`. (VideoPlayer's `formatSpeedLabel` writes sub-1×
33
+ * speeds as a delta — mpv style — which reads wrong on a reading player.)
34
+ */
35
+ export function formatListenSpeed(rate) {
36
+ return `${clampSpeed(rate)}×`;
37
+ }
38
+ export function stepSpeed(current, direction) {
39
+ const cur = clampSpeed(current);
40
+ const i = LISTEN_SPEEDS.indexOf(cur);
41
+ if (i !== -1)
42
+ return LISTEN_SPEEDS[Math.min(LISTEN_SPEEDS.length - 1, Math.max(0, i + direction))];
43
+ if (direction > 0)
44
+ return LISTEN_SPEEDS.find((s) => s > cur) ?? cur;
45
+ for (let j = LISTEN_SPEEDS.length - 1; j >= 0; j--)
46
+ if (LISTEN_SPEEDS[j] < cur)
47
+ return LISTEN_SPEEDS[j];
48
+ return cur;
49
+ }
50
+ /**
51
+ * Read prefs. Missing/corrupt storage or hostile JSON → null (use the
52
+ * defaults); a hostile `speed` degrades to 1× rather than poisoning the
53
+ * player, and `voiceId` must be a non-empty string to survive.
54
+ */
55
+ export function loadListenPrefs(store, key) {
56
+ if (!store || typeof key !== 'string' || key === '')
57
+ return null;
58
+ try {
59
+ const raw = store.getItem(key);
60
+ if (raw == null || raw === '')
61
+ return null;
62
+ const parsed = JSON.parse(raw);
63
+ if (parsed == null || typeof parsed !== 'object')
64
+ return null;
65
+ const obj = parsed;
66
+ const speed = typeof obj.speed === 'number' && Number.isFinite(obj.speed) ? clampSpeed(obj.speed) : NaN;
67
+ const voiceId = typeof obj.voiceId === 'string' && obj.voiceId !== '' ? obj.voiceId : null;
68
+ if (!Number.isFinite(speed) && voiceId === null)
69
+ return null;
70
+ return { speed: Number.isFinite(speed) ? speed : 1, voiceId };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ /** Write prefs. Best-effort: a throwing/full store is swallowed. */
77
+ export function saveListenPrefs(store, key, prefs) {
78
+ if (!store || typeof key !== 'string' || key === '')
79
+ return;
80
+ try {
81
+ store.setItem(key, JSON.stringify({ speed: clampSpeed(prefs.speed), voiceId: prefs.voiceId ?? null }));
82
+ }
83
+ catch {
84
+ /* private mode, quota — the session keeps its in-memory values */
85
+ }
86
+ }
87
+ /** The default store: this browser's localStorage, or null on SSR/private mode. */
88
+ export function localListenStore() {
89
+ try {
90
+ return typeof localStorage === 'undefined' ? null : localStorage;
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ /** Default key prefix — hosts pass `storageKey` to namespace per-page/feature. */
97
+ export const LISTEN_PREFS_KEY = 'jatui.listen:prefs';
@@ -0,0 +1,101 @@
1
+ /**
2
+ * ListenControl sequencing — the ONE timeline across per-sentence audio
3
+ * files (jatui-eev2n).
4
+ *
5
+ * A "listen to this page" player is a playlist, not one media element: the
6
+ * host synthesizes each sentence/paragraph separately, so the player walks
7
+ * segments sequentially and reports ONE document-wide time. Every position —
8
+ * the elapsed clock, the progress fill, the chapter ticks, a ±15s skip that
9
+ * crosses a segment boundary, a seek from the slider — maps through
10
+ * `buildTimeline`'s offsets, the same one-axis discipline the VideoPlayer
11
+ * band uses (`video/band/axis.ts`).
12
+ *
13
+ * Pure and DOM-free: safe on garbage input, node-testable.
14
+ */
15
+ import type { ListenChapter, ListenSegment, ListenSegmentPage, ListenSegmentSource } from './types';
16
+ /** Placeholder width of a segment whose duration is not known yet (pending synth). */
17
+ export declare const DEFAULT_ESTIMATE_SEC = 3;
18
+ /**
19
+ * A "previous sentence" press RESTARTS the current segment when it is already
20
+ * this deep in — past that it means "the one before". Podcast |</<< convention.
21
+ */
22
+ export declare const SENTENCE_RESTART_SEC = 3;
23
+ /** The document-wide timeline: each segment's start, effective duration and total. */
24
+ export interface ListenTimeline {
25
+ /** Start time of each segment on the one timeline. */
26
+ offsets: number[];
27
+ /** Measured > host-supplied > estimated duration per segment. */
28
+ durations: number[];
29
+ /** `offsets[n] + durations[n]` — the end of the last segment. */
30
+ total: number;
31
+ /** True where the duration is the estimate, not a real one. */
32
+ estimated: boolean[];
33
+ }
34
+ /**
35
+ * Is this segment playable right now? A segment exists before its audio does
36
+ * (`url: null` while synthesizing).
37
+ */
38
+ export declare function isPlayable(segment: ListenSegment | null | undefined): boolean;
39
+ /** First index ≥ `from` with playable audio; -1 when none. */
40
+ export declare function firstPlayableFrom(segments: ListenSegment[], from?: number): number;
41
+ /**
42
+ * The one timeline. An unknown duration borrows `estimateSec` so later
43
+ * segments still get stable offsets (self-correcting as real durations land);
44
+ * `estimated[i]` says which, so the UI can hatch the not-yet-real tail.
45
+ */
46
+ export declare function buildTimeline(segments: ListenSegment[], estimateSec?: number): ListenTimeline;
47
+ /** Where does document time `t` live? Binary search; `t` past the end clamps to the last segment's end. */
48
+ export declare function locate(t: number, timeline: ListenTimeline): {
49
+ index: number;
50
+ into: number;
51
+ } | null;
52
+ /**
53
+ * Document-time seek: ±delta from `t`, clamped to [0, total]. Returns the
54
+ * segment to load and the offset into it — a skip that lands mid-segment
55
+ * resumes inside it; one that lands past an estimate still resolves (the
56
+ * offset self-corrects when the real duration arrives).
57
+ */
58
+ export declare function seekTo(t: number, timeline: ListenTimeline): {
59
+ index: number;
60
+ into: number;
61
+ } | null;
62
+ /**
63
+ * Previous / next SENTENCE (the skip-sentence controls and ←/→ keys). Each
64
+ * segment is one sentence, so the move is index±1 — with one exception: deep
65
+ * inside the current segment (past `SENTENCE_RESTART_SEC`) "previous" means
66
+ * "restart this one". Returns the target index, or null at the edge.
67
+ */
68
+ export declare function jumpSentence(index: number, into: number, count: number, direction: 1 | -1, restartAfterSec?: number): number | null;
69
+ /** A chapter tick for every chapter that owns at least one segment; `frac` is its 0–100 % position. */
70
+ export declare function chapterTicks(chapters: ListenChapter[], segments: ListenSegment[], timeline: ListenTimeline): {
71
+ id: string;
72
+ label: string;
73
+ frac: number;
74
+ }[];
75
+ /** The chapter active at document time `t` — for the bar's section caption. */
76
+ export declare function activeChapter(chapters: ListenChapter[], segments: ListenSegment[], timeline: ListenTimeline, t: number): {
77
+ id: string;
78
+ label: string;
79
+ } | null;
80
+ /**
81
+ * Merge one page into the running list. New ids append in page order; a
82
+ * REPEATED id is an update — the host streaming a later page that fills
83
+ * `url`/`durationSec` in for a segment it announced before. Only defined
84
+ * fields overwrite (a page that re-announces `{id}` alone changes nothing),
85
+ * so late-arriving audio can never blank a known field.
86
+ */
87
+ export declare function mergePages(existing: ListenSegment[], page: ListenSegmentPage): ListenSegment[];
88
+ /**
89
+ * Normalize the four source shapes (async iterable, sync iterable, Promise of
90
+ * a page, bare page) into one async iteration, so the component consumes a
91
+ * single `for await`. A rejected/throwing source ends the iteration — the
92
+ * component keeps whatever pages landed and flags `done`.
93
+ */
94
+ export declare function iterateSource(source: ListenSegmentSource, voiceId: string): AsyncGenerator<ListenSegmentPage, void, undefined>;
95
+ /**
96
+ * The contiguous run of NOT-yet-synthesized segments at the end of the list —
97
+ * the common TTS shape (synthesized in order). The progress bar hatches this
98
+ * region to say "more is coming". Returns the start index, or -1 when every
99
+ * trailing segment already has audio.
100
+ */
101
+ export declare function pendingTailStart(segments: ListenSegment[]): number;
@@ -0,0 +1,224 @@
1
+ /**
2
+ * ListenControl sequencing — the ONE timeline across per-sentence audio
3
+ * files (jatui-eev2n).
4
+ *
5
+ * A "listen to this page" player is a playlist, not one media element: the
6
+ * host synthesizes each sentence/paragraph separately, so the player walks
7
+ * segments sequentially and reports ONE document-wide time. Every position —
8
+ * the elapsed clock, the progress fill, the chapter ticks, a ±15s skip that
9
+ * crosses a segment boundary, a seek from the slider — maps through
10
+ * `buildTimeline`'s offsets, the same one-axis discipline the VideoPlayer
11
+ * band uses (`video/band/axis.ts`).
12
+ *
13
+ * Pure and DOM-free: safe on garbage input, node-testable.
14
+ */
15
+ /** Placeholder width of a segment whose duration is not known yet (pending synth). */
16
+ export const DEFAULT_ESTIMATE_SEC = 3;
17
+ /**
18
+ * A "previous sentence" press RESTARTS the current segment when it is already
19
+ * this deep in — past that it means "the one before". Podcast |</<< convention.
20
+ */
21
+ export const SENTENCE_RESTART_SEC = 3;
22
+ /**
23
+ * Is this segment playable right now? A segment exists before its audio does
24
+ * (`url: null` while synthesizing).
25
+ */
26
+ export function isPlayable(segment) {
27
+ return typeof segment?.url === 'string' && segment.url.length > 0;
28
+ }
29
+ /** First index ≥ `from` with playable audio; -1 when none. */
30
+ export function firstPlayableFrom(segments, from = 0) {
31
+ for (let i = Math.max(0, from); i < segments.length; i++)
32
+ if (isPlayable(segments[i]))
33
+ return i;
34
+ return -1;
35
+ }
36
+ /**
37
+ * The one timeline. An unknown duration borrows `estimateSec` so later
38
+ * segments still get stable offsets (self-correcting as real durations land);
39
+ * `estimated[i]` says which, so the UI can hatch the not-yet-real tail.
40
+ */
41
+ export function buildTimeline(segments, estimateSec = DEFAULT_ESTIMATE_SEC) {
42
+ const est = Number.isFinite(estimateSec) && estimateSec > 0 ? estimateSec : DEFAULT_ESTIMATE_SEC;
43
+ const offsets = [];
44
+ const durations = [];
45
+ const estimated = [];
46
+ let at = 0;
47
+ for (const s of segments) {
48
+ offsets.push(at);
49
+ const d = s.durationSec;
50
+ const known = Number.isFinite(d) && d > 0 ? d : est;
51
+ estimated.push(!(Number.isFinite(d) && d > 0));
52
+ durations.push(known);
53
+ at += known;
54
+ }
55
+ return { offsets, durations, estimated, total: at };
56
+ }
57
+ /** Where does document time `t` live? Binary search; `t` past the end clamps to the last segment's end. */
58
+ export function locate(t, timeline) {
59
+ const n = timeline.offsets.length;
60
+ if (n === 0)
61
+ return null;
62
+ if (!Number.isFinite(t) || t <= 0)
63
+ return { index: 0, into: 0 };
64
+ // Binary search the highest segment whose offset <= t.
65
+ let lo = 0;
66
+ let hi = n - 1;
67
+ while (lo < hi) {
68
+ const mid = (lo + hi + 1) >> 1;
69
+ if (timeline.offsets[mid] <= t)
70
+ lo = mid;
71
+ else
72
+ hi = mid - 1;
73
+ }
74
+ return { index: lo, into: Math.min(t - timeline.offsets[lo], timeline.durations[lo]) };
75
+ }
76
+ /**
77
+ * Document-time seek: ±delta from `t`, clamped to [0, total]. Returns the
78
+ * segment to load and the offset into it — a skip that lands mid-segment
79
+ * resumes inside it; one that lands past an estimate still resolves (the
80
+ * offset self-corrects when the real duration arrives).
81
+ */
82
+ export function seekTo(t, timeline) {
83
+ const n = timeline.offsets.length;
84
+ if (n === 0)
85
+ return null;
86
+ const clamped = Math.min(Math.max(0, Number.isFinite(t) ? t : 0), timeline.total);
87
+ const at = locate(clamped, timeline);
88
+ return at ? { index: at.index, into: Math.min(at.into, timeline.durations[at.index]) } : null;
89
+ }
90
+ /**
91
+ * Previous / next SENTENCE (the skip-sentence controls and ←/→ keys). Each
92
+ * segment is one sentence, so the move is index±1 — with one exception: deep
93
+ * inside the current segment (past `SENTENCE_RESTART_SEC`) "previous" means
94
+ * "restart this one". Returns the target index, or null at the edge.
95
+ */
96
+ export function jumpSentence(index, into, count, direction, restartAfterSec = SENTENCE_RESTART_SEC) {
97
+ if (count === 0 || !Number.isFinite(index))
98
+ return null;
99
+ const i = Math.min(Math.max(0, Math.floor(index)), count - 1);
100
+ if (direction === 1)
101
+ return i < count - 1 ? i + 1 : null;
102
+ // Backwards: restart the current sentence first when we are already deep in it.
103
+ if (Number.isFinite(into) && into > restartAfterSec)
104
+ return i;
105
+ return i > 0 ? i - 1 : null;
106
+ }
107
+ /** A chapter tick for every chapter that owns at least one segment; `frac` is its 0–100 % position. */
108
+ export function chapterTicks(chapters, segments, timeline) {
109
+ if (timeline.total <= 0 || chapters.length === 0)
110
+ return [];
111
+ const firstOf = new Map();
112
+ for (let i = 0; i < segments.length; i++) {
113
+ const cid = segments[i].chapterId;
114
+ if (cid && !firstOf.has(cid))
115
+ firstOf.set(cid, i);
116
+ }
117
+ const ticks = [];
118
+ for (const c of chapters) {
119
+ const i = firstOf.get(c.id);
120
+ if (i === undefined)
121
+ continue;
122
+ ticks.push({ id: c.id, label: c.label, frac: Math.min(100, (timeline.offsets[i] / timeline.total) * 100) });
123
+ }
124
+ return ticks;
125
+ }
126
+ /** The chapter active at document time `t` — for the bar's section caption. */
127
+ export function activeChapter(chapters, segments, timeline, t) {
128
+ if (chapters.length === 0 || segments.length === 0)
129
+ return null;
130
+ const at = locate(t, timeline);
131
+ if (!at)
132
+ return null;
133
+ // Walk back from the current segment to the nearest one that names a chapter.
134
+ for (let i = at.index; i >= 0; i--) {
135
+ const cid = segments[i].chapterId;
136
+ if (!cid)
137
+ continue;
138
+ const c = chapters.find((x) => x.id === cid);
139
+ if (c)
140
+ return { id: c.id, label: c.label };
141
+ }
142
+ return null;
143
+ }
144
+ /**
145
+ * Merge one page into the running list. New ids append in page order; a
146
+ * REPEATED id is an update — the host streaming a later page that fills
147
+ * `url`/`durationSec` in for a segment it announced before. Only defined
148
+ * fields overwrite (a page that re-announces `{id}` alone changes nothing),
149
+ * so late-arriving audio can never blank a known field.
150
+ */
151
+ export function mergePages(existing, page) {
152
+ const incoming = Array.isArray(page?.segments) ? page.segments : [];
153
+ if (incoming.length === 0)
154
+ return existing;
155
+ const out = existing.slice();
156
+ const byId = new Map();
157
+ out.forEach((s, i) => byId.set(s.id, i));
158
+ for (const raw of incoming) {
159
+ if (!raw || typeof raw.id !== 'string' || raw.id === '')
160
+ continue;
161
+ const i = byId.get(raw.id);
162
+ if (i === undefined) {
163
+ byId.set(raw.id, out.length);
164
+ out.push({ ...raw });
165
+ }
166
+ else {
167
+ const prev = out[i];
168
+ out[i] = {
169
+ ...prev,
170
+ ...(raw.text !== undefined ? { text: raw.text } : {}),
171
+ ...(raw.url !== undefined ? { url: raw.url } : {}),
172
+ ...(raw.durationSec !== undefined ? { durationSec: raw.durationSec } : {}),
173
+ ...(raw.chapterId !== undefined ? { chapterId: raw.chapterId } : {})
174
+ };
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+ /**
180
+ * Normalize the four source shapes (async iterable, sync iterable, Promise of
181
+ * a page, bare page) into one async iteration, so the component consumes a
182
+ * single `for await`. A rejected/throwing source ends the iteration — the
183
+ * component keeps whatever pages landed and flags `done`.
184
+ */
185
+ export async function* iterateSource(source, voiceId) {
186
+ const result = source(voiceId);
187
+ if (result == null)
188
+ return;
189
+ const asPromise = result;
190
+ if (typeof asPromise.then === 'function') {
191
+ yield await asPromise;
192
+ return;
193
+ }
194
+ const asAsync = result;
195
+ if (typeof asAsync[Symbol.asyncIterator] === 'function') {
196
+ for await (const page of asAsync) {
197
+ if (page != null)
198
+ yield page;
199
+ }
200
+ return;
201
+ }
202
+ const asSync = result;
203
+ if (typeof asSync[Symbol.iterator] === 'function') {
204
+ for (const page of asSync) {
205
+ if (page != null)
206
+ yield page;
207
+ }
208
+ return;
209
+ }
210
+ // Bare page.
211
+ yield result;
212
+ }
213
+ /**
214
+ * The contiguous run of NOT-yet-synthesized segments at the end of the list —
215
+ * the common TTS shape (synthesized in order). The progress bar hatches this
216
+ * region to say "more is coming". Returns the start index, or -1 when every
217
+ * trailing segment already has audio.
218
+ */
219
+ export function pendingTailStart(segments) {
220
+ let i = segments.length;
221
+ while (i > 0 && !isPlayable(segments[i - 1]))
222
+ i--;
223
+ return i < segments.length ? i : -1;
224
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ListenControl data contracts (jatui-eev2n) — the "listen to this page"
3
+ * playback component. Pure types, DOM-free, node-testable like
4
+ * `video/types.ts`; the component re-exports them so
5
+ * `import type … from 'jatui'` keeps working.
6
+ */
7
+ /** One listenable unit — a sentence or paragraph, whichever the host splits at. */
8
+ export interface ListenSegment {
9
+ /**
10
+ * Stable id. `onSegmentChange` fires with it as playback crosses segment
11
+ * boundaries; the host keys its highlight + scroll-follow off it.
12
+ */
13
+ id: string;
14
+ /** The text being read — feeds the bar's caption and aria-live line. */
15
+ text?: string;
16
+ /**
17
+ * Audio for this segment. `null`/`undefined` while the host is still
18
+ * synthesizing it — the player starts on the first chunk that HAS a url
19
+ * and lights the rest in as pages arrive.
20
+ */
21
+ url?: string | null;
22
+ /**
23
+ * Duration in seconds when the host knows it (TTS usually reports it).
24
+ * Missing durations are measured when the audio loads and ESTIMATED
25
+ * (`estimateSec`) until then, so the progress bar can lay out before
26
+ * every chunk exists.
27
+ */
28
+ durationSec?: number;
29
+ /** Section (`ListenChapter.id`) this segment belongs to — drives the progress ticks. */
30
+ chapterId?: string;
31
+ }
32
+ /** A chapter/section heading — renders as a tick on the progress bar. */
33
+ export interface ListenChapter {
34
+ id: string;
35
+ label: string;
36
+ }
37
+ /** A synthetic voice the host offers. The list is host-supplied; the player persists the pick. */
38
+ export interface ListenVoice {
39
+ id: string;
40
+ label: string;
41
+ }
42
+ /**
43
+ * One page from the async segment source. Pages arrive as chunks finish
44
+ * synthesizing; a page may REPEAT an earlier id to fill its `url` in late
45
+ * (merge-by-id — `mergePages` in `listen/segments.ts`).
46
+ */
47
+ export interface ListenSegmentPage {
48
+ segments: ListenSegment[];
49
+ /** True on the final page — no more are coming for this voice. */
50
+ done?: boolean;
51
+ }
52
+ /**
53
+ * Host-supplied async segment source: ids → audio. Called once per voice
54
+ * (again when the voice changes) and consumed page by page, so playback can
55
+ * start on the first chunk while the rest synthesizes. A plain page, a
56
+ * Promise of one, a sync or async iterable of them all work.
57
+ */
58
+ export type ListenSegmentSource = (voiceId: string) => AsyncIterable<ListenSegmentPage> | Iterable<ListenSegmentPage> | Promise<ListenSegmentPage> | ListenSegmentPage;
59
+ /** Playback status. `buffering` = the user pressed play but the current segment has no audio yet. */
60
+ export type ListenStatus = 'idle' | 'playing' | 'paused' | 'buffering' | 'ended';
61
+ /**
62
+ * Instance API (bind:this on ListenControl) — the same actions the bar's
63
+ * buttons and keyboard map drive, for hosts wiring their own global keys or
64
+ * external controls.
65
+ */
66
+ export interface ListenApi {
67
+ play(): void;
68
+ pause(): void;
69
+ toggle(): void;
70
+ next(): void;
71
+ prev(): void;
72
+ /** ±delta seconds on the document-wide timeline. */
73
+ skip(deltaSec: number): void;
74
+ /** Seek to a document-wide time (seconds). */
75
+ seekTo(sec: number): void;
76
+ open(): void;
77
+ close(): void;
78
+ readonly status: ListenStatus;
79
+ /** The segment under the playhead — null once the document finished. */
80
+ readonly currentId: string | null;
81
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ListenControl data contracts (jatui-eev2n) — the "listen to this page"
3
+ * playback component. Pure types, DOM-free, node-testable like
4
+ * `video/types.ts`; the component re-exports them so
5
+ * `import type … from 'jatui'` keeps working.
6
+ */
7
+ export {};
@@ -13,5 +13,5 @@ export { createMotionLevelStore } from './store';
13
13
  export type { MotionState, MotionLevelStore, MotionLevelStoreOptions } from './store';
14
14
  export { glide } from '../transitions/glide';
15
15
  export type { GlideOrigin, GlideParams } from '../transitions/glide';
16
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
16
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from '../actions/reveal';
17
17
  export type { RevealOptions, RevealVariant } from '../actions/reveal';
@@ -8,4 +8,4 @@ export { cubicBezier, motionEasing } from './easing';
8
8
  export { motionFade, motionScale, motionSlide, resolveMotionTiming } from './transitions';
9
9
  export { createMotionLevelStore } from './store';
10
10
  export { glide } from '../transitions/glide';
11
- export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS } from '../actions/reveal';
11
+ export { reveal, REVEAL_HIDDEN_CLASS, REVEAL_BASE_CLASS, REVEAL_OFF_SELECTOR, REVEAL_MAX_DELAY_SECONDS } from '../actions/reveal';
@@ -1,5 +1,5 @@
1
1
  import type { AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecorderSnapshot } from './audioRecorderCore';
2
- export type { AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecordingResult } from './audioRecorderCore';
2
+ export type { AudioRecorderChunk, AudioRecorderError, AudioRecorderErrorKind, AudioRecorderFrame, AudioRecorderMode, AudioRecorderOptions, AudioRecorderPhase, AudioRecordingResult } from './audioRecorderCore';
3
3
  export interface AudioRecorder {
4
4
  /** False when the platform has no getUserMedia/MediaRecorder (SSR, Node). */
5
5
  readonly supported: boolean;
@@ -15,6 +15,11 @@ export interface AudioRecorder {
15
15
  readonly elapsedMs: number;
16
16
  /** Last error, or null. */
17
17
  readonly error: AudioRecorderSnapshot['error'];
18
+ /** Live mic MediaStream while the recorder holds it (starting/recording),
19
+ * else null (jatui-ft2ot). Reactive — read it in templates/effects. For
20
+ * hosts that attach the raw stream (WebRTC, a custom MediaRecorder with
21
+ * requestData()-driven streaming). */
22
+ readonly stream: MediaStream | null;
18
23
  start(): Promise<void>;
19
24
  stop(): void;
20
25
  pause(): void;
@@ -13,6 +13,7 @@ export function createAudioRecorder(options = {}) {
13
13
  snap.sizeBytes = s.sizeBytes;
14
14
  snap.elapsedMs = s.elapsedMs;
15
15
  snap.error = s.error;
16
+ snap.stream = s.stream;
16
17
  });
17
18
 
18
19
  return {
@@ -47,6 +48,10 @@ export function createAudioRecorder(options = {}) {
47
48
  get error() {
48
49
  return snap.error;
49
50
  },
51
+
52
+ get stream() {
53
+ return snap.stream;
54
+ },
50
55
  start: () => core.start(),
51
56
  stop: () => core.stop(),
52
57
  pause: () => core.pause(),