@joewinke/jatui 0.14.0 → 0.15.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 +570 -8
- package/dist/components/VideoPlayer.svelte.d.ts +74 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +5 -0
- package/dist/video/README.md +1 -1
- package/dist/video/presence/flip.d.ts +145 -0
- package/dist/video/presence/flip.js +253 -0
- package/dist/video/presence/idle.d.ts +82 -0
- package/dist/video/presence/idle.js +145 -0
- package/dist/video/presence/resume.d.ts +76 -0
- package/dist/video/presence/resume.js +139 -0
- package/dist/video/presence/winddown.d.ts +106 -0
- package/dist/video/presence/winddown.js +104 -0
- package/dist/video/presence.d.ts +25 -21
- package/dist/video/presence.js +25 -21
- package/dist/video/settings.js +17 -1
- package/package.json +1 -1
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
|
|
20
20
|
// mos-5lzns.32 — rate composer (smart speed, speaker pace, skip ad reads).
|
|
21
21
|
export type { VideoAdSpan, RateReport } from '../video/rate';
|
|
22
|
+
// mos-5lzns.34 — presence (wind-down, resume-at-sentence, FLIP open).
|
|
23
|
+
export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
|
|
24
|
+
export type { ResumePoint } from '../video/advisors/resume-point';
|
|
22
25
|
</script>
|
|
23
26
|
|
|
24
27
|
<script lang="ts">
|
|
@@ -186,6 +189,36 @@
|
|
|
186
189
|
type AudioBrokenReason,
|
|
187
190
|
type AudioState
|
|
188
191
|
} from '../video/audio';
|
|
192
|
+
import {
|
|
193
|
+
desktopPresent,
|
|
194
|
+
idleDetectionPermission,
|
|
195
|
+
localResumeStore,
|
|
196
|
+
planResumeSnap,
|
|
197
|
+
posterBackground,
|
|
198
|
+
requestIdleDetection,
|
|
199
|
+
resumeToSave,
|
|
200
|
+
shouldBeginWindDown,
|
|
201
|
+
startDesktopWatch,
|
|
202
|
+
toResumeCtx,
|
|
203
|
+
coerceResumeSec,
|
|
204
|
+
flipOpen,
|
|
205
|
+
windDownAfterMs,
|
|
206
|
+
windDownHint,
|
|
207
|
+
windDownLevels,
|
|
208
|
+
windDownProgress,
|
|
209
|
+
RESUME_SAVE_MS,
|
|
210
|
+
RESUME_WHEEL_SEC,
|
|
211
|
+
WIND_DOWN_AFTER_DEFAULT,
|
|
212
|
+
WIND_DOWN_PROBE_MS,
|
|
213
|
+
type DesktopSignal,
|
|
214
|
+
type DesktopWatch,
|
|
215
|
+
type FlipSource,
|
|
216
|
+
type PresenceSource,
|
|
217
|
+
type VideoResumeStore,
|
|
218
|
+
type WindDownPhase,
|
|
219
|
+
type WindDownState
|
|
220
|
+
} from '../video/presence';
|
|
221
|
+
import type { ResumePoint } from '../video/advisors/resume-point';
|
|
189
222
|
|
|
190
223
|
let {
|
|
191
224
|
src,
|
|
@@ -255,6 +288,18 @@
|
|
|
255
288
|
adSpans = null,
|
|
256
289
|
skipAds = false,
|
|
257
290
|
onRateChange = undefined,
|
|
291
|
+
windDown = undefined,
|
|
292
|
+
windDownAfter = WIND_DOWN_AFTER_DEFAULT,
|
|
293
|
+
windDownNightOnly = true,
|
|
294
|
+
idleDetection = false,
|
|
295
|
+
onWindDown = undefined,
|
|
296
|
+
resumeKey = null,
|
|
297
|
+
resumeStore = undefined,
|
|
298
|
+
resumeSnap = true,
|
|
299
|
+
onResume = undefined,
|
|
300
|
+
openFrom = undefined,
|
|
301
|
+
openPoster = null,
|
|
302
|
+
openMotion = true,
|
|
258
303
|
class: className = ''
|
|
259
304
|
}: {
|
|
260
305
|
src: string;
|
|
@@ -463,6 +508,76 @@
|
|
|
463
508
|
skipAds?: boolean;
|
|
464
509
|
/** The composed rate changed (real change only). `base` is what the UI shows. */
|
|
465
510
|
onRateChange?: (report: RateReport) => void;
|
|
511
|
+
/**
|
|
512
|
+
* Wind-down (mos-5lzns.34): when nobody is there, the picture and sound
|
|
513
|
+
* fade over 20s and the video pauses, keeping your place. The fade IS
|
|
514
|
+
* the prompt — any input anywhere on the page (or the desktop coming
|
|
515
|
+
* back) cancels it and brings everything straight back. Omitted/false ⇒
|
|
516
|
+
* the feature does not exist on this player: no listeners, no timers,
|
|
517
|
+
* no variables.
|
|
518
|
+
*
|
|
519
|
+
* The evidence of absence is the DESKTOP when `idleDetection` is on and
|
|
520
|
+
* granted (Chromium's Idle Detection API: system-wide idle, and a locked
|
|
521
|
+
* screen skips the wait entirely); otherwise it is PAGE presence — no
|
|
522
|
+
* input on this page for `windDownAfter` minutes — which is weaker
|
|
523
|
+
* evidence (a video legitimately plays for an hour untouched), and is
|
|
524
|
+
* reported as `source: 'page'` so the host can label it.
|
|
525
|
+
*/
|
|
526
|
+
windDown?: boolean;
|
|
527
|
+
/** Minutes of absence before the fade begins, 10–90 (default 30). */
|
|
528
|
+
windDownAfter?: number;
|
|
529
|
+
/**
|
|
530
|
+
* Only wind down while night is armed (default true — falling asleep
|
|
531
|
+
* mid-video is a night problem). Uses the `night` mode when present,
|
|
532
|
+
* else the local-clock auto window.
|
|
533
|
+
*/
|
|
534
|
+
windDownNightOnly?: boolean;
|
|
535
|
+
/**
|
|
536
|
+
* Opt in to the Idle Detection API (Chromium only; permission-gated).
|
|
537
|
+
* The player NEVER prompts without this. Already granted ⇒ it starts at
|
|
538
|
+
* once; still `prompt` ⇒ the permission is requested on the next click
|
|
539
|
+
* or key inside the player (the API needs a user gesture). Better: call
|
|
540
|
+
* `requestIdleDetection()` from the click that enables the setting.
|
|
541
|
+
* Unsupported or denied ⇒ the page-presence fallback, reported.
|
|
542
|
+
*/
|
|
543
|
+
idleDetection?: boolean;
|
|
544
|
+
/** Phase/source changes: `off` | `watching` | `fading` | `paused`, and `desktop` | `page`. */
|
|
545
|
+
onWindDown?: (state: WindDownState) => void;
|
|
546
|
+
/**
|
|
547
|
+
* Resume (mos-5lzns.34): present ⇒ the player remembers where this video
|
|
548
|
+
* was left under this key and returns there when it opens again. An
|
|
549
|
+
* explicit `startAt` or a `?t=` deep link always wins over a stored point.
|
|
550
|
+
*/
|
|
551
|
+
resumeKey?: string | null;
|
|
552
|
+
/**
|
|
553
|
+
* Where resume points live — `{ load(key), save(key, sec | null) }`,
|
|
554
|
+
* either may be async. Default: this browser's localStorage. jatui never
|
|
555
|
+
* calls an API; a host with per-user server state passes its own.
|
|
556
|
+
*/
|
|
557
|
+
resumeStore?: VideoResumeStore;
|
|
558
|
+
/**
|
|
559
|
+
* Snap the resume point back to the nearest sentence / speaker turn /
|
|
560
|
+
* chapter within 15s once that data lands, and say the line you're
|
|
561
|
+
* walking back into (quoted, never paraphrased). Stands down if you've
|
|
562
|
+
* moved the playhead yourself. Default on; off ⇒ the raw second.
|
|
563
|
+
*/
|
|
564
|
+
resumeSnap?: boolean;
|
|
565
|
+
/** The player moved the playhead for a resume: `from` the stored second, to `point`. */
|
|
566
|
+
onResume?: (from: number, point: ResumePoint) => void;
|
|
567
|
+
/**
|
|
568
|
+
* Open like an object (mos-5lzns.34): the clicked card (an element or a
|
|
569
|
+
* rect snapshot). Present ⇒ on mount the stage grows out of that
|
|
570
|
+
* rectangle (FLIP over WAAPI, 260ms) with `openPoster` painted over the
|
|
571
|
+
* video until its first frame decodes. `null` ⇒ the poster only. The
|
|
572
|
+
* poster paints on EVERY path — under prefers-reduced-motion, with
|
|
573
|
+
* `openMotion` off, or with the card scrolled away — because a black
|
|
574
|
+
* rectangle is a bug at any motion setting. Omitted ⇒ nothing changes.
|
|
575
|
+
*/
|
|
576
|
+
openFrom?: FlipSource | null;
|
|
577
|
+
/** The card's (already-loaded) thumbnail. Defaults to `poster`. */
|
|
578
|
+
openPoster?: string | null;
|
|
579
|
+
/** Run the motion half of the open (default true). Off ⇒ poster only. */
|
|
580
|
+
openMotion?: boolean;
|
|
466
581
|
class?: string;
|
|
467
582
|
} = $props();
|
|
468
583
|
|
|
@@ -698,11 +813,15 @@
|
|
|
698
813
|
zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
|
|
699
814
|
}
|
|
700
815
|
|
|
701
|
-
function flashOsd(text: string) {
|
|
816
|
+
function flashOsd(text: string, ms = 900) {
|
|
817
|
+
if (osdTimer) clearTimeout(osdTimer);
|
|
818
|
+
if (!text || ms <= 0) {
|
|
819
|
+
osdOn = false;
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
702
822
|
osd = text;
|
|
703
823
|
osdOn = true;
|
|
704
|
-
|
|
705
|
-
osdTimer = setTimeout(() => (osdOn = false), 900);
|
|
824
|
+
osdTimer = setTimeout(() => (osdOn = false), ms);
|
|
706
825
|
}
|
|
707
826
|
|
|
708
827
|
// ── Seeking ─────────────────────────────────────────────────────────────
|
|
@@ -898,7 +1017,10 @@
|
|
|
898
1017
|
};
|
|
899
1018
|
return {
|
|
900
1019
|
playing: !v.paused && !v.ended,
|
|
901
|
-
|
|
1020
|
+
// A wind-down fade is not the mix getting quieter: hold the
|
|
1021
|
+
// rider's estimate exactly as a hidden tab does, or it rides
|
|
1022
|
+
// the gain back up against the fade.
|
|
1023
|
+
hidden: document.hidden || windPhase === 'fading',
|
|
902
1024
|
audible: !v.muted && v.volume > 0,
|
|
903
1025
|
time: v.currentTime,
|
|
904
1026
|
audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
|
|
@@ -910,7 +1032,9 @@
|
|
|
910
1032
|
onBroken: (reason) => onAudioBroken?.(reason)
|
|
911
1033
|
});
|
|
912
1034
|
audio = s;
|
|
913
|
-
|
|
1035
|
+
// Mid-fade the ~400 volume writes are ours, not the user's: skip them
|
|
1036
|
+
// (the restore at cancel/finish lands after the phase flips, and syncs).
|
|
1037
|
+
const sync = () => untrack(() => windPhase !== 'fading' && s.apply(audioOpts(v)));
|
|
914
1038
|
const gesture = () => s.gesture();
|
|
915
1039
|
const media = ['volumechange', 'loadstart', 'loadedmetadata'];
|
|
916
1040
|
const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
|
|
@@ -931,7 +1055,8 @@
|
|
|
931
1055
|
target: loudnessTarget,
|
|
932
1056
|
limiter: limiter === true,
|
|
933
1057
|
quiet: quietListening === true,
|
|
934
|
-
volume
|
|
1058
|
+
// Quiet listening reads the volume the USER chose — not the fade's.
|
|
1059
|
+
volume: windPhase === 'fading' ? windSavedVolume : v.volume,
|
|
935
1060
|
muted: v.muted,
|
|
936
1061
|
night: nightArmedNow
|
|
937
1062
|
};
|
|
@@ -1293,6 +1418,380 @@
|
|
|
1293
1418
|
});
|
|
1294
1419
|
});
|
|
1295
1420
|
|
|
1421
|
+
// ── Presence (video/presence/*, mos-5lzns.34) ───────────────────────────
|
|
1422
|
+
// Three optional behaviours, each absent until its prop says otherwise:
|
|
1423
|
+
// wind-down (`windDown`), resume-at-sentence (`resumeKey`), FLIP open
|
|
1424
|
+
// (`openFrom`). The modules advise; everything that touches the element
|
|
1425
|
+
// happens here.
|
|
1426
|
+
|
|
1427
|
+
// Wind-down. `windProgress` > 0 is the only thing that paints (the
|
|
1428
|
+
// --vp-wind-down variable); 0 leaves no trace. The fade is driven by a
|
|
1429
|
+
// 50ms interval rather than rAF on purpose: a locked screen hides the
|
|
1430
|
+
// document, rAF stops dead in a hidden document, and a fade that freezes
|
|
1431
|
+
// at 10% while the video plays on is the one outcome worse than no fade.
|
|
1432
|
+
// Timers only get throttled (≈1/s) there, so the fade still completes.
|
|
1433
|
+
let windPhase: WindDownPhase = $state('off');
|
|
1434
|
+
let windProgress = $state(0);
|
|
1435
|
+
let windSource: PresenceSource | null = $state(null);
|
|
1436
|
+
let desktopSignal: DesktopSignal | null = null;
|
|
1437
|
+
let desktopWatch: DesktopWatch | null = null;
|
|
1438
|
+
let idleAskPending = false;
|
|
1439
|
+
let lastInputAt = Date.now();
|
|
1440
|
+
let windSavedVolume = 1;
|
|
1441
|
+
// The element the fade is acting on — held so a teardown (unmount, src
|
|
1442
|
+
// swap) can hand the volume back even after bind:this has let go.
|
|
1443
|
+
let windEl: HTMLVideoElement | null = null;
|
|
1444
|
+
let windFadeStart = 0;
|
|
1445
|
+
let windFadeTimer: ReturnType<typeof setInterval> | null = null;
|
|
1446
|
+
|
|
1447
|
+
const windOn = $derived(windDown === true);
|
|
1448
|
+
|
|
1449
|
+
function windArmed(): boolean {
|
|
1450
|
+
if (!windOn) return false;
|
|
1451
|
+
if (!windDownNightOnly) return true;
|
|
1452
|
+
return nightArmed(night ?? 'auto', new Date(), prefersDark, nightWindow);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function stopWindFade() {
|
|
1456
|
+
if (windFadeTimer) clearInterval(windFadeTimer);
|
|
1457
|
+
windFadeTimer = null;
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function windBegin() {
|
|
1461
|
+
const v = videoEl;
|
|
1462
|
+
if (!v || windPhase === 'fading') return;
|
|
1463
|
+
windPhase = 'fading';
|
|
1464
|
+
windEl = v;
|
|
1465
|
+
windSavedVolume = v.volume;
|
|
1466
|
+
windFadeStart = Date.now();
|
|
1467
|
+
const hint = windDownHint('fading', windSource ?? 'page');
|
|
1468
|
+
flashOsd(hint, 6000);
|
|
1469
|
+
announce(hint);
|
|
1470
|
+
stopWindFade();
|
|
1471
|
+
windFadeTimer = setInterval(windStep, 50);
|
|
1472
|
+
windStep();
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
function windStep() {
|
|
1476
|
+
const v = videoEl;
|
|
1477
|
+
if (!v || windPhase !== 'fading') return stopWindFade();
|
|
1478
|
+
const p = windDownProgress(Date.now() - windFadeStart);
|
|
1479
|
+
const lv = windDownLevels(p);
|
|
1480
|
+
v.volume = Math.max(0, Math.min(1, windSavedVolume * lv.volume));
|
|
1481
|
+
windProgress = p;
|
|
1482
|
+
if (p < 1) return;
|
|
1483
|
+
// Pause, hand the volume back for next time, and leave the picture dark
|
|
1484
|
+
// until something happens. The phase flips BEFORE pause() so the pause
|
|
1485
|
+
// handler doesn't read our own pause as the user cancelling.
|
|
1486
|
+
stopWindFade();
|
|
1487
|
+
windPhase = 'paused';
|
|
1488
|
+
v.pause();
|
|
1489
|
+
v.volume = windSavedVolume;
|
|
1490
|
+
const hint = windDownHint('paused', windSource ?? 'page');
|
|
1491
|
+
flashOsd(hint, 8000);
|
|
1492
|
+
announce(hint);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/** Anything that says you're here: restore, and (mid-fade) playback simply carries on. */
|
|
1496
|
+
function windCancel() {
|
|
1497
|
+
if (windPhase === 'fading') {
|
|
1498
|
+
stopWindFade();
|
|
1499
|
+
if (windEl) windEl.volume = windSavedVolume;
|
|
1500
|
+
flashOsd('', 0);
|
|
1501
|
+
}
|
|
1502
|
+
windEl = null;
|
|
1503
|
+
if (windPhase === 'fading' || windPhase === 'paused') windPhase = windOn ? 'watching' : 'off';
|
|
1504
|
+
windProgress = 0;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
function windProbe() {
|
|
1508
|
+
const v = videoEl;
|
|
1509
|
+
if (!windOn || !v) return;
|
|
1510
|
+
const begin = shouldBeginWindDown({
|
|
1511
|
+
armed: windArmed(),
|
|
1512
|
+
playing: !v.paused && !v.ended,
|
|
1513
|
+
active: windPhase === 'fading',
|
|
1514
|
+
source: windSource ?? 'page',
|
|
1515
|
+
desktop: desktopSignal,
|
|
1516
|
+
quietMs: Date.now() - lastInputAt,
|
|
1517
|
+
afterMs: windDownAfterMs(windDownAfter),
|
|
1518
|
+
hidden: typeof document !== 'undefined' && document.hidden
|
|
1519
|
+
});
|
|
1520
|
+
if (begin) windBegin();
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
function onDesktopSignal(sig: DesktopSignal | null) {
|
|
1524
|
+
desktopSignal = sig;
|
|
1525
|
+
if (desktopPresent(sig)) {
|
|
1526
|
+
if (windPhase === 'fading') windCancel();
|
|
1527
|
+
} else if (sig) {
|
|
1528
|
+
windProbe();
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
function startDesktop() {
|
|
1533
|
+
desktopWatch?.stop();
|
|
1534
|
+
const w = startDesktopWatch({ thresholdMs: windDownAfterMs(windDownAfter), onChange: onDesktopSignal });
|
|
1535
|
+
desktopWatch = w;
|
|
1536
|
+
void w.ready.then((ok) => {
|
|
1537
|
+
if (desktopWatch !== w) return;
|
|
1538
|
+
windSource = ok ? 'desktop' : 'page';
|
|
1539
|
+
if (!ok) desktopSignal = null;
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function stopDesktop() {
|
|
1544
|
+
desktopWatch?.stop();
|
|
1545
|
+
desktopWatch = null;
|
|
1546
|
+
desktopSignal = null;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// The only path to a permission prompt: the host opted in AND this is a
|
|
1550
|
+
// user gesture inside the player. Called synchronously from the handler,
|
|
1551
|
+
// so the browser still sees the activation.
|
|
1552
|
+
//
|
|
1553
|
+
// Wired to pointerdown, pointerup, click and keydown: which of them grants
|
|
1554
|
+
// activation depends on the input (a touch pointerdown grants none; its
|
|
1555
|
+
// pointerup does). An ask the browser refused for want of activation
|
|
1556
|
+
// ('unsupported' while the permission still reads 'prompt') re-arms for
|
|
1557
|
+
// the next gesture; a 'denied' is an answer and is never re-asked.
|
|
1558
|
+
let idleAskInFlight = false;
|
|
1559
|
+
function maybeAskIdle() {
|
|
1560
|
+
if (!idleAskPending || idleAskInFlight || !windOn || idleDetection !== true) return;
|
|
1561
|
+
idleAskPending = false;
|
|
1562
|
+
idleAskInFlight = true;
|
|
1563
|
+
void requestIdleDetection().then(async (r) => {
|
|
1564
|
+
idleAskInFlight = false;
|
|
1565
|
+
if (!windOn || idleDetection !== true) return;
|
|
1566
|
+
if (r === 'granted') startDesktop();
|
|
1567
|
+
else if (r === 'unsupported' && (await idleDetectionPermission()) === 'prompt') idleAskPending = true;
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
// Feature lifecycle: on ⇒ page listeners + the probe; off ⇒ every trace
|
|
1572
|
+
// removed (a fade in progress is cancelled and the volume handed back).
|
|
1573
|
+
$effect(() => {
|
|
1574
|
+
if (!windOn) {
|
|
1575
|
+
untrack(() => {
|
|
1576
|
+
windCancel();
|
|
1577
|
+
windPhase = 'off';
|
|
1578
|
+
windSource = null;
|
|
1579
|
+
});
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
untrack(() => {
|
|
1583
|
+
if (windPhase === 'off') windPhase = 'watching';
|
|
1584
|
+
if (windSource === null) windSource = 'page';
|
|
1585
|
+
lastInputAt = Date.now();
|
|
1586
|
+
});
|
|
1587
|
+
// Page input only ever CANCELS a fade (and resets the page-presence
|
|
1588
|
+
// clock); for the desktop source it never postpones the probe.
|
|
1589
|
+
const onInput = () => {
|
|
1590
|
+
lastInputAt = Date.now();
|
|
1591
|
+
if (windPhase === 'fading' || windPhase === 'paused') windCancel();
|
|
1592
|
+
};
|
|
1593
|
+
const evs = ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
|
|
1594
|
+
for (const ev of evs) window.addEventListener(ev, onInput, { passive: true, capture: true });
|
|
1595
|
+
// Coming back to the tab is presence too: the page clock restarts, so a
|
|
1596
|
+
// long stretch spent in another tab can't wind down the moment you return.
|
|
1597
|
+
const onVisible = () => {
|
|
1598
|
+
if (document.visibilityState === 'visible') lastInputAt = Date.now();
|
|
1599
|
+
};
|
|
1600
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
1601
|
+
const probe = setInterval(windProbe, WIND_DOWN_PROBE_MS);
|
|
1602
|
+
return () => {
|
|
1603
|
+
for (const ev of evs) window.removeEventListener(ev, onInput, { capture: true });
|
|
1604
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
1605
|
+
clearInterval(probe);
|
|
1606
|
+
// Off or unmounted mid-fade: nothing may outlive the feature.
|
|
1607
|
+
windCancel();
|
|
1608
|
+
};
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// The desktop source: only with the explicit opt-in, never prompting here.
|
|
1612
|
+
$effect(() => {
|
|
1613
|
+
const on = windOn && idleDetection === true;
|
|
1614
|
+
void windDownAfter; // the detector's threshold — restart on change
|
|
1615
|
+
if (!on) return;
|
|
1616
|
+
let cancelled = false;
|
|
1617
|
+
untrack(() => {
|
|
1618
|
+
void idleDetectionPermission().then((perm) => {
|
|
1619
|
+
if (cancelled) return;
|
|
1620
|
+
if (perm === 'granted') startDesktop();
|
|
1621
|
+
else if (perm === 'prompt') idleAskPending = true;
|
|
1622
|
+
else windSource = 'page';
|
|
1623
|
+
});
|
|
1624
|
+
});
|
|
1625
|
+
return () => {
|
|
1626
|
+
cancelled = true;
|
|
1627
|
+
idleAskPending = false;
|
|
1628
|
+
stopDesktop();
|
|
1629
|
+
if (windOn) windSource = 'page';
|
|
1630
|
+
};
|
|
1631
|
+
});
|
|
1632
|
+
|
|
1633
|
+
// A new source is a new video: nothing carries over.
|
|
1634
|
+
$effect(() => {
|
|
1635
|
+
void src;
|
|
1636
|
+
untrack(() => {
|
|
1637
|
+
if (windPhase === 'fading' || windPhase === 'paused') windCancel();
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
let lastWindReport = '';
|
|
1642
|
+
$effect(() => {
|
|
1643
|
+
const state: WindDownState = { phase: windPhase, source: windOn ? windSource : null };
|
|
1644
|
+
const sig = state.phase + '|' + state.source;
|
|
1645
|
+
if (sig === lastWindReport) return;
|
|
1646
|
+
const first = lastWindReport === '';
|
|
1647
|
+
lastWindReport = sig;
|
|
1648
|
+
// Absent feature: never report (it doesn't exist on this player).
|
|
1649
|
+
if (first && state.phase === 'off') return;
|
|
1650
|
+
untrack(() => onWindDown?.(state));
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
// Resume. The stored point is applied once per key, after metadata, unless
|
|
1654
|
+
// an explicit startAt / ?t= says otherwise or you've already moved; the
|
|
1655
|
+
// snap (advisors/resume-point via presence/resume.ts) waits for the
|
|
1656
|
+
// boundaries to land. Nothing is written until the stored point has been
|
|
1657
|
+
// read AND applied — closing a video before its metadata loads must never
|
|
1658
|
+
// clobber the good point with a 0.
|
|
1659
|
+
const fallbackResumeStore = localResumeStore();
|
|
1660
|
+
const resumeOn = $derived(typeof resumeKey === 'string' && resumeKey.length > 0);
|
|
1661
|
+
const activeResumeStore = $derived(resumeStore ?? fallbackResumeStore);
|
|
1662
|
+
const resumeCtx = $derived(resumeOn ? toResumeCtx(transcript, speakers, chapters) : null);
|
|
1663
|
+
let resumeActiveKey: string | null = null;
|
|
1664
|
+
let resumeLoaded = false;
|
|
1665
|
+
let resumeFrom: number | null = null;
|
|
1666
|
+
let pendingSnap: number | null = null;
|
|
1667
|
+
let resumeLastSave = 0;
|
|
1668
|
+
// The element whose position is being remembered — kept so the final write
|
|
1669
|
+
// on unmount still lands after bind:this has let go of it.
|
|
1670
|
+
let resumeEl: HTMLVideoElement | null = null;
|
|
1671
|
+
|
|
1672
|
+
function applyStoredResume() {
|
|
1673
|
+
const v = videoEl;
|
|
1674
|
+
if (resumeFrom == null || !v) return;
|
|
1675
|
+
if (startAt != null || urlStartAt != null) {
|
|
1676
|
+
resumeFrom = null; // the host's / the link's intent wins
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (v.readyState < 1) return; // onLoadedMetadata calls again
|
|
1680
|
+
const at = resumeFrom;
|
|
1681
|
+
resumeFrom = null;
|
|
1682
|
+
if (v.currentTime > RESUME_WHEEL_SEC) return; // you already moved — leave it
|
|
1683
|
+
seekTo(at);
|
|
1684
|
+
onResume?.(at, { at, reason: 'exact', label: '' });
|
|
1685
|
+
if (resumeSnap) {
|
|
1686
|
+
pendingSnap = at;
|
|
1687
|
+
trySnap();
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
function trySnap() {
|
|
1692
|
+
const v = videoEl;
|
|
1693
|
+
if (pendingSnap == null || !v || !resumeCtx) return;
|
|
1694
|
+
const plan = planResumeSnap(pendingSnap, v.currentTime, resumeCtx);
|
|
1695
|
+
if (plan.kind === 'wait') return;
|
|
1696
|
+
const from = pendingSnap;
|
|
1697
|
+
pendingSnap = null;
|
|
1698
|
+
if (plan.kind !== 'seek') return;
|
|
1699
|
+
seekTo(plan.point.at);
|
|
1700
|
+
if (plan.point.label) {
|
|
1701
|
+
flashOsd(plan.point.label, 5000);
|
|
1702
|
+
announce(plan.point.label);
|
|
1703
|
+
}
|
|
1704
|
+
onResume?.(from, plan.point);
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function saveResume(force: boolean) {
|
|
1708
|
+
const key = resumeActiveKey;
|
|
1709
|
+
const v = videoEl ?? resumeEl;
|
|
1710
|
+
if (videoEl) resumeEl = videoEl;
|
|
1711
|
+
if (!key || !resumeLoaded || resumeFrom != null || !v || v.readyState < 1) return;
|
|
1712
|
+
const now = Date.now();
|
|
1713
|
+
if (!force && now - resumeLastSave < RESUME_SAVE_MS) return;
|
|
1714
|
+
const sec = resumeToSave(v.currentTime, effectiveDuration);
|
|
1715
|
+
if (sec === undefined) return;
|
|
1716
|
+
resumeLastSave = now;
|
|
1717
|
+
try {
|
|
1718
|
+
void Promise.resolve(activeResumeStore.save(key, sec)).catch(() => {});
|
|
1719
|
+
} catch {
|
|
1720
|
+
/* a host store that throws is a missed save, never a broken player */
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
$effect(() => {
|
|
1725
|
+
const key = resumeOn ? (resumeKey as string) : null;
|
|
1726
|
+
const store = activeResumeStore;
|
|
1727
|
+
untrack(() => {
|
|
1728
|
+
if (key === resumeActiveKey) return;
|
|
1729
|
+
saveResume(true); // the outgoing key's last position, if still readable
|
|
1730
|
+
resumeActiveKey = key;
|
|
1731
|
+
resumeLoaded = false;
|
|
1732
|
+
resumeFrom = null;
|
|
1733
|
+
pendingSnap = null;
|
|
1734
|
+
resumeLastSave = 0;
|
|
1735
|
+
if (!key) return;
|
|
1736
|
+
let loaded: ReturnType<VideoResumeStore['load']>;
|
|
1737
|
+
try {
|
|
1738
|
+
loaded = store.load(key);
|
|
1739
|
+
} catch {
|
|
1740
|
+
loaded = null;
|
|
1741
|
+
}
|
|
1742
|
+
Promise.resolve(loaded).then(
|
|
1743
|
+
(val) => {
|
|
1744
|
+
if (resumeActiveKey !== key) return;
|
|
1745
|
+
resumeLoaded = true;
|
|
1746
|
+
resumeFrom = coerceResumeSec(val);
|
|
1747
|
+
applyStoredResume();
|
|
1748
|
+
},
|
|
1749
|
+
() => {
|
|
1750
|
+
if (resumeActiveKey === key) resumeLoaded = true;
|
|
1751
|
+
}
|
|
1752
|
+
);
|
|
1753
|
+
});
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
// The snap runs when the boundaries land, not at open.
|
|
1757
|
+
$effect(() => {
|
|
1758
|
+
void resumeCtx;
|
|
1759
|
+
untrack(trySnap);
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
// A hidden tab may never come back — write the position on the way out.
|
|
1763
|
+
$effect(() => {
|
|
1764
|
+
if (!resumeOn) return;
|
|
1765
|
+
const flush = () => saveResume(true);
|
|
1766
|
+
const onVis = () => {
|
|
1767
|
+
if (document.visibilityState === 'hidden') flush();
|
|
1768
|
+
};
|
|
1769
|
+
window.addEventListener('pagehide', flush);
|
|
1770
|
+
document.addEventListener('visibilitychange', onVis);
|
|
1771
|
+
return () => {
|
|
1772
|
+
flush();
|
|
1773
|
+
window.removeEventListener('pagehide', flush);
|
|
1774
|
+
document.removeEventListener('visibilitychange', onVis);
|
|
1775
|
+
};
|
|
1776
|
+
});
|
|
1777
|
+
|
|
1778
|
+
// FLIP open. Read once, at open — this is about how the player ARRIVES.
|
|
1779
|
+
// The poster paints on every path and clears on the first decoded frame.
|
|
1780
|
+
let stageWrapEl: HTMLDivElement | undefined = $state();
|
|
1781
|
+
let openPosterOn = $state(false);
|
|
1782
|
+
let openDone = false;
|
|
1783
|
+
const openPosterImage = $derived(posterBackground(openPoster ?? poster));
|
|
1784
|
+
$effect(() => {
|
|
1785
|
+
const shell = stageWrapEl;
|
|
1786
|
+
if (!shell || openDone) return;
|
|
1787
|
+
untrack(() => {
|
|
1788
|
+
openDone = true;
|
|
1789
|
+
if (openFrom === undefined) return;
|
|
1790
|
+
openPosterOn = !!openPosterImage && !(videoEl && videoEl.readyState >= 2);
|
|
1791
|
+
void flipOpen({ from: openMotion ? openFrom : null, shell, hasPoster: openPosterOn });
|
|
1792
|
+
});
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1296
1795
|
// ── Band pointer handlers ───────────────────────────────────────────────
|
|
1297
1796
|
function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
|
|
1298
1797
|
const canvas = canvasEl;
|
|
@@ -1698,6 +2197,7 @@
|
|
|
1698
2197
|
seekTo(start);
|
|
1699
2198
|
}
|
|
1700
2199
|
}
|
|
2200
|
+
applyStoredResume();
|
|
1701
2201
|
}
|
|
1702
2202
|
|
|
1703
2203
|
/**
|
|
@@ -1728,6 +2228,7 @@
|
|
|
1728
2228
|
viewStart = follow.start;
|
|
1729
2229
|
viewEnd = follow.end;
|
|
1730
2230
|
}
|
|
2231
|
+
if (resumeOn) saveResume(false);
|
|
1731
2232
|
if (videoEl) onTimeUpdate?.(videoEl);
|
|
1732
2233
|
}
|
|
1733
2234
|
|
|
@@ -1778,9 +2279,13 @@
|
|
|
1778
2279
|
aria-label="Video player"
|
|
1779
2280
|
tabindex="-1"
|
|
1780
2281
|
onkeydown={onRootKeyDown}
|
|
2282
|
+
onpointerdowncapture={maybeAskIdle}
|
|
2283
|
+
onpointerupcapture={maybeAskIdle}
|
|
2284
|
+
onclickcapture={maybeAskIdle}
|
|
2285
|
+
onkeydowncapture={maybeAskIdle}
|
|
1781
2286
|
>
|
|
1782
2287
|
<div class="vp-main">
|
|
1783
|
-
<div class="vp-stage-wrap">
|
|
2288
|
+
<div class="vp-stage-wrap" bind:this={stageWrapEl}>
|
|
1784
2289
|
{#if nightPresent}
|
|
1785
2290
|
<!-- Bias light: the frame's own colours, blurred, spilled behind the
|
|
1786
2291
|
picture. Earlier in the DOM than the (positioned) stage, so the
|
|
@@ -1804,6 +2309,7 @@
|
|
|
1804
2309
|
style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
|
|
1805
2310
|
style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
|
|
1806
2311
|
style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
|
|
2312
|
+
style:--vp-wind-down={windProgress > 0 ? windProgress.toFixed(3) : undefined}
|
|
1807
2313
|
onwheel={onStageWheel}
|
|
1808
2314
|
>
|
|
1809
2315
|
<video
|
|
@@ -1818,8 +2324,19 @@
|
|
|
1818
2324
|
}}
|
|
1819
2325
|
onplay={() => {
|
|
1820
2326
|
if (nightPresent || guardPresent) nightTick();
|
|
2327
|
+
if (windPhase === 'paused') windCancel();
|
|
2328
|
+
if (windOn) windProbe();
|
|
2329
|
+
}}
|
|
2330
|
+
onpause={() => {
|
|
2331
|
+
syncUrlNow();
|
|
2332
|
+
// You paused mid-fade: that's an answer too — hand the volume back.
|
|
2333
|
+
if (windPhase === 'fading') windCancel();
|
|
2334
|
+
if (resumeOn) saveResume(true);
|
|
2335
|
+
}}
|
|
2336
|
+
onended={() => {
|
|
2337
|
+
if (resumeOn) saveResume(true);
|
|
1821
2338
|
}}
|
|
1822
|
-
|
|
2339
|
+
onloadeddata={() => (openPosterOn = false)}
|
|
1823
2340
|
{src}
|
|
1824
2341
|
{loop}
|
|
1825
2342
|
crossorigin={crossorigin ?? undefined}
|
|
@@ -1837,6 +2354,13 @@
|
|
|
1837
2354
|
menu but isn't painted on top of the frames' own text (jat-abwim). -->
|
|
1838
2355
|
<track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
|
|
1839
2356
|
</video>
|
|
2357
|
+
{#if openPosterOn}
|
|
2358
|
+
<!-- Open-like-an-object poster (mos-5lzns.34): the card's own
|
|
2359
|
+
thumbnail OVER the video's opaque letterbox until the first frame
|
|
2360
|
+
decodes — a layer behind it would be hidden by the very box it
|
|
2361
|
+
exists to cover. Below the night veils and the OSD by DOM order. -->
|
|
2362
|
+
<div class="vp-open-poster" style:--vp-open-poster={openPosterImage} aria-hidden="true"></div>
|
|
2363
|
+
{/if}
|
|
1840
2364
|
{#if nightPresent}
|
|
1841
2365
|
<!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
|
|
1842
2366
|
point and fog the picture; multiplying leaves blacks black. At
|
|
@@ -1844,6 +2368,15 @@
|
|
|
1844
2368
|
<div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
|
|
1845
2369
|
<div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
|
|
1846
2370
|
{/if}
|
|
2371
|
+
{#if windProgress > 0}
|
|
2372
|
+
<!-- Wind-down veil (mos-5lzns.34): DARKENS toward neutral over the
|
|
2373
|
+
picture and the night veils. The video itself is never made
|
|
2374
|
+
translucent — whatever sits behind it (the page, the bias
|
|
2375
|
+
light) would show through and the fade would brighten. A DOM
|
|
2376
|
+
layer, so the night meter still reads raw frames. Under the
|
|
2377
|
+
OSD, which carries the hint. Gone the instant progress is 0. -->
|
|
2378
|
+
<div class="vp-wind-veil" aria-hidden="true"></div>
|
|
2379
|
+
{/if}
|
|
1847
2380
|
<div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
|
|
1848
2381
|
</div>
|
|
1849
2382
|
</div>
|
|
@@ -2786,6 +3319,35 @@
|
|
|
2786
3319
|
position: relative;
|
|
2787
3320
|
}
|
|
2788
3321
|
|
|
3322
|
+
/* ── Presence (mos-5lzns.34) ──────────────────────────────────────────
|
|
3323
|
+
Wind-down: a neutral veil driven frame by frame from script, so no
|
|
3324
|
+
transition to fight.
|
|
3325
|
+
Its own variable, never folded into --vp-night-bright: "this is bright"
|
|
3326
|
+
and "you are not here" are different statements, and one must not be
|
|
3327
|
+
able to cancel the other out. Unset ⇒ opacity 1, no pixel changed. */
|
|
3328
|
+
.vp-wind-veil {
|
|
3329
|
+
position: absolute;
|
|
3330
|
+
inset: 0;
|
|
3331
|
+
pointer-events: none;
|
|
3332
|
+
border-radius: inherit;
|
|
3333
|
+
background: var(--color-neutral);
|
|
3334
|
+
opacity: calc(0.92 * var(--vp-wind-down, 0));
|
|
3335
|
+
transition: none;
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
/* The open poster: a stand-in for the first frame, so it sits exactly
|
|
3339
|
+
where the video will put its picture (contain, not cover). */
|
|
3340
|
+
.vp-open-poster {
|
|
3341
|
+
position: absolute;
|
|
3342
|
+
inset: 0;
|
|
3343
|
+
pointer-events: none;
|
|
3344
|
+
border-radius: inherit;
|
|
3345
|
+
background-image: var(--vp-open-poster, none);
|
|
3346
|
+
background-repeat: no-repeat;
|
|
3347
|
+
background-position: center;
|
|
3348
|
+
background-size: contain;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
2789
3351
|
/* Bias light: peripheral light, so the blur is large on purpose — any
|
|
2790
3352
|
detail in it reads as a distracting second image. */
|
|
2791
3353
|
.vp-bias {
|