@joewinke/jatui 0.51.0 → 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,
@@ -308,6 +321,25 @@
308
321
  followResumeMs = FOLLOW_RESUME_IDLE_MS,
309
322
  transcriptLineMenu = true,
310
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,
311
343
  theater = $bindable(false),
312
344
  theaterMode = 'width',
313
345
  theaterButton = true,
@@ -510,6 +542,17 @@
510
542
  * simply not on the menu — the player never guesses a URL.
511
543
  */
512
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>;
513
556
  /**
514
557
  * Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
515
558
  * only says it wants the page's width; the HOST lays its page out
@@ -1005,6 +1048,20 @@
1005
1048
  let lineMenuY = $state(0);
1006
1049
  let lineMenuSeg = $state<VideoTranscriptSegment | null>(null);
1007
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
+
1008
1065
  let zoomTimer: ReturnType<typeof setTimeout> | null = null;
1009
1066
  let osdTimer: ReturnType<typeof setTimeout> | null = null;
1010
1067
 
@@ -1489,6 +1546,7 @@
1489
1546
  if (row.when === 'chapters') return sortedChapters.length > 0;
1490
1547
  if (row.when === 'rail') return railOn;
1491
1548
  if (row.when === 'bookmarks') return onAddBookmark != null;
1549
+ if (row.when === 'capture') return frameCaptureOn;
1492
1550
  if (row.when === 'transcript') return sortedSegments.length > 0;
1493
1551
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
1494
1552
  if (row.when === 'night') return nightPresent;
@@ -3249,13 +3307,14 @@
3249
3307
  // Space/Enter on a focused button activates THAT button (play, a
3250
3308
  // speaker chip, a transcript row); toggling playback too would undo it.
3251
3309
  if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
3252
- // The transcript line menu (jatui-3wl5x) owns the keyboard while it is
3253
- // open: arrows/Enter drive it (its panel preventDefaults what it uses,
3254
- // which the defaultPrevented guard above already yields to), Escape
3255
- // closes it, and nothing else drives the player underneath a floating
3256
- // menu. Tab is the shared ContextMenu's own behaviour (focus moves out
3257
- // with the menu still up; click/Escape dismiss it).
3258
- 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;
3259
3318
  // mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
3260
3319
  // activation) while focus is inside it; without this guard a rail `k`
3261
3320
  // would also toggle play/pause and a rail `j` also seek ±5s.
@@ -3422,6 +3481,15 @@
3422
3481
  cycleNight();
3423
3482
  }
3424
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;
3425
3493
  case 'Home':
3426
3494
  seekTo(0);
3427
3495
  break;
@@ -3617,6 +3685,157 @@
3617
3685
  return items;
3618
3686
  });
3619
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
+
3620
3839
  // ── Transcript search (jatui-xmtnv) ───────────────────────────────────
3621
3840
  /** `/`: open the box (showing the transcript if it was hidden) and hand
3622
3841
  * it focus once it mounts — the effect below lands it. */
@@ -4250,6 +4469,26 @@
4250
4469
  aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
4251
4470
  oninput={onVolumeInput}
4252
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}
4253
4492
  {#if !isAudio}
4254
4493
  <button
4255
4494
  type="button"
@@ -4645,6 +4884,22 @@
4645
4884
  onClose={() => (lineMenuSeg = null)}
4646
4885
  />
4647
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}
4648
4903
  </div>
4649
4904
 
4650
4905
  <style>
@@ -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
@@ -20,6 +20,7 @@ props + callback surfaces.
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
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' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",