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