@joewinke/jatui 0.50.1 → 0.52.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.
@@ -26,6 +26,8 @@
26
26
  export type { ResumePoint } from '../video/advisors/resume-point';
27
27
  // mos-5lzns.35 — settings registry + panel.
28
28
  export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
29
+ // jatui-8nu4g — frame capture (what the host's "set as poster" hook receives).
30
+ export type { VideoFrameCapture } from '../video/capture';
29
31
  </script>
30
32
 
31
33
  <script lang="ts">
@@ -199,6 +201,17 @@
199
201
  } from '../video/rate';
200
202
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
201
203
  import { isContextMenuKey, lineTextWithTime } from '../video/lineMenu';
204
+ import {
205
+ CAPTURE_NO_CORS_MESSAGE,
206
+ CAPTURE_NO_FRAME_MESSAGE,
207
+ captureActions,
208
+ captureFilename,
209
+ clipboardImageSupported,
210
+ frameDrawSize,
211
+ isCrossOriginCaptureError,
212
+ type CaptureAction,
213
+ type VideoFrameCapture
214
+ } from '../video/capture';
202
215
  import {
203
216
  biasCells,
204
217
  biasGradient,
@@ -267,6 +280,7 @@
267
280
  SETTINGS_WRITE_DELAY_MS,
268
281
  VIDEO_SETTINGS,
269
282
  REMEMBERED_MUTED,
283
+ REMEMBERED_SPEAKER_SPEEDS,
270
284
  REMEMBERED_TRANSCRIPT,
271
285
  REMEMBERED_VOLUME,
272
286
  type SettingAvailability,
@@ -307,6 +321,25 @@
307
321
  followResumeMs = FOLLOW_RESUME_IDLE_MS,
308
322
  transcriptLineMenu = true,
309
323
  buildTimeLink = undefined,
324
+ /**
325
+ * Frame capture (jatui-8nu4g, extension-library parity library.js:9262):
326
+ * a camera button on the band transport and the `C` key open a menu of
327
+ * the frame verbs — Save frame as PNG, Copy frame to clipboard (only
328
+ * where the platform can actually write an image; otherwise save-only),
329
+ * and Set as poster when `onCaptureFrame` is supplied. Video only —
330
+ * audio has no frame to grab. Default on; `false` removes the button
331
+ * and the key.
332
+ */
333
+ frameCapture = true,
334
+ /**
335
+ * The host's capture hook — fires with the grabbed frame (PNG Blob,
336
+ * data URL, playback time, pixel size, suggested name) when the viewer
337
+ * picks "Set as poster"; MOS Studio uploads it and pins the video's
338
+ * poster. Without it the menu item simply doesn't exist. May be async;
339
+ * the player waits for it before reporting done, and a rejection is
340
+ * said, not hidden.
341
+ */
342
+ onCaptureFrame = undefined,
310
343
  theater = $bindable(false),
311
344
  theaterMode = 'width',
312
345
  theaterButton = true,
@@ -509,6 +542,17 @@
509
542
  * simply not on the menu — the player never guesses a URL.
510
543
  */
511
544
  buildTimeLink?: (sec: number) => string;
545
+ /**
546
+ * Frame capture (jatui-8nu4g) — see the destructure note above. Reads
547
+ * the frame through a canvas, which a cross-origin video without CORS
548
+ * taints: the export throws SecurityError and the player SAYS so
549
+ * ("Can't capture this video (no CORS)") instead of failing silently.
550
+ * Pass `crossorigin="anonymous"` for cross-origin media you want
551
+ * frame-readable.
552
+ */
553
+ frameCapture?: boolean;
554
+ /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
555
+ onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
512
556
  /**
513
557
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
514
558
  * only says it wants the page's width; the HOST lays its page out
@@ -1004,6 +1048,20 @@
1004
1048
  let lineMenuY = $state(0);
1005
1049
  let lineMenuSeg = $state<VideoTranscriptSegment | null>(null);
1006
1050
 
1051
+ // ── Frame capture menu (jatui-8nu4g, extension library.js:9262) ────────
1052
+ let captureMenuOpen = $state(false);
1053
+ let captureMenuX = $state(0);
1054
+ let captureMenuY = $state(0);
1055
+ let captureBtnEl: HTMLButtonElement | undefined = $state();
1056
+ /** Feature on: video only, and the host hasn't opted out. */
1057
+ const frameCaptureOn = $derived(frameCapture && !isAudio);
1058
+ /** Can this platform write an IMAGE to the clipboard? Static per page. */
1059
+ const captureClipboardOk = $derived(clipboardImageSupported());
1060
+ /** The verbs on the menu, in order — the pure decision, mapped to items. */
1061
+ const captureMenuActions = $derived(
1062
+ captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
1063
+ );
1064
+
1007
1065
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
1008
1066
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
1009
1067
 
@@ -1488,6 +1546,7 @@
1488
1546
  if (row.when === 'chapters') return sortedChapters.length > 0;
1489
1547
  if (row.when === 'rail') return railOn;
1490
1548
  if (row.when === 'bookmarks') return onAddBookmark != null;
1549
+ if (row.when === 'capture') return frameCaptureOn;
1491
1550
  if (row.when === 'transcript') return sortedSegments.length > 0;
1492
1551
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
1493
1552
  if (row.when === 'night') return nightPresent;
@@ -1566,6 +1625,53 @@
1566
1625
  resumeFollow();
1567
1626
  }
1568
1627
 
