@joewinke/jatui 0.34.1 → 0.35.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.
@@ -0,0 +1,87 @@
1
+ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, ListenSegmentSource, ListenStatus, ListenApi } from '../listen/types';
2
+ /**
3
+ * ListenControl — "listen to this page" as one compact control.
4
+ *
5
+ * A small pill that expands into a playback bar: play/pause, −15s/+15s and
6
+ * previous/next sentence, speed (0.75–2×), a voice picker, a document-wide
7
+ * progress bar with chapter/section ticks, time, close. Built for hosts
8
+ * that synthesize audio per sentence (TTS): the host supplies an async
9
+ * SEGMENT SOURCE — sentences/paragraphs with ids → audio URLs — and the
10
+ * player starts on the first chunk while the rest synthesizes, hatching
11
+ * the not-yet-real tail of the progress bar. Every segment boundary fires
12
+ * `onSegmentChange(id)` so the host can highlight + scroll-follow.
13
+ *
14
+ * ── One timeline across per-sentence files ──────────────────────────────
15
+ * A "listen" player is a playlist, not one media element. Each segment has
16
+ * its own URL, so the engine walks segments sequentially through ONE
17
+ * hidden `<audio>` element and reports ONE document-wide time: the
18
+ * elapsed clock, the progress fill, the ticks, a ±15s skip that crosses a
19
+ * boundary and a seek from the slider all map through `buildTimeline`'s
20
+ * offsets (listen/segments.ts) — the same one-axis discipline as the
21
+ * VideoPlayer band. Durations the host knows (`durationSec`, TTS usually
22
+ * reports them) lay the timeline out immediately; unknown ones are
23
+ * measured on load and ESTIMATED until then, self-correcting as they land.
24
+ *
25
+ * The transport row borrows VideoPlayer's `kind="audio"` / `transport=
26
+ * "band"` shape (round primary play, ghost transport buttons, tabular
27
+ * clock) and reuses its pure time formatting (`video/time.ts`); the
28
+ * per-segment playlist engine is what doesn't fit inside VideoPlayer and
29
+ * lives here instead.
30
+ *
31
+ * ── Media Session ───────────────────────────────────────────────────────
32
+ * Lock-screen/headset/OS controls are wired while open: metadata (the
33
+ * `title` prop + current voice), play/pause, previous/next track (the
34
+ * sentence skip), seekbackward/seekforward/seekto, and ONE document-wide
35
+ * position state. Feature-detected end to end — nothing runs where the
36
+ * API is missing (listen/mediaSession.ts).
37
+ *
38
+ * ── Scope honesty ───────────────────────────────────────────────────────
39
+ * The keyboard map (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed,
40
+ * Escape) fires only while focus is inside the player — a library
41
+ * component must not hijack the host page's keys. Hosts wanting global
42
+ * keys bind `api` and wire their own handler with the same actions.
43
+ *
44
+ * Theming is token-only (DaisyUI semantic vars); motion is reduced-motion
45
+ * aware; everything is labelled; on narrow screens the expanded bar
46
+ * becomes a fixed sticky mini-bar at the bottom.
47
+ */
48
+ import { type Snippet } from 'svelte';
49
+ import type { ListenApi, ListenChapter, ListenSegment, ListenSegmentSource, ListenVoice } from '../listen/types';
50
+ interface Props {
51
+ /**
52
+ * Host-supplied async segment source (sentences/paragraphs with ids →
53
+ * audio). Called per voice; pages arrive as chunks synthesize and are
54
+ * merged by id, so a later page can fill a segment's `url` in late.
55
+ * Pass a STABLE reference — a fresh inline arrow re-runs the source.
56
+ */
57
+ source: ListenSegmentSource;
58
+ /** The voices the host offers. Empty ⇒ no picker (one implicit voice, ''). */
59
+ voices?: ListenVoice[];
60
+ /** The picked voice (bindable). Default: the first voice, persisted to storage. */
61
+ voiceId?: string;
62
+ /** Sections for the progress ticks + the caption's chapter label. */
63
+ chapters?: ListenChapter[];
64
+ /** Document title — lock-screen metadata + the bar's aria-label. */
65
+ title?: string;
66
+ /** The pill's label. Default `Listen`. */
67
+ triggerLabel?: string;
68
+ /** Expanded state (bindable). */
69
+ open?: boolean;
70
+ /** Load the source on mount instead of waiting for the first open (TTS cost is the host's call). */
71
+ preload?: boolean;
72
+ /** Estimate for a segment whose duration isn't known yet. Default 3s. */
73
+ estimateSec?: number;
74
+ /** localStorage key prefix for speed/voice. Default `jatui.listen` (→ `jatui.listen:prefs`). */
75
+ storageKey?: string;
76
+ /** Segment-boundary hook — host-side highlight + scroll-follow. */
77
+ onSegmentChange?: (id: string | null, segment: ListenSegment | null) => void;
78
+ onOpenChange?: (open: boolean) => void;
79
+ /** Instance API for hosts wiring their own global keys / external controls. */
80
+ api?: ListenApi;
81
+ class?: string;
82
+ /** Custom trigger content (replaces the headphones+label default). */
83
+ children?: Snippet;
84
+ }
85
+ declare const ListenControl: import("svelte").Component<Props, {}, "open" | "voiceId" | "api">;
86
+ type ListenControl = ReturnType<typeof ListenControl>;
87
+ export default ListenControl;
@@ -613,7 +613,14 @@
613
613
  >
