@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.
- package/dist/components/VideoPlayer.svelte +262 -7
- package/dist/components/VideoPlayer.svelte.d.ts +13 -0
- package/dist/video/README.md +1 -0
- 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/package.json +1 -1
|
@@ -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)
|
|
3253
|
-
//
|
|
3254
|
-
//
|
|
3255
|
-
//
|
|
3256
|
-
//
|
|
3257
|
-
//
|
|
3258
|
-
|
|
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
|
package/dist/video/README.md
CHANGED
|
@@ -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
|
+
}
|
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' },
|