@joewinke/jatui 0.36.1 → 0.36.2
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
CHANGED
|
@@ -122,7 +122,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
122
122
|
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed) and zoom over the band. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
123
123
|
| `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
|
|
124
124
|
| `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
|
|
125
|
-
| `ListenControl` | "Listen to this page" — a compact pill that expands into a playback bar over the host's PER-SENTENCE audio (TTS): play/pause
|
|
125
|
+
| `ListenControl` | "Listen to this page" — a compact pill that expands into a TWO-ROW playback bar over the host's PER-SENTENCE audio (TTS): row 1 = play/pause · chapter chip + current sentence (full remaining width) · ONE settings pill ("1.25× · Sarah" → speed 0.75–2× + voice menu) · close; row 2 = prev/next sentence and −15s/+15s · a document-wide progress bar with chapter ticks (a hatched tail marks the not-yet-synthesized part), time, close. The host supplies an async `source` (sentences/paragraphs with ids → audio URLs, streamed page by page so playback starts on the first chunk while the rest synthesizes; pages merge by id so late URLs fill in) and gets `onSegmentChange(id)` at every boundary for highlight + scroll-follow. Media Session (lock-screen/headset controls, one document-wide position), keyboard scoped to the player (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed, Escape), speed+voice persisted in localStorage, `bind:api` for hosts wiring their own global keys, reduced-motion aware, token-only theming, never overflows its host (border-box, `max-width: 100%`, `min-width: 0` chain), fixed sticky mini-bar (same two rows) on narrow screens. Internals in `src/lib/listen/` (one timeline across per-sentence files — `buildTimeline` offsets; see `video/` for the doctrine). Demo synthesizes real WAVs locally. | `source`, `voices?`, `voiceId?` (bindable), `chapters?`, `title?`, `triggerLabel?`, `open?` (bindable), `preload?`, `estimateSec?`, `storageKey?`, `onSegmentChange?`, `onOpenChange?`, `api?` (bindable), `class?`, `children?` (custom trigger) |
|
|
126
126
|
|
|
127
127
|
**`VideoPlayer` vs `TranscriptPlayer`:** VideoPlayer is the generalization — it does everything TranscriptPlayer does plus the timeline band, and its props are structurally compatible with TranscriptPlayer's (`TranscriptSegment[]`, `TranscriptChapter[]`, `TranscriptBookmark[]`, `TranscriptSpeakerTalkTime[]` all pass straight through). `TranscriptPlayer` stays exported and unchanged for the existing meeting-recordings viewer; use `VideoPlayer` for anything new.
|
|
128
128
|
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
/**
|
|
17
17
|
* ListenControl — "listen to this page" as one compact control.
|
|
18
18
|
*
|
|
19
|
-
* A small pill that expands into a playback bar: play/pause,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* A small pill that expands into a two-row playback bar: play/pause, the
|
|
20
|
+
* current sentence, one speed (0.75–2×) + voice menu, close; then
|
|
21
|
+
* previous/next sentence, −15s/+15s, a document-wide progress bar with
|
|
22
|
+
* chapter/section ticks, and time. Built for hosts
|
|
22
23
|
* that synthesize audio per sentence (TTS): the host supplies an async
|
|
23
24
|
* SEGMENT SOURCE — sentences/paragraphs with ids → audio URLs — and the
|
|
24
25
|
* player starts on the first chunk while the rest synthesizes, hatching
|
|
@@ -36,6 +37,14 @@
|
|
|
36
37
|
* reports them) lay the timeline out immediately; unknown ones are
|
|
37
38
|
* measured on load and ESTIMATED until then, self-correcting as they land.
|
|
38
39
|
*
|
|
40
|
+
* ── Layout: two rows (jatui-zl9dp) ──────────────────────────────────────
|
|
41
|
+
* Row 1 is what you're hearing: play/pause, the chapter chip + current
|
|
42
|
+
* sentence (it gets ALL the remaining width), ONE settings pill folding
|
|
43
|
+
* speed + voice into a small menu, close. Row 2 is where you are: prev
|
|
44
|
+
* sentence, −15s, +15s, next sentence, then the progress bar and the
|
|
45
|
+
* clock. The bar is `box-sizing: border-box` + `max-width: 100%` with
|
|
46
|
+
* `min-width: 0` down the flex chain, so it never overflows its host.
|
|
47
|
+
*
|
|
39
48
|
* The transport row borrows VideoPlayer's `kind="audio"` / `transport=
|
|
40
49
|
* "band"` shape (round primary play, ghost transport buttons, tabular
|
|
41
50
|
* clock) and reuses its pure time formatting (`video/time.ts`); the
|
|
@@ -61,6 +70,7 @@
|
|
|
61
70
|
*/
|
|
62
71
|
|
|
63
72
|
import { onMount, tick, untrack, type Snippet } from 'svelte';
|
|
73
|
+
import { floating } from '../actions/floating';
|
|
64
74
|
import { formatTime } from '../video/time';
|
|
65
75
|
import {
|
|
66
76
|
buildTimeline,
|
|
@@ -160,6 +170,12 @@
|
|
|
160
170
|
let audioEl: HTMLAudioElement | null = $state(null);
|
|
161
171
|
let playBtnEl: HTMLButtonElement | null = $state(null);
|
|
162
172
|
let triggerEl: HTMLButtonElement | null = $state(null);
|
|
173
|
+
let settingsBtnEl: HTMLButtonElement | null = $state(null);
|
|
174
|
+
let settingsMenuEl: HTMLDivElement | null = $state(null);
|
|
175
|
+
let settingsOpen = $state(false);
|
|
176
|
+
// The click that dismisses the menu (outside-click, capture phase) is
|
|
177
|
+
// also the settings button's own click — swallow that one re-open.
|
|
178
|
+
let settingsJustDismissed = false;
|
|
163
179
|
let segments = $state<ListenSegment[]>([]);
|
|
164
180
|
let sourceError = $state('');
|
|
165
181
|
let currentIdx = $state(0);
|
|
@@ -429,9 +445,77 @@
|
|
|
429
445
|
function setSpeed(next: number): void {
|
|
430
446
|
speed = clampSpeed(next);
|
|
431
447
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
448
|
+
|
|
449
|
+
// ── Settings menu: speed + voice in ONE control ─────────────────────────
|
|
450
|
+
async function toggleSettings(): Promise<void> {
|
|
451
|
+
if (settingsJustDismissed) return;
|
|
452
|
+
if (settingsOpen) {
|
|
453
|
+
closeSettings();
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
settingsOpen = true;
|
|
457
|
+
await tick();
|
|
458
|
+
// Land on the checked speed — arrows move from where you are.
|
|
459
|
+
const items = settingsItems();
|
|
460
|
+
(items.find((el) => el.getAttribute('aria-checked') === 'true') ?? items[0])?.focus();
|
|
461
|
+
}
|
|
462
|
+
function closeSettings(refocus = true): void {
|
|
463
|
+
if (!settingsOpen) return;
|
|
464
|
+
settingsOpen = false;
|
|
465
|
+
if (refocus) untrack(() => settingsBtnEl?.focus());
|
|
466
|
+
}
|
|
467
|
+
function dismissSettings(): void {
|
|
468
|
+
if (!settingsOpen) return;
|
|
469
|
+
settingsJustDismissed = true;
|
|
470
|
+
setTimeout(() => (settingsJustDismissed = false), 0);
|
|
471
|
+
closeSettings(false);
|
|
472
|
+
}
|
|
473
|
+
function settingsItems(): HTMLButtonElement[] {
|
|
474
|
+
return Array.from(settingsMenuEl?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? []);
|
|
475
|
+
}
|
|
476
|
+
function pickSpeed(s: number): void {
|
|
477
|
+
setSpeed(s);
|
|
478
|
+
closeSettings();
|
|
479
|
+
}
|
|
480
|
+
function pickVoice(id: string): void {
|
|
481
|
+
voiceId = id;
|
|
482
|
+
closeSettings();
|
|
483
|
+
}
|
|
484
|
+
function onSettingsKeydown(e: KeyboardEvent): void {
|
|
485
|
+
const items = settingsItems();
|
|
486
|
+
const i = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
487
|
+
let to = -1;
|
|
488
|
+
switch (e.key) {
|
|
489
|
+
case 'Escape':
|
|
490
|
+
// The menu's own Escape — never the bar's close.
|
|
491
|
+
e.preventDefault();
|
|
492
|
+
e.stopPropagation();
|
|
493
|
+
closeSettings();
|
|
494
|
+
return;
|
|
495
|
+
case 'Tab':
|
|
496
|
+
closeSettings(false);
|
|
497
|
+
return;
|
|
498
|
+
case 'ArrowDown':
|
|
499
|
+
to = i < 0 ? 0 : (i + 1) % items.length;
|
|
500
|
+
break;
|
|
501
|
+
case 'ArrowUp':
|
|
502
|
+
to = i <= 0 ? items.length - 1 : i - 1;
|
|
503
|
+
break;
|
|
504
|
+
case 'Home':
|
|
505
|
+
to = 0;
|
|
506
|
+
break;
|
|
507
|
+
case 'End':
|
|
508
|
+
to = items.length - 1;
|
|
509
|
+
break;
|
|
510
|
+
default:
|
|
511
|
+
// Enter/Space activate the focused item natively; keep the
|
|
512
|
+
// bar's map (Space = play) from also seeing them.
|
|
513
|
+
e.stopPropagation();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
e.preventDefault();
|
|
517
|
+
e.stopPropagation();
|
|
518
|
+
items[to]?.focus();
|
|
435
519
|
}
|
|
436
520
|
|
|
437
521
|
// ── Open / close + focus ─────────────────────────────────────────────────
|
|
@@ -445,6 +529,7 @@
|
|
|
445
529
|
function close(): void {
|
|
446
530
|
if (!open) return;
|
|
447
531
|
doPause();
|
|
532
|
+
settingsOpen = false;
|
|
448
533
|
open = false;
|
|
449
534
|
onOpenChange?.(false);
|
|
450
535
|
clearListenSession(getMediaSession());
|
|
@@ -596,116 +681,113 @@
|
|
|
596
681
|
aria-label="Listen to this page"
|
|
597
682
|
onkeydown={onBarKeydown}
|
|
598
683
|
>
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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>
|
|
684
|
+
<!-- Row 1: what you're hearing — play · chapter + sentence (all the
|
|
685
|
+
remaining width) · speed/voice settings · close. -->
|
|
686
|
+
<div class="lc-row lc-row-now">
|
|
687
|
+
<button
|
|
688
|
+
type="button"
|
|
689
|
+
class="lc-play"
|
|
690
|
+
aria-label={wantPlay && !endedFlag ? 'Pause' : 'Play'}
|
|
691
|
+
title="{wantPlay && !endedFlag ? 'Pause' : 'Play'} · Space"
|
|
692
|
+
onclick={toggle}
|
|
693
|
+
bind:this={playBtnEl}
|
|
694
|
+
>
|
|
695
|
+
{#if wantPlay && !endedFlag}
|
|
696
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
697
|
+
<path d="M7 5h3.5v14H7zM13.5 5H17v14h-3.5z" fill="currentColor" />
|
|
698
|
+
</svg>
|
|
699
|
+
{:else}
|
|
700
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
|
701
|
+
<path d="M8 5.5v13l10.5-6.5z" fill="currentColor" />
|
|
702
|
+
</svg>
|
|
703
|
+
{/if}
|
|
704
|
+
</button>
|
|
642
705
|
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
{
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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>
|
|
706
|
+
<div class="lc-caption">
|
|
707
|
+
{#if sourceError}
|
|
708
|
+
<span class="lc-caption-text lc-error" role="status">Couldn't load audio — {sourceError}</span>
|
|
709
|
+
{:else}
|
|
710
|
+
{#if activeCh}<span class="lc-chapter">{activeCh.label}</span>{/if}
|
|
711
|
+
{#if caption}
|
|
712
|
+
<span class="lc-caption-text" title={caption}>{caption}</span>
|
|
713
|
+
{:else if title}
|
|
714
|
+
<span class="lc-caption-text lc-caption-muted">{title}</span>
|
|
684
715
|
{/if}
|
|
685
|
-
|
|
716
|
+
{/if}
|
|
686
717
|
</div>
|
|
687
|
-
</div>
|
|
688
718
|
|
|
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
719
|
<button
|
|
701
720
|
type="button"
|
|
702
|
-
class="lc-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
721
|
+
class="lc-settings"
|
|
722
|
+
class:lc-settings-open={settingsOpen}
|
|
723
|
+
aria-haspopup="menu"
|
|
724
|
+
aria-expanded={settingsOpen}
|
|
725
|
+
aria-controls={settingsOpen ? `${uid}-settings` : undefined}
|
|
726
|
+
aria-label="Playback settings — speed {speedLabel()}{activeVoiceLabel && voices.length > 1 ? `, voice ${activeVoiceLabel}` : ''}"
|
|
727
|
+
title="Speed{voices.length > 1 ? ' & voice' : ''} · [ and ] step speed"
|
|
728
|
+
onclick={toggleSettings}
|
|
729
|
+
bind:this={settingsBtnEl}
|
|
706
730
|
>
|
|
707
|
-
{speedLabel()}
|
|
731
|
+
<span class="lc-speed">{speedLabel()}</span>
|
|
732
|
+
{#if voices.length > 1 && activeVoiceLabel}
|
|
733
|
+
<span class="lc-settings-dot" aria-hidden="true">·</span>
|
|
734
|
+
<span class="lc-voice-label">{activeVoiceLabel}</span>
|
|
735
|
+
{/if}
|
|
736
|
+
<svg class="lc-caret" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
737
|
+
<path d="M6 9l6 6 6-6" />
|
|
738
|
+
</svg>
|
|
708
739
|
</button>
|
|
740
|
+
{#if settingsOpen}
|
|
741
|
+
<div
|
|
742
|
+
id="{uid}-settings"
|
|
743
|
+
class="lc-menu"
|
|
744
|
+
role="menu"
|
|
745
|
+
aria-label="Playback settings"
|
|
746
|
+
tabindex="-1"
|
|
747
|
+
onkeydown={onSettingsKeydown}
|
|
748
|
+
bind:this={settingsMenuEl}
|
|
749
|
+
use:floating={{ anchor: settingsBtnEl, placement: 'bottom-end', flip: true, shift: true, offset: 6, onDismiss: dismissSettings }}
|
|
750
|
+
>
|
|
751
|
+
<div class="lc-menu-group" role="group" aria-labelledby="{uid}-speed-h">
|
|
752
|
+
<div class="lc-menu-heading" id="{uid}-speed-h">Speed</div>
|
|
753
|
+
<div class="lc-menu-speeds">
|
|
754
|
+
{#each LISTEN_SPEEDS as s (s)}
|
|
755
|
+
<button
|
|
756
|
+
type="button"
|
|
757
|
+
role="menuitemradio"
|
|
758
|
+
class="lc-menu-speed"
|
|
759
|
+
aria-checked={clampSpeed(speed) === s}
|
|
760
|
+
tabindex="-1"
|
|
761
|
+
onclick={() => pickSpeed(s)}
|
|
762
|
+
>
|
|
763
|
+
{formatListenSpeed(s)}
|
|
764
|
+
</button>
|
|
765
|
+
{/each}
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
{#if voices.length > 1}
|
|
769
|
+
<div class="lc-menu-group" role="group" aria-labelledby="{uid}-voice-h">
|
|
770
|
+
<div class="lc-menu-heading" id="{uid}-voice-h">Voice</div>
|
|
771
|
+
{#each voices as v (v.id)}
|
|
772
|
+
<button
|
|
773
|
+
type="button"
|
|
774
|
+
role="menuitemradio"
|
|
775
|
+
class="lc-menu-voice"
|
|
776
|
+
aria-checked={v.id === activeVoiceId}
|
|
777
|
+
tabindex="-1"
|
|
778
|
+
onclick={() => pickVoice(v.id)}
|
|
779
|
+
>
|
|
780
|
+
<svg class="lc-menu-check" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
781
|
+
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
|
782
|
+
</svg>
|
|
783
|
+
<span>{v.label}</span>
|
|
784
|
+
</button>
|
|
785
|
+
{/each}
|
|
786
|
+
</div>
|
|
787
|
+
{/if}
|
|
788
|
+
</div>
|
|
789
|
+
{/if}
|
|
790
|
+
|
|
709
791
|
<button type="button" class="lc-tbtn lc-close" aria-label="Close player" title="Close · Esc" onclick={close}>
|
|
710
792
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
|
711
793
|
<path d="M6 6l12 12M18 6L6 18" />
|
|
@@ -713,6 +795,67 @@
|
|
|
713
795
|
</button>
|
|
714
796
|
</div>
|
|
715
797
|
|
|
798
|
+
<!-- Row 2: where you are — sentence/15s transport · progress · clock. -->
|
|
799
|
+
<div class="lc-row lc-row-time">
|
|
800
|
+
<div class="lc-transport" role="group" aria-label="Transport">
|
|
801
|
+
<button type="button" class="lc-tbtn" aria-label="Previous sentence" title="Previous sentence · ←" onclick={prev}>
|
|
802
|
+
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
|
803
|
+
<path d="M19 5.5v13L9.5 12z" fill="currentColor" />
|
|
804
|
+
<path d="M6 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
805
|
+
</svg>
|
|
806
|
+
</button>
|
|
807
|
+
<button type="button" class="lc-tbtn lc-skip" aria-label="Back 15 seconds" title="Back 15s · Shift+←" onclick={() => skip(-15)}>
|
|
808
|
+
<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">
|
|
809
|
+
<path d="M11.5 4.5V2L7 5.5l4.5 3.5V6.5a6.5 6.5 0 1 1-6.5 6.5" />
|
|
810
|
+
<text x="12.7" y="17.8" font-size="7" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">15</text>
|
|
811
|
+
</svg>
|
|
812
|
+
</button>
|
|
813
|
+
<button type="button" class="lc-tbtn lc-skip" aria-label="Forward 15 seconds" title="Forward 15s · Shift+→" onclick={() => skip(15)}>
|
|
814
|
+
<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">
|
|
815
|
+
<path d="M12.5 4.5V2l4.5 3.5-4.5 3.5V6.5a6.5 6.5 0 1 0 6.5 6.5" />
|
|
816
|
+
<text x="11.3" y="17.8" font-size="7" font-weight="700" text-anchor="middle" fill="currentColor" stroke="none">15</text>
|
|
817
|
+
</svg>
|
|
818
|
+
</button>
|
|
819
|
+
<button type="button" class="lc-tbtn" aria-label="Next sentence" title="Next sentence · →" onclick={next}>
|
|
820
|
+
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
|
|
821
|
+
<path d="M5 5.5v13L14.5 12z" fill="currentColor" />
|
|
822
|
+
<path d="M18 5.5v13" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
|
823
|
+
</svg>
|
|
824
|
+
</button>
|
|
825
|
+
</div>
|
|
826
|
+
<div class="lc-progress">
|
|
827
|
+
<div class="lc-track" aria-hidden="true">
|
|
828
|
+
<div class="lc-fill" style:left="0" style:width="{fillPct}%"></div>
|
|
829
|
+
{#if pendingStartPct >= 0}
|
|
830
|
+
<div class="lc-pending" style:left="{pendingStartPct}%"></div>
|
|
831
|
+
{/if}
|
|
832
|
+
{#each ticks as t (t.id)}
|
|
833
|
+
<span class="lc-tick" style:left="{t.frac}%" title={t.label}></span>
|
|
834
|
+
{/each}
|
|
835
|
+
<span class="lc-head" style:left="{fillPct}%"></span>
|
|
836
|
+
</div>
|
|
837
|
+
<input
|
|
838
|
+
type="range"
|
|
839
|
+
class="lc-range"
|
|
840
|
+
min="0"
|
|
841
|
+
max={total > 0 ? total : 1}
|
|
842
|
+
step="0.05"
|
|
843
|
+
value={elapsed}
|
|
844
|
+
disabled={total <= 0}
|
|
845
|
+
aria-label="Playback position"
|
|
846
|
+
aria-valuetext="{formatTime(elapsed, useHours)} of {formatTime(total, useHours)}"
|
|
847
|
+
oninput={onSeekInput}
|
|
848
|
+
/>
|
|
849
|
+
</div>
|
|
850
|
+
<span class="lc-clock">
|
|
851
|
+
<span class="lc-clock-now">{formatTime(elapsed, useHours)}</span>
|
|
852
|
+
{#if total > 0}
|
|
853
|
+
<span class="lc-clock-sep" aria-hidden="true">/</span>
|
|
854
|
+
<span>{formatTime(total, useHours)}</span>
|
|
855
|
+
{/if}
|
|
856
|
+
</span>
|
|
857
|
+
</div>
|
|
858
|
+
|
|
716
859
|
<span class="lc-sr" role="status" aria-live="polite">
|
|
717
860
|
{status === 'buffering' ? 'Loading audio…' : status === 'ended' ? 'Finished' : ''}
|
|
718
861
|
</span>
|
|
@@ -806,21 +949,41 @@
|
|
|
806
949
|
}
|
|
807
950
|
}
|
|
808
951
|
|
|
809
|
-
/* ── The playback bar
|
|
952
|
+
/* ── The playback bar: two rows, never wider than its host ─────────────── */
|
|
953
|
+
.listen {
|
|
954
|
+
min-width: 0;
|
|
955
|
+
max-width: 100%;
|
|
956
|
+
}
|
|
957
|
+
.lc-bar,
|
|
958
|
+
.lc-bar *,
|
|
959
|
+
.lc-menu,
|
|
960
|
+
.lc-menu * {
|
|
961
|
+
box-sizing: border-box;
|
|
962
|
+
}
|
|
810
963
|
.lc-bar {
|
|
811
964
|
position: sticky;
|
|
812
965
|
top: 0;
|
|
813
966
|
z-index: 40;
|
|
814
967
|
display: flex;
|
|
815
|
-
|
|
816
|
-
gap: 0.
|
|
817
|
-
|
|
968
|
+
flex-direction: column;
|
|
969
|
+
gap: 0.2rem;
|
|
970
|
+
width: 100%;
|
|
971
|
+
max-width: 100%;
|
|
972
|
+
min-width: 0;
|
|
973
|
+
padding: 0.4rem 0.55rem 0.35rem;
|
|
818
974
|
background: var(--color-base-100);
|
|
819
975
|
border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
820
976
|
border-radius: var(--jatui-radius-box, 0.75rem);
|
|
821
977
|
box-shadow: 0 4px 16px color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
822
978
|
font-size: 0.78rem;
|
|
823
979
|
}
|
|
980
|
+
.lc-row {
|
|
981
|
+
display: flex;
|
|
982
|
+
align-items: center;
|
|
983
|
+
gap: 0.4rem;
|
|
984
|
+
min-width: 0;
|
|
985
|
+
max-width: 100%;
|
|
986
|
+
}
|
|
824
987
|
|
|
825
988
|
.lc-play {
|
|
826
989
|
display: inline-grid;
|
|
@@ -845,6 +1008,7 @@
|
|
|
845
1008
|
.lc-tbtn {
|
|
846
1009
|
display: inline-grid;
|
|
847
1010
|
place-items: center;
|
|
1011
|
+
flex: none;
|
|
848
1012
|
min-width: 1.75rem;
|
|
849
1013
|
height: 1.75rem;
|
|
850
1014
|
padding: 0 0.15rem;
|
|
@@ -862,25 +1026,20 @@
|
|
|
862
1026
|
}
|
|
863
1027
|
.lc-play:focus-visible,
|
|
864
1028
|
.lc-tbtn:focus-visible,
|
|
865
|
-
.lc-
|
|
1029
|
+
.lc-settings:focus-visible,
|
|
1030
|
+
.lc-menu button:focus-visible {
|
|
866
1031
|
outline: 2px solid var(--color-primary);
|
|
867
1032
|
outline-offset: 2px;
|
|
868
1033
|
}
|
|
869
1034
|
|
|
870
|
-
.
|
|
871
|
-
display: flex;
|
|
872
|
-
flex-direction: column;
|
|
873
|
-
gap: 0.15rem;
|
|
874
|
-
flex: 1 1 auto;
|
|
875
|
-
min-width: 0;
|
|
876
|
-
}
|
|
877
|
-
|
|
1035
|
+
/* Row 1 — the sentence takes every pixel the controls don't. */
|
|
878
1036
|
.lc-caption {
|
|
879
1037
|
display: flex;
|
|
880
|
-
align-items:
|
|
1038
|
+
align-items: center;
|
|
881
1039
|
gap: 0.5rem;
|
|
1040
|
+
flex: 1 1 auto;
|
|
882
1041
|
min-width: 0;
|
|
883
|
-
font-size: 0.
|
|
1042
|
+
font-size: 0.8rem;
|
|
884
1043
|
line-height: 1.35;
|
|
885
1044
|
}
|
|
886
1045
|
.lc-chapter {
|
|
@@ -895,21 +1054,162 @@
|
|
|
895
1054
|
font-size: 0.62rem;
|
|
896
1055
|
}
|
|
897
1056
|
.lc-caption-text {
|
|
898
|
-
|
|
1057
|
+
flex: 1 1 auto;
|
|
1058
|
+
color: var(--color-base-content);
|
|
899
1059
|
white-space: nowrap;
|
|
900
1060
|
overflow: hidden;
|
|
901
1061
|
text-overflow: ellipsis;
|
|
902
1062
|
min-width: 0;
|
|
903
1063
|
}
|
|
1064
|
+
.lc-caption-muted {
|
|
1065
|
+
color: var(--lc-chrome);
|
|
1066
|
+
}
|
|
904
1067
|
.lc-error {
|
|
905
1068
|
color: var(--color-error);
|
|
906
1069
|
}
|
|
907
1070
|
|
|
908
|
-
.
|
|
1071
|
+
/* One settings pill: "1.25× · Sarah" → speed + voice menu. */
|
|
1072
|
+
.lc-settings {
|
|
1073
|
+
display: inline-flex;
|
|
1074
|
+
align-items: center;
|
|
1075
|
+
gap: 0.3rem;
|
|
1076
|
+
flex: none;
|
|
1077
|
+
min-width: 0;
|
|
1078
|
+
max-width: 11rem;
|
|
1079
|
+
height: 1.75rem;
|
|
1080
|
+
padding: 0 0.5rem 0 0.6rem;
|
|
1081
|
+
border: 1px solid color-mix(in oklch, var(--color-base-content) 14%, transparent);
|
|
1082
|
+
border-radius: 999px;
|
|
1083
|
+
background: none;
|
|
1084
|
+
color: var(--lc-chrome);
|
|
1085
|
+
font-size: 0.74rem;
|
|
1086
|
+
font-variant-numeric: tabular-nums;
|
|
1087
|
+
cursor: pointer;
|
|
1088
|
+
transition: color var(--motion-fast, 150ms) var(--motion-glide, ease-out),
|
|
1089
|
+
border-color var(--motion-fast, 150ms) var(--motion-glide, ease-out),
|
|
1090
|
+
background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1091
|
+
}
|
|
1092
|
+
.lc-settings:hover,
|
|
1093
|
+
.lc-settings-open {
|
|
1094
|
+
color: var(--color-base-content);
|
|
1095
|
+
border-color: color-mix(in oklch, var(--color-primary) 45%, transparent);
|
|
1096
|
+
background: color-mix(in oklch, var(--color-base-content) 5%, transparent);
|
|
1097
|
+
}
|
|
1098
|
+
.lc-speed {
|
|
1099
|
+
flex: none;
|
|
1100
|
+
font-weight: 600;
|
|
1101
|
+
}
|
|
1102
|
+
.lc-settings-dot {
|
|
1103
|
+
flex: none;
|
|
1104
|
+
opacity: 0.5;
|
|
1105
|
+
}
|
|
1106
|
+
.lc-voice-label {
|
|
1107
|
+
min-width: 0;
|
|
1108
|
+
overflow: hidden;
|
|
1109
|
+
text-overflow: ellipsis;
|
|
1110
|
+
white-space: nowrap;
|
|
1111
|
+
}
|
|
1112
|
+
.lc-caret {
|
|
1113
|
+
flex: none;
|
|
1114
|
+
opacity: 0.6;
|
|
1115
|
+
transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1116
|
+
}
|
|
1117
|
+
.lc-settings-open .lc-caret {
|
|
1118
|
+
transform: rotate(180deg);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
.lc-menu {
|
|
1122
|
+
z-index: 60;
|
|
1123
|
+
min-width: 12rem;
|
|
1124
|
+
max-width: calc(100vw - 1rem);
|
|
1125
|
+
padding: 0.35rem;
|
|
1126
|
+
background: var(--color-base-100);
|
|
1127
|
+
color: var(--color-base-content);
|
|
1128
|
+
border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
1129
|
+
border-radius: var(--jatui-radius-box, 0.75rem);
|
|
1130
|
+
box-shadow: 0 8px 24px color-mix(in oklch, var(--color-base-content) 16%, transparent);
|
|
1131
|
+
font-size: 0.78rem;
|
|
1132
|
+
}
|
|
1133
|
+
.lc-menu:focus {
|
|
1134
|
+
outline: none;
|
|
1135
|
+
}
|
|
1136
|
+
.lc-menu-group + .lc-menu-group {
|
|
1137
|
+
margin-top: 0.3rem;
|
|
1138
|
+
padding-top: 0.3rem;
|
|
1139
|
+
border-top: 1px solid color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
1140
|
+
}
|
|
1141
|
+
.lc-menu-heading {
|
|
1142
|
+
padding: 0.15rem 0.4rem 0.25rem;
|
|
1143
|
+
color: var(--lc-chrome);
|
|
1144
|
+
font-size: 0.62rem;
|
|
1145
|
+
font-weight: 600;
|
|
1146
|
+
text-transform: uppercase;
|
|
1147
|
+
letter-spacing: 0.06em;
|
|
1148
|
+
}
|
|
1149
|
+
.lc-menu-speeds {
|
|
1150
|
+
display: grid;
|
|
1151
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
1152
|
+
gap: 0.2rem;
|
|
1153
|
+
}
|
|
1154
|
+
.lc-menu-speed,
|
|
1155
|
+
.lc-menu-voice {
|
|
1156
|
+
border: none;
|
|
1157
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
1158
|
+
background: none;
|
|
1159
|
+
color: inherit;
|
|
1160
|
+
font: inherit;
|
|
1161
|
+
cursor: pointer;
|
|
1162
|
+
transition: background-color var(--motion-fast, 150ms) var(--motion-glide, ease-out);
|
|
1163
|
+
}
|
|
1164
|
+
.lc-menu-speed {
|
|
1165
|
+
height: 1.75rem;
|
|
1166
|
+
font-variant-numeric: tabular-nums;
|
|
1167
|
+
}
|
|
1168
|
+
.lc-menu-voice {
|
|
909
1169
|
display: flex;
|
|
910
1170
|
align-items: center;
|
|
911
|
-
gap: 0.
|
|
1171
|
+
gap: 0.4rem;
|
|
1172
|
+
width: 100%;
|
|
1173
|
+
min-height: 1.85rem;
|
|
1174
|
+
padding: 0 0.45rem;
|
|
1175
|
+
text-align: left;
|
|
1176
|
+
}
|
|
1177
|
+
.lc-menu-voice span {
|
|
912
1178
|
min-width: 0;
|
|
1179
|
+
overflow: hidden;
|
|
1180
|
+
text-overflow: ellipsis;
|
|
1181
|
+
white-space: nowrap;
|
|
1182
|
+
}
|
|
1183
|
+
.lc-menu-speed:hover,
|
|
1184
|
+
.lc-menu-voice:hover {
|
|
1185
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
1186
|
+
}
|
|
1187
|
+
.lc-menu-speed[aria-checked='true'] {
|
|
1188
|
+
background: var(--color-primary);
|
|
1189
|
+
color: var(--color-primary-content);
|
|
1190
|
+
font-weight: 600;
|
|
1191
|
+
}
|
|
1192
|
+
.lc-menu-check {
|
|
1193
|
+
flex: none;
|
|
1194
|
+
visibility: hidden;
|
|
1195
|
+
color: var(--color-primary);
|
|
1196
|
+
}
|
|
1197
|
+
.lc-menu-voice[aria-checked='true'] {
|
|
1198
|
+
font-weight: 600;
|
|
1199
|
+
}
|
|
1200
|
+
.lc-menu-voice[aria-checked='true'] .lc-menu-check {
|
|
1201
|
+
visibility: visible;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/* Row 2 — transport sits on the progress row, left of the bar. */
|
|
1205
|
+
.lc-row-time {
|
|
1206
|
+
gap: 0.5rem;
|
|
1207
|
+
}
|
|
1208
|
+
.lc-transport {
|
|
1209
|
+
display: inline-flex;
|
|
1210
|
+
align-items: center;
|
|
1211
|
+
gap: 0.1rem;
|
|
1212
|
+
flex: none;
|
|
913
1213
|
}
|
|
914
1214
|
|
|
915
1215
|
/* ── Progress: a native range for keyboard + AT, painted track behind ──── */
|
|
@@ -917,7 +1217,7 @@
|
|
|
917
1217
|
position: relative;
|
|
918
1218
|
flex: 1 1 auto;
|
|
919
1219
|
height: 1rem;
|
|
920
|
-
min-width:
|
|
1220
|
+
min-width: 3rem;
|
|
921
1221
|
}
|
|
922
1222
|
.lc-progress:has(.lc-range:focus-visible) {
|
|
923
1223
|
outline: 2px solid var(--color-primary);
|
|
@@ -989,6 +1289,7 @@
|
|
|
989
1289
|
flex: none;
|
|
990
1290
|
white-space: nowrap;
|
|
991
1291
|
font-variant-numeric: tabular-nums;
|
|
1292
|
+
color: var(--lc-chrome);
|
|
992
1293
|
}
|
|
993
1294
|
.lc-clock-now {
|
|
994
1295
|
color: var(--color-base-content);
|
|
@@ -998,22 +1299,6 @@
|
|
|
998
1299
|
opacity: 0.5;
|
|
999
1300
|
}
|
|
1000
1301
|
|
|
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
1302
|
.lc-close {
|
|
1018
1303
|
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
1019
1304
|
}
|
|
@@ -1029,15 +1314,26 @@
|
|
|
1029
1314
|
white-space: nowrap;
|
|
1030
1315
|
border: 0;
|
|
1031
1316
|
}
|
|
1032
|
-
.lc-voice .lc-sr {
|
|
1033
|
-
position: absolute;
|
|
1034
|
-
}
|
|
1035
1317
|
|
|
1036
1318
|
.lc-audio {
|
|
1037
1319
|
display: none;
|
|
1038
1320
|
}
|
|
1039
1321
|
|
|
1040
|
-
|
|
1322
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1323
|
+
.lc-play,
|
|
1324
|
+
.lc-settings,
|
|
1325
|
+
.lc-caret,
|
|
1326
|
+
.lc-menu-speed,
|
|
1327
|
+
.lc-menu-voice {
|
|
1328
|
+
transition: none;
|
|
1329
|
+
}
|
|
1330
|
+
.lc-play:hover,
|
|
1331
|
+
.lc-play:active {
|
|
1332
|
+
transform: none;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/* ── Mobile: the same two rows, as a fixed sticky mini-bar ─────────────── */
|
|
1041
1337
|
@media (max-width: 639px) {
|
|
1042
1338
|
.lc-bar {
|
|
1043
1339
|
position: fixed;
|
|
@@ -1049,16 +1345,23 @@
|
|
|
1049
1345
|
border-radius: var(--jatui-radius-box, 0.75rem) var(--jatui-radius-box, 0.75rem) 0 0;
|
|
1050
1346
|
border-bottom: none;
|
|
1051
1347
|
padding-bottom: calc(0.4rem + env(safe-area-inset-bottom, 0px));
|
|
1052
|
-
gap: 0.3rem;
|
|
1053
1348
|
}
|
|
1054
|
-
.lc-
|
|
1055
|
-
|
|
1056
|
-
.lc-caption {
|
|
1057
|
-
display: none;
|
|
1349
|
+
.lc-row {
|
|
1350
|
+
gap: 0.3rem;
|
|
1058
1351
|
}
|
|
1059
1352
|
.lc-play {
|
|
1060
1353
|
width: 2.4rem;
|
|
1061
1354
|
height: 2.4rem;
|
|
1062
1355
|
}
|
|
1356
|
+
/* The pill keeps the speed; the voice name lives in the menu. */
|
|
1357
|
+
.lc-settings-dot,
|
|
1358
|
+
.lc-voice-label {
|
|
1359
|
+
display: none;
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
@media (max-width: 359px) {
|
|
1363
|
+
.lc-skip {
|
|
1364
|
+
display: none;
|
|
1365
|
+
}
|
|
1063
1366
|
}
|
|
1064
1367
|
</style>
|
|
@@ -2,9 +2,10 @@ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, List
|
|
|
2
2
|
/**
|
|
3
3
|
* ListenControl — "listen to this page" as one compact control.
|
|
4
4
|
*
|
|
5
|
-
* A small pill that expands into a playback bar: play/pause,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* A small pill that expands into a two-row playback bar: play/pause, the
|
|
6
|
+
* current sentence, one speed (0.75–2×) + voice menu, close; then
|
|
7
|
+
* previous/next sentence, −15s/+15s, a document-wide progress bar with
|
|
8
|
+
* chapter/section ticks, and time. Built for hosts
|
|
8
9
|
* that synthesize audio per sentence (TTS): the host supplies an async
|
|
9
10
|
* SEGMENT SOURCE — sentences/paragraphs with ids → audio URLs — and the
|
|
10
11
|
* player starts on the first chunk while the rest synthesizes, hatching
|
|
@@ -22,6 +23,14 @@ export type { ListenSegment, ListenChapter, ListenVoice, ListenSegmentPage, List
|
|
|
22
23
|
* reports them) lay the timeline out immediately; unknown ones are
|
|
23
24
|
* measured on load and ESTIMATED until then, self-correcting as they land.
|
|
24
25
|
*
|
|
26
|
+
* ── Layout: two rows (jatui-zl9dp) ──────────────────────────────────────
|
|
27
|
+
* Row 1 is what you're hearing: play/pause, the chapter chip + current
|
|
28
|
+
* sentence (it gets ALL the remaining width), ONE settings pill folding
|
|
29
|
+
* speed + voice into a small menu, close. Row 2 is where you are: prev
|
|
30
|
+
* sentence, −15s, +15s, next sentence, then the progress bar and the
|
|
31
|
+
* clock. The bar is `box-sizing: border-box` + `max-width: 100%` with
|
|
32
|
+
* `min-width: 0` down the flex chain, so it never overflows its host.
|
|
33
|
+
*
|
|
25
34
|
* The transport row borrows VideoPlayer's `kind="audio"` / `transport=
|
|
26
35
|
* "band"` shape (round primary play, ghost transport buttons, tabular
|
|
27
36
|
* clock) and reuses its pure time formatting (`video/time.ts`); the
|