@joewinke/jatui 0.50.0 → 0.51.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 CHANGED
@@ -177,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
177
177
  | `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. Items take optional `progress` (0–100): an upload ring draws around the tile and a tiny % occupies the remove-× corner, cross-fading into the × at 100 (same slot, no layout shift; per-tile `role="progressbar"`). | `items: ThumbnailItem[]` (`{id, url?, alt, progress?}`), `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
178
178
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
179
179
  | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
180
- | `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, `Shift+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. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **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, t theater, ? 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; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped). Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. 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'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
180
+ | `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, `Shift+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. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **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, t theater, ? 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; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. 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'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
181
181
  | `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?` |
182
182
  | `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?` |
183
183
  | `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) |
@@ -267,6 +267,7 @@
267
267
  SETTINGS_WRITE_DELAY_MS,
268
268
  VIDEO_SETTINGS,
269
269
  REMEMBERED_MUTED,
270
+ REMEMBERED_SPEAKER_SPEEDS,
270
271
  REMEMBERED_TRANSCRIPT,
271
272
  REMEMBERED_VOLUME,
272
273
  type SettingAvailability,
@@ -1566,6 +1567,53 @@
1566
1567
  resumeFollow();
1567
1568
  }
1568
1569
 
1570
+ // ── Per-speaker manual speed (E73, jatui-p3m3u) ──────────────────
1571
+ // Extension parity (library.js:4605): the chip menu's "this speaker at
1572
+ // 1.5×". Remembered per viewer through the remembered-settings split (the
1573
+ // host's settingsStore, else this browser's localStorage) — a choice made
1574
+ // before the load-time read resolves wins, like every remembered state.
1575
+ const localSpeedStore = localSettingsStore();
1576
+ let speedsTouched = false;
1577
+ let viewerSpeakerSpeeds = $state<Record<string, number>>({});
1578
+
1579
+ /** Set (or, with rate 1/null, clear) one speaker's manual speed and save it. */
1580
+ function setSpeakerSpeed(key: string, rate: number | null) {
1581
+ const next = { ...viewerSpeakerSpeeds };
1582
+ const r = rate == null || rate === 1 ? null : Math.min(16, Math.max(0.0625, rate));
1583
+ if (r != null) next[key] = r;
1584
+ else delete next[key];
1585
+ viewerSpeakerSpeeds = next;
1586
+ speedsTouched = true;
1587
+ writeRemembered(
1588
+ settingsStore ?? localSpeedStore,
1589
+ REMEMBERED_SPEAKER_SPEEDS.id,
1590
+ Object.keys(next).length > 0 ? { ...next } : {}
1591
+ );
1592
+ }
1593
+
1594
+ function speakerSpeedOf(key: string): number {
1595
+ return viewerSpeakerSpeeds[key] ?? 1;
1596
+ }
1597
+
1598
+ // The load-time read, once: a junk store reads as nothing stored, and a
1599
+ // choice made before the read resolves wins (the settings session's rule).
1600
+ $effect(() => {
1601
+ const store = settingsStore ?? localSpeedStore;
1602
+ let cancelled = false;
1603
+ untrack(() => {
1604
+ void Promise.resolve(store.get(REMEMBERED_SPEAKER_SPEEDS.id))
1605
+ .then((raw) => {
1606
+ if (cancelled || speedsTouched) return;
1607
+ const v = REMEMBERED_SPEAKER_SPEEDS.coerce(raw);
1608
+ if (v !== undefined) viewerSpeakerSpeeds = v;
1609
+ })
1610
+ .catch(() => {});
1611
+ });
1612
+ return () => {
1613
+ cancelled = true;
1614
+ };
1615
+ });
1616
+
1569
1617
  // ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
1570
1618
  // rate = base × speaker × silence. `base` is the only rate a human chose
1571
1619
  // and the only one the OSD reports; the advisors multiply into the APPLIED
@@ -1574,10 +1622,27 @@
1574
1622
  // exactly `base` — the contract in video/rate/compose.ts.
1575
1623
  const rateTurns = $derived(toRateTurns(speakers));
1576
1624
  const rateSegments = $derived(toRateSegments(transcript));
1625
+ // E73 (jatui-p3m3u) — the viewer's hand-set per-speaker speeds (the chip
1626
+ // menu, remembered per viewer). They multiply with the automatic advisor
1627
+ // and the base, whatever either is doing; an empty map is the identity.
1628
+ // The chip/legend keys a speaker by IDENTITY (speakerKeyOf), the rate
1629
+ // composer keys by NAME (speakerNameOf) — the index bridges the two.
1630
+ const viewerSpeedByName = $derived.by(() => {
1631
+ const keys = Object.keys(viewerSpeakerSpeeds);
1632
+ if (keys.length === 0) return null;
1633
+ const out: Record<string, number> = {};
1634
+ for (const key of keys) {
1635
+ const info = speakerIndex.get(key);
1636
+ const name = info && info.name !== 'Unknown speaker' ? info.name : key;
1637
+ out[name] = viewerSpeakerSpeeds[key];
1638
+ }
1639
+ return out;
1640
+ });
1577
1641
  const rateAdvisors = $derived({
1578
1642
  speakerRates: speakerPace
1579
1643
  ? speakerRateMap({ turns: rateTurns, segments: rateSegments, manual: speakerRates, measure: true })
1580
1644
  : null,
1645
+ manual: viewerSpeedByName,
1581
1646
  turns: rateTurns,
1582
1647
  silence: smartSpeed ? silenceMapFor(waveform, effectiveDuration) : null
1583
1648
  });
@@ -3679,6 +3744,31 @@
3679
3744
  return t.speakerIdentity ?? t.speakerName ?? UNKNOWN_KEY;
3680
3745
  }
3681
3746
 
3747
+ // ── Chip speed menu (E73, jatui-p3m3u) ──────────────────────────────────
3748
+ // One menu open at a time; a click anywhere else closes it.
3749
+ let speedMenuKey = $state<string | null>(null);
3750
+ const SPEED_CHOICES = [0.75, 1, 1.25, 1.5, 1.75, 2, 3];
3751
+
3752
+ function toggleSpeedMenu(key: string) {
3753
+ speedMenuKey = speedMenuKey === key ? null : key;
3754
+ }
3755
+ function pickSpeakerSpeed(key: string, name: string, rate: number) {
3756
+ setSpeakerSpeed(key, rate);
3757
+ speedMenuKey = null;
3758
+ flashOsd(rate === 1 ? `${name} · 1×` : `${name} · ${formatSpeedLabel(rate)}`);
3759
+ }
3760
+ $effect(() => {
3761
+ if (speedMenuKey == null) return;
3762
+ const close = (e: PointerEvent) => {
3763
+ // A press inside the open menu (or on its trigger) is the menu's own
3764
+ // click — the buttons close it themselves.
3765
+ if ((e.target as Element | null)?.closest?.('.vp-speed-menu')) return;
3766
+ speedMenuKey = null;
3767
+ };
3768
+ document.addEventListener('pointerdown', close);
3769
+ return () => document.removeEventListener('pointerdown', close);
3770
+ });
3771
+
3682
3772
  function submitBookmark() {
3683
3773
  onAddBookmark?.(currentTime, bookmarkLabel.trim());
3684
3774
  bookmarkLabel = '';
@@ -4201,6 +4291,7 @@
4201
4291
  {@const key = talkTimeKey(tt)}
4202
4292
  {@const name = tt.speakerName ?? 'Unknown speaker'}
4203
4293
  <span class="vp-legend-item">
4294
+ <span class="vp-speed-menu">
4204
4295
  <button
4205
4296
  type="button"
4206
4297
  class="vp-chip"
@@ -4219,6 +4310,37 @@
4219
4310
  {name}
4220
4311
  <span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
4221
4312
  </button>
4313
+ <!-- E73: this speaker's own speed, remembered. 1× is the
4314
+ reset; the composer clamps the product. -->
4315
+ <button
4316
+ type="button"
4317
+ class="vp-chip-speed"
4318
+ class:on={speakerSpeedOf(key) !== 1}
4319
+ onclick={() => toggleSpeedMenu(key)}
4320
+ aria-expanded={speedMenuKey === key}
4321
+ aria-haspopup="menu"
4322
+ aria-label="Speed for {name}: {formatSpeedLabel(speakerSpeedOf(key))}"
4323
+ title="Speed for {name}"
4324
+ >
4325
+ {formatSpeedLabel(speakerSpeedOf(key))}
4326
+ </button>
4327
+ {#if speedMenuKey === key}
4328
+ <div class="vp-speed-pop" role="menu" aria-label="Speed for {name}">
4329
+ {#each SPEED_CHOICES as choice (choice)}
4330
+ <button
4331
+ type="button"
4332
+ role="menuitemradio"
4333
+ aria-checked={speakerSpeedOf(key) === choice}
4334
+ class="vp-speed-opt"
4335
+ class:current={speakerSpeedOf(key) === choice}
4336
+ onclick={() => pickSpeakerSpeed(key, name, choice)}
4337
+ >
4338
+ {formatSpeedLabel(choice)}{choice === 1 ? ' (reset)' : ''}
4339
+ </button>
4340
+ {/each}
4341
+ </div>
4342
+ {/if}
4343
+ </span>
4222
4344
  <button
4223
4345
  type="button"
4224
4346
  class="vp-mute"
@@ -5499,6 +5621,66 @@
5499
5621
  border-color: var(--color-primary);
5500
5622
  }
5501
5623
 
5624
+ /* E73 — per-speaker speed trigger + its menu. The wrapper is the
5625
+ click-outside boundary: a press inside stays the menu's own click. */
5626
+ .vp-speed-menu {
5627
+ position: relative;
5628
+ display: inline-flex;
5629
+ align-items: center;
5630
+ }
5631
+
5632
+ .vp-chip-speed {
5633
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-xs, 2px);
5634
+ background: none;
5635
+ border: none;
5636
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5637
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
5638
+ font-size: inherit;
5639
+ font-variant-numeric: tabular-nums;
5640
+ cursor: pointer;
5641
+ }
5642
+
5643
+ .vp-chip-speed.on {
5644
+ color: var(--color-primary);
5645
+ font-weight: 600;
5646
+ }
5647
+
5648
+ .vp-speed-pop {
5649
+ position: absolute;
5650
+ z-index: 30;
5651
+ top: calc(100% + 4px);
5652
+ left: 0;
5653
+ display: flex;
5654
+ flex-direction: column;
5655
+ min-width: 7rem;
5656
+ padding: var(--jatui-space-sm, 4px);
5657
+ background: var(--color-base-100);
5658
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 15%, transparent);
5659
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5660
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.25);
5661
+ }
5662
+
5663
+ .vp-speed-opt {
5664
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
5665
+ background: none;
5666
+ border: none;
5667
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5668
+ color: var(--color-base-content);
5669
+ font-size: inherit;
5670
+ font-variant-numeric: tabular-nums;
5671
+ text-align: left;
5672
+ cursor: pointer;
5673
+ }
5674
+
5675
+ .vp-speed-opt:hover {
5676
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
5677
+ }
5678
+
5679
+ .vp-speed-opt.current {
5680
+ color: var(--color-primary);
5681
+ font-weight: 600;
5682
+ }
5683
+
5502
5684
  .vp-chip-clear {
5503
5685
  background: none;
5504
5686
  color: var(--color-primary);
@@ -18,9 +18,10 @@ props + callback surfaces.
18
18
  | `band.ts` | .28 (done) — extended by **.30** | The timeline band: zoom window (`BandWindow`, `zoomAt`, `followPlayhead`), hit tests (`segmentAt`, `chapterAt`, `biasToSegmentStart`, `nearestBookmark`), and `paintBand` (canvas). .30 adds one-axis arbitration + the light ribbon; its state lives HERE. |
19
19
  | `band/` | **.30** (done) | `window.ts` — the zoom-window math (moved out of band.ts, re-exported there; shared by every band layer without an import cycle). `axis.ts` — one-axis four-state arbitration (`resolveAxis`, `BandState`, the churn-guarded microtask `createBandReporter`), `playheadX` (the one "where you are" mapping — ≤0.2px agreement) and `overviewBracket`. `luma.ts` — the light track (`VideoLumaTrack`, the sidecar verbatim) → ribbon (`v² × 0.55`, max-per-interval). `markers.ts` — `VideoMarker` comments (clustered dots) + like heat. |
20
20
  | `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
