@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, −15s/+15s and prev/next sentence, speed (0.75–2×), voice picker, 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, fixed sticky mini-bar 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) |
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, −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
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
- function cycleSpeed(): void {
433
- const i = LISTEN_SPEEDS.indexOf(clampSpeed(speed));
434
- setSpeed(i === -1 ? 1 : LISTEN_SPEEDS[(i + 1) % LISTEN_SPEEDS.length]);
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
- <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>
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
- <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>
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
- </span>
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-tbtn lc-speed"
703
- aria-label="Playback speed {speedLabel()}"
704
- title="Speed · click to change, [ and ] step"
705
- onclick={cycleSpeed}
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
- align-items: center;
816
- gap: 0.4rem;
817
- padding: 0.4rem 0.55rem;
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-voice-select:focus-visible {
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
- .lc-body {
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: baseline;
1038
+ align-items: center;
881
1039
  gap: 0.5rem;
1040
+ flex: 1 1 auto;
882
1041
  min-width: 0;
883
- font-size: 0.72rem;
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
- color: var(--lc-chrome);
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
- .lc-timerow {
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.5rem;
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: 4rem;
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
- /* ── Mobile: the bar becomes a fixed sticky mini-bar ───────────────────── */
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-skip,
1055
- .lc-voice,
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, −15s/+15s and
6
- * previous/next sentence, speed (0.75–2×), a voice picker, a document-wide
7
- * progress bar with chapter/section ticks, time, close. Built for hosts
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.36.1",
3
+ "version": "0.36.2",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",