@joewinke/jatui 0.51.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -1
- package/dist/components/VideoPlayer.svelte +510 -8
- package/dist/components/VideoPlayer.svelte.d.ts +19 -0
- package/dist/video/README.md +2 -0
- package/dist/video/advisors/README.md +1 -1
- package/dist/video/capture.d.ts +90 -0
- package/dist/video/capture.js +104 -0
- package/dist/video/keys.d.ts +4 -3
- package/dist/video/keys.js +7 -2
- package/dist/video/qrShare.d.ts +55 -0
- package/dist/video/qrShare.js +86 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -177,7 +177,7 @@ Item-by-item review surfaces (MOS review-ux, design mos-z8vgx). One `VerdictOpti
|
|
|
177
177
|
| `ThumbnailGrid` | Generic thumbnail strip (chimaro port) — image tiles with text-tile fallback, +N overflow counter, remove/add affordances. Decoupled from any media library: pass `{id, url, alt}` items, or a `cell` snippet for full control of tile contents. `removeMode` — `'hover'` (default) reveals the × on hover/focus and keeps it visible on coarse (touch) pointers (ContextMenuTrigger pattern); `'always'` pins it for touch-first flows. `gridCols` (e.g. `'grid-cols-2 sm:grid-cols-4'`) switches the strip to a responsive CSS grid with column-filling tiles. Items take optional `progress` (0–100): an upload ring draws around the tile and a tiny % occupies the remove-× corner, cross-fading into the × at 100 (same slot, no layout shift; per-tile `role="progressbar"`). | `items: ThumbnailItem[]` (`{id, url?, alt, progress?}`), `max`, `size` (`xs\|sm\|md\|lg`), `removable`+`onRemove`, `removeMode` (`hover\|always`), `addable`+`onAdd`, `onSelect`, `gridCols`, `class`, `cell` snippet |
|
|
178
178
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
179
179
|
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
180
|
-
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
180
|
+
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker, `Shift+T` toggle) and a "Talk time" legend (2+ speakers only; one voice gets no legend and no per-row name). **`kind="audio"` renders no stage**, and **`transport="band"`** (the audio default) drops the native `<video controls>` bar for a transport row on the band (play/pause, `now / total`, speed, mute + volume, fullscreen for video), so the band is the ONE timeline. A double-click on the picture toggles fullscreen (band transport; with native controls the browser owns the gesture), a buffering spinner shows while the media is waiting, and volume/mute survive a reload through `settingsStore` (else guarded localStorage). **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and the full keyboard map (Space/K, arrows/J/L, vol/mute, −/= speed + Backspace reset, </> frame step, [/] chapters, B bookmark, t theater, ? help overlay), and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek, alt = speed; a wheel/keyboard volume change also flashes a clear volume OSD — glyph, level bar and %, "Muted" at 0, opacity-only fade) and zoom over the band; Shift+wheel over the transcript steps line by line (a plain wheel over the transcript scrolls the pane natively — the page is never trapped); `/` opens an in-transcript search (only when a transcript exists and focus is not in a text field; the omnibar yields): matches highlight in place with a live `n / m` count (`aria-live`), Enter/Shift+Enter walk next/previous (seek + the line follows), Esc closes the search first in the Esc layering. Right-click a transcript line (or `Menu` / `Shift+F10` on a focused one) for the **line menu** — Play from here, Copy text, Copy text with timestamp (`[0:02] text`), Copy link at this time when `buildTimeLink` supplies the URL builder (without it the item is absent; `?t=` shape via `buildTimeUrl`); Escape closes and returns focus to the line, the player's keys are dead while it floats, and in fill theater Esc closes the menu without leaving the theater. A QR button on the transport (when `buildTimeLink` exists) opens a **continue-on-phone** card — the deep link at the current second as a scannable SVG QR; clicking the code copies it (jatui-3k7cp). Internals live in `src/lib/video/` (band/keys/rate/deepLink/settings + contracts for audio/night/presence — see that README; gap audit vs the extension: `VIDEO-PLAYER-GAP-AUDIT.md`). | `src`, `kind?` (`'video' \| 'audio'`), `transport?` (`'native' \| 'band'`), `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `crossorigin?` (`'anonymous'` — what frame-reading features need), `syncUrlTime?` (`?t=` deep-link, opt-in), `transcriptPosition?` (`'below' \| 'side'`), `transcriptMaxHeight?` (pane bound — default `min(40vh, 28rem)` below, the player's height beside; the active-line follow scrolls only the pane; only a gesture that actually moves the PANE pauses it — a wheel that scrolls the page doesn't — behind a "Resume following" pill, and it resumes by itself on seek / line or chapter click / play after pause / `followResumeMs` (6000) idle while playing; a hovered chapter fills its whole span in the theme accent with full-height edges), `followResumeMs?`, `transcriptLineMenu?` (true — right-click/`Menu`/`Shift+F10` on a transcript line opens the line menu; `false` leaves transcript lines to the browser's own context menu), `buildTimeLink?` ((sec) → URL for "Copy link at this time"; the host owns the address shape), `qrContinue?` (true — the QR button: the host's `buildTimeLink` at the current second as an SVG QR card, click the code to copy; hidden entirely when no `buildTimeLink` — the player never guesses a URL), `theater?` (bindable — theater mode, NOT fullscreen: control-bar button + `t`, the HOST lays the page out full-width; a side transcript moves below; remembered per viewer as the `player.theater` setting via `settingsStore`, else guarded localStorage), `theaterMode?` (`'width'` default — the host widens its page \| `'fill'` — the player becomes a fixed full-viewport dialog: `role="dialog"` + `aria-modal`, page scroll locked, Tab trapped, Esc/the button exits and returns focus, largest 16:9 stage that fits, transcript beside it ≥64rem else below, z from `--jatui-z-theater`; a remembered theater never fills on load, only a viewer gesture), `theaterButton?` (true), `onTheaterChange?`, `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
181
181
|
| `MediaCapture` | One-tap audio/selfie-video/screen(+mic) recording — a host-agnostic PRIMITIVE. Owns permission requests, MIME negotiation, the record/stop state machine, live self-preview, and mixes screen+mic audio into one track via AudioContext. Never uploads anything — hands the finished clip to `onCapture` as a Blob and the host decides what to do with it. `onCapture` may be async; the component shows "processing" until it resolves. | `onCapture: (result: MediaCaptureResult) => void \| Promise<void>`, `modes?` (default all three), `maxDurationMs?` (default 120000), `label?`, `hint?`, `compact?`, `disabled?` |
|
|
182
182
|
| `MultimodalCaptureField` | "Upload a file, or record yourself going over it" as one input surface — composes `FileDropzone` (capped to one file) with `MediaCapture`. File and recording are independent (either, both, or neither); neither half uploads anything, same "validate and hand back" contract as `FileDropzone`. | `onFileSelected?`, `onFileRemoved?`, `onCapture?`, `onRecordingRemoved?`, `accept?`, `maxFileSize?`, `modes?`, `maxDurationMs?`, `file?`/`fileUrl?`/`fileName?`, `recordingSummary?` |
|
|
183
183
|
| `ListenControl` | "Listen to this page" — a compact pill that expands into a TWO-ROW playback bar over the host's PER-SENTENCE audio (TTS): row 1 = play/pause · chapter chip + current sentence (full remaining width) · ONE settings pill ("1.25× · Sarah" → speed 0.75–2× + voice menu) · close; row 2 = prev/next sentence and −15s/+15s · a document-wide progress bar with chapter ticks (a hatched tail marks the not-yet-synthesized part), time, close. The host supplies an async `source` (sentences/paragraphs with ids → audio URLs, streamed page by page so playback starts on the first chunk while the rest synthesizes; pages merge by id so late URLs fill in) and gets `onSegmentChange(id)` at every boundary for highlight + scroll-follow. Media Session (lock-screen/headset controls, one document-wide position), keyboard scoped to the player (Space/K, ←/→ sentences, Shift+←/→ ±15s, [ / ] speed, Escape), speed+voice persisted in localStorage, `bind:api` for hosts wiring their own global keys, reduced-motion aware, token-only theming, never overflows its host (border-box, `max-width: 100%`, `min-width: 0` chain), fixed sticky mini-bar (same two rows) on narrow screens. Internals in `src/lib/listen/` (one timeline across per-sentence files — `buildTimeline` offsets; see `video/` for the doctrine). Demo synthesizes real WAVs locally. | `source`, `voices?`, `voiceId?` (bindable), `chapters?`, `title?`, `triggerLabel?`, `open?` (bindable), `preload?`, `estimateSec?`, `storageKey?`, `onSegmentChange?`, `onOpenChange?`, `api?` (bindable), `class?`, `children?` (custom trigger) |
|
|
@@ -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,18 @@
|
|
|
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 { qrSymbol, shareLinkAt } from '../video/qrShare';
|
|
205
|
+
import {
|
|
206
|
+
CAPTURE_NO_CORS_MESSAGE,
|
|
207
|
+
CAPTURE_NO_FRAME_MESSAGE,
|
|
208
|
+
captureActions,
|
|
209
|
+
captureFilename,
|
|
210
|
+
clipboardImageSupported,
|
|
211
|
+
frameDrawSize,
|
|
212
|
+
isCrossOriginCaptureError,
|
|
213
|
+
type CaptureAction,
|
|
214
|
+
type VideoFrameCapture
|
|
215
|
+
} from '../video/capture';
|
|
202
216
|
import {
|
|
203
217
|
biasCells,
|
|
204
218
|
biasGradient,
|
|
@@ -308,6 +322,35 @@
|
|
|
308
322
|
followResumeMs = FOLLOW_RESUME_IDLE_MS,
|
|
309
323
|
transcriptLineMenu = true,
|
|
310
324
|
buildTimeLink = undefined,
|
|
325
|
+
/**
|
|
326
|
+
* Frame capture (jatui-8nu4g, extension-library parity library.js:9262):
|
|
327
|
+
* a camera button on the band transport and the `C` key open a menu of
|
|
328
|
+
* the frame verbs — Save frame as PNG, Copy frame to clipboard (only
|
|
329
|
+
* where the platform can actually write an image; otherwise save-only),
|
|
330
|
+
* and Set as poster when `onCaptureFrame` is supplied. Video only —
|
|
331
|
+
* audio has no frame to grab. Default on; `false` removes the button
|
|
332
|
+
* and the key.
|
|
333
|
+
*/
|
|
334
|
+
frameCapture = true,
|
|
335
|
+
/**
|
|
336
|
+
* The host's capture hook — fires with the grabbed frame (PNG Blob,
|
|
337
|
+
* data URL, playback time, pixel size, suggested name) when the viewer
|
|
338
|
+
* picks "Set as poster"; MOS Studio uploads it and pins the video's
|
|
339
|
+
* poster. Without it the menu item simply doesn't exist. May be async;
|
|
340
|
+
* the player waits for it before reporting done, and a rejection is
|
|
341
|
+
* said, not hidden.
|
|
342
|
+
*/
|
|
343
|
+
onCaptureFrame = undefined,
|
|
344
|
+
/**
|
|
345
|
+
* Continue on phone (jatui-3k7cp, extension-library parity
|
|
346
|
+
* library.js:4635): a QR button on the band transport opens a card with
|
|
347
|
+
* the deep link AT THE CURRENT SECOND as a scannable QR code — the
|
|
348
|
+
* host's `buildTimeLink` builds it (MOS builds its watch URL with
|
|
349
|
+
* `?t=`); clicking the code copies the link. Hidden entirely when the
|
|
350
|
+
* host supplies no `buildTimeLink` — the player never guesses a URL.
|
|
351
|
+
* Default on; `false` removes the button.
|
|
352
|
+
*/
|
|
353
|
+
qrContinue = true,
|
|
311
354
|
theater = $bindable(false),
|
|
312
355
|
theaterMode = 'width',
|
|
313
356
|
theaterButton = true,
|
|
@@ -510,6 +553,23 @@
|
|
|
510
553
|
* simply not on the menu — the player never guesses a URL.
|
|
511
554
|
*/
|
|
512
555
|
buildTimeLink?: (sec: number) => string;
|
|
556
|
+
/**
|
|
557
|
+
* Frame capture (jatui-8nu4g) — see the destructure note above. Reads
|
|
558
|
+
* the frame through a canvas, which a cross-origin video without CORS
|
|
559
|
+
* taints: the export throws SecurityError and the player SAYS so
|
|
560
|
+
* ("Can't capture this video (no CORS)") instead of failing silently.
|
|
561
|
+
* Pass `crossorigin="anonymous"` for cross-origin media you want
|
|
562
|
+
* frame-readable.
|
|
563
|
+
*/
|
|
564
|
+
frameCapture?: boolean;
|
|
565
|
+
/** The host's "set as poster" hook — receives a `VideoFrameCapture`. */
|
|
566
|
+
onCaptureFrame?: (capture: VideoFrameCapture) => void | Promise<void>;
|
|
567
|
+
/**
|
|
568
|
+
* Continue on phone (jatui-3k7cp) — see the destructure note above.
|
|
569
|
+
* Needs `buildTimeLink`: the URL is the host's (the player never
|
|
570
|
+
* guesses one), so without a builder there is simply no QR button.
|
|
571
|
+
*/
|
|
572
|
+
qrContinue?: boolean;
|
|
513
573
|
/**
|
|
514
574
|
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
515
575
|
* only says it wants the page's width; the HOST lays its page out
|
|
@@ -1005,6 +1065,60 @@
|
|
|
1005
1065
|
let lineMenuY = $state(0);
|
|
1006
1066
|
let lineMenuSeg = $state<VideoTranscriptSegment | null>(null);
|
|
1007
1067
|
|
|
1068
|
+
// ── Frame capture menu (jatui-8nu4g, extension library.js:9262) ────────
|
|
1069
|
+
let captureMenuOpen = $state(false);
|
|
1070
|
+
let captureMenuX = $state(0);
|
|
1071
|
+
let captureMenuY = $state(0);
|
|
1072
|
+
let captureBtnEl: HTMLButtonElement | undefined = $state();
|
|
1073
|
+
/** Feature on: video only, and the host hasn't opted out. */
|
|
1074
|
+
const frameCaptureOn = $derived(frameCapture && !isAudio);
|
|
1075
|
+
/** Can this platform write an IMAGE to the clipboard? Static per page. */
|
|
1076
|
+
const captureClipboardOk = $derived(clipboardImageSupported());
|
|
1077
|
+
/** The verbs on the menu, in order — the pure decision, mapped to items. */
|
|
1078
|
+
const captureMenuActions = $derived(
|
|
1079
|
+
captureActions({ clipboardImage: captureClipboardOk, hostHook: onCaptureFrame != null })
|
|
1080
|
+
);
|
|
1081
|
+
|
|
1082
|
+
// ── Continue on phone (jatui-3k7cp, extension library.js:4635) ───────
|
|
1083
|
+
let qrOpen = $state(false);
|
|
1084
|
+
let qrBtnEl: HTMLButtonElement | undefined = $state();
|
|
1085
|
+
/** The link the open symbol was built from — frozen at open so the code
|
|
1086
|
+
* under the phone's camera never changes mid-scan. */
|
|
1087
|
+
let qrLink = $state('');
|
|
1088
|
+
/** The second the link was frozen at — the card's own clock. */
|
|
1089
|
+
let qrSec = $state(0);
|
|
1090
|
+
/** Feature on: the host must own the URL (buildTimeLink). Without a
|
|
1091
|
+
* builder there is nothing to encode — the player never guesses one —
|
|
1092
|
+
* so the button simply doesn't exist. */
|
|
1093
|
+
const qrShareOn = $derived(qrContinue && typeof buildTimeLink === 'function');
|
|
1094
|
+
/** The symbol for the frozen link. Null ⇒ the card says the link is
|
|
1095
|
+
* beyond the encoder's ceiling and keeps the copy verb working. */
|
|
1096
|
+
const qrSym = $derived(qrOpen && qrLink ? qrSymbol(qrLink) : null);
|
|
1097
|
+
|
|
1098
|
+
/** Open the QR card: freeze the host's link at the CURRENT SECOND, then
|
|
1099
|
+
* show the card. A builder that answers nothing (or throws) is said on
|
|
1100
|
+
* the OSD — never a silent no-op, never an empty card. */
|
|
1101
|
+
function openQrCard() {
|
|
1102
|
+
const link = shareLinkAt(buildTimeLink, currentTime);
|
|
1103
|
+
if (!link) {
|
|
1104
|
+
flashOsd('No link to share');
|
|
1105
|
+
announce('No link to share — the host did not supply one');
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
qrLink = link;
|
|
1109
|
+
qrSec = Math.max(0, Math.floor(currentTime));
|
|
1110
|
+
qrOpen = true;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** Clicking the code copies the link (and closes — the scan or the
|
|
1114
|
+
* paste is the point; the card's job is done). The failure is said. */
|
|
1115
|
+
async function copyQrLink() {
|
|
1116
|
+
const ok = await copyText(qrLink);
|
|
1117
|
+
flashOsd(ok ? 'Link copied' : 'Copy failed');
|
|
1118
|
+
announce(ok ? 'Link copied to clipboard' : 'Copy failed');
|
|
1119
|
+
if (ok) qrOpen = false;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1008
1122
|
let zoomTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1009
1123
|
let osdTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1010
1124
|
|
|
@@ -1489,6 +1603,7 @@
|
|
|
1489
1603
|
if (row.when === 'chapters') return sortedChapters.length > 0;
|
|
1490
1604
|
if (row.when === 'rail') return railOn;
|
|
1491
1605
|
if (row.when === 'bookmarks') return onAddBookmark != null;
|
|
1606
|
+
if (row.when === 'capture') return frameCaptureOn;
|
|
1492
1607
|
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
1493
1608
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
1494
1609
|
if (row.when === 'night') return nightPresent;
|
|
@@ -3228,7 +3343,10 @@
|
|
|
3228
3343
|
// menu closes itself, the theater must not leave with it. The
|
|
3229
3344
|
// panel's own handler may already have closed it by the time this
|
|
3230
3345
|
// bubble-phase handler runs, hence the target check too.
|
|
3231
|
-
!(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]')))
|
|
3346
|
+
!(lineMenuOpen || (e.target instanceof Element && e.target.closest('[role="menu"]'))) &&
|
|
3347
|
+
// The continue-on-phone card (jatui-3k7cp) owns Escape while it
|
|
3348
|
+
// is open — Escape closes the card, never the theater.
|
|
3349
|
+
!qrOpen
|
|
3232
3350
|
) {
|
|
3233
3351
|
e.preventDefault();
|
|
3234
3352
|
toggleTheater();
|
|
@@ -3249,13 +3367,23 @@
|
|
|
3249
3367
|
// Space/Enter on a focused button activates THAT button (play, a
|
|
3250
3368
|
// speaker chip, a transcript row); toggling playback too would undo it.
|
|
3251
3369
|
if (tag === 'BUTTON' && (e.key === ' ' || e.key === 'Enter')) return;
|
|
3252
|
-
// The transcript line menu (jatui-3wl5x)
|
|
3253
|
-
//
|
|
3254
|
-
//
|
|
3255
|
-
//
|
|
3256
|
-
//
|
|
3257
|
-
//
|
|
3258
|
-
|
|
3370
|
+
// The transcript line menu (jatui-3wl5x) and the frame-capture menu
|
|
3371
|
+
// (jatui-8nu4g) own the keyboard while they are open: arrows/Enter drive
|
|
3372
|
+
// them (their panel preventDefaults what it uses, which the
|
|
3373
|
+
// defaultPrevented guard above already yields to), Escape closes, and
|
|
3374
|
+
// nothing else drives the player underneath a floating menu. Tab is the
|
|
3375
|
+
// shared ContextMenu's own behaviour (focus moves out with the menu
|
|
3376
|
+
// still up; click/Escape dismiss it).
|
|
3377
|
+
if (lineMenuOpen || captureMenuOpen) return;
|
|
3378
|
+
// The continue-on-phone card (jatui-3k7cp) owns Escape while it is
|
|
3379
|
+
// open; nothing else drives the player underneath it.
|
|
3380
|
+
if (qrOpen) {
|
|
3381
|
+
if (e.key === 'Escape') {
|
|
3382
|
+
e.preventDefault();
|
|
3383
|
+
qrOpen = false;
|
|
3384
|
+
}
|
|
3385
|
+
return;
|
|
3386
|
+
}
|
|
3259
3387
|
// mos-777hj — the chapter rail owns j/k (listNav) and Enter/Space (row
|
|
3260
3388
|
// activation) while focus is inside it; without this guard a rail `k`
|
|
3261
3389
|
// would also toggle play/pause and a rail `j` also seek ±5s.
|
|
@@ -3422,6 +3550,15 @@
|
|
|
3422
3550
|
cycleNight();
|
|
3423
3551
|
}
|
|
3424
3552
|
return;
|
|
3553
|
+
case 'c':
|
|
3554
|
+
case 'C':
|
|
3555
|
+
// Frame capture (jatui-8nu4g): video only; `frameCapture={false}`
|
|
3556
|
+
// removes the key with the button. Off ⇒ not ours — no
|
|
3557
|
+
// preventDefault, the page may keep it.
|
|
3558
|
+
if (!frameCaptureOn) return;
|
|
3559
|
+
e.preventDefault();
|
|
3560
|
+
openCaptureMenuAtTrigger();
|
|
3561
|
+
return;
|
|
3425
3562
|
case 'Home':
|
|
3426
3563
|
seekTo(0);
|
|
3427
3564
|
break;
|
|
@@ -3617,6 +3754,157 @@
|
|
|
3617
3754
|
return items;
|
|
3618
3755
|
});
|
|
3619
3756
|
|
|
3757
|
+
// ── Frame capture (jatui-8nu4g, extension library.js:9262) ─────────────
|
|
3758
|
+
// The camera button (band transport) and the `C` key both open ONE menu
|
|
3759
|
+
// of frame verbs. The extension also burned a caption into the frame and
|
|
3760
|
+
// ran OCR on it — deliberately not ported (the player has no OCR budget;
|
|
3761
|
+
// hosts that want it can run it on the hook's dataUrl).
|
|
3762
|
+
|
|
3763
|
+
/** Open the menu anchored to the camera button (a click) — or, on a
|
|
3764
|
+
* native transport with no button, the stage (`C` still works there);
|
|
3765
|
+
* the position:fixed panel is viewport-clamped by floating-ui, so the
|
|
3766
|
+
* anchor is a hint, not a law. */
|
|
3767
|
+
function openCaptureMenuAtTrigger() {
|
|
3768
|
+
const rect =
|
|
3769
|
+
captureBtnEl?.getBoundingClientRect() ??
|
|
3770
|
+
stageWrapEl?.getBoundingClientRect() ??
|
|
3771
|
+
rootEl?.getBoundingClientRect();
|
|
3772
|
+
captureMenuX = rect?.left ?? 0;
|
|
3773
|
+
captureMenuY = rect ? rect.bottom + 6 : 0;
|
|
3774
|
+
captureMenuOpen = true;
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3777
|
+
/** The current frame as a canvas at natural size. Null when there is
|
|
3778
|
+
* nothing to grab yet — no metadata (videoWidth is 0 until it) or no
|
|
3779
|
+
* decoded frame (drawImage refuses to make up pixels). Callers SAY
|
|
3780
|
+
* null — never a silent no-op. */
|
|
3781
|
+
function captureCanvas(): { canvas: HTMLCanvasElement; width: number; height: number } | null {
|
|
3782
|
+
const size = frameDrawSize(videoEl);
|
|
3783
|
+
if (!size || !videoEl) return null;
|
|
3784
|
+
const canvas = document.createElement('canvas');
|
|
3785
|
+
canvas.width = size.width;
|
|
3786
|
+
canvas.height = size.height;
|
|
3787
|
+
const ctx = canvas.getContext('2d');
|
|
3788
|
+
if (!ctx) return null;
|
|
3789
|
+
try {
|
|
3790
|
+
ctx.drawImage(videoEl, 0, 0, size.width, size.height);
|
|
3791
|
+
} catch {
|
|
3792
|
+
return null; // no decoded frame yet (readyState too low)
|
|
3793
|
+
}
|
|
3794
|
+
return { canvas, width: size.width, height: size.height };
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
/** canvas.toBlob as a promise. A tainted canvas throws SecurityError out
|
|
3798
|
+
* of toBlob itself — re-thrown here so every verb can check the NAME. */
|
|
3799
|
+
function canvasToPng(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
3800
|
+
return new Promise((resolve, reject) => {
|
|
3801
|
+
try {
|
|
3802
|
+
canvas.toBlob(
|
|
3803
|
+
(blob) => (blob ? resolve(blob) : reject(new Error('toBlob produced no blob'))),
|
|
3804
|
+
'image/png'
|
|
3805
|
+
);
|
|
3806
|
+
} catch (e) {
|
|
3807
|
+
reject(e);
|
|
3808
|
+
}
|
|
3809
|
+
});
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
function flashNoFrame() {
|
|
3813
|
+
flashOsd(CAPTURE_NO_FRAME_MESSAGE);
|
|
3814
|
+
announce('No frame yet — wait until the video has loaded a picture');
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
/** The taint verdict, said twice: OSD for the eye, polite region for the
|
|
3818
|
+
* screen reader — a silent failure would read as a broken button. */
|
|
3819
|
+
function flashNoCors() {
|
|
3820
|
+
flashOsd(CAPTURE_NO_CORS_MESSAGE);
|
|
3821
|
+
announce(`${CAPTURE_NO_CORS_MESSAGE} — the video host must allow cross-origin reads (CORS) to export frames.`);
|
|
3822
|
+
}
|
|
3823
|
+
|
|
3824
|
+
function flashCaptureFailed(what: string) {
|
|
3825
|
+
flashOsd(`${what} failed`);
|
|
3826
|
+
announce(`${what} failed`);
|
|
3827
|
+
}
|
|
3828
|
+
|
|
3829
|
+
/** Save frame as PNG: draw → toBlob → anchor download. A cross-origin
|
|
3830
|
+
* video without CORS throws SecurityError out of toBlob itself —
|
|
3831
|
+
* caught, said, never silent. */
|
|
3832
|
+
function saveFrame() {
|
|
3833
|
+
const c = captureCanvas();
|
|
3834
|
+
if (!c) return flashNoFrame();
|
|
3835
|
+
try {
|
|
3836
|
+
c.canvas.toBlob((blob) => {
|
|
3837
|
+
if (!blob) return flashCaptureFailed('Capture');
|
|
3838
|
+
const url = URL.createObjectURL(blob);
|
|
3839
|
+
const a = document.createElement('a');
|
|
3840
|
+
a.href = url;
|
|
3841
|
+
a.download = captureFilename(currentTime);
|
|
3842
|
+
document.body.appendChild(a);
|
|
3843
|
+
a.click();
|
|
3844
|
+
a.remove();
|
|
3845
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
3846
|
+
flashOsd('Frame saved');
|
|
3847
|
+
announce('Frame saved as PNG');
|
|
3848
|
+
}, 'image/png');
|
|
3849
|
+
} catch (e) {
|
|
3850
|
+
if (isCrossOriginCaptureError(e)) flashNoCors();
|
|
3851
|
+
else flashCaptureFailed('Capture');
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
/** Copy frame: ClipboardItem carrying a PROMISE for the blob — the write
|
|
3856
|
+
* call lands inside the click's user-gesture budget while the pixels
|
|
3857
|
+
* encode async (Safari's gesture timer is the constraint). Unsupported
|
|
3858
|
+
* platforms never get here: the item is omitted, save-only degrade. */
|
|
3859
|
+
async function copyFrame() {
|
|
3860
|
+
const c = captureCanvas();
|
|
3861
|
+
if (!c) return flashNoFrame();
|
|
3862
|
+
try {
|
|
3863
|
+
await navigator.clipboard.write([new ClipboardItem({ 'image/png': canvasToPng(c.canvas) })]);
|
|
3864
|
+
flashOsd('Copied');
|
|
3865
|
+
announce('Frame copied to clipboard');
|
|
3866
|
+
} catch (e) {
|
|
3867
|
+
if (isCrossOriginCaptureError(e)) return flashNoCors();
|
|
3868
|
+
flashCaptureFailed('Copy');
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
/** Set as poster: hand the frame to the host. MOS Studio uploads the
|
|
3873
|
+
* blob and pins the video's poster; other hosts own their meaning. The
|
|
3874
|
+
* host's rejection is said, not hidden. */
|
|
3875
|
+
async function sendFrameToHost() {
|
|
3876
|
+
const c = captureCanvas();
|
|
3877
|
+
if (!c) return flashNoFrame();
|
|
3878
|
+
try {
|
|
3879
|
+
const blob = await canvasToPng(c.canvas);
|
|
3880
|
+
const dataUrl = c.canvas.toDataURL('image/png');
|
|
3881
|
+
const capture: VideoFrameCapture = {
|
|
3882
|
+
blob,
|
|
3883
|
+
dataUrl,
|
|
3884
|
+
timeSec: currentTime,
|
|
3885
|
+
width: c.width,
|
|
3886
|
+
height: c.height,
|
|
3887
|
+
name: captureFilename(currentTime)
|
|
3888
|
+
};
|
|
3889
|
+
await onCaptureFrame?.(capture);
|
|
3890
|
+
flashOsd('Frame captured');
|
|
3891
|
+
announce('Frame sent to host');
|
|
3892
|
+
} catch (e) {
|
|
3893
|
+
if (isCrossOriginCaptureError(e)) return flashNoCors();
|
|
3894
|
+
flashCaptureFailed('Set as poster');
|
|
3895
|
+
}
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
/** The menu's items for the verbs this page can actually run. */
|
|
3899
|
+
const captureMenuItems = $derived.by<ContextMenuItem[]>(() => {
|
|
3900
|
+
const verbs: Record<CaptureAction, ContextMenuItem> = {
|
|
3901
|
+
save: { label: 'Save frame as PNG', icon: '⤓', onSelect: saveFrame },
|
|
3902
|
+
copy: { label: 'Copy frame to clipboard', icon: '⧉', onSelect: copyFrame },
|
|
3903
|
+
poster: { label: 'Set as poster', icon: '🖼️', onSelect: sendFrameToHost }
|
|
3904
|
+
};
|
|
3905
|
+
return captureMenuActions.map((a) => verbs[a]);
|
|
3906
|
+
});
|
|
3907
|
+
|
|
3620
3908
|
// ── Transcript search (jatui-xmtnv) ───────────────────────────────────
|
|
3621
3909
|
/** `/`: open the box (showing the transcript if it was hidden) and hand
|
|
3622
3910
|
* it focus once it mounts — the effect below lands it. */
|
|
@@ -4250,6 +4538,49 @@
|
|
|
4250
4538
|
aria-valuetext="{Math.round((mutedNow ? 0 : volumeLevel) * 100)}%"
|
|
4251
4539
|
oninput={onVolumeInput}
|
|
4252
4540
|
/>
|
|
4541
|
+
{#if frameCaptureOn}
|
|
4542
|
+
<!-- Frame capture (jatui-8nu4g): opens the frame verbs menu
|
|
4543
|
+
(save PNG / copy / set as poster). A picture verb, so it
|
|
4544
|
+
sits with the picture verbs at the end of the cluster. -->
|
|
4545
|
+
<button
|
|
4546
|
+
type="button"
|
|
4547
|
+
class="vp-tbtn"
|
|
4548
|
+
bind:this={captureBtnEl}
|
|
4549
|
+
aria-haspopup="menu"
|
|
4550
|
+
aria-expanded={captureMenuOpen}
|
|
4551
|
+
aria-label="Capture frame"
|
|
4552
|
+
title="Capture frame · C"
|
|
4553
|
+
onclick={openCaptureMenuAtTrigger}
|
|
4554
|
+
>
|
|
4555
|
+
<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">
|
|
4556
|
+
<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" />
|
|
4557
|
+
<circle cx="12" cy="13" r="3.2" />
|
|
4558
|
+
</svg>
|
|
4559
|
+
</button>
|
|
4560
|
+
{/if}
|
|
4561
|
+
{#if qrShareOn}
|
|
4562
|
+
<!-- Continue on phone (jatui-3k7cp): opens the QR card for the
|
|
4563
|
+
host's buildTimeLink at the current second. A share verb,
|
|
4564
|
+
so it sits beside the capture verb at the end of the
|
|
4565
|
+
cluster. Works for audio too — the link is the point,
|
|
4566
|
+
not the picture. -->
|
|
4567
|
+
<button
|
|
4568
|
+
type="button"
|
|
4569
|
+
class="vp-tbtn"
|
|
4570
|
+
bind:this={qrBtnEl}
|
|
4571
|
+
aria-haspopup="dialog"
|
|
4572
|
+
aria-expanded={qrOpen}
|
|
4573
|
+
aria-label="Continue on phone"
|
|
4574
|
+
title="Continue on phone"
|
|
4575
|
+
onclick={openQrCard}
|
|
4576
|
+
>
|
|
4577
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true">
|
|
4578
|
+
<!-- QR glyph: three finder squares + a few data modules -->
|
|
4579
|
+
<path d="M3.5 3.5h6v6h-6zM14.5 3.5h6v6h-6zM3.5 14.5h6v6h-6z" />
|
|
4580
|
+
<path d="M14.5 14.5h2.5v2.5h-2.5zM19 14.5h1.5v1.5H19zM14.5 19h1.5v1.5h-1.5zM18.5 18.5h2v2h-2z" fill="currentColor" stroke="none" />
|
|
4581
|
+
</svg>
|
|
4582
|
+
</button>
|
|
4583
|
+
{/if}
|
|
4253
4584
|
{#if !isAudio}
|
|
4254
4585
|
<button
|
|
4255
4586
|
type="button"
|
|
@@ -4645,6 +4976,71 @@
|
|
|
4645
4976
|
onClose={() => (lineMenuSeg = null)}
|
|
4646
4977
|
/>
|
|
4647
4978
|
{/if}
|
|
4979
|
+
|
|
4980
|
+
{#if frameCaptureOn}
|
|
4981
|
+
<!-- Frame-capture menu (jatui-8nu4g, extension library.js:9262): ONE
|
|
4982
|
+
panel, opened at the camera button (click) or wherever the `C` key
|
|
4983
|
+
was pressed from (anchored to the stage on a native transport).
|
|
4984
|
+
Rendered inside the root so the fill-theater Tab trap keeps cycling
|
|
4985
|
+
through its items; position:fixed + floating-ui place it wherever
|
|
4986
|
+
this spot sits in the layout. The header is the frame's own clock. -->
|
|
4987
|
+
<ContextMenu
|
|
4988
|
+
bind:open={captureMenuOpen}
|
|
4989
|
+
x={captureMenuX}
|
|
4990
|
+
y={captureMenuY}
|
|
4991
|
+
items={captureMenuItems}
|
|
4992
|
+
header={formatTime(currentTime, useHours)}
|
|
4993
|
+
/>
|
|
4994
|
+
{/if}
|
|
4995
|
+
|
|
4996
|
+
{#if qrShareOn && qrOpen}
|
|
4997
|
+
<!-- Continue-on-phone card (jatui-3k7cp, extension library.js:4635):
|
|
4998
|
+
the same overlay family as the `?` help card — centered over the
|
|
4999
|
+
player, big enough to scan from arm's length, backdrop click or
|
|
5000
|
+
Escape closes. The link was FROZEN at open (qrLink), so the code
|
|
5001
|
+
under the phone's camera never changes mid-scan. The code is a real
|
|
5002
|
+
button: click it (or press Enter/Space on it) and the link is on
|
|
5003
|
+
the clipboard. Rendered as SVG <path> data from qrSymbol() — never
|
|
5004
|
+
{@html} of untrusted text. The black-on-white symbol is deliberate:
|
|
5005
|
+
QR contrast rules are about the SCANNER, not the theme, and a
|
|
5006
|
+
theme-tinted code scans far worse in dim rooms. -->
|
|
5007
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
5008
|
+
<div class="vp-qr-backdrop" onclick={(e) => { if (e.target === e.currentTarget) qrOpen = false; }}>
|
|
5009
|
+
<div class="vp-qr-card" role="dialog" aria-label="Continue on phone">
|
|
5010
|
+
<div class="vp-qr-title">Continue on phone</div>
|
|
5011
|
+
<div class="vp-qr-time">{formatTime(qrSec, useHours)}</div>
|
|
5012
|
+
{#if qrSym}
|
|
5013
|
+
<button
|
|
5014
|
+
type="button"
|
|
5015
|
+
class="vp-qr-code"
|
|
5016
|
+
aria-label="Copy the link: {qrLink}"
|
|
5017
|
+
title="Click to copy the link"
|
|
5018
|
+
onclick={copyQrLink}
|
|
5019
|
+
>
|
|
5020
|
+
<svg
|
|
5021
|
+
viewBox="0 0 {qrSym.size} {qrSym.size}"
|
|
5022
|
+
shape-rendering="crispEdges"
|
|
5023
|
+
role="img"
|
|
5024
|
+
aria-hidden="true"
|
|
5025
|
+
>
|
|
5026
|
+
<rect width="{qrSym.size}" height="{qrSym.size}" fill="#ffffff" />
|
|
5027
|
+
<path d={qrSym.path} fill="#000000" />
|
|
5028
|
+
</svg>
|
|
5029
|
+
<span class="vp-qr-hint">Click to copy the link</span>
|
|
5030
|
+
</button>
|
|
5031
|
+
<div class="vp-qr-link" title={qrLink}>{qrLink}</div>
|
|
5032
|
+
{:else}
|
|
5033
|
+
<!-- The encoder's ceiling (version 10) — said, with the copy
|
|
5034
|
+
verb kept working: the QR is the affordance, the link is
|
|
5035
|
+
the point. -->
|
|
5036
|
+
<p class="vp-qr-fallback">
|
|
5037
|
+
This link is too long for a QR code.
|
|
5038
|
+
</p>
|
|
5039
|
+
<button type="button" class="vp-qr-copy" onclick={copyQrLink}>Copy the link instead</button>
|
|
5040
|
+
{/if}
|
|
5041
|
+
</div>
|
|
5042
|
+
</div>
|
|
5043
|
+
{/if}
|
|
4648
5044
|
</div>
|
|
4649
5045
|
|
|
4650
5046
|
<style>
|
|
@@ -5986,6 +6382,112 @@
|
|
|
5986
6382
|
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
5987
6383
|
}
|
|
5988
6384
|
|
|
6385
|
+
/* ── Continue on phone (jatui-3k7cp) — the QR card ─────────────────── */
|
|
6386
|
+
/* Same overlay family as the `?` help card: centered over the player,
|
|
6387
|
+
backdrop click / Escape closes, focus stays in the root. */
|
|
6388
|
+
.vp-qr-backdrop {
|
|
6389
|
+
position: absolute;
|
|
6390
|
+
inset: 0;
|
|
6391
|
+
display: flex;
|
|
6392
|
+
align-items: center;
|
|
6393
|
+
justify-content: center;
|
|
6394
|
+
background: color-mix(in oklch, var(--color-base-100) 70%, transparent);
|
|
6395
|
+
z-index: 10;
|
|
6396
|
+
border-radius: inherit;
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
.vp-qr-card {
|
|
6400
|
+
display: flex;
|
|
6401
|
+
flex-direction: column;
|
|
6402
|
+
align-items: center;
|
|
6403
|
+
gap: var(--jatui-space-sm, 4px);
|
|
6404
|
+
max-width: min(20rem, 90%);
|
|
6405
|
+
padding: var(--jatui-space-lg, 12px);
|
|
6406
|
+
background: var(--color-base-100);
|
|
6407
|
+
border: var(--jatui-border-w, 1px) solid
|
|
6408
|
+
color-mix(in oklch, var(--color-base-content) 18%, transparent);
|
|
6409
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
6410
|
+
color: var(--color-base-content);
|
|
6411
|
+
box-shadow: 0 8px 30px color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
6412
|
+
}
|
|
6413
|
+
|
|
6414
|
+
.vp-qr-title {
|
|
6415
|
+
font-weight: 600;
|
|
6416
|
+
font-size: 0.875rem;
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
.vp-qr-time {
|
|
6420
|
+
font-size: 0.75rem;
|
|
6421
|
+
opacity: 0.65;
|
|
6422
|
+
font-variant-numeric: tabular-nums;
|
|
6423
|
+
}
|
|
6424
|
+
|
|
6425
|
+
/* The code itself is a real button — click copies. The black-on-white
|
|
6426
|
+
symbol is FIXED colour, not theme tokens: QR contrast is about the
|
|
6427
|
+
scanner, not the palette (a theme-tinted code scans badly in dim
|
|
6428
|
+
rooms). Crisp edges keep modules square at every scale. */
|
|
6429
|
+
.vp-qr-code {
|
|
6430
|
+
display: flex;
|
|
6431
|
+
flex-direction: column;
|
|
6432
|
+
align-items: center;
|
|
6433
|
+
gap: var(--jatui-space-xs, 2px);
|
|
6434
|
+
padding: var(--jatui-space-sm, 4px);
|
|
6435
|
+
border-radius: var(--jatui-radius-box, 0.5rem);
|
|
6436
|
+
background: transparent;
|
|
6437
|
+
border: none;
|
|
6438
|
+
cursor: pointer;
|
|
6439
|
+
}
|
|
6440
|
+
|
|
6441
|
+
.vp-qr-code:focus-visible {
|
|
6442
|
+
outline: 2px solid var(--color-primary);
|
|
6443
|
+
outline-offset: 2px;
|
|
6444
|
+
}
|
|
6445
|
+
|
|
6446
|
+
.vp-qr-code svg {
|
|
6447
|
+
width: 13rem;
|
|
6448
|
+
height: 13rem;
|
|
6449
|
+
max-width: 100%;
|
|
6450
|
+
display: block;
|
|
6451
|
+
border-radius: 0.25rem;
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
.vp-qr-hint {
|
|
6455
|
+
font-size: 0.6875rem;
|
|
6456
|
+
opacity: 0.6;
|
|
6457
|
+
}
|
|
6458
|
+
|
|
6459
|
+
.vp-qr-link {
|
|
6460
|
+
max-width: 100%;
|
|
6461
|
+
overflow: hidden;
|
|
6462
|
+
text-overflow: ellipsis;
|
|
6463
|
+
white-space: nowrap;
|
|
6464
|
+
font-size: 0.6875rem;
|
|
6465
|
+
opacity: 0.75;
|
|
6466
|
+
direction: rtl;
|
|
6467
|
+
text-align: left;
|
|
6468
|
+
}
|
|
6469
|
+
|
|
6470
|
+
.vp-qr-fallback {
|
|
6471
|
+
font-size: 0.8125rem;
|
|
6472
|
+
opacity: 0.8;
|
|
6473
|
+
margin: var(--jatui-space-sm, 4px) 0 0;
|
|
6474
|
+
}
|
|
6475
|
+
|
|
6476
|
+
.vp-qr-copy {
|
|
6477
|
+
padding: var(--jatui-space-xs, 2px) var(--jatui-space-md, 6px);
|
|
6478
|
+
border-radius: var(--jatui-radius-field, 0.5rem);
|
|
6479
|
+
border: var(--jatui-border-w, 1px) solid
|
|
6480
|
+
color-mix(in oklch, var(--color-base-content) 25%, transparent);
|
|
6481
|
+
background: transparent;
|
|
6482
|
+
color: inherit;
|
|
6483
|
+
font-size: 0.8125rem;
|
|
6484
|
+
cursor: pointer;
|
|
6485
|
+
}
|
|
6486
|
+
|
|
6487
|
+
.vp-qr-copy:hover {
|
|
6488
|
+
background: color-mix(in oklch, var(--color-base-content) 10%, transparent);
|
|
6489
|
+
}
|
|
6490
|
+
|
|
5989
6491
|
.vp-keys-title {
|
|
5990
6492
|
font-size: 0.66rem;
|
|
5991
6493
|
font-weight: 700;
|
|
@@ -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,23 @@ 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>;
|
|
225
|
+
/**
|
|
226
|
+
* Continue on phone (jatui-3k7cp) — see the destructure note above.
|
|
227
|
+
* Needs `buildTimeLink`: the URL is the host's (the player never
|
|
228
|
+
* guesses one), so without a builder there is simply no QR button.
|
|
229
|
+
*/
|
|
230
|
+
qrContinue?: boolean;
|
|
212
231
|
/**
|
|
213
232
|
* Theater mode (jatui-ee2ty) — bindable. NOT fullscreen: the player
|
|
214
233
|
* only says it wants the page's width; the HOST lays its page out
|
package/dist/video/README.md
CHANGED
|
@@ -20,7 +20,9 @@ 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`. |
|
|
25
|
+
| `qrShare.ts` | **jatui-3k7cp** | "Continue on phone" pure pieces (extension-library parity, library.js:4635): `qrSymbol` — the QR of a link as SVG PATH DATA over `advisors/qr`'s zero-dep encoder (dark modules run-merged into one `path` the test re-parses; quiet zone 4; null, never a throw, when the text can't be a symbol — the component says "too long" and keeps copy working), and `shareLinkAt` (the host's `buildTimeLink` at the FLOORED current second; every broken shape degrades to null). The URL is the host's — no builder, no button. The component owns the card: centered like the `?` help overlay, link frozen at open so the code under the phone's camera never changes mid-scan, clicking the code copies, SVG only — never `{@html}` of untrusted text. |
|
|
24
26
|
| `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
27
|
| `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. |
|
|
26
28
|
| `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
|
|
@@ -16,5 +16,5 @@
|
|
|
16
16
|
* | `speaker-rate` | each speaker's measured wpm, the rates that even them out | rate composer's `speaker` advisor (.32) |
|
|
17
17
|
* | `resume-point` | the nearest real boundary at-or-before a stored time + the line to say | resume-at-sentence (.34) |
|
|
18
18
|
* | `quiet-listening` | the loudness-compensated tone graph + how much of it is wanted | audio graph (.31) |
|
|
19
|
-
* | `qr` | the deep link as a scannable symbol | "continue on phone" (
|
|
19
|
+
* | `qr` | the deep link as a scannable symbol | "continue on phone" (jatui-3k7cp, via `../qrShare`) |
|
|
20
20
|
*/
|
|
@@ -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
|
+
}
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -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)
|
|
15
|
-
* (
|
|
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[];
|
package/dist/video/keys.js
CHANGED
|
@@ -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)
|
|
15
|
-
* (
|
|
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' },
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
|
|
3
|
+
* / lib/qr.js): the video you are watching moves from the desk to a phone
|
|
4
|
+
* mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
|
|
5
|
+
* the host's `buildTimeLink` builder (the same one the transcript line menu's
|
|
6
|
+
* "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
|
|
7
|
+
* viewer points a phone camera at it, and clicking the symbol copies the link.
|
|
8
|
+
*
|
|
9
|
+
* The URL is the HOST's: the player never guesses one, and the whole feature
|
|
10
|
+
* is absent when no builder is supplied. The encoder underneath is the
|
|
11
|
+
* zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
|
|
12
|
+
* dependency — see that file's header for why).
|
|
13
|
+
*
|
|
14
|
+
* The pure pieces live here so they stay node-testable; the component owns
|
|
15
|
+
* the DOM: the button, the card, the clipboard write.
|
|
16
|
+
*
|
|
17
|
+
* The symbol is rendered as SVG PATH DATA — dark modules become one `path`
|
|
18
|
+
* element the host stylesheet/theme can never reflow — never `{@html}` of
|
|
19
|
+
* untrusted text.
|
|
20
|
+
*/
|
|
21
|
+
import { type EccLevel } from './advisors/qr';
|
|
22
|
+
/** The spec's minimum quiet zone — 4 modules of light around the symbol. */
|
|
23
|
+
export declare const QR_QUIET_ZONE = 4;
|
|
24
|
+
export interface QrSymbol {
|
|
25
|
+
/** SVG viewBox side in modules: the symbol plus the quiet zone on both sides. */
|
|
26
|
+
size: number;
|
|
27
|
+
/**
|
|
28
|
+
* Path data covering exactly the dark modules, one subpath per horizontal
|
|
29
|
+
* run (`M{x} {y}h{w}v1h-{w}z`), coordinates offset by the quiet zone so
|
|
30
|
+
* the viewBox is `0 0 size size`. Designed to be re-parsed: the unit test
|
|
31
|
+
* reconstructs the grid from this string and compares it against
|
|
32
|
+
* `qrMatrix`'s own modules.
|
|
33
|
+
*/
|
|
34
|
+
path: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The QR symbol for `text` as SVG geometry, or null when it cannot exist —
|
|
38
|
+
* non-string/empty input, or text beyond the encoder's ceiling (version 10
|
|
39
|
+
* at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
|
|
40
|
+
* "too long" and keeps the copy verb working — a missing symbol must not
|
|
41
|
+
* take the whole action down.
|
|
42
|
+
*/
|
|
43
|
+
export declare function qrSymbol(text: unknown, opts?: {
|
|
44
|
+
ecc?: EccLevel;
|
|
45
|
+
quietZone?: number;
|
|
46
|
+
}): QrSymbol | null;
|
|
47
|
+
/**
|
|
48
|
+
* The shareable link at a playback second, from the host's builder. Floors
|
|
49
|
+
* the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
|
|
50
|
+
* convention as deepLink.ts's writes), and degrades to null rather than
|
|
51
|
+
* throwing: a builder that isn't a function, a non-finite time, a builder
|
|
52
|
+
* that throws, or one that answers empty all mean "no link to share", which
|
|
53
|
+
* the component says instead of opening an empty card.
|
|
54
|
+
*/
|
|
55
|
+
export declare function shareLinkAt(build: unknown, sec: number): string | null;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Continue on phone" (jatui-3k7cp — extension-library parity, library.js:4635
|
|
3
|
+
* / lib/qr.js): the video you are watching moves from the desk to a phone
|
|
4
|
+
* mid-sentence. The player shows a QR of the deep link AT THE CURRENT SECOND —
|
|
5
|
+
* the host's `buildTimeLink` builder (the same one the transcript line menu's
|
|
6
|
+
* "Copy link at this time" uses; MOS builds its watch URL with `?t=`) — the
|
|
7
|
+
* viewer points a phone camera at it, and clicking the symbol copies the link.
|
|
8
|
+
*
|
|
9
|
+
* The URL is the HOST's: the player never guesses one, and the whole feature
|
|
10
|
+
* is absent when no builder is supplied. The encoder underneath is the
|
|
11
|
+
* zero-dependency ISO 18004 port in `advisors/qr.ts` (deliberately no npm
|
|
12
|
+
* dependency — see that file's header for why).
|
|
13
|
+
*
|
|
14
|
+
* The pure pieces live here so they stay node-testable; the component owns
|
|
15
|
+
* the DOM: the button, the card, the clipboard write.
|
|
16
|
+
*
|
|
17
|
+
* The symbol is rendered as SVG PATH DATA — dark modules become one `path`
|
|
18
|
+
* element the host stylesheet/theme can never reflow — never `{@html}` of
|
|
19
|
+
* untrusted text.
|
|
20
|
+
*/
|
|
21
|
+
import { qrMatrix } from './advisors/qr';
|
|
22
|
+
/** The spec's minimum quiet zone — 4 modules of light around the symbol. */
|
|
23
|
+
export const QR_QUIET_ZONE = 4;
|
|
24
|
+
/**
|
|
25
|
+
* The QR symbol for `text` as SVG geometry, or null when it cannot exist —
|
|
26
|
+
* non-string/empty input, or text beyond the encoder's ceiling (version 10
|
|
27
|
+
* at this ECC; ~213 bytes at M). Null, never a throw: the component SAYS
|
|
28
|
+
* "too long" and keeps the copy verb working — a missing symbol must not
|
|
29
|
+
* take the whole action down.
|
|
30
|
+
*/
|
|
31
|
+
export function qrSymbol(text, opts) {
|
|
32
|
+
if (typeof text !== 'string' || text === '')
|
|
33
|
+
return null;
|
|
34
|
+
let quiet = Math.trunc(Number(opts?.quietZone));
|
|
35
|
+
if (!Number.isFinite(quiet) || quiet < 0)
|
|
36
|
+
quiet = QR_QUIET_ZONE;
|
|
37
|
+
quiet = Math.min(quiet, 8);
|
|
38
|
+
let matrix;
|
|
39
|
+
try {
|
|
40
|
+
matrix = qrMatrix(text, { ecc: opts?.ecc ?? 'M' });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const { size, modules } = matrix;
|
|
46
|
+
let path = '';
|
|
47
|
+
for (let y = 0; y < size; y++) {
|
|
48
|
+
const row = modules[y];
|
|
49
|
+
let x = 0;
|
|
50
|
+
while (x < size) {
|
|
51
|
+
if (!row[x]) {
|
|
52
|
+
x++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let run = 1;
|
|
56
|
+
while (x + run < size && row[x + run])
|
|
57
|
+
run++;
|
|
58
|
+
path += `M${x + quiet} ${y + quiet}h${run}v1h-${run}z`;
|
|
59
|
+
x += run;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { size: size + quiet * 2, path };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The shareable link at a playback second, from the host's builder. Floors
|
|
66
|
+
* the second (the link's promise is "picks up at 1:23", not "1:22.87" — same
|
|
67
|
+
* convention as deepLink.ts's writes), and degrades to null rather than
|
|
68
|
+
* throwing: a builder that isn't a function, a non-finite time, a builder
|
|
69
|
+
* that throws, or one that answers empty all mean "no link to share", which
|
|
70
|
+
* the component says instead of opening an empty card.
|
|
71
|
+
*/
|
|
72
|
+
export function shareLinkAt(build, sec) {
|
|
73
|
+
if (typeof build !== 'function')
|
|
74
|
+
return null;
|
|
75
|
+
const s = Number(sec);
|
|
76
|
+
if (!Number.isFinite(s) || s < 0)
|
|
77
|
+
return null;
|
|
78
|
+
let link;
|
|
79
|
+
try {
|
|
80
|
+
link = build(Math.floor(s));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return typeof link === 'string' && link !== '' ? link : null;
|
|
86
|
+
}
|