1628
+ // ── Per-speaker manual speed (E73, jatui-p3m3u) ──────────────────
1629
+ // Extension parity (library.js:4605): the chip menu's "this speaker at
1630
+ // 1.5×". Remembered per viewer through the remembered-settings split (the
1631
+ // host's settingsStore, else this browser's localStorage) — a choice made
1632
+ // before the load-time read resolves wins, like every remembered state.
1633
+ const localSpeedStore = localSettingsStore();
1634
+ let speedsTouched = false;
1635
+ let viewerSpeakerSpeeds = $state<Record<string, number>>({});
1636
+
1637
+ /** Set (or, with rate 1/null, clear) one speaker's manual speed and save it. */
1638
+ function setSpeakerSpeed(key: string, rate: number | null) {
1639
+ const next = { ...viewerSpeakerSpeeds };
1640
+ const r = rate == null || rate === 1 ? null : Math.min(16, Math.max(0.0625, rate));
1641
+ if (r != null) next[key] = r;
1642
+ else delete next[key];
1643
+ viewerSpeakerSpeeds = next;
1644
+ speedsTouched = true;
1645
+ writeRemembered(
1646
+ settingsStore ?? localSpeedStore,
1647
+ REMEMBERED_SPEAKER_SPEEDS.id,
1648
+ Object.keys(next).length > 0 ? { ...next } : {}
1649
+ );
1650
+ }
1651
+
1652
+ function speakerSpeedOf(key: string): number {
1653
+ return viewerSpeakerSpeeds[key] ?? 1;
1654
+ }
1655
+
1656
+ // The load-time read, once: a junk store reads as nothing stored, and a
1657
+ // choice made before the read resolves wins (the settings session's rule).
1658
+ $effect(() => {
1659
+ const store = settingsStore ?? localSpeedStore;
1660
+ let cancelled = false;
1661
+ untrack(() => {
1662
+ void Promise.resolve(store.get(REMEMBERED_SPEAKER_SPEEDS.id))
1663
+ .then((raw) => {
1664
+ if (cancelled || speedsTouched) return;
1665
+ const v = REMEMBERED_SPEAKER_SPEEDS.coerce(raw);
1666
+ if (v !== undefined) viewerSpeakerSpeeds = v;
1667
+ })
1668
+ .catch(() => {});
1669
+ });
1670
+ return () => {
1671
+ cancelled = true;
1672
+ };
1673
+ });
1674
+
1569
1675
  // ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
1570
1676
  // rate = base × speaker × silence. `base` is the only rate a human chose
1571
1677
  // and the only one the OSD reports; the advisors multiply into the APPLIED
@@ -1574,10 +1680,27 @@
1574
1680
  // exactly `base` — the contract in video/rate/compose.ts.
1575
1681
  const rateTurns = $derived(toRateTurns(speakers));
1576
1682
  const rateSegments = $derived(toRateSegments(transcript));
1683
+ // E73 (jatui-p3m3u) — the viewer's hand-set per-speaker speeds (the chip
1684
+ // menu, remembered per viewer). They multiply with the automatic advisor
1685
+ // and the base, whatever either is doing; an empty map is the identity.
1686
+ // The chip/legend keys a speaker by IDENTITY (speakerKeyOf), the rate
1687
+ // composer keys by NAME (speakerNameOf) — the index bridges the two.
1688
+ const viewerSpeedByName = $derived.by(() => {
1689
+ const keys = Object.keys(viewerSpeakerSpeeds);
1690
+ if (keys.length === 0) return null;
1691
+ const out: Record<string, number> = {};
1692
+ for (const key of keys) {
1693
+ const info = speakerIndex.get(key);
1694
+ const name = info && info.name !== 'Unknown speaker' ? info.name : key;
1695
+ out[name] = viewerSpeakerSpeeds[key];
1696
+ }
1697
+ return out;
1698
+ });
1577
1699
  const rateAdvisors = $derived({
1578
1700
  speakerRates: speakerPace
1579
1701
  ? speakerRateMap({ turns: rateTurns, segments: rateSegments, manual: speakerRates, measure: true })
1580
1702
  : null,
1703
+ manual: viewerSpeedByName,
1581
1704
  turns: rateTurns,
1582
1705
  silence: smartSpeed ? silenceMapFor(waveform, effectiveDuration) : null
1583
1706
  });
@@ -3184,13 +3307,14 @@
3184
3307
  // Space/Enter on a focused button activates THAT button (play, a
3185
3308
  // speaker chip, a transcript row); toggling playback too would undo it.
3186
3309
  if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
3187
- // The transcript line menu (jatui-3wl5x) owns the keyboard while it is
3188
- // open: arrows/Enter drive it (its panel preventDefaults what it uses,
3189
- // which the defaultPrevented guard above already yields to), Escape
3190
- // closes it, and nothing else drives the player underneath a floating
3191
- // menu. Tab is the shared ContextMenu's own behaviour (focus moves out
3192
- // with the menu still up; click/Escape dismiss it).
3193
- if (lineMenuOpen) return;
3310
+ // The transcript line menu (jatui-3wl5x) and the frame-capture menu
3311
+ // (jatui-8nu4g) own the keyboard while they are open: arrows/Enter drive
3312
+ // them (their panel preventDefaults what it uses, which the
3313
+ // defaultPrevented guard above already yields to), Escape closes, and
3314
+ // nothing else drives the player underneath a floating menu. Tab is the
3315
+ // shared ContextMenu's own behaviour (focus moves out with the menu
3316
+ // still up; click/Escape dismiss it).
3317
+ if (lineMenuOpen || captureMenuOpen) return;
3194
3318
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3195
3319
  // activation) while focus is inside it; without this guard a rail `k`