614
614
  </div>
615
615
  {#if showHint}
616
- <p class="w-full text-2xs text-base-content/60" data-media-zoom-hint>{hint}</p>
616
+ <!-- Keys are useless on a touch screen: hidden on coarse pointers
617
+ (the checklist block's footer hint hides the same way,
618
+ mos-lsmve.16 F7 — the 4-line wrap pushed the note below the
619
+ fold). -->
620
+ <p
621
+ class="w-full text-2xs text-base-content/60 [@media(pointer:coarse)]:hidden"
622
+ data-media-zoom-hint>{hint}</p
623
+ >
617
624
  {/if}
618
625
  </div>
619
626
 
@@ -6,7 +6,9 @@
6
6
  marked `opensNote` (Reject / Needs work / Fail…). It does NOT open on
7
7
  mount, on a repeat of the same verdict, or when the round changes.
8
8
  - The note is optional: under an EMPTY note on a note-worthy verdict sits
9
- the soft NOTE_HINT — never a validation block.
9
+ the soft NOTE_HINT — never a validation block. The hint never claims the
10
+ verdict was saved when it was not: a `saveFailed` host (its own save
11
+ state) or this note's own failed save silences it (mos-lsmve.16 F6).
10
12
  - Auto-grows with its content.
11
13
  - Per-note save state from `onSave`: "Saving…" → "Saved ✓", or
12
14
  "Not saved" + Retry when it throws/rejects. Saves are debounced while
@@ -16,7 +18,12 @@
16
18
  discard.
17
19
  - `round` keys the note to the current round: a new round resets the save
18
20
  state, drops any pending save for the old round, and collapses the note
19
- if the host gives it an empty value.
21
+ if the host gives it an empty value. The round effect is DECLARED BEFORE
22
+ the verdict effect on purpose (mos-lsmve.16 F9): when a round change and
23
+ a note-worthy verdict change land in the same flush, Svelte runs
24
+ instance effects in declaration order, so the verdict's open runs LAST
25
+ and survives the round's collapse — the reviewer is looking at a note
26
+ for the verdict they just cast, and it must not blink away.
20
27
  -->
21
28
  <script module lang="ts">
22
29
  /** The soft inline hint under an EMPTY note field on a note-worthy verdict. */
@@ -42,6 +49,10 @@
42
49
  options?: readonly VerdictOption[];
43
50
  /** Current round key (version, retestSince…). */
44
51
  round?: string | number | null;
52
+ /** The host's own save failed (e.g. the verdict's save): the hint must
53
+ * not claim the verdict is saved while the block shows "Not saved"
54
+ * (mos-lsmve.16 F6). */
55
+ saveFailed?: boolean;
45
56
  /** Persist the note. Throw / reject to show "Not saved". */
46
57
  onSave?: (text: string, ctx: { round: string | number | null }) => void | Promise<void>;
47
58
  /** Every keystroke, before the debounce. */
@@ -66,6 +77,7 @@
66
77
  verdict = null,
67
78
  options = [],
68
79
  round = null,
80
+ saveFailed = false,
69
81
  onSave,
70
82
  onInput,
71
83
  debounceMs = 600,
@@ -88,20 +100,12 @@
88
100
 
89
101
  const noteWorthy = $derived(!!verdict && options.some((o) => o.id === verdict && o.opensNote));
90
102
 
91
- // Open + focus when the verdict CHANGES to a note-worthy one. The first run
92
- // only records the starting verdict, so a saved state never pops open.
93
- let lastVerdict: string | null | undefined = undefined;
94
- $effect(() => {
95
- const v = verdict;
96
- const prev = lastVerdict;
97
- lastVerdict = v;
98
- if (prev === undefined || v === prev || !v) return;
99
- if (!untrack(() => options.some((o) => o.id === v && o.opensNote))) return;
100
- open = true;
101
- tick().then(() => textarea?.focus());
102
- });
103
-
104
103
  // A new round: forget the old round's save state and pending save.
104
+ // DECLARED BEFORE the verdict effect below (mos-lsmve.16 F9): instance
105
+ // effects run in declaration order, so when a round change and a
106
+ // note-worthy verdict change land in the same flush, the round's
107
+ // collapse happens FIRST and the verdict's open survives — the note
108
+ // never blinks away under the verdict the reviewer just cast.
105
109
  let lastRound: string | number | null | undefined = undefined;
106
110
  $effect(() => {
107
111
  const r = round;
@@ -115,6 +119,19 @@
115
119
  if (!untrack(() => value)) open = false;
116
120
  });
117
121
 
122
+ // Open + focus when the verdict CHANGES to a note-worthy one. The first run
123
+ // only records the starting verdict, so a saved state never pops open.
124
+ let lastVerdict: string | null | undefined = undefined;
125
+ $effect(() => {
126
+ const v = verdict;
127
+ const prev = lastVerdict;
128
+ lastVerdict = v;
129
+ if (prev === undefined || v === prev || !v) return;
130
+ if (!untrack(() => options.some((o) => o.id === v && o.opensNote))) return;
131
+ open = true;
132
+ tick().then(() => textarea?.focus());
133
+ });
134
+
118
135
  // Auto-grow: reset, then fit the content.
119
136
  $effect(() => {
120
137
  void value;
@@ -206,7 +223,11 @@
206
223
  onblur={flush}
207
224
  onkeydown={onKeydown}
208
225
  ></textarea>
209
- {#if noteWorthy && !value.trim()}
226
+ <!-- The hint asserts the verdict was saved — it must not say that
227
+ while a save has failed (the host's own, or this note's own
228
+ retryable one): the alert above is saying the opposite
229
+ (mos-lsmve.16 F6). -->
230
+ {#if noteWorthy && !value.trim() && saveState !== 'error' && !saveFailed}
210
231
  <p class="text-xs text-base-content/60">{NOTE_HINT}</p>
211
232
  {/if}
212
233
  </div>
@@ -13,6 +13,10 @@ interface Props {
13
13
  options?: readonly VerdictOption[];
14
14
  /** Current round key (version, retestSince…). */
15
15
  round?: string | number | null;
16
+ /** The host's own save failed (e.g. the verdict's save): the hint must
17
+ * not claim the verdict is saved while the block shows "Not saved"
18
+ * (mos-lsmve.16 F6). */
19
+ saveFailed?: boolean;
16
20
  /** Persist the note. Throw / reject to show "Not saved". */
17
21
  onSave?: (text: string, ctx: {
18
22
  round: string | number | null;
@@ -13,12 +13,17 @@
13
13
  - One-key hotkeys from `options[].key` (and `u` = Undo) while focus is in
14
14
  the control. Handled keys are preventDefault-ed so a page-level
15
15
  reviewKeys() handler skips them.
16
+ - `trailing` renders the row's trailing actions (a Note toggle…) in ONE
17
+ non-breaking group with Undo, so a narrow card wraps the whole group —
18
+ the note button never strands alone on its own row (mos-lsmve.16 F7:
19
+ it used to wrap alone, below Undo).
16
20
  - Sizes: `sm` (cards, default) and `xs` (inline strips).
17
21
 
18
22
  The `options` array is the same one ReviewNote (`opensNote`) and
19
23
  reviewKeys (`key`) take.
20
24
  -->
21
25
  <script lang="ts">
26
+ import type { Snippet } from 'svelte';
22
27
  import type { VerdictOption, VerdictTone } from '../../actions/reviewKeys';
23
28
 
24
29
  interface Props {
@@ -35,6 +40,9 @@
35
40
  undoLabel?: string;
36
41
  /** Show each option's hotkey as a keycap inside its button. */
37
42
  showKeys?: boolean;
43
+ /** Trailing actions (Note toggle…), kept in one non-breaking group
44
+ * with Undo so the wrap boundary falls before the pair. */
45
+ trailing?: Snippet;
38
46
  /** Local hotkeys while focus is inside the control. Default true. */
39
47
  hotkeys?: boolean;
40
48
  disabled?: boolean;
@@ -50,6 +58,7 @@
50
58
  label = 'Verdict',
51
59
  undoLabel = 'Undo',
52
60
  showKeys = false,
61
+ trailing = undefined,
53
62
  hotkeys = true,
54
63
  disabled = false,
55
64
  class: className = ''
@@ -145,19 +154,27 @@
145
154
  </button>
146
155
  {/each}
147
156
  </div>
148
- {#if value && onUndo}
149
- <button
150
- type="button"
151
- class="btn btn-ghost {sizeClass} gap-1 px-2 text-base-content/75 hover:text-base-content"
152
- aria-label="{undoLabel} — {label}"
153
- aria-keyshortcuts="u"
154
- {disabled}
155
- onclick={() => onUndo?.()}
156
- >
157
- <svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
158
- <path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" />
159
- </svg>
160
- {undoLabel}
161
- </button>
157
+ <!-- Undo + trailing actions in one ATOMIC span: the row's wrap boundary
158
+ falls before the pair, so Undo and whatever trails it (the Note
159
+ toggle) always sit on the same line (mos-lsmve.16 F7). -->
160
+ {#if value && onUndo || trailing}
161
+ <span class="flex items-center gap-2">
162
+ {#if value && onUndo}
163
+ <button
164
+ type="button"
165
+ class="btn btn-ghost {sizeClass} gap-1 px-2 text-base-content/75 hover:text-base-content"
166
+ aria-label="{undoLabel} — {label}"
167
+ aria-keyshortcuts="u"
168
+ {disabled}
169
+ onclick={() => onUndo?.()}
170
+ >
171
+ <svg viewBox="0 0 24 24" class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
172
+ <path d="M9 14 4 9l5-5" /><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11" />
173
+ </svg>
174
+ {undoLabel}
175
+ </button>
176
+ {/if}
177
+ {#if trailing}{@render trailing()}{/if}
178
+ </span>
162
179
  {/if}
163
180
  </div>
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { VerdictOption } from '../../actions/reviewKeys';
2
3
  interface Props {
3
4
  options: readonly VerdictOption[];
@@ -13,6 +14,9 @@ interface Props {
13
14
  undoLabel?: string;
14
15
  /** Show each option's hotkey as a keycap inside its button. */
15
16
  showKeys?: boolean;
17
+ /** Trailing actions (Note toggle…), kept in one non-breaking group
18
+ * with Undo so the wrap boundary falls before the pair. */
19
+ trailing?: Snippet;
16
20
  /** Local hotkeys while focus is inside the control. Default true. */
17
21
  hotkeys?: boolean;
18
22
  disabled?: boolean;
@@ -2,7 +2,7 @@ import type { DataTableProps } from './types.js';
2
2
  declare function $$render<TData>(): {
3
3
  props: DataTableProps<TData>;
4
4
  exports: {};
5
- bindings: "collapsed" | "sorting" | "api";
5
+ bindings: "api" | "collapsed" | "sorting";
6
6
  slots: {};
7
7
  events: {};
8
8
  };
@@ -10,7 +10,7 @@ declare class __sveltets_Render<TData> {
10
10
  props(): ReturnType<typeof $$render<TData>>['props'];
11
11
  events(): ReturnType<typeof $$render<TData>>['events'];
12
12
  slots(): ReturnType<typeof $$render<TData>>['slots'];
13
- bindings(): "collapsed" | "sorting" | "api";
13
+ bindings(): "api" | "collapsed" | "sorting";
14
14
  exports(): {};
15
15
  }
16
16
  interface $$IsomorphicComponent {
package/dist/index.d.ts CHANGED
@@ -18,6 +18,10 @@ export type { ChapterBlock, ChapterStateVisual } from './components/replay/Chapt
18
18
  export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelte';
19
19
  export type { TranscriptSegment, TranscriptBookmark, TranscriptChapter, TranscriptSpeakerTalkTime, } from './components/TranscriptPlayer.svelte';
20
20
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
21
+ export { default as ListenControl } from './components/ListenControl.svelte';
22
+ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, ListenSegmentSource, ListenStatus, ListenApi } from './listen/types';
23
+ export { LISTEN_KEYS } from './listen/keys';
24
+ export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatListenSpeed } from './listen/prefs';
21
25
  export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource, } from './components/VideoPlayer.svelte';
22
26
  export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
23
27
  export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
package/dist/index.js CHANGED
@@ -33,6 +33,12 @@ export { default as TranscriptPlayer } from './components/TranscriptPlayer.svelt
33
33
  // optional so a bare mp4 still gets the full playback UX. TranscriptPlayer
34
34
  // stays exported unchanged — meetings already depends on it.
35
35
  export { default as VideoPlayer } from './components/VideoPlayer.svelte';
36
+ // ListenControl (jatui-eev2n) — "listen to this page" playback over the host's
37
+ // per-sentence audio source. The component re-exports its types too; these
38
+ // are the same names, so either import site works.
39
+ export { default as ListenControl } from './components/ListenControl.svelte';
40
+ export { LISTEN_KEYS } from './listen/keys';
41
+ export { LISTEN_SPEEDS, SPEED_FLOOR, SPEED_CEIL, clampSpeed, stepSpeed, formatListenSpeed } from './listen/prefs';
36
42
  // Rate composer (mos-5lzns.32): the same name → rate table the player applies
37
43
  // under `speakerPace` (manual beats measured), for a host drawing its own
38
44
  // per-speaker speed UI.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * ListenControl keyboard map (jatui-eev2n) — the same single-source discipline
3
+ * as `video/keys.ts`: a key that exists is in the map, a key in the map
4
+ * exists. The component's keydown handler and any host help surface both read
5
+ * LISTEN_KEYS.
6
+ *
7
+ * Map parity: Space/K play-pause, ←/→ prev/next SENTENCE (a reading player's
8
+ * primary navigation — the segments ARE the sentences), Shift+←/→ the ±15s
9
+ * skip, [ / ] speed down/up, Escape close. Deliberately NOT bound globally:
10
+ * the map fires only while focus is inside the player (a library component
11
+ * must not hijack the host page's keys — hosts wanting global keys wire the
12
+ * exported `ListenApi` themselves).
13
+ */
14
+ export interface ListenKeyRow {
15
+ /** Keycap label — `/`-separated alternates, as shown in a help table. */
16
+ keys: string;
17
+ description: string;
18
+ }
19
+ export declare const LISTEN_KEYS: ListenKeyRow[];
20
+ export type ListenKeyAction = 'toggle' | 'prev' | 'next' | 'back' | 'forward' | 'slower' | 'faster' | 'close';
21
+ /**
22
+ * One keydown → one action (or null). Pure: takes the two KeyboardEvent
23
+ * fields the map needs, so a synthetic event in node tests resolves the same
24
+ * way a real one does.
25
+ */
26
+ export declare function resolveListenKey(event: {
27
+ key: string;
28
+ shiftKey?: boolean;
29
+ }): ListenKeyAction | null;
30
+ /**
31
+ * Targets whose keys already mean something native — a focused button gets
32
+ * Space as its activation key, the progress `<input type=range>` owns the
33
+ * arrows, a `<select>` owns typeahead. The component skips the map for these
34
+ * (Escape excepted — closing the player outranks every widget).
35
+ */
36
+ export declare function isNativeControlTarget(target: EventTarget | null): boolean;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * ListenControl keyboard map (jatui-eev2n) — the same single-source discipline
3
+ * as `video/keys.ts`: a key that exists is in the map, a key in the map
4
+ * exists. The component's keydown handler and any host help surface both read
5
+ * LISTEN_KEYS.
6
+ *
7
+ * Map parity: Space/K play-pause, ←/→ prev/next SENTENCE (a reading player's
8
+ * primary navigation — the segments ARE the sentences), Shift+←/→ the ±15s
9
+ * skip, [ / ] speed down/up, Escape close. Deliberately NOT bound globally:
10
+ * the map fires only while focus is inside the player (a library component
11
+ * must not hijack the host page's keys — hosts wanting global keys wire the
12
+ * exported `ListenApi` themselves).
13
+ */
14
+ import { isTypingTarget } from '../utils/keyboardShortcutRegistry';
15
+ export const LISTEN_KEYS = [
16
+ { keys: 'Space / K', description: 'Play / pause' },
17
+ { keys: '← / →', description: 'Previous / next sentence' },
18
+ { keys: 'Shift + ← / →', description: 'Skip ∓15s' },
19
+ { keys: '[ / ]', description: 'Speed down / up (0.75–2×)' },
20
+ { keys: 'Escape', description: 'Close the player' }
21
+ ];
22
+ /**
23
+ * One keydown → one action (or null). Pure: takes the two KeyboardEvent
24
+ * fields the map needs, so a synthetic event in node tests resolves the same
25
+ * way a real one does.
26
+ */
27
+ export function resolveListenKey(event) {
28
+ const key = typeof event?.key === 'string' ? event.key : '';
29
+ const shift = event?.shiftKey === true;
30
+ switch (key) {
31
+ case ' ':
32
+ case 'Spacebar': // legacy IE/Edge spelling
33
+ return shift ? null : 'toggle';
34
+ case 'k':
35
+ case 'K':
36
+ return 'toggle';
37
+ case 'ArrowLeft':
38
+ return shift ? 'back' : 'prev';
39
+ case 'ArrowRight':
40
+ return shift ? 'forward' : 'next';
41
+ case '[':
42
+ return 'slower';
43
+ case ']':
44
+ return 'faster';
45
+ case 'Escape':
46
+ return 'close';
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Targets whose keys already mean something native — a focused button gets
53
+ * Space as its activation key, the progress `<input type=range>` owns the
54
+ * arrows, a `<select>` owns typeahead. The component skips the map for these
55
+ * (Escape excepted — closing the player outranks every widget).
56
+ */
57
+ export function isNativeControlTarget(target) {
58
+ if (isTypingTarget(target))
59
+ return true;
60
+ // Duck-typed (not `instanceof`) so node tests without DOM globals work.
61
+ // `<input type=range>` (the progress bar) is not a typing target but still
62
+ // owns its own arrow keys natively — as does any other input.
63
+ const el = target;
64
+ if (typeof el?.tagName !== 'string')
65
+ return false;
66
+ const tag = el.tagName.toUpperCase();
67
+ return tag === 'BUTTON' || tag === 'INPUT';
68
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * ListenControl Media Session glue (jatui-eev2n) — lock-screen / headset /
3
+ * OS-media controls for a page whose audio is a SEQUENCE of per-sentence
4
+ * files. The browser only ever sees one session; this module keeps it fed:
5
+ *
6
+ * · metadata — the document title + the current voice (artist);
7
+ * · action handlers — play/pause, previous/next track (sentence),
8
+ * seekbackward/seekforward (the ±15s skip), seekto;
9
+ * · position state — ONE document-wide position, throttled by the component
10
+ * (`navigator.mediaSession.setPositionState` throws on an invalid
11
+ * duration/rate combination, so every write is guarded).
12
+ *
13
+ * Feature-detected end to end: everywhere the API is missing (every non-secure
14
+ * context, older Safari, jsdom, node) these are silent no-ops. Pure enough to
15
+ * drive from a fake session in node tests — the DOM `MediaSession` type is
16
+ * deliberately mirrored structurally, not imported.
17
+ */
18
+ /** The slice of `navigator.mediaSession` ListenControl uses. */
19
+ export interface MediaSessionLike {
20
+ metadata: {
21
+ title?: string;
22
+ artist?: string;
23
+ album?: string;
24
+ artwork?: {
25
+ src: string;
26
+ sizes?: string;
27
+ type?: string;
28
+ }[];
29
+ } | null;
30
+ setActionHandler(action: string, handler: ((details: {
31
+ seekOffset?: number;
32
+ seekTime?: number;
33
+ fastSeek?: boolean;
34
+ }) => void) | null): void;
35
+ setPositionState(state: {
36
+ duration: number;
37
+ playbackRate?: number;
38
+ position?: number;
39
+ }): void;
40
+ }
41
+ /** Navigator-like lookup — injectable so node tests can pass a fake. */
42
+ export type NavigatorLike = {
43
+ mediaSession?: MediaSessionLike;
44
+ } | null | undefined;
45
+ /** The session, or null where the Media Session API doesn't exist. */
46
+ export declare function getMediaSession(nav?: NavigatorLike): MediaSessionLike | null;
47
+ export interface ListenMediaMeta {
48
+ title: string;
49
+ artist?: string;
50
+ album?: string;
51
+ /** Optional cover art URL(s) — host-supplied. */
52
+ artwork?: string[];
53
+ }
54
+ /** Best-effort metadata write. No-op without a session; never throws. */
55
+ export declare function setListenMetadata(session: MediaSessionLike | null, meta: ListenMediaMeta): void;
56
+ /**
57
+ * Wire the transport actions. Each handler is registered on its own try/catch:
58
+ * browsers reject actions they don't implement individually, and one refusal
59
+ * must not cost the rest. A null handler value clears that action.
60
+ */
61
+ export declare function setListenActions(session: MediaSessionLike | null, handlers: {
62
+ play?: (() => void) | null;
63
+ pause?: (() => void) | null;
64
+ previoustrack?: (() => void) | null;
65
+ nexttrack?: (() => void) | null;
66
+ seekbackward?: ((details: {
67
+ seekOffset?: number;
68
+ }) => void) | null;
69
+ seekforward?: ((details: {
70
+ seekOffset?: number;
71
+ }) => void) | null;
72
+ seekto?: ((details: {
73
+ seekTime?: number;
74
+ fastSeek?: boolean;
75
+ }) => void) | null;
76
+ stop?: (() => void) | null;
77
+ }): void;
78
+ /** Hand the lock-screen back: null metadata, null handlers. */
79
+ export declare function clearListenSession(session: MediaSessionLike | null): void;
80
+ /**
81
+ * One document-wide position write. Guards the two ways this throws: no
82
+ * session, and an invalid state (negative/NaN duration, position outside it,
83
+ * a non-positive rate) — `setPositionState` rejects those with an exception,
84
+ * which must never surface from a timeupdate.
85
+ */
86
+ export declare function updateListenPosition(session: MediaSessionLike | null, state: {
87
+ duration: number;
88
+ position: number;
89
+ playbackRate: number;
90
+ }): void;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * ListenControl Media Session glue (jatui-eev2n) — lock-screen / headset /
3
+ * OS-media controls for a page whose audio is a SEQUENCE of per-sentence
4
+ * files. The browser only ever sees one session; this module keeps it fed:
5
+ *
6
+ * · metadata — the document title + the current voice (artist);
7
+ * · action handlers — play/pause, previous/next track (sentence),
8
+ * seekbackward/seekforward (the ±15s skip), seekto;
9
+ * · position state — ONE document-wide position, throttled by the component
10
+ * (`navigator.mediaSession.setPositionState` throws on an invalid
11
+ * duration/rate combination, so every write is guarded).
12
+ *
13
+ * Feature-detected end to end: everywhere the API is missing (every non-secure
14
+ * context, older Safari, jsdom, node) these are silent no-ops. Pure enough to
15
+ * drive from a fake session in node tests — the DOM `MediaSession` type is
16
+ * deliberately mirrored structurally, not imported.
17
+ */
18
+ /** The session, or null where the Media Session API doesn't exist. */
19
+ export function getMediaSession(nav = typeof navigator === 'undefined' ? null : navigator) {
20
+ const session = nav?.mediaSession;
21
+ if (!session || typeof session.setActionHandler !== 'function')
22
+ return null;
23
+ return session;
24
+ }
25
+ /** Best-effort metadata write. No-op without a session; never throws. */
26
+ export function setListenMetadata(session, meta) {
27
+ if (!session)
28
+ return;
29
+ try {
30
+ const plain = {
31
+ title: meta.title,
32
+ artist: meta.artist ?? '',
33
+ album: meta.album ?? '',
34
+ ...(meta.artwork && meta.artwork.length
35
+ ? { artwork: meta.artwork.map((src) => ({ src, sizes: '512x512' })) }
36
+ : {})
37
+ };
38
+ // `MediaMetadata` is browser-global; node/fake sessions get the plain shape.
39
+ const Ctor = typeof MediaMetadata === 'undefined' ? null : MediaMetadata;
40
+ session.metadata = Ctor ? new Ctor(plain) : plain;
41
+ }
42
+ catch {
43
+ /* hostile metadata — keep playing without lock-screen info */
44
+ }
45
+ }
46
+ /**
47
+ * Wire the transport actions. Each handler is registered on its own try/catch:
48
+ * browsers reject actions they don't implement individually, and one refusal
49
+ * must not cost the rest. A null handler value clears that action.
50
+ */
51
+ export function setListenActions(session, handlers) {
52
+ if (!session)
53
+ return;
54
+ for (const [action, handler] of Object.entries(handlers)) {
55
+ if (handler === undefined)
56
+ continue;
57
+ try {
58
+ session.setActionHandler(action, handler);
59
+ }
60
+ catch {
61
+ /* this browser doesn't implement that action */
62
+ }
63
+ }
64
+ }
65
+ /** Hand the lock-screen back: null metadata, null handlers. */
66
+ export function clearListenSession(session) {
67
+ if (!session)
68
+ return;
69
+ try {
70
+ session.metadata = null;
71
+ }
72
+ catch {
73
+ /* ignore */
74
+ }
75
+ setListenActions(session, {
76
+ play: null,
77
+ pause: null,
78
+ previoustrack: null,
79
+ nexttrack: null,
80
+ seekbackward: null,
81
+ seekforward: null,
82
+ seekto: null,
83
+ stop: null
84
+ });
85
+ }
86
+ /**
87
+ * One document-wide position write. Guards the two ways this throws: no
88
+ * session, and an invalid state (negative/NaN duration, position outside it,
89
+ * a non-positive rate) — `setPositionState` rejects those with an exception,
90
+ * which must never surface from a timeupdate.
91
+ */
92
+ export function updateListenPosition(session, state) {
93
+ if (!session)
94
+ return;
95
+ const { duration, position, playbackRate } = state;
96
+ if (!Number.isFinite(duration) || duration <= 0)
97
+ return;
98
+ if (!Number.isFinite(position) || position < 0 || position > duration)
99
+ return;
100
+ if (!Number.isFinite(playbackRate) || playbackRate <= 0)
101
+ return;
102
+ try {
103
+ session.setPositionState({ duration, position, playbackRate });
104
+ }
105
+ catch {
106
+ /* raced with a source change — the next timeupdate rewrites it */
107
+ }
108
+ }