@joewinke/jatui 0.34.2 → 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.
- package/CLAUDE.md +1 -0
- package/dist/components/ListenControl.svelte +1064 -0
- package/dist/components/ListenControl.svelte.d.ts +87 -0
- package/dist/components/table/DataTable.svelte.d.ts +2 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +6 -0
- package/dist/listen/keys.d.ts +36 -0
- package/dist/listen/keys.js +68 -0
- package/dist/listen/mediaSession.d.ts +90 -0
- package/dist/listen/mediaSession.js +108 -0
- package/dist/listen/prefs.d.ts +49 -0
- package/dist/listen/prefs.js +97 -0
- package/dist/listen/segments.d.ts +101 -0
- package/dist/listen/segments.js +224 -0
- package/dist/listen/types.d.ts +81 -0
- package/dist/listen/types.js +7 -0
- package/package.json +1 -1
|
@@ -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>
|