3196
3320
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -3357,6 +3481,15 @@
3357
3481
  cycleNight();
3358
3482
  }
3359
3483
  return;
3484
+ case 'c':
3485
+ case 'C':
3486
+ // Frame capture (jatui-8nu4g): video only; `frameCapture={false}`
3487
+ // removes the key with the button. Off ⇒ not ours — no
3488
+ // preventDefault, the page may keep it.
3489
+ if (!frameCaptureOn) return;
3490
+ e.preventDefault();
3491
+ openCaptureMenuAtTrigger();
3492
+ return;
3360
3493
  case 'Home':
3361
3494
  seekTo(0);
3362
3495
  break;
@@ -3552,6 +3685,157 @@
3552
3685
  return items;
3553
3686
  });
3554
3687
 
3688
+ // ── Frame capture (jatui-8nu4g, extension library.js:9262) ─────────────
3689
+ // The camera button (band transport) and the `C` key both open ONE menu
3690
+ // of frame verbs. The extension also burned a caption into the frame and
3691
+ // ran OCR on it — deliberately not ported (the player has no OCR budget;
3692
+ // hosts that want it can run it on the hook's dataUrl).
3693
+
3694
+ /** Open the menu anchored to the camera button (a click) — or, on a
3695
+ * native transport with no button, the stage (`C` still works there);
3696
+ * the position:fixed panel is viewport-clamped by floating-ui, so the
3697
+ * anchor is a hint, not a law. */
3698
+ function openCaptureMenuAtTrigger() {
3699
+ const rect =
3700
+ captureBtnEl?.getBoundingClientRect() ??
3701
+ stageWrapEl?.getBoundingClientRect() ??
3702
+ rootEl?.getBoundingClientRect();
3703
+ captureMenuX = rect?.left ?? 0;
3704
+ captureMenuY = rect ? rect.bottom + 6 : 0;
3705
+ captureMenuOpen = true;
3706
+ }
3707
+
3708
+ /** The current frame as a canvas at natural size. Null when there is
3709
+ * nothing to grab yet — no metadata (videoWidth is 0 until it) or no
3710
+ * decoded frame (drawImage refuses to make up pixels). Callers SAY
3711
+ * null — never a silent no-op. */
3712
+ function captureCanvas(): { canvas: HTMLCanvasElement; width: number; height: number } | null {
3713
+ const size = frameDrawSize(videoEl);
3714
+ if (!size || !videoEl) return null;
3715
+ const canvas = document.createElement('canvas');
3716
+ canvas.width = size.width;
3717
+ canvas.height = size.height;
3718
+ const ctx = canvas.getContext('2d');
3719
+ if (!ctx) return null;
3720
+ try {
3721
+ ctx.drawImage(videoEl, 0, 0, size.width, size.height);
3722
+ } catch {
3723
+ return null; // no decoded frame yet (readyState too low)
3724
+ }
3725
+ return { canvas, width: size.width, height: size.height };
3726
+ }
3727
+
3728
+ /** canvas.toBlob as a promise. A tainted canvas throws SecurityError out
3729
+ * of toBlob itself — re-thrown here so every verb can check the NAME. */
3730
+ function canvasToPng(canvas: HTMLCanvasElement): Promise<Blob> {
3731
+ return new Promise((resolve, reject) => {
3732
+ try {
3733
+ canvas.toBlob(
3734
+ (blob) => (blob ? resolve(blob) : reject(new Error('toBlob produced no blob'))),
3735
+ 'image/png'
3736
+ );
3737
+ } catch (e) {
3738
+ reject(e);
3739
+ }
3740
+ });
3741
+ }
3742
+
3743
+ function flashNoFrame() {
3744
+ flashOsd(CAPTURE_NO_FRAME_MESSAGE);
3745
+ announce('No frame yet — wait until the video has loaded a picture');
3746
+ }
3747
+
3748
+ /** The taint verdict, said twice: OSD for the eye, polite region for the
3749
+ * screen reader — a silent failure would read as a broken button. */
3750
+ function flashNoCors() {
3751
+ flashOsd(CAPTURE_NO_CORS_MESSAGE);
3752
+ announce(`${CAPTURE_NO_CORS_MESSAGE} — the video host must allow cross-origin reads (CORS) to export frames.`);
3753
+ }
3754
+
3755
+ function flashCaptureFailed(what: string) {
3756
+ flashOsd(`${what} failed`);
3757
+ announce(`${what} failed`);
3758
+ }
3759
+
3760
+ /** Save frame as PNG: draw → toBlob → anchor download. A cross-origin
3761
+ * video without CORS throws SecurityError out of toBlob itself —
3762
+ * caught, said, never silent. */
3763
+ function saveFrame() {
3764
+ const c = captureCanvas();
3765
+ if (!c) return flashNoFrame();
3766
+ try {
3767
+ c.canvas.toBlob((blob) => {
3768
+ if (!blob) return flashCaptureFailed('Capture');
3769
+ const url = URL.createObjectURL(blob);
3770
+ const a = document.createElement('a');
3771
+ a.href = url;
3772
+ a.download = captureFilename(currentTime);
3773
+ document.body.appendChild(a);
3774
+ a.click();
3775
+ a.remove();
3776
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
3777
+ flashOsd('Frame saved');
3778
+ announce('Frame saved as PNG');
3779
+ }, 'image/png');
3780
+ } catch (e) {
3781
+ if (isCrossOriginCaptureError(e)) flashNoCors();
3782
+ else flashCaptureFailed('Capture');
3783
+ }
3784
+ }
3785
+
3786
+ /** Copy frame: ClipboardItem carrying a PROMISE for the blob — the write
3787
+ * call lands inside the click's user-gesture budget while the pixels
3788
+ * encode async (Safari's gesture timer is the constraint). Unsupported
3789
+ * platforms never get here: the item is omitted, save-only degrade. */
3790
+ async function copyFrame() {
3791
+ const c = captureCanvas();
3792
+ if (!c) return flashNoFrame();
3793
+ try {
3794
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': canvasToPng(c.canvas) })]);
3795
+ flashOsd('Copied');
3796
+ announce('Frame copied to clipboard');
3797
+ } catch (e) {
3798
+ if (isCrossOriginCaptureError(e)) return flashNoCors();
3799
+ flashCaptureFailed('Copy');
3800
+ }
3801
+ }
3802
+
3803
+ /** Set as poster: hand the frame to the host. MOS Studio uploads the
3804
+ * blob and pins the video's poster; other hosts own their meaning. The
3805
+ * host's rejection is said, not hidden. */
3806
+ async function sendFrameToHost() {
3807
+ const c = captureCanvas();
3808
+ if (!c) return flashNoFrame();
3809
+ try {
3810
+ const blob = await canvasToPng(c.canvas);
3811
+ const dataUrl = c.canvas.toDataURL('image/png');
3812
+ const capture: VideoFrameCapture = {
3813
+ blob,
3814
+ dataUrl,
3815
+ timeSec: currentTime,
3816
+ width: c.width,
3817
+ height: c.height,
3818
+ name: captureFilename(currentTime)
3819
+ };
3820
+ await onCaptureFrame?.(capture);
3821
+ flashOsd('Frame captured');
3822
+ announce('Frame sent to host');
3823
+ } catch (e) {
3824
+ if (isCrossOriginCaptureError(e)) return flashNoCors();
3825
+ flashCaptureFailed('Set as poster');
3826
+ }
3827
+ }
3828
+
3829
+ /** The menu's items for the verbs this page can actually run. */
3830
+ const captureMenuItems = $derived.by<ContextMenuItem[]>(() => {
3831
+ const verbs: Record<CaptureAction, ContextMenuItem> = {
3832
+ save: { label: 'Save frame as PNG', icon: '⤓', onSelect: saveFrame },
3833
+ copy: { label: 'Copy frame to clipboard', icon: '⧉', onSelect: copyFrame },
3834
+ poster: { label: 'Set as poster', icon: '🖼️', onSelect: sendFrameToHost }
3835
+ };
3836
+ return captureMenuActions.map((a) => verbs[a]);
3837
+ });
3838
+
3555
3839
  // ── Transcript search (jatui-xmtnv) ───────────────────────────────────