21
- | `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. None of them write `playbackRate`; they advise. |
21
+ | `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. `manual` (E73, jatui-p3m3u) — the viewer's remembered per-speaker speeds from the chip menu, multiplying with the automatic advisor; the composer clamps the product. None of them write `playbackRate`; they advise. |
22
22
  | `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved) + `buildTimeUrl` (jatui-3wl5x) — the `?t=` URL builder hosts pass as `buildTimeLink` (MOS's "copy link at this time" shape). |
23
23
  | `lineMenu.ts` | **jatui-3wl5x** | The transcript line right-click menu's pure pieces: `isContextMenuKey` (the keyboard's right-click — Context-menu key or Shift+F10) and `lineTextWithTime` (`[1:23] text`, the row's own clock). The component owns the DOM — which row opened it, cursor vs row anchoring, focus return — and the menu itself is the shared `ContextMenu`. |
24
+ | `transcriptSearch.ts` | **jatui-xmtnv** (E25/E57) | The transcript search + wheel line-scrub pure helpers: `searchHits` (case-insensitive, non-overlapping), `highlightRowHtml` (`<mark>`s around matches, everything else escaped — host transcript text is never markup), `pickMatch` (Enter walks matches from the playhead, wrapping), `lineScrubTarget` + `transcriptWheelDecision`/`effectiveWheelDelta` (Shift+wheel steps a line per ~100px notch; a plain wheel is never claimed). The component owns the box, focus, Esc layering and the `n / m` count. |
24
25
  | `settings.ts` + `settings/` | **.35** (done) — knobs added by .30–.34 | `settings.ts` is the barrel. `registry.ts` — `VIDEO_SETTINGS`: id, group, label, **mandatory hint**, the prop it drives, the availability gate (`when`); `coerce` never throws (hostile values, throwing `valueOf`, Symbols → undefined). Labels/hints are the single source for the panel AND a host help page. `store.ts` — the host-supplied `VideoSettingsStore` (`get`/`set`, either async; jatui never persists on its own) and `createSettingsSession`: coerced reads, a choice made on screen beats a late read, per-id debounced writes flushed on close. `panel.ts` — `panelGroups` (rows only for features this player has — omitted, never disabled), value formatting, radio stepping. `SettingsPanel.svelte` — the generated dialog (switch / radio group / range, focus trap, Esc/`,` close, focus handed back). `remembered.ts` (jatui-xz6so) — remembered MEDIA state (E12: volume + mute survive a reload), coerced like settings and persisted through the same store split (`settingsStore`, else guarded localStorage) but deliberately never panel rows: remembering is not configuring. In the component a stored choice overlays its prop but never creates a presence-gated feature; `night`/`nightDepth` are written through their bindings and `N`/the moon wheel are saved back. The one permission ask is `sleep.desktopIdle` turned on, from that click. A knob lands WITH its reader. |
25
26
  | `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
