@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,1064 @@
1
+ <script module lang="ts">
2
+ // Public type surface — re-exported so `import type … from 'jatui'` works
3
+ // without knowing the internals path (the VideoPlayer pattern).
4
+ export type {
5
+ ListenSegment,
6
+ ListenChapter,
7
+ ListenVoice,
8
+ ListenSegmentPage,
9
+ ListenSegmentSource,
10
+ ListenStatus,
11
+ ListenApi
12
+ } from '../listen/types';
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ /**
17
+ * ListenControl — "listen to this page" as one compact control.
18
+ *
19
+ * A small pill that expands into a playback bar: play/pause, −15s/+15s and
20
+ * previous/next sentence, speed (0.75–2×), a voice picker, a document-wide
21
+ * progress bar with chapter/section ticks, time, close. Built for hosts
22
+ * that synthesize audio per sentence (TTS): the host supplies an async
23
+ * SEGMENT SOURCE — sentences/paragraphs with ids → audio URLs — and the
24
+ * player starts on the first chunk while the rest synthesizes, hatching
25
+ * the not-yet-real tail of the progress bar. Every segment boundary fires
26
+ * `onSegmentChange(id)` so the host can highlight + scroll-follow.
27
+ *
28
+ * ── One timeline across per-sentence files ──────────────────────────────
29
+ * A "listen" player is a playlist, not one media element. Each segment has
30
+ * its own URL, so the engine walks segments sequentially through ONE
31
+ * hidden `<audio>` element and reports ONE document-wide time: the
32
+ * elapsed clock, the progress fill, the ticks, a ±15s skip that crosses a
33
+ * boundary and a seek from the slider all map through `buildTimeline`'s
34
+ * offsets (listen/segments.ts) — the same one-axis discipline as the
35
+ * VideoPlayer band. Durations the host knows (`durationSec`, TTS usually
36
+ * reports them) lay the timeline out immediately; unknown ones are
37
+ * measured on load and ESTIMATED until then, self-correcting as they land.
38
+ *
39
+ * The transport row borrows VideoPlayer's `kind="audio"` / `transport=
40
+ * "band"` shape (round primary play, ghost transport buttons, tabular
41
+ * clock) and reuses its pure time formatting (`video/time.ts`); the
42
+ * per-segment playlist engine is what doesn't fit inside VideoPlayer and
43
+ * lives here instead.
44
+ *
45
+ * ── Media Session ───────────────────────────────────────────────────────
46
+ * Lock-screen/headset/OS controls are wired while open: metadata (the
47
+ * `title` prop + current voice), play/pause, previous/next track (the
48
+ * sentence skip), seekbackward/seekforward/seekto, and ONE document-wide
49
+ * position state. Feature-detected end to end — nothing runs where the
50
+ * API is missing (listen/mediaSession.ts).
51
+ *
52
+ * ── Scope honesty ───────────────────────────────────────────────────────
53
+ * The keyboard map (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed,
54
+ * Escape) fires only while focus is inside the player — a library
55
+ * component must not hijack the host page's keys. Hosts wanting global
56
+ * keys bind `api` and wire their own handler with the same actions.
57
+ *
58
+ * Theming is token-only (DaisyUI semantic vars); motion is reduced-motion
59
+ * aware; everything is labelled; on narrow screens the expanded bar
60
+ * becomes a fixed sticky mini-bar at the bottom.
61
+ */
62
+
63
+ import { onMount, tick, untrack, type Snippet } from 'svelte';
64
+ import { formatTime } from '../video/time';
65
+ import {
66
+ buildTimeline,
67
+ chapterTicks,
68
+ activeChapter,
69
+ isPlayable,
70
+ iterateSource,
71
+ jumpSentence,
72
+ mergePages,
73
+ pendingTailStart,
74
+ seekTo
75
+ } from '../listen/segments';
76
+ import { LISTEN_KEYS, isNativeControlTarget, resolveListenKey } from '../listen/keys';
77
+ import {
78
+ clampSpeed,
79
+ formatListenSpeed,
80
+ LISTEN_SPEEDS,
81
+ loadListenPrefs,
82
+ localListenStore,
83
+ saveListenPrefs,
84
+ stepSpeed
85
+ } from '../listen/prefs';
86
+ import {
87
+ clearListenSession,
88
+ getMediaSession,
89
+ setListenActions,
90
+ setListenMetadata,
91
+ updateListenPosition
92
+ } from '../listen/mediaSession';
93
+ import type {
94
+ ListenApi,
95
+ ListenChapter,
96
+ ListenSegment,
97
+ ListenSegmentSource,
98
+ ListenStatus,
99
+ ListenVoice
100
+ } from '../listen/types';
101
+
102
+ interface Props {
103
+ /**
104
+ * Host-supplied async segment source (sentences/paragraphs with ids →
105
+ * audio). Called per voice; pages arrive as chunks synthesize and are
106
+ * merged by id, so a later page can fill a segment's `url` in late.
107
+ * Pass a STABLE reference — a fresh inline arrow re-runs the source.
108
+ */
109
+ source: ListenSegmentSource;
110
+ /** The voices the host offers. Empty ⇒ no picker (one implicit voice, ''). */
111
+ voices?: ListenVoice[];
112
+ /** The picked voice (bindable). Default: the first voice, persisted to storage. */
113
+ voiceId?: string;
114
+ /** Sections for the progress ticks + the caption's chapter label. */
115
+ chapters?: ListenChapter[];
116
+ /** Document title — lock-screen metadata + the bar's aria-label. */
117
+ title?: string;
118
+ /** The pill's label. Default `Listen`. */
119
+ triggerLabel?: string;
120
+ /** Expanded state (bindable). */
121
+ open?: boolean;
122
+ /** Load the source on mount instead of waiting for the first open (TTS cost is the host's call). */
123
+ preload?: boolean;
124
+ /** Estimate for a segment whose duration isn't known yet. Default 3s. */
125
+ estimateSec?: number;
126
+ /** localStorage key prefix for speed/voice. Default `jatui.listen` (→ `jatui.listen:prefs`). */
127
+ storageKey?: string;
128
+ /** Segment-boundary hook — host-side highlight + scroll-follow. */
129
+ onSegmentChange?: (id: string | null, segment: ListenSegment | null) => void;
130
+ onOpenChange?: (open: boolean) => void;
131
+ /** Instance API for hosts wiring their own global keys / external controls. */
132
+ api?: ListenApi;
133
+ class?: string;
134
+ /** Custom trigger content (replaces the headphones+label default). */
135
+ children?: Snippet;
136
+ }
137
+
138
+ let {
139
+ source,
140
+ voices = [],
141
+ voiceId = $bindable(''),
142
+ chapters = [],
143
+ title = '',
144
+ triggerLabel = 'Listen',
145
+ open = $bindable(false),
146
+ preload = false,
147
+ estimateSec = 3,
148
+ storageKey = 'jatui.listen',
149
+ onSegmentChange,
150
+ onOpenChange,
151
+ api = $bindable(),
152
+ class: className = '',
153
+ children
154
+ }: Props = $props();
155
+
156
+ const instanceId = $props.id();
157
+ const uid = `listen-${instanceId}`;
158
+
159
+ // ── Engine state ─────────────────────────────────────────────────────────
160
+ let audioEl: HTMLAudioElement | null = $state(null);
161
+ let playBtnEl: HTMLButtonElement | null = $state(null);
162
+ let triggerEl: HTMLButtonElement | null = $state(null);
163
+ let segments = $state<ListenSegment[]>([]);
164
+ let sourceError = $state('');
165
+ let currentIdx = $state(0);
166
+ let curTime = $state(0); // position inside the current segment
167
+ let loadedId = $state<string | null>(null); // segment id the audio element holds
168
+ let wantPlay = $state(false); // the user's play intent — survives a wait for audio
169
+ let status = $state<ListenStatus>('idle');
170
+ let endedFlag = $state(false); // playback reached the end of the document
171
+ let speed = $state(1);
172
+ let prefsHydrated = false;
173
+
174
+ // Plain (non-reactive) engine bookkeeping.
175
+ let loadedSource: ListenSegmentSource | null = null;
176
+ let loadedVoice: string | null = null;
177
+ let sourceToken = 0;
178
+ let pendingSeekInto = 0;
179
+ let pendingResumeId: string | null = null;
180
+ let lastEmittedId: string | null = null;
181
+ let msBound = false;
182
+ let lastPosWrite = 0;
183
+
184
+ // ── Derived ──────────────────────────────────────────────────────────────
185
+ const activeVoiceId = $derived(voiceId || voices[0]?.id || '');
186
+ const activeVoiceLabel = $derived(voices.find((v) => v.id === activeVoiceId)?.label ?? '');
187
+ const timeline = $derived(buildTimeline(segments, estimateSec));
188
+ const total = $derived(timeline.total);
189
+ const useHours = $derived(total >= 3600);
190
+ const currentSegment = $derived(segments[currentIdx] ?? null);
191
+ const elapsed = $derived((timeline.offsets[currentIdx] ?? 0) + curTime);
192
+ const currentPlayable = $derived(isPlayable(currentSegment));
193
+ const ticks = $derived(chapterTicks(chapters, segments, timeline));
194
+ const activeCh = $derived(activeChapter(chapters, segments, timeline, elapsed));
195
+ const fillPct = $derived(total > 0 ? Math.min(100, Math.max(0, (elapsed / total) * 100)) : 0);
196
+ const pendingStartIdx = $derived(pendingTailStart(segments));
197
+ const pendingStartPct = $derived(
198
+ pendingStartIdx >= 0 && total > 0 ? Math.min(100, (timeline.offsets[pendingStartIdx] / total) * 100) : -1
199
+ );
200
+ const caption = $derived(typeof currentSegment?.text === 'string' ? currentSegment.text : '');
201
+
202
+ function speedLabel(): string {
203
+ return formatListenSpeed(speed);
204
+ }
205
+
206
+ // ── Segment emission (the highlight/scroll-follow hook) ─────────────────
207
+ $effect(() => {
208
+ if (!open) return;
209
+ const seg = endedFlag ? null : (segments[currentIdx] ?? null);
210
+ const id = seg?.id ?? null;
211
+ if (id === lastEmittedId) return;
212
+ lastEmittedId = id;
213
+ const cb = onSegmentChange;
214
+ untrack(() => cb?.(id, seg ?? null));
215
+ });
216
+
217
+ // ── Source loading: one voice at a time, pages merged by id ─────────────
218
+ $effect(() => {
219
+ const src = source;
220
+ const vid = activeVoiceId;
221
+ if (!src || (!open && !preload)) return;
222
+ if (loadedSource === src && loadedVoice === vid) return; // already loading/loaded
223
+ // A voice CHANGE keeps the listener's place: remember the current id
224
+ // and re-find it in the fresh list as pages arrive.
225
+ const keepId = loadedVoice != null && loadedVoice !== vid ? (segments[currentIdx]?.id ?? null) : null;
226
+ loadedSource = src;
227
+ loadedVoice = vid;
228
+ const token = ++sourceToken;
229
+ segments = [];
230
+ sourceError = '';
231
+ loadedId = null;
232
+ currentIdx = 0;
233
+ curTime = 0;
234
+ pendingResumeId = keepId;
235
+ void runSource(src, vid, token);
236
+ });
237
+
238
+ async function runSource(src: ListenSegmentSource, vid: string, token: number): Promise<void> {
239
+ try {
240
+ for await (const page of iterateSource(src, vid)) {
241
+ if (token !== sourceToken) return; // superseded (voice change / new source)
242
+ segments = mergePages(segments, page);
243
+ if (pendingResumeId != null) {
244
+ const i = segments.findIndex((s) => s.id === pendingResumeId);
245
+ if (i >= 0) {
246
+ currentIdx = i;
247
+ pendingResumeId = null;
248
+ }
249
+ }
250
+ }
251
+ } catch (err) {
252
+ if (token === sourceToken) sourceError = err instanceof Error ? err.message : String(err);
253
+ }
254
+ }
255
+
256
+ // ── The one audio element ────────────────────────────────────────────────
257
+ // Loader: keep the element's src in step with the current segment. Fires
258
+ // again whenever segments mutate (a late url arrives) or the index moves.
259
+ $effect(() => {
260
+ const a = audioEl;
261
+ if (!a || !open) return;
262
+ const seg = segments[currentIdx];
263
+ if (!seg || !isPlayable(seg)) {
264
+ if (wantPlay) status = 'buffering';
265
+ return;
266
+ }
267
+ if (loadedId === seg.id) return;
268
+ loadedId = seg.id;
269
+ curTime = 0;
270
+ a.src = seg.url as string;
271
+ a.playbackRate = speed;
272
+ if (wantPlay) {
273
+ status = 'buffering';
274
+ void tryPlay();
275
+ }
276
+ });
277
+
278
+ // Play intent: when the user wants playback but the current segment's
279
+ // audio is still synthesizing, start the moment it lands.
280
+ $effect(() => {
281
+ const a = audioEl;
282
+ if (!a || !wantPlay || !open) return;
283
+ const seg = segments[currentIdx];
284
+ if (!seg || !isPlayable(seg)) {
285
+ status = 'buffering';
286
+ return;
287
+ }
288
+ if (loadedId === seg.id && a.paused) void tryPlay();
289
+ });
290
+
291
+ // Speed follows the human's knob on every (re)load.
292
+ $effect(() => {
293
+ const a = audioEl;
294
+ const s = speed;
295
+ if (a && loadedId != null) a.playbackRate = s;
296
+ });
297
+
298
+ async function tryPlay(): Promise<void> {
299
+ const a = audioEl;
300
+ if (!a) return;
301
+ try {
302
+ await a.play();
303
+ // onplay sets status; keep it honest if the promise raced ahead.
304
+ if (!a.paused) status = 'playing';
305
+ } catch (err) {
306
+ // AbortError: a newer load superseded this play — ignore.
307
+ if ((err as DOMException)?.name === 'AbortError') return;
308
+ // NotAllowedError etc: autoplay refused — stand down, keep the bar.
309
+ wantPlay = false;
310
+ status = 'paused';
311
+ }
312
+ }
313
+
314
+ function onAudioPlay(): void {
315
+ status = 'playing';
316
+ endedFlag = false;
317
+ }
318
+ function onAudioPause(): void {
319
+ // A natural end also pauses; onended (right after) says 'ended'.
320
+ status = endedFlag ? 'ended' : 'paused';
321
+ }
322
+ function onAudioWaiting(): void {
323
+ if (wantPlay) status = 'buffering';
324
+ }
325
+ function onAudioEnded(): void {
326
+ if (currentIdx < segments.length - 1) {
327
+ goTo(currentIdx + 1, 0);
328
+ } else {
329
+ wantPlay = false;
330
+ endedFlag = true;
331
+ status = 'ended';
332
+ }
333
+ }
334
+ function onAudioTimeUpdate(): void {
335
+ const a = audioEl;
336
+ if (!a) return;
337
+ curTime = a.currentTime;
338
+ // One document-wide position for the lock screen, throttled to 1 Hz.
339
+ const now = Date.now();
340
+ if (open && now - lastPosWrite > 1000) {
341
+ lastPosWrite = now;
342
+ updateListenPosition(getMediaSession(), { duration: total, position: elapsed, playbackRate: speed });
343
+ }
344
+ }
345
+ function onAudioLoadedMetadata(): void {
346
+ const a = audioEl;
347
+ if (!a) return;
348
+ const seg = segments[currentIdx];
349
+ // Measure an unknown duration — the timeline self-corrects.
350
+ if (seg && seg.durationSec == null && Number.isFinite(a.duration) && a.duration > 0) {
351
+ segments[currentIdx] = { ...seg, durationSec: a.duration };
352
+ }
353
+ if (pendingSeekInto > 0 && Number.isFinite(a.duration) && a.duration > 0) {
354
+ a.currentTime = Math.min(pendingSeekInto, a.duration);
355
+ curTime = a.currentTime;
356
+ }
357
+ pendingSeekInto = 0;
358
+ }
359
+ function onAudioError(): void {
360
+ // A broken segment must not strand the listener: skip onward.
361
+ if (wantPlay && currentIdx < segments.length - 1) {
362
+ goTo(currentIdx + 1, 0);
363
+ } else if (wantPlay) {
364
+ wantPlay = false;
365
+ status = 'paused';
366
+ }
367
+ }
368
+
369
+ // ── Transport ────────────────────────────────────────────────────────────
370
+ function playIntent(): void {
371
+ if (endedFlag) {
372
+ endedFlag = false;
373
+ goTo(0, 0);
374
+ }
375
+ wantPlay = true;
376
+ }
377
+ function doPause(): void {
378
+ wantPlay = false;
379
+ audioEl?.pause();
380
+ }
381
+ function toggle(): void {
382
+ if (wantPlay) doPause();
383
+ else playIntent();
384
+ }
385
+ function next(): void {
386
+ if (currentIdx < segments.length - 1) goTo(currentIdx + 1, 0);
387
+ }
388
+ function prev(): void {
389
+ const target = jumpSentence(currentIdx, curTime, segments.length, -1);
390
+ if (target == null) return;
391
+ goTo(target, 0);
392
+ }
393
+ /** ±delta seconds on the ONE timeline — crosses segment boundaries. */
394
+ function skip(deltaSec: number): void {
395
+ if (!Number.isFinite(deltaSec) || deltaSec === 0) return;
396
+ const at = seekTo(elapsed + deltaSec, timeline);
397
+ if (at) goTo(at.index, at.into);
398
+ }
399
+ function seekToSec(t: number): void {
400
+ if (!Number.isFinite(t)) return;
401
+ const at = seekTo(t, timeline);
402
+ if (at) goTo(at.index, at.into);
403
+ }
404
+ function goTo(idx: number, into: number): void {
405
+ if (idx !== currentIdx) {
406
+ currentIdx = idx;
407
+ pendingSeekInto = into;
408
+ curTime = 0;
409
+ seekCurrentNow(into); // usually a no-op (fresh load); cheap to try
410
+ } else {
411
+ pendingSeekInto = into;
412
+ seekCurrentNow(into);
413
+ }
414
+ }
415
+ function seekCurrentNow(into: number): void {
416
+ const a = audioEl;
417
+ const seg = segments[currentIdx];
418
+ if (!a || !seg || loadedId !== seg.id) return; // fresh load — loadedmetadata applies it
419
+ if (Number.isFinite(a.duration) && a.duration > 0) {
420
+ a.currentTime = Math.min(into, a.duration);
421
+ curTime = a.currentTime;
422
+ }
423
+ }
424
+ function onSeekInput(e: Event): void {
425
+ const v = Number((e.currentTarget as HTMLInputElement).value);
426
+ if (!Number.isFinite(v)) return;
427
+ seekToSec(v);
428
+ }
429
+ function setSpeed(next: number): void {
430
+ speed = clampSpeed(next);
431
+ }
432
+ function cycleSpeed(): void {
433
+ const i = LISTEN_SPEEDS.indexOf(clampSpeed(speed));
434
+ setSpeed(i === -1 ? 1 : LISTEN_SPEEDS[(i + 1) % LISTEN_SPEEDS.length]);
435
+ }
436
+
437
+ // ── Open / close + focus ─────────────────────────────────────────────────
438
+ async function openUp(): Promise<void> {
439
+ if (open) return;
440
+ open = true;
441
+ onOpenChange?.(true);
442
+ await tick();
443
+ playBtnEl?.focus();
444
+ }
445
+ function close(): void {
446
+ if (!open) return;
447
+ doPause();
448
+ open = false;
449
+ onOpenChange?.(false);
450
+ clearListenSession(getMediaSession());
451
+ msBound = false;
452
+ untrack(() => triggerEl?.focus());
453
+ }
454
+
455
+ // ── Media Session ────────────────────────────────────────────────────────
456
+ $effect(() => {
457
+ if (!open) return;
458
+ const session = getMediaSession();
459
+ if (!session || msBound) return;
460
+ msBound = true;
461
+ setListenActions(session, {
462
+ play: () => playIntent(),
463
+ pause: () => doPause(),
464
+ previoustrack: () => prev(),
465
+ nexttrack: () => next(),
466
+ seekbackward: (d) => skip(-(d?.seekOffset ?? 15)),
467
+ seekforward: (d) => skip(d?.seekOffset ?? 15),
468
+ seekto: (d) => {
469
+ if (d?.seekTime != null) seekToSec(d.seekTime);
470
+ }
471
+ });
472
+ });
473
+ $effect(() => {
474
+ if (!open) return;
475
+ const session = getMediaSession();
476
+ const meta = { title: title || triggerLabel, artist: activeVoiceLabel, album: 'Listen' };
477
+ untrack(() => setListenMetadata(session, meta));
478
+ });
479
+ // A rate change rewrites position state immediately (the throttle in
480
+ // ontimeupdate would otherwise let the lock screen drift).
481
+ $effect(() => {
482
+ const s = speed;
483
+ if (!open) return;
484
+ updateListenPosition(getMediaSession(), { duration: total, position: elapsed, playbackRate: s });
485
+ });
486
+
487
+ // ── Keyboard — scoped to the player, never the host page ────────────────
488
+ function onBarKeydown(e: KeyboardEvent): void {
489
+ if (e.key === 'Escape') {
490
+ e.stopPropagation();
491
+ close();
492
+ return;
493
+ }
494
+ if (isNativeControlTarget(e.target)) return; // buttons/range/select own their keys
495
+ const action = resolveListenKey(e);
496
+ if (!action) return;
497
+ e.preventDefault();
498
+ applyAction(action);
499
+ }
500
+ function applyAction(action: ReturnType<typeof resolveListenKey>): void {
501
+ switch (action) {
502
+ case 'toggle':
503
+ toggle();
504
+ break;
505
+ case 'prev':
506
+ prev();
507
+ break;
508
+ case 'next':
509
+ next();
510
+ break;
511
+ case 'back':
512
+ skip(-15);
513
+ break;
514
+ case 'forward':
515
+ skip(15);
516
+ break;
517
+ case 'slower':
518
+ setSpeed(stepSpeed(speed, -1));
519
+ break;
520
+ case 'faster':
521
+ setSpeed(stepSpeed(speed, 1));
522
+ break;
523
+ default:
524
+ break;
525
+ }
526
+ }
527
+
528
+ // ── Prefs: speed + voice in THIS browser's localStorage ─────────────────
529
+ onMount(() => {
530
+ const stored = loadListenPrefs(localListenStore(), `${storageKey}:prefs`);
531
+ if (stored) {
532
+ speed = clampSpeed(stored.speed);
533
+ if (stored.voiceId && voices.some((v) => v.id === stored.voiceId)) voiceId = stored.voiceId;
534
+ }
535
+ prefsHydrated = true;
536
+ });
537
+ $effect(() => {
538
+ const s = speed;
539
+ const v = activeVoiceId;
540
+ if (!prefsHydrated) return;
541
+ saveListenPrefs(localListenStore(), `${storageKey}:prefs`, { speed: s, voiceId: v || null });
542
+ });
543
+
544
+ // ── Instance API ─────────────────────────────────────────────────────────
545
+ const apiImpl: ListenApi = {
546
+ play: () => playIntent(),
547
+ pause: () => doPause(),
548
+ toggle: () => toggle(),
549
+ next: () => next(),
550
+ prev: () => prev(),
551
+ skip: (d) => skip(d),
552
+ seekTo: (t) => seekToSec(t),
553
+ open: () => void openUp(),
554
+ close: () => close(),
555
+ get status() {
556
+ return status;
557
+ },
558
+ get currentId() {
559
+ return endedFlag ? null : (segments[currentIdx]?.id ?? null);
560
+ }
561
+ };
562
+ $effect(() => {
563
+ api = apiImpl;
564
+ });
565
+ </script>
566
+
567
+ <div class="listen {className}">
568
+ {#if !open}
569
+ <button
570
+ id="{uid}-trigger"
571
+ type="button"
572
+ class="lc-trigger"
573
+ aria-expanded="false"
574
+ title="Listen to this page"
575
+ onclick={openUp}
576
+ bind:this={triggerEl}
577
+ >
578
+ {#if status === 'playing'}
579
+ <span class="lc-eq" aria-hidden="true"><span></span><span></span><span></span></span>
580
+ {/if}
581
+ {#if children}
582
+ {@render children()}
583
+ {:else}
584
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
585
+ <path d="M4 15v-2a8 8 0 0 1 16 0v2" />
586
+ <rect x="2.5" y="14" width="4.5" height="7" rx="1.8" />
587
+ <rect x="17" y="14" width="4.5" height="7" rx="1.8" />
588
+ </svg>
589
+ <span class="lc-trigger-label">{triggerLabel}</span>
590
+ {/if}
591
+ </button>
592
+ {:else}
593
+ <section
594
+ class="lc-bar"
595
+ role="region"
596
+ aria-label="Listen to this page"
597
+ onkeydown={onBarKeydown}
598
+ >
599
+ <button
600
+ type="button"
601
+ class="lc-play"
602
+ aria-label={wantPlay && !endedFlag ? 'Pause' : 'Play'}
603
+ title="{wantPlay && !endedFlag ? 'Pause' : 'Play'} · Space"
604
+ onclick={toggle}
605
+ bind:this={playBtnEl}
606
+ >
607
+ {#if wantPlay && !endedFlag}
608
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
609
+ <path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
610
+ </svg>
611
+ {:else}
612
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
613
+ <path d="M8 5.5v13l10.5-6.5z" fill="currentColor" />
614
+ </svg>
615
+ {/if}
616
+ </button>
617
+
618
+ <button type="button" class="lc-tbtn" aria-label="Previous sentence" title="Previous sentence · ←" onclick={prev}>
619
+ <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
620
+ <path d="M19 5.5v13L9.5 12z" fill="currentColor" />
621
+ <path d="M6 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
622
+ </svg>
623
+ </button>
624
+ <button type="button" class="lc-tbtn lc-skip" aria-label="Back 15 seconds" title="Back 15s · Shift+←" onclick={() => skip(-15)}>
625
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
626
+ <path d="M11.5 4.5V2L7 5.5l4.5 3.5V6.5a6.5 6.5 0 1 1-6.5 6.5" />
627
+ <text x="12.7" y="17.8" font-size="7" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">15</text>
628
+ </svg>
629
+ </button>
630
+ <button type="button" class="lc-tbtn lc-skip" aria-label="Forward 15 seconds" title="Forward 15s · Shift+→" onclick={() => skip(15)}>
631
+ <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
632
+ <path d="M12.5 4.5V2l4.5 3.5-4.5 3.5V6.5a6.5 6.5 0 1 0 6.5 6.5" />
633
+ <text x="11.3" y="17.8" font-size="7" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">15</text>
634
+ </svg>
635
+ </button>
636
+ <button type="button" class="lc-tbtn" aria-label="Next sentence" title="Next sentence · →" onclick={next}>
637
+ <svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
638
+ <path d="M5 5.5v13L14.5 12z" fill="currentColor" />
639
+ <path d="M18 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
640
+ </svg>
641
+ </button>
642
+
643
+ <div class="lc-body">
644
+ {#if caption || activeCh || sourceError}
645
+ <div class="lc-caption">
646
+ {#if sourceError}
647
+ <span class="lc-caption-text lc-error" role="status">Couldn't load audio — {sourceError}</span>
648
+ {:else}
649
+ {#if activeCh}<span class="lc-chapter">{activeCh.label}</span>{/if}
650
+ {#if caption}<span class="lc-caption-text">{caption}</span>{/if}
651
+ {/if}
652
+ </div>
653
+ {/if}
654
+ <div class="lc-timerow">
655
+ <div class="lc-progress">
656
+ <div class="lc-track" aria-hidden="true">
657
+ <div class="lc-fill" style:left="0" style:width="{fillPct}%"></div>
658
+ {#if pendingStartPct >= 0}
659
+ <div class="lc-pending" style:left="{pendingStartPct}%"></div>
660
+ {/if}
661
+ {#each ticks as t (t.id)}
662
+ <span class="lc-tick" style:left="{t.frac}%" title={t.label}></span>
663
+ {/each}
664
+ <span class="lc-head" style:left="{fillPct}%"></span>
665
+ </div>
666
+ <input
667
+ type="range"
668
+ class="lc-range"
669
+ min="0"
670
+ max={total > 0 ? total : 1}
671
+ step="0.05"
672
+ value={elapsed}
673
+ disabled={total <= 0}
674
+ aria-label="Playback position"
675
+ aria-valuetext="{formatTime(elapsed, useHours)} of {formatTime(total, useHours)}"
676
+ oninput={onSeekInput}
677
+ />
678
+ </div>
679
+ <span class="lc-clock">
680
+ <span class="lc-clock-now">{formatTime(elapsed, useHours)}</span>
681
+ {#if total > 0}
682
+ <span class="lc-clock-sep" aria-hidden="true">/</span>
683
+ <span>{formatTime(total, useHours)}</span>
684
+ {/if}
685
+ </span>
686
+ </div>
687
+ </div>
688
+
689
+ <div class="lc-side">
690
+ {#if voices.length > 1}
691
+ <label class="lc-voice">
692
+ <span class="lc-sr">Voice</span>
693
+ <select class="select select-sm select-bordered lc-voice-select" bind:value={voiceId} aria-label="Voice">
694
+ {#each voices as v (v.id)}
695
+ <option value={v.id}>{v.label}</option>
696
+ {/each}
697
+ </select>
698
+ </label>
699
+ {/if}
700
+ <button
701
+ type="button"
702
+ class="lc-tbtn lc-speed"
703
+ aria-label="Playback speed {speedLabel()}"
704
+ title="Speed · click to change, [ and ] step"
705
+ onclick={cycleSpeed}
706
+ >
707
+ {speedLabel()}
708
+ </button>
709
+ <button type="button" class="lc-tbtn lc-close" aria-label="Close player" title="Close · Esc" onclick={close}>
710
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
711
+ <path d="M6 6l12 12M18 6L6 18" />
712
+ </svg>
713
+ </button>
714
+ </div>
715
+
716
+ <span class="lc-sr" role="status" aria-live="polite">
717
+ {status === 'buffering' ? 'Loading audio…' : status === 'ended' ? 'Finished' : ''}
718
+ </span>
719
+ </section>
720
+ {/if}
721
+
722
+ <!-- The engine's one media element — lives outside the open/close markup
723
+ so closing the bar never unmounts (and thus never reloads) it. -->
724
+ <audio
725
+ class="lc-audio"
726
+ preload="auto"
727
+ bind:this={audioEl}
728
+ onplay={onAudioPlay}
729
+ onpause={onAudioPause}
730
+ onended={onAudioEnded}
731
+ ontimeupdate={onAudioTimeUpdate}
732
+ onloadedmetadata={onAudioLoadedMetadata}
733
+ onwaiting={onAudioWaiting}
734
+ onerror={onAudioError}
735
+ ></audio>
736
+ </div>
737
+
738
+ <style>
739
+ .listen {
740
+ --lc-chrome: color-mix(in oklch, var(--color-base-content) 72%, transparent);
741
+ }
742
+
743
+ /* ── Trigger pill ──────────────────────────────────────────────────────── */
744
+ .lc-trigger {
745
+ display: inline-flex;
746
+ align-items: center;
747
+ gap: 0.4rem;
748
+ padding: 0.3rem 0.7rem;
749
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);
750
+ border-radius: 999px;
751
+ background: var(--color-base-100);
752
+ color: var(--lc-chrome);
753
+ font-size: 0.8rem;
754
+ font-weight: 500;
755
+ line-height: 1;
756
+ cursor: pointer;
757
+ transition: color var(--motion-fast, 150ms) var(--motion-glide, ease-out),
758
+ border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
759
+ }
760
+ .lc-trigger:hover {
761
+ color: var(--color-base-content);
762
+ border-color: color-mix(in oklch, var(--color-primary) 45%, transparent);
763
+ }
764
+ .lc-trigger:focus-visible {
765
+ outline: 2px solid var(--color-primary);
766
+ outline-offset: 2px;
767
+ }
768
+
769
+ /* Playing indicator — three tiny bars, reduced-motion stays static. */
770
+ .lc-eq {
771
+ display: inline-flex;
772
+ align-items: flex-end;
773
+ gap: 1.5px;
774
+ height: 0.7rem;
775
+ }
776
+ .lc-eq span {
777
+ width: 2.5px;
778
+ background: var(--color-primary);
779
+ border-radius: 1px;
780
+ animation: lc-eq 900ms ease-in-out infinite;
781
+ }
782
+ .lc-eq span:nth-child(1) {
783
+ height: 60%;
784
+ animation-delay: -300ms;
785
+ }
786
+ .lc-eq span:nth-child(2) {
787
+ height: 100%;
788
+ animation-delay: -600ms;
789
+ }
790
+ .lc-eq span:nth-child(3) {
791
+ height: 75%;
792
+ }
793
+ @keyframes lc-eq {
794
+ 0%,
795
+ 100% {
796
+ transform: scaleY(0.45);
797
+ }
798
+ 50% {
799
+ transform: scaleY(1);
800
+ }
801
+ }
802
+ @media (prefers-reduced-motion: reduce) {
803
+ .lc-eq span {
804
+ animation: none;
805
+ transform: none;
806
+ }
807
+ }
808
+
809
+ /* ── The playback bar ──────────────────────────────────────────────────── */
810
+ .lc-bar {
811
+ position: sticky;
812
+ top: 0;
813
+ z-index: 40;
814
+ display: flex;
815
+ align-items: center;
816
+ gap: 0.4rem;
817
+ padding: 0.4rem 0.55rem;
818
+ background: var(--color-base-100);
819
+ border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
820
+ border-radius: var(--jatui-radius-box, 0.75rem);
821
+ box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 10%, transparent);
822
+ font-size: 0.78rem;
823
+ }
824
+
825
+ .lc-play {
826
+ display: inline-grid;
827
+ place-items: center;
828
+ width: 2rem;
829
+ height: 2rem;
830
+ flex: none;
831
+ border: none;
832
+ border-radius: 999px;
833
+ background: var(--color-primary);
834
+ color: var(--color-primary-content);
835
+ cursor: pointer;
836
+ transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
837
+ }
838
+ .lc-play:hover {
839
+ transform: scale(1.06);
840
+ }
841
+ .lc-play:active {
842
+ transform: scale(0.96);
843
+ }
844
+
845
+ .lc-tbtn {
846
+ display: inline-grid;
847
+ place-items: center;
848
+ min-width: 1.75rem;
849
+ height: 1.75rem;
850
+ padding: 0 0.15rem;
851
+ border: none;
852
+ border-radius: var(--jatui-radius-field, 0.25rem);
853
+ background: none;
854
+ color: var(--lc-chrome);
855
+ font-size: inherit;
856
+ font-variant-numeric: tabular-nums;
857
+ cursor: pointer;
858
+ }
859
+ .lc-tbtn:hover {
860
+ color: var(--color-base-content);
861
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
862
+ }
863
+ .lc-play:focus-visible,
864
+ .lc-tbtn:focus-visible,
865
+ .lc-voice-select:focus-visible {
866
+ outline: 2px solid var(--color-primary);
867
+ outline-offset: 2px;
868
+ }
869
+
870
+ .lc-body {
871
+ display: flex;
872
+ flex-direction: column;
873
+ gap: 0.15rem;
874
+ flex: 1 1 auto;
875
+ min-width: 0;
876
+ }
877
+
878
+ .lc-caption {
879
+ display: flex;
880
+ align-items: baseline;
881
+ gap: 0.5rem;
882
+ min-width: 0;
883
+ font-size: 0.72rem;
884
+ line-height: 1.35;
885
+ }
886
+ .lc-chapter {
887
+ flex: none;
888
+ padding: 0.05rem 0.4rem;
889
+ border-radius: 999px;
890
+ background: color-mix(in oklch, var(--color-primary) 14%, transparent);
891
+ color: var(--color-primary);
892
+ font-weight: 600;
893
+ text-transform: uppercase;
894
+ letter-spacing: 0.06em;
895
+ font-size: 0.62rem;
896
+ }
897
+ .lc-caption-text {
898
+ color: var(--lc-chrome);
899
+ white-space: nowrap;
900
+ overflow: hidden;
901
+ text-overflow: ellipsis;
902
+ min-width: 0;
903
+ }
904
+ .lc-error {
905
+ color: var(--color-error);
906
+ }
907
+
908
+ .lc-timerow {
909
+ display: flex;
910
+ align-items: center;
911
+ gap: 0.5rem;
912
+ min-width: 0;
913
+ }
914
+
915
+ /* ── Progress: a native range for keyboard + AT, painted track behind ──── */
916
+ .lc-progress {
917
+ position: relative;
918
+ flex: 1 1 auto;
919
+ height: 1rem;
920
+ min-width: 4rem;
921
+ }
922
+ .lc-progress:has(.lc-range:focus-visible) {
923
+ outline: 2px solid var(--color-primary);
924
+ outline-offset: 2px;
925
+ border-radius: 4px;
926
+ }
927
+ .lc-track {
928
+ position: absolute;
929
+ inset: 0.4rem 0;
930
+ border-radius: 999px;
931
+ background: color-mix(in oklch, var(--color-base-content) 14%, transparent);
932
+ overflow: hidden;
933
+ }
934
+ .lc-fill {
935
+ position: absolute;
936
+ top: 0;
937
+ bottom: 0;
938
+ background: var(--color-primary);
939
+ }
940
+ .lc-pending {
941
+ position: absolute;
942
+ top: 0;
943
+ bottom: 0;
944
+ right: 0;
945
+ background: repeating-linear-gradient(
946
+ -45deg,
947
+ transparent 0 4px,
948
+ color-mix(in oklch, var(--color-base-content) 10%, transparent) 4px 8px
949
+ );
950
+ }
951
+ .lc-tick {
952
+ position: absolute;
953
+ top: 0;
954
+ bottom: 0;
955
+ width: 2px;
956
+ background: color-mix(in oklch, var(--color-base-100) 75%, var(--color-base-content));
957
+ }
958
+ .lc-head {
959
+ position: absolute;
960
+ top: 50%;
961
+ width: 0.65rem;
962
+ height: 0.65rem;
963
+ border-radius: 999px;
964
+ background: var(--color-primary);
965
+ border: 2px solid var(--color-base-100);
966
+ transform: translate(-50%, -50%);
967
+ }
968
+ .lc-range {
969
+ position: absolute;
970
+ inset: 0;
971
+ width: 100%;
972
+ height: 100%;
973
+ margin: 0;
974
+ appearance: none;
975
+ -webkit-appearance: none;
976
+ background: transparent;
977
+ opacity: 0;
978
+ cursor: pointer;
979
+ }
980
+ .lc-range::-webkit-slider-thumb {
981
+ appearance: none;
982
+ width: 1rem;
983
+ height: 1rem;
984
+ }
985
+
986
+ .lc-clock {
987
+ display: inline-flex;
988
+ gap: 0.3em;
989
+ flex: none;
990
+ white-space: nowrap;
991
+ font-variant-numeric: tabular-nums;
992
+ }
993
+ .lc-clock-now {
994
+ color: var(--color-base-content);
995
+ font-weight: 600;
996
+ }
997
+ .lc-clock-sep {
998
+ opacity: 0.5;
999
+ }
1000
+
1001
+ .lc-side {
1002
+ display: inline-flex;
1003
+ align-items: center;
1004
+ gap: 0.25rem;
1005
+ flex: none;
1006
+ }
1007
+ .lc-voice-select {
1008
+ max-width: 9rem;
1009
+ height: 1.75rem;
1010
+ min-height: 1.75rem;
1011
+ font-size: 0.72rem;
1012
+ }
1013
+ .lc-speed {
1014
+ min-width: 2.4rem;
1015
+ justify-items: center;
1016
+ }
1017
+ .lc-close {
1018
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
1019
+ }
1020
+
1021
+ .lc-sr {
1022
+ position: absolute;
1023
+ width: 1px;
1024
+ height: 1px;
1025
+ padding: 0;
1026
+ margin: -1px;
1027
+ overflow: hidden;
1028
+ clip: rect(0 0 0 0);
1029
+ white-space: nowrap;
1030
+ border: 0;
1031
+ }
1032
+ .lc-voice .lc-sr {
1033
+ position: absolute;
1034
+ }
1035
+
1036
+ .lc-audio {
1037
+ display: none;
1038
+ }
1039
+
1040
+ /* ── Mobile: the bar becomes a fixed sticky mini-bar ───────────────────── */
1041
+ @media (max-width: 639px) {
1042
+ .lc-bar {
1043
+ position: fixed;
1044
+ top: auto;
1045
+ left: 0;
1046
+ right: 0;
1047
+ bottom: 0;
1048
+ z-index: 50;
1049
+ border-radius: var(--jatui-radius-box, 0.75rem) var(--jatui-radius-box, 0.75rem) 0 0;
1050
+ border-bottom: none;
1051
+ padding-bottom: calc(0.4rem + env(safe-area-inset-bottom, 0px));
1052
+ gap: 0.3rem;
1053
+ }
1054
+ .lc-skip,
1055
+ .lc-voice,
1056
+ .lc-caption {
1057
+ display: none;
1058
+ }
1059
+ .lc-play {
1060
+ width: 2.4rem;
1061
+ height: 2.4rem;
1062
+ }
1063
+ }
1064
+ </style>