3556
3840
  /** `/`: open the box (showing the transcript if it was hidden) and hand
3557
3841
  * it focus once it mounts — the effect below lands it. */
@@ -3679,6 +3963,31 @@
3679
3963
  return t.speakerIdentity ?? t.speakerName ?? UNKNOWN_KEY;
3680
3964
  }
3681
3965
 
3966
+ // ── Chip speed menu (E73, jatui-p3m3u) ──────────────────────────────────
3967
+ // One menu open at a time; a click anywhere else closes it.
3968
+ let speedMenuKey = $state<string | null>(null);
3969
+ const SPEED_CHOICES = [0.75, 1, 1.25, 1.5, 1.75, 2, 3];
3970
+
3971
+ function toggleSpeedMenu(key: string) {
3972
+ speedMenuKey = speedMenuKey === key ? null : key;
3973
+ }
3974
+ function pickSpeakerSpeed(key: string, name: string, rate: number) {
3975
+ setSpeakerSpeed(key, rate);
3976
+ speedMenuKey = null;
3977
+ flashOsd(rate === 1 ? `${name} · 1×` : `${name} · ${formatSpeedLabel(rate)}`);
3978
+ }
3979
+ $effect(() => {
3980
+ if (speedMenuKey == null) return;
3981
+ const close = (e: PointerEvent) => {
3982
+ // A press inside the open menu (or on its trigger) is the menu's own
3983
+ // click — the buttons close it themselves.
3984
+ if ((e.target as Element | null)?.closest?.('.vp-speed-menu')) return;
3985
+ speedMenuKey = null;
3986
+ };
3987
+ document.addEventListener('pointerdown', close);
3988
+ return () => document.removeEventListener('pointerdown', close);
3989
+ });
3990
+
3682
3991
  function submitBookmark() {
3683
3992
  onAddBookmark?.(currentTime, bookmarkLabel.trim());
3684
3993
  bookmarkLabel = '';
@@ -4160,6 +4469,26 @@
4160
4469
  aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
4161
4470
  oninput={onVolumeInput}
4162
4471
  />
4472
+ {#if frameCaptureOn}
4473
+ <!-- Frame capture (jatui-8nu4g): opens the frame verbs menu
4474
+ (save PNG / copy / set as poster). A picture verb, so it
4475
+ sits with the picture verbs at the end of the cluster. -->
4476
+ <button
4477
+ type="button"
4478
+ class="vp-tbtn"
4479
+ bind:this={captureBtnEl}
4480
+ aria-haspopup="menu"
4481
+ aria-expanded={captureMenuOpen}
4482
+ aria-label="Capture frame"
4483
+ title="Capture frame · C"
4484
+ onclick={openCaptureMenuAtTrigger}
4485
+ >
4486
+ <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
4487
+ <path d="M3 8.6A1.6 1.6 0 0 1 4.6 7h3.4l1.3-2.2h5.4L16 7h3.4A1.6 1.6 0 0 1 21 8.6v8.8a1.6 1.6 0 0 1-1.6 1.6H4.6A1.6 1.6 0 0 1 3 17.4z" />
4488
+ <circle cx="12" cy="13" r="3.2" />
4489
+ </svg>
4490
+ </button>
4491
+ {/if}
4163
4492
  {#if !isAudio}
4164
4493
  <button
4165
4494
  type="button"
@@ -4201,6 +4530,7 @@
4201
4530
  {@const key = talkTimeKey(tt)}
4202
4531
  {@const name = tt.speakerName ?? 'Unknown speaker'}
4203
4532
  <span class="vp-legend-item">
4533
+ <span class="vp-speed-menu">
4204
4534
  <button
4205
4535
  type="button"
4206
4536
  class="vp-chip"
@@ -4219,6 +4549,37 @@
4219
4549
  {name}
4220
4550
  <span class="vp-chip-time">{formatDuration(tt.seconds)}</span>
4221
4551
  </button>
4552
+ <!-- E73: this speaker's own speed, remembered. 1× is the
4553
+ reset; the composer clamps the product. -->
4554
+ <button
4555
+ type="button"
4556
+ class="vp-chip-speed"
4557
+ class:on={speakerSpeedOf(key) !== 1}
4558
+ onclick={() => toggleSpeedMenu(key)}
4559
+ aria-expanded={speedMenuKey === key}
4560
+ aria-haspopup="menu"
4561
+ aria-label="Speed for {name}: {formatSpeedLabel(speakerSpeedOf(key))}"
4562
+ title="Speed for {name}"
4563
+ >
4564
+ {formatSpeedLabel(speakerSpeedOf(key))}
4565
+ </button>
4566
+ {#if speedMenuKey === key}
4567
+ <div class="vp-speed-pop" role="menu" aria-label="Speed for {name}">
4568
+ {#each SPEED_CHOICES as choice (choice)}
4569
+ <button
4570
+ type="button"
4571
+ role="menuitemradio"
4572
+ aria-checked={speakerSpeedOf(key) === choice}
4573
+ class="vp-speed-opt"
4574
+ class:current={speakerSpeedOf(key) === choice}
4575
+ onclick={() => pickSpeakerSpeed(key, name, choice)}
4576
+ >
4577
+ {formatSpeedLabel(choice)}{choice === 1 ? ' (reset)' : ''}
4578
+ </button>
4579
+ {/each}
4580
+ </div>
4581
+ {/if}
4582
+ </span>
4222
4583
  <button
4223
4584
  type="button"
4224
4585
  class="vp-mute"
@@ -4523,6 +4884,22 @@
4523
4884
  onClose={() => (lineMenuSeg = null)}
4524
4885
  />
4525
4886
  {/if}
4887
+
4888
+ {#if frameCaptureOn}
4889
+ <!-- Frame-capture menu (jatui-8nu4g, extension library.js:9262): ONE
4890
+ panel, opened at the camera button (click) or wherever the `C` key
4891
+ was pressed from (anchored to the stage on a native transport).
4892
+ Rendered inside the root so the fill-theater Tab trap keeps cycling
4893
+ through its items; position:fixed + floating-ui place it wherever
4894
+ this spot sits in the layout. The header is the frame's own clock. -->
4895
+ <ContextMenu
4896
+ bind:open={captureMenuOpen}
4897
+ x={captureMenuX}
4898
+ y={captureMenuY}
4899
+ items={captureMenuItems}
4900
+ header={formatTime(currentTime, useHours)}
4901
+ />
4902
+ {/if}
4526
4903
  </div>
4527
4904
 
4528
4905
  <style>
@@ -5499,6 +5876,66 @@
5499
5876
  border-color: var(--color-primary);
5500
5877
  }
5501
5878
 
5879
+ /* E73 — per-speaker speed trigger + its menu. The wrapper is the
5880
+ click-outside boundary: a press inside stays the menu's own click. */
5881
+ .vp-speed-menu {
5882
+ position: relative;
5883
+ display: inline-flex;
5884
+ align-items: center;
5885
+ }
5886
+
5887
+ .vp-chip-speed {
5888
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-xs, 2px);
5889
+ background: none;
5890
+ border: none;
5891
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5892
+ color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
5893
+ font-size: inherit;
5894
+ font-variant-numeric: tabular-nums;
5895
+ cursor: pointer;
5896
+ }
5897
+
5898
+ .vp-chip-speed.on {
5899
+ color: var(--color-primary);
5900
+ font-weight: 600;
5901
+ }
5902
+
5903
+ .vp-speed-pop {
5904
+ position: absolute;
5905
+ z-index: 30;
5906
+ top: calc(100% + 4px);
5907
+ left: 0;
5908
+ display: flex;
5909
+ flex-direction: column;
5910
+ min-width: 7rem;
5911
+ padding: var(--jatui-space-sm, 4px);
5912
+ background: var(--color-base-100);
5913
+ border: var(--jatui-border-w, 1px) solid color-mix(in oklch, var(--color-base-content) 15%, transparent);
5914
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5915
+ box-shadow: 0 4px 12px rgb(0 0 0 / 0.25);
5916
+ }
5917
+
5918
+ .vp-speed-opt {
5919
+ padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
5920
+ background: none;
5921
+ border: none;
5922
+ border-radius: var(--jatui-radius-selector, 0.375rem);
5923
+ color: var(--color-base-content);
5924
+ font-size: inherit;
5925
+ font-variant-numeric: tabular-nums;
5926
+ text-align: left;
5927
+ cursor: pointer;
5928
+ }
5929
+
5930
+ .vp-speed-opt:hover {
5931
+ background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
5932
+ }
5933
+
5934
+ .vp-speed-opt.current {
5935
+ color: var(--color-primary);
5936
+ font-weight: 600;
5937
+ }
5938
+
5502
5939
  .vp-chip-clear {
5503
5940
  background: none;
5504
5941
  color: var(--color-primary);
@@ -9,6 +9,7 @@ export type { VideoAdSpan, RateReport } from '../video/rate';
9
9
  export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
10
10
  export type { ResumePoint } from '../video/advisors/resume-point';
11
11
  export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
12
+ export type { VideoFrameCapture } from '../video/capture';
12
13
  /**
13
14
  * VideoPlayer — one video surface for everything, from a bare mp4 to a
14
15
  * fully-analysed recording.
@@ -71,6 +72,7 @@ import { type BandState } from '../video/band/axis';
71
72
  import { type VideoLumaTrack } from '../video/band/luma';
72
73
  import { type VideoMarker } from '../video/band/markers';
73
74
  import { type RateReport, type VideoAdSpan } from '../video/rate';
75
+ import { type VideoFrameCapture } from '../video/capture';
74
76
  import { type NightMode, type NightWindow } from '../video/night/clock';
75
77
  import { type AudioBrokenReason, type AudioState } from '../video/audio';
76
78
  import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
@@ -209,6 +211,17 @@ type $$ComponentProps = {
209
211
  * simply not on the menu — the player never guesses a URL.
210
212
  */
211
213
  buildTimeLink?: (sec: number) => string;
214
+ /**
215
+ * Frame capture (jatui-8nu4g) — see the destructure note above. Reads
216
+ * the frame through a canvas, which a cross-origin video without CORS
217
+ * taints: the export throws SecurityError and the player SAYS so
218
+ * ("Can't capture this video (no CORS)") instead of failing silently.
219
+ * Pass `crossorigin="anonymous"` for cross-origin media you want
220
+ * frame-readable.
221
+ */
222
+ frameCapture?: boolean;
223
+ /** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
224
+ onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
212
225
  /**
213
226
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
214
227
  * only says it wants the page's width; the HOST lays its page out
@@ -18,8 +18,9 @@ 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
+ | `capture.ts` | **jatui-8nu4g** (done) | The frame-capture menu's pure pieces (extension-library parity, library.js:9262): `captureFilename` (the frame clock as the name — `frame-1m30s.png`, junk times clamp to 0), `clipboardImageSupported` (secure context + `navigator.clipboard.write` + `ClipboardItem` — false ⇒ save-only degrade, the Copy row is OMITTED, never dead), `captureActions` (which verbs the menu carries — save always, copy only where the platform can write an image, poster only when the host supplied `onCaptureFrame`), `frameDrawSize` (null before metadata — a 0×0 canvas is never a valid capture), `isCrossOriginCaptureError` (the SecurityError NAME is the contract — a tainted canvas throws SecurityError out of toBlob/toDataURL itself) and `VideoFrameCapture` (what the host's "set as poster" hook receives: blob, dataUrl, timeSec, width, height, name). The component owns the canvas and the `<video>` (drawImage / toBlob / toDataURL / download / clipboard calls live there); a cross-origin video without CORS is SAID — `CAPTURE_NO_CORS_MESSAGE` on the OSD + polite region, never a silent no-op. |
23
24
  | `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
25
  | `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. |
25
26
  | `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. |
@@ -0,0 +1,90 @@
1
+ /**
2
+ * VideoPlayer frame capture (jatui-8nu4g — extension-library parity,
3
+ * library.js:9262) — the capture menu's PURE pieces, per the advisor
4
+ * doctrine: no DOM here, no element, no settings reads. The component owns
5
+ * the canvas and the `<video>` (the drawImage / toBlob / toDataURL /
6
+ * download / clipboard calls live there); everything that DECIDES lives in
7
+ * this file so it stays node-testable.
8
+ *
9
+ * The verbs: Save frame as PNG (always), Copy frame to clipboard (only
10
+ * where the platform can actually write an image — secure context +
11
+ * `navigator.clipboard.write` + `ClipboardItem` — otherwise the item is
12
+ * OMITTED, never a dead menu row), and Set as poster (only when the host
13
+ * supplies `onCaptureFrame`; the host owns what "poster" means).
14
+ *
15
+ * Cross-origin video without CORS taints the canvas: toBlob/toDataURL
16
+ * throw SecurityError. The component catches that and says so —
17
+ * `CAPTURE_NO_CORS_MESSAGE` — never a silent no-op.
18
+ */
19
+ /** The OSD + polite-region message for a tainted canvas (no CORS). */
20
+ export declare const CAPTURE_NO_CORS_MESSAGE = "Can't capture this video (no CORS)";
21
+ /** The message when there is no decodable frame to grab yet. */
22
+ export declare const CAPTURE_NO_FRAME_MESSAGE = "No frame yet";
23
+ /**
24
+ * The suggested download name for the frame at `sec`: `frame-1m30s.png`,
25
+ * `frame-1h02m30s.png` on long videos, seconds zero-padded. A junk time
26
+ * (negative, NaN) clamps to 0 — a filename never carries NaN.
27
+ */
28
+ export declare function captureFilename(sec: number): string;
29
+ /** The pieces of the platform a clipboard IMAGE write needs. */
30
+ export interface ClipboardImageEnv {
31
+ /** `navigator.clipboard?.write` — undefined off a secure context. */
32
+ clipboardWrite?: unknown;
33
+ /** `window.isSecureContext`. */
34
+ isSecureContext?: boolean;
35
+ /** The global `ClipboardItem` constructor. */
36
+ clipboardItem?: unknown;
37
+ }
38
+ /**
39
+ * Can this platform write an image to the clipboard? A clipboard image
40
+ * write needs a secure context (navigator.clipboard only exists on HTTPS /
41
+ * localhost), the `write` method, and the `ClipboardItem` constructor.
42
+ * False ⇒ the component degrades to save-only: the Copy item is omitted,
43
+ * never rendered dead.
44
+ */
45
+ export declare function clipboardImageSupported(env?: ClipboardImageEnv): boolean;
46
+ /** The capture menu's verbs, in menu order. */
47
+ export type CaptureAction = 'save' | 'copy' | 'poster';
48
+ /**
49
+ * Which verbs the capture menu carries. Save is always there; Copy only
50
+ * where the platform can write an image (degrade to save-only); the poster
51
+ * hook only when the host supplied `onCaptureFrame`. Omitted — never
52
+ * disabled — matching the settings-panel doctrine.
53
+ */
54
+ export declare function captureActions(opts?: {
55
+ clipboardImage?: boolean;
56
+ hostHook?: boolean;
57
+ }): CaptureAction[];
58
+ /** What `onCaptureFrame` receives when the viewer picks "Set as poster". */
59
+ export interface VideoFrameCapture {
60
+ /** The frame as an `image/png` Blob — upload it, attach it, store it. */
61
+ blob: Blob;
62
+ /** The same frame as a PNG data URL — preview without an objectURL lifecycle. */
63
+ dataUrl: string;
64
+ /** Playback time the frame was grabbed at (seconds). */
65
+ timeSec: number;
66
+ /** Natural frame dimensions in px. */
67
+ width: number;
68
+ height: number;
69
+ /** Suggested filename — `captureFilename(timeSec)`. */
70
+ name: string;
71
+ }
72
+ /**
73
+ * The natural frame size to draw at, or null before metadata has landed
74
+ * (videoWidth/videoHeight are 0 until then — there is nothing to grab yet,
75
+ * and a 0×0 canvas is never a valid capture).
76
+ */
77
+ export declare function frameDrawSize(video: {
78
+ videoWidth?: number | null;
79
+ videoHeight?: number | null;
80
+ }): {
81
+ width: number;
82
+ height: number;
83
+ } | null;
84
+ /**
85
+ * A SecurityError from the canvas means the media's bytes are not readable
86
+ * from this origin (cross-origin without CORS) — the export path, not the
87
+ * player, is refusing. Other engines word it differently; the NAME is the
88
+ * contract.
89
+ */
90
+ export declare function isCrossOriginCaptureError(e: unknown): boolean;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * VideoPlayer frame capture (jatui-8nu4g — extension-library parity,
3
+ * library.js:9262) — the capture menu's PURE pieces, per the advisor
4
+ * doctrine: no DOM here, no element, no settings reads. The component owns
5
+ * the canvas and the `<video>` (the drawImage / toBlob / toDataURL /
6
+ * download / clipboard calls live there); everything that DECIDES lives in
7
+ * this file so it stays node-testable.
8
+ *
9
+ * The verbs: Save frame as PNG (always), Copy frame to clipboard (only
10
+ * where the platform can actually write an image — secure context +
11
+ * `navigator.clipboard.write` + `ClipboardItem` — otherwise the item is
12
+ * OMITTED, never a dead menu row), and Set as poster (only when the host
13
+ * supplies `onCaptureFrame`; the host owns what "poster" means).
14
+ *
15
+ * Cross-origin video without CORS taints the canvas: toBlob/toDataURL
16
+ * throw SecurityError. The component catches that and says so —
17
+ * `CAPTURE_NO_CORS_MESSAGE` — never a silent no-op.
18
+ */
19
+ /** The OSD + polite-region message for a tainted canvas (no CORS). */
20
+ export const CAPTURE_NO_CORS_MESSAGE = "Can't capture this video (no CORS)";
21
+ /** The message when there is no decodable frame to grab yet. */
22
+ export const CAPTURE_NO_FRAME_MESSAGE = 'No frame yet';
23
+ /**
24
+ * The suggested download name for the frame at `sec`: `frame-1m30s.png`,
25
+ * `frame-1h02m30s.png` on long videos, seconds zero-padded. A junk time
26
+ * (negative, NaN) clamps to 0 — a filename never carries NaN.
27
+ */
28
+ export function captureFilename(sec) {
29
+ const t = Number.isFinite(sec) ? Math.max(0, Math.floor(sec)) : 0;
30
+ const h = Math.floor(t / 3600);
31
+ const m = Math.floor((t % 3600) / 60);
32
+ const s = t % 60;
33
+ const ss = String(s).padStart(2, '0');
34
+ const clock = h > 0 ? `${h}h${String(m).padStart(2, '0')}m${ss}s` : `${m}m${ss}s`;
35
+ return `frame-${clock}.png`;
36
+ }
37
+ /**
38
+ * Reads the real platform (SSR/node safe — every global behind a typeof).
39
+ * Isolated from the predicate so tests inject fakes instead of stubbing
40
+ * globals.
41
+ */
42
+ function currentClipboardImageEnv() {
43
+ if (typeof navigator === 'undefined')
44
+ return { isSecureContext: false, clipboardItem: undefined };
45
+ const clip = navigator.clipboard;
46
+ let item = undefined;
47
+ try {
48
+ // Reference the global indirectly: a bare `typeof ClipboardItem` is
49
+ // fine, but keeping it in one place documents that ONLY its existence
50
+ // matters here, not what it can hold.
51
+ item = typeof ClipboardItem === 'function' ? ClipboardItem : undefined;
52
+ }
53
+ catch {
54
+ item = undefined;
55
+ }
56
+ return {
57
+ clipboardWrite: clip?.write,
58
+ isSecureContext: typeof window !== 'undefined' ? window.isSecureContext === true : false,
59
+ clipboardItem: item
60
+ };
61
+ }
62
+ /**
63
+ * Can this platform write an image to the clipboard? A clipboard image
64
+ * write needs a secure context (navigator.clipboard only exists on HTTPS /
65
+ * localhost), the `write` method, and the `ClipboardItem` constructor.
66
+ * False ⇒ the component degrades to save-only: the Copy item is omitted,
67
+ * never rendered dead.
68
+ */
69
+ export function clipboardImageSupported(env = currentClipboardImageEnv()) {
70
+ return typeof env.clipboardWrite === 'function' && env.isSecureContext === true && typeof env.clipboardItem === 'function';
71
+ }
72
+ /**
73
+ * Which verbs the capture menu carries. Save is always there; Copy only
74
+ * where the platform can write an image (degrade to save-only); the poster
75
+ * hook only when the host supplied `onCaptureFrame`. Omitted — never
76
+ * disabled — matching the settings-panel doctrine.
77
+ */
78
+ export function captureActions(opts = {}) {
79
+ const actions = ['save'];
80
+ if (opts.clipboardImage)
81
+ actions.push('copy');
82
+ if (opts.hostHook)
83
+ actions.push('poster');
84
+ return actions;
85
+ }
86
+ /**
87
+ * The natural frame size to draw at, or null before metadata has landed
88
+ * (videoWidth/videoHeight are 0 until then — there is nothing to grab yet,
89
+ * and a 0×0 canvas is never a valid capture).
90
+ */
91
+ export function frameDrawSize(video) {
92
+ const w = video?.videoWidth ?? 0;
93
+ const h = video?.videoHeight ?? 0;
94
+ return w > 0 && h > 0 ? { width: w, height: h } : null;
95
+ }
96
+ /**
97
+ * A SecurityError from the canvas means the media's bytes are not readable
98
+ * from this origin (cross-origin without CORS) — the export path, not the
99
+ * player, is refusing. Other engines word it differently; the NAME is the
100
+ * contract.
101
+ */
102
+ export function isCrossOriginCaptureError(e) {
103
+ return e instanceof DOMException && e.name === 'SecurityError';
104
+ }
@@ -11,8 +11,9 @@
11
11
  * none yet).
12
12
  * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
13
13
  * not player verbs). N (night cycle) landed with the night module
14
- * (mos-5lzns.33) and `,` (settings) with the settings panel
15
- * (mos-5lzns.35), each gated on the host opting in.
14
+ * (mos-5lzns.33), `,` (settings) with the settings panel (mos-5lzns.35) and
15
+ * C (frame capture) with the capture menu (jatui-8nu4g, extension
16
+ * library.js:9262), each gated on the host opting in.
16
17
  */
17
18
  export interface PlayerKeyRow {
18
19
  /** Keycap label as shown in the help overlay — `/`-separated alternates. */
@@ -22,7 +23,7 @@ export interface PlayerKeyRow {
22
23
  * Availability gate: the row is only advertised (and the key only bound)
23
24
  * when the feature's data exists. Undefined ⇒ always active.
24
25
  */
25
- when?: 'chapters' | 'rail' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
26
+ when?: 'chapters' | 'rail' | 'bookmarks' | 'capture' | 'transcript' | 'comments' | 'night' | 'settings' | 'theater';
26
27
  }
27
28
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
28
29
  export declare const PLAYER_KEYS: PlayerKeyRow[];
@@ -11,8 +11,9 @@
11
11
  * none yet).
12
12
  * Deliberately NOT ported: F/M (favourite/file-under — library-app verbs,
13
13
  * not player verbs). N (night cycle) landed with the night module
14
- * (mos-5lzns.33) and `,` (settings) with the settings panel
15
- * (mos-5lzns.35), each gated on the host opting in.
14
+ * (mos-5lzns.33), `,` (settings) with the settings panel (mos-5lzns.35) and
15
+ * C (frame capture) with the capture menu (jatui-8nu4g, extension
16
+ * library.js:9262), each gated on the host opting in.
16
17
  */
17
18
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
18
19
  export const PLAYER_KEYS = [
@@ -32,6 +33,10 @@ export const PLAYER_KEYS = [
32
33
  // and "people's reactions to it" sit under one hand (mos-5lzns.30).
33
34
  { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
34
35
  { keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
36
+ // C (jatui-8nu4g, extension library.js:9262) — the frame-capture menu:
37
+ // save the current frame as PNG, copy it (where the platform can write
38
+ // images), and set as poster when the host supplies the hook. Video only.
39
+ { keys: 'C', description: 'Frame capture: save PNG / copy / set as poster', when: 'capture' },
35
40
  // t = theater, YouTube's key; the transcript moved to Shift+T (jatui-ee2ty).
36
41
  { keys: 't', description: 'Theater mode on / off', when: 'theater' },
37
42
  { keys: 'Shift+T', description: 'Cycle transcript layout: side / below / hidden', when: 'transcript' },
@@ -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.52.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",