26
27
  | `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
@@ -17,6 +17,13 @@ export declare const RATE_TICK_MS = 120;
17
17
  export interface RateAdvisors {
18
18
  /** null ⇒ the speaker advisor is off (collapses to 1). */
19
19
  speakerRates: RateMap | null;
20
+ /**
21
+ * Viewer hand-set per-speaker speeds (the chip menu, remembered). They
22
+ * MULTIPLY with the automatic advisor — "this speaker at 1.5×" is the
23
+ * viewer's intent and stands on top of whatever the pace advisor measured.
24
+ * null/empty ⇒ 1 (the identity; see compose.ts).
25
+ */
26
+ manual?: RateMap | null;
20
27
  turns: RateTurn[];
21
28
  /** null ⇒ smart speed is off or has no map yet (collapses to 1). */
22
29
  silence: SilenceMap | null;
@@ -5,11 +5,14 @@ import { speakerFactor } from './speaker';
5
5
  export const RATE_TICK_MS = 120;
6
6
  /** The composed rate at playhead `t` for the human's `base`. */
7
7
  export function adviseRate(base, t, a) {
8
- const speaker = speakerFactor(t, a.turns, a.speakerRates);
8
+ const speaker = speakerFactor(t, a.turns, a.speakerRates) * speakerFactor(t, a.turns, a.manual ?? null);
9
9
  const silence = silenceFactor(t, a.silence, a.boost ?? null);
10
10
  return { speaker, silence, rate: composeRate(base, speaker, silence).rate };
11
11
  }
12
12
  /** Is any advisor able to move the rate? False ⇒ the loop need not run. */
13
13
  export function advisorsActive(a) {
14
- return !!a.silence || (!!a.speakerRates && a.turns.length > 0 && Object.keys(a.speakerRates).length > 0);
14
+ const manual = a.manual;
15
+ return (!!a.silence ||
16
+ (!!manual && a.turns.length > 0 && Object.keys(manual).length > 0) ||
17
+ (!!a.speakerRates && a.turns.length > 0 && Object.keys(a.speakerRates).length > 0));
15
18
  }
@@ -35,5 +35,15 @@ export declare const REMEMBERED_VOLUME: RememberedSpec<number>;
35
35
  */
36
36
  export type TranscriptLayout = 'side' | 'below' | 'hidden';
37
37
  export declare const REMEMBERED_TRANSCRIPT: RememberedSpec<TranscriptLayout>;
38
+ /**
39
+ * Remembered per-speaker playback speed (E73, jatui-p3m3u) — the chip menu's
40
+ * "this speaker at 1.5×" survives a reload. A record keyed by speaker NAME
41
+ * (the display name in the legend), coerced entry-by-entry: junk names and
42
+ * rates outside the composer's clamp range drop out, so a hostile store reads
43
+ * as nothing stored rather than a speed of 10⁹. Composed multiplicatively
44
+ * with the user's speed and the automatic pace advisor by the rate composer
45
+ * (rate/tick.ts), which clamps the product.
46
+ */
47
+ export declare const REMEMBERED_SPEAKER_SPEEDS: RememberedSpec<Record<string, number>>;
38
48
  /** Whether playback starts muted. */
39
49
  export declare const REMEMBERED_MUTED: RememberedSpec<boolean>;
@@ -39,6 +39,40 @@ export const REMEMBERED_TRANSCRIPT = {
39
39
  return undefined;
40
40
  }
41
41
  };
42
+ /**
43
+ * Remembered per-speaker playback speed (E73, jatui-p3m3u) — the chip menu's
44
+ * "this speaker at 1.5×" survives a reload. A record keyed by speaker NAME
45
+ * (the display name in the legend), coerced entry-by-entry: junk names and
46
+ * rates outside the composer's clamp range drop out, so a hostile store reads
47
+ * as nothing stored rather than a speed of 10⁹. Composed multiplicatively
48
+ * with the user's speed and the automatic pace advisor by the rate composer
49
+ * (rate/tick.ts), which clamps the product.
50
+ */
51
+ export const REMEMBERED_SPEAKER_SPEEDS = {
52
+ id: 'player.speakerSpeeds',
53
+ default: {},
54
+ coerce(raw) {
55
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
56
+ return undefined;
57
+ const src = raw;
58
+ const out = {};
59
+ let any = false;
60
+ for (const [k, v] of Object.entries(src)) {
61
+ const name = k.trim();
62
+ try {
63
+ const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN;
64
+ if (name && Number.isFinite(n) && n >= 0.0625 && n <= 16) {
65
+ out[name] = n;
66
+ any = true;
67
+ }
68
+ }
69
+ catch {
70
+ /* a throwing valueOf is a value not stored */
71
+ }
72
+ }
73
+ return any ? out : undefined;
74
+ }
75
+ };
42
76
  /** Whether playback starts muted. */
43
77
  export const REMEMBERED_MUTED = {
44
78
  id: 'player.muted',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.50.0",
3
+ "version": "0.51.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",