@joewinke/jatui 0.50.1 → 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.
@@ -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,7 +18,7 @@ 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
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. |
@@ -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.1",
3
+ "version": "0.51.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",