@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.
@@ -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
- if (osdTimer) clearTimeout(osdTimer);
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
- hidden: document.hidden,
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
- const sync = () => untrack(() => s.apply(audioOpts(v)));
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: v.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
- onpause={syncUrlNow}
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 {