@joewinke/jatui 0.14.0 → 0.16.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,11 @@
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';
25
+ // mos-5lzns.35 — settings registry + panel.
26
+ export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
22
27
  </script>
23
28
 
24
29
  <script lang="ts">
@@ -186,6 +191,47 @@
186
191
  type AudioBrokenReason,
187
192
  type AudioState
188
193
  } from '../video/audio';
194
+ import {
195
+ desktopPresent,
196
+ idleDetectionPermission,
197
+ localResumeStore,
198
+ planResumeSnap,
199
+ posterBackground,
200
+ requestIdleDetection,
201
+ resumeToSave,
202
+ shouldBeginWindDown,
203
+ startDesktopWatch,
204
+ toResumeCtx,
205
+ coerceResumeSec,
206
+ flipOpen,
207
+ windDownAfterMs,
208
+ windDownHint,
209
+ windDownLevels,
210
+ windDownProgress,
211
+ RESUME_SAVE_MS,
212
+ RESUME_WHEEL_SEC,
213
+ WIND_DOWN_AFTER_DEFAULT,
214
+ WIND_DOWN_PROBE_MS,
215
+ type DesktopSignal,
216
+ type DesktopWatch,
217
+ type FlipSource,
218
+ type PresenceSource,
219
+ type VideoResumeStore,
220
+ type WindDownPhase,
221
+ type WindDownState
222
+ } from '../video/presence';
223
+ import type { ResumePoint } from '../video/advisors/resume-point';
224
+ import { idleDetectionSupported } from '../video/presence';
225
+ import {
226
+ createSettingsSession,
227
+ panelGroups,
228
+ VIDEO_SETTINGS,
229
+ type SettingAvailability,
230
+ type SettingsSession,
231
+ type SettingValue,
232
+ type VideoSettingsStore
233
+ } from '../video/settings';
234
+ import SettingsPanel from '../video/settings/SettingsPanel.svelte';
189
235
 
190
236
  let {
191
237
  src,
@@ -229,7 +275,7 @@
229
275
  syncUrlTime = false,
230
276
  luma = null,
231
277
  markers = [],
232
- oneAxis = true,
278
+ oneAxis: oneAxisProp = true,
233
279
  onBandChange = undefined,
234
280
  onMarkerClick = undefined,
235
281
  onTimeUpdate = undefined,
@@ -238,23 +284,39 @@
238
284
  night = $bindable(),
239
285
  nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
240
286
  nightWindow = null,
241
- biasLight = true,
242
- predictive = true,
243
- strobeGuard = true,
287
+ biasLight: biasLightProp = true,
288
+ predictive: predictiveProp = true,
289
+ strobeGuard: strobeGuardProp = true,
244
290
  onNightModeChange = undefined,
245
291
  onNightBlind = undefined,
246
- loudness = undefined,
247
- loudnessTarget = AUDIO_TARGET_DEFAULT,
248
- quietListening = undefined,
249
- limiter = undefined,
292
+ loudness: loudnessProp = undefined,
293
+ loudnessTarget: loudnessTargetProp = AUDIO_TARGET_DEFAULT,
294
+ quietListening: quietListeningProp = undefined,
295
+ limiter: limiterProp = undefined,
250
296
  onAudioState = undefined,
251
297
  onAudioBroken = undefined,
252
- smartSpeed = false,
253
- speakerPace = false,
298
+ smartSpeed: smartSpeedProp = false,
299
+ speakerPace: speakerPaceProp = false,
254
300
  speakerRates = null,
255
301
  adSpans = null,
256
- skipAds = false,
302
+ skipAds: skipAdsProp = false,
257
303
  onRateChange = undefined,
304
+ windDown: windDownProp = undefined,
305
+ windDownAfter: windDownAfterProp = WIND_DOWN_AFTER_DEFAULT,
306
+ windDownNightOnly: windDownNightOnlyProp = true,
307
+ idleDetection: idleDetectionProp = false,
308
+ onWindDown = undefined,
309
+ resumeKey = null,
310
+ resumeStore = undefined,
311
+ resumeSnap: resumeSnapProp = true,
312
+ onResume = undefined,
313
+ openFrom = undefined,
314
+ openPoster = null,
315
+ openMotion: openMotionProp = true,
316
+ settingsStore = undefined,
317
+ settingsPanel = false,
318
+ settingsHelpHref = null,
319
+ onSettingChange = undefined,
258
320
  class: className = ''
259
321
  }: {
260
322
  src: string;
@@ -463,9 +525,138 @@
463
525
  skipAds?: boolean;
464
526
  /** The composed rate changed (real change only). `base` is what the UI shows. */
465
527
  onRateChange?: (report: RateReport) => void;
528
+ /**
529
+ * Wind-down (mos-5lzns.34): when nobody is there, the picture and sound
530
+ * fade over 20s and the video pauses, keeping your place. The fade IS
531
+ * the prompt — any input anywhere on the page (or the desktop coming
532
+ * back) cancels it and brings everything straight back. Omitted/false ⇒
533
+ * the feature does not exist on this player: no listeners, no timers,
534
+ * no variables.
535
+ *
536
+ * The evidence of absence is the DESKTOP when `idleDetection` is on and
537
+ * granted (Chromium's Idle Detection API: system-wide idle, and a locked
538
+ * screen skips the wait entirely); otherwise it is PAGE presence — no
539
+ * input on this page for `windDownAfter` minutes — which is weaker
540
+ * evidence (a video legitimately plays for an hour untouched), and is
541
+ * reported as `source: 'page'` so the host can label it.
542
+ */
543
+ windDown?: boolean;
544
+ /** Minutes of absence before the fade begins, 10–90 (default 30). */
545
+ windDownAfter?: number;
546
+ /**
547
+ * Only wind down while night is armed (default true — falling asleep
548
+ * mid-video is a night problem). Uses the `night` mode when present,
549
+ * else the local-clock auto window.
550
+ */
551
+ windDownNightOnly?: boolean;
552
+ /**
553
+ * Opt in to the Idle Detection API (Chromium only; permission-gated).
554
+ * The player NEVER prompts without this. Already granted ⇒ it starts at
555
+ * once; still `prompt` ⇒ the permission is requested on the next click
556
+ * or key inside the player (the API needs a user gesture). Better: call
557
+ * `requestIdleDetection()` from the click that enables the setting.
558
+ * Unsupported or denied ⇒ the page-presence fallback, reported.
559
+ */
560
+ idleDetection?: boolean;
561
+ /** Phase/source changes: `off` | `watching` | `fading` | `paused`, and `desktop` | `page`. */
562
+ onWindDown?: (state: WindDownState) => void;
563
+ /**
564
+ * Resume (mos-5lzns.34): present ⇒ the player remembers where this video
565
+ * was left under this key and returns there when it opens again. An
566
+ * explicit `startAt` or a `?t=` deep link always wins over a stored point.
567
+ */
568
+ resumeKey?: string | null;
569
+ /**
570
+ * Where resume points live — `{ load(key), save(key, sec | null) }`,
571
+ * either may be async. Default: this browser's localStorage. jatui never
572
+ * calls an API; a host with per-user server state passes its own.
573
+ */
574
+ resumeStore?: VideoResumeStore;
575
+ /**
576
+ * Snap the resume point back to the nearest sentence / speaker turn /
577
+ * chapter within 15s once that data lands, and say the line you're
578
+ * walking back into (quoted, never paraphrased). Stands down if you've
579
+ * moved the playhead yourself. Default on; off ⇒ the raw second.
580
+ */
581
+ resumeSnap?: boolean;
582
+ /** The player moved the playhead for a resume: `from` the stored second, to `point`. */
583
+ onResume?: (from: number, point: ResumePoint) => void;
584
+ /**
585
+ * Open like an object (mos-5lzns.34): the clicked card (an element or a
586
+ * rect snapshot). Present ⇒ on mount the stage grows out of that
587
+ * rectangle (FLIP over WAAPI, 260ms) with `openPoster` painted over the
588
+ * video until its first frame decodes. `null` ⇒ the poster only. The
589
+ * poster paints on EVERY path — under prefers-reduced-motion, with
590
+ * `openMotion` off, or with the card scrolled away — because a black
591
+ * rectangle is a bug at any motion setting. Omitted ⇒ nothing changes.
592
+ */
593
+ openFrom?: FlipSource | null;
594
+ /** The card's (already-loaded) thumbnail. Defaults to `poster`. */
595
+ openPoster?: string | null;
596
+ /** Run the motion half of the open (default true). Off ⇒ poster only. */
597
+ openMotion?: boolean;
598
+ /**
599
+ * Settings (mos-5lzns.35): where the viewer's standing choices live —
600
+ * `{ get(id), set(id, value) }`, either may be async, keyed by the ids
601
+ * in `VIDEO_SETTINGS`. jatui never persists on its own (no
602
+ * localStorage, no API): a host with per-user state passes its own
603
+ * adapter. Every stored value is coerced; junk reads as nothing.
604
+ *
605
+ * A stored choice overrides the matching prop — the prop is the
606
+ * host's DEFAULT, the stored value is the viewer's decision. It never
607
+ * creates a feature the host left out: a stored `audio.level` does
608
+ * nothing on a player with no `loudness` prop. `night.mode` /
609
+ * `night.depth` are written into the bindable `night` / `nightDepth`,
610
+ * and `N` / the moon wheel are saved back through the store.
611
+ */
612
+ settingsStore?: VideoSettingsStore;
613
+ /**
614
+ * Show the settings panel (default off — inert): a gear in the control
615
+ * strip and the `,` key open it. The panel is generated from the
616
+ * registry and shows only rows for features THIS player has (host
617
+ * opted in and the data exists). Without a `settingsStore`, choices
618
+ * last while the player is mounted.
619
+ */
620
+ settingsPanel?: boolean;
621
+ /** A host page explaining the settings — linked from the panel's footer. */
622
+ settingsHelpHref?: string | null;
623
+ /** The viewer changed a setting (panel, `N`, the moon wheel). Coerced value. */
624
+ onSettingChange?: (id: string, value: SettingValue) => void;
466
625
  class?: string;
467
626
  } = $props();
468
627
 
628
+ // ── Settings overlay (mos-5lzns.35) ─────────────────────────────────────
629
+ // The viewer's stored/panel choices, coerced, keyed by registry id. Each
630
+ // feature reads the EFFECTIVE value below under its prop's own name, so
631
+ // no reader changed: the choice wins over the host's prop, but a
632
+ // presence-gated feature (prop left undefined) stays absent whatever is
633
+ // stored. night / nightDepth are bindable and written directly instead.
634
+ let userSettings = $state<Record<string, SettingValue>>({});
635
+ function chosen<T>(id: string, prop: T): T {
636
+ const v = userSettings[id];
637
+ return (v === undefined ? prop : v) as T;
638
+ }
639
+ function chosenIfPresent<T>(id: string, prop: T | undefined): T | undefined {
640
+ return prop === undefined ? undefined : chosen(id, prop);
641
+ }
642
+ const oneAxis = $derived(chosen('player.oneAxis', oneAxisProp));
643
+ const biasLight = $derived(chosen('night.bias', biasLightProp));
644
+ const predictive = $derived(chosen('player.predictive', predictiveProp));
645
+ const strobeGuard = $derived(chosen('player.strobeGuard', strobeGuardProp));
646
+ const loudness = $derived(chosenIfPresent('audio.level', loudnessProp));
647
+ const loudnessTarget = $derived(chosen('audio.target', loudnessTargetProp));
648
+ const quietListening = $derived(chosenIfPresent('audio.quiet', quietListeningProp));
649
+ const limiter = $derived(chosenIfPresent('audio.limiter', limiterProp));
650
+ const smartSpeed = $derived(chosen('player.smartSpeed', smartSpeedProp));
651
+ const speakerPace = $derived(chosen('player.speakerRate', speakerPaceProp));
652
+ const skipAds = $derived(chosen('player.skipAds', skipAdsProp));
653
+ const windDown = $derived(chosenIfPresent('sleep.enabled', windDownProp));
654
+ const windDownAfter = $derived(chosen('sleep.after', windDownAfterProp));
655
+ const windDownNightOnly = $derived(chosen('sleep.nightOnly', windDownNightOnlyProp));
656
+ const idleDetection = $derived(chosen('sleep.desktopIdle', idleDetectionProp));
657
+ const resumeSnap = $derived(chosen('player.resumeSnap', resumeSnapProp));
658
+ const openMotion = $derived(chosen('player.openMotion', openMotionProp));
659
+
469
660
  // prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
470
661
  // under the reduce preference the video holds on its poster instead. Tracked
471
662
  // live (media query listener) so a mid-session preference flip is honoured, and
@@ -663,6 +854,7 @@
663
854
  if (row.when === 'transcript') return sortedSegments.length > 0;
664
855
  if (row.when === 'comments') return socialMarkers.comments.length > 0;
665
856
  if (row.when === 'night') return nightPresent;
857
+ if (row.when === 'settings') return settingsPanel;
666
858
  return true;
667
859
  })
668
860
  );
@@ -698,11 +890,15 @@
698
890
  zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
699
891
  }
700
892
 
701
- function flashOsd(text: string) {
893
+ function flashOsd(text: string, ms = 900) {
894
+ if (osdTimer) clearTimeout(osdTimer);
895
+ if (!text || ms <= 0) {
896
+ osdOn = false;
897
+ return;
898
+ }
702
899
  osd = text;
703
900
  osdOn = true;
704
- if (osdTimer) clearTimeout(osdTimer);
705
- osdTimer = setTimeout(() => (osdOn = false), 900);
901
+ osdTimer = setTimeout(() => (osdOn = false), ms);
706
902
  }
707
903
 
708
904
  // ── Seeking ─────────────────────────────────────────────────────────────
@@ -898,7 +1094,10 @@
898
1094
  };
899
1095
  return {
900
1096
  playing: !v.paused && !v.ended,
901
- hidden: document.hidden,
1097
+ // A wind-down fade is not the mix getting quieter: hold the
1098
+ // rider's estimate exactly as a hidden tab does, or it rides
1099
+ // the gain back up against the fade.
1100
+ hidden: document.hidden || windPhase === 'fading',
902
1101
  audible: !v.muted && v.volume > 0,
903
1102
  time: v.currentTime,
904
1103
  audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
@@ -910,7 +1109,9 @@
910
1109
  onBroken: (reason) => onAudioBroken?.(reason)
911
1110
  });
912
1111
  audio = s;
913
- const sync = () => untrack(() => s.apply(audioOpts(v)));
1112
+ // Mid-fade the ~400 volume writes are ours, not the user's: skip them
1113
+ // (the restore at cancel/finish lands after the phase flips, and syncs).
1114
+ const sync = () => untrack(() => windPhase !== 'fading' && s.apply(audioOpts(v)));
914
1115
  const gesture = () => s.gesture();
915
1116
  const media = ['volumechange', 'loadstart', 'loadedmetadata'];
916
1117
  const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
@@ -931,7 +1132,8 @@
931
1132
  target: loudnessTarget,
932
1133
  limiter: limiter === true,
933
1134
  quiet: quietListening === true,
934
- volume: v.volume,
1135
+ // Quiet listening reads the volume the USER chose — not the fade's.
1136
+ volume: windPhase === 'fading' ? windSavedVolume : v.volume,
935
1137
  muted: v.muted,
936
1138
  night: nightArmedNow
937
1139
  };
@@ -1129,6 +1331,7 @@
1129
1331
  function setNightMode(mode: NightMode) {
1130
1332
  night = mode;
1131
1333
  onNightModeChange?.(mode);
1334
+ rememberSetting('night.mode', mode);
1132
1335
  }
1133
1336
  function cycleNight() {
1134
1337
  if (night === undefined) return;
@@ -1143,6 +1346,7 @@
1143
1346
  e.stopPropagation();
1144
1347
  if (!nightArmedNow) setNightMode('on');
1145
1348
  nightDepth = nudgeDepth(nightDepth, e.deltaY < 0 ? 1 : -1);
1349
+ rememberSetting('night.depth', nightDepth);
1146
1350
  flashOsd(`Night ${Math.round(clampDepth(nightDepth) * 100)}%`);
1147
1351
  }
1148
1352
 
@@ -1293,6 +1497,523 @@
1293
1497
  });
1294
1498
  });
1295
1499
 
1500
+ // ── Presence (video/presence/*, mos-5lzns.34) ───────────────────────────
1501
+ // Three optional behaviours, each absent until its prop says otherwise:
1502
+ // wind-down (`windDown`), resume-at-sentence (`resumeKey`), FLIP open
1503
+ // (`openFrom`). The modules advise; everything that touches the element
1504
+ // happens here.
1505
+
1506
+ // Wind-down. `windProgress` > 0 is the only thing that paints (the
1507
+ // --vp-wind-down variable); 0 leaves no trace. The fade is driven by a
1508
+ // 50ms interval rather than rAF on purpose: a locked screen hides the
1509
+ // document, rAF stops dead in a hidden document, and a fade that freezes
1510
+ // at 10% while the video plays on is the one outcome worse than no fade.
1511
+ // Timers only get throttled (≈1/s) there, so the fade still completes.
1512
+ let windPhase: WindDownPhase = $state('off');
1513
+ let windProgress = $state(0);
1514
+ let windSource: PresenceSource | null = $state(null);
1515
+ let desktopSignal: DesktopSignal | null = null;
1516
+ let desktopWatch: DesktopWatch | null = null;
1517
+ let idleAskPending = false;
1518
+ let lastInputAt = Date.now();
1519
+ let windSavedVolume = 1;
1520
+ // The element the fade is acting on — held so a teardown (unmount, src
1521
+ // swap) can hand the volume back even after bind:this has let go.
1522
+ let windEl: HTMLVideoElement | null = null;
1523
+ let windFadeStart = 0;
1524
+ let windFadeTimer: ReturnType<typeof setInterval> | null = null;
1525
+
1526
+ const windOn = $derived(windDown === true);
1527
+
1528
+ function windArmed(): boolean {
1529
+ if (!windOn) return false;
1530
+ if (!windDownNightOnly) return true;
1531
+ return nightArmed(night ?? 'auto', new Date(), prefersDark, nightWindow);
1532
+ }
1533
+
1534
+ function stopWindFade() {
1535
+ if (windFadeTimer) clearInterval(windFadeTimer);
1536
+ windFadeTimer = null;
1537
+ }
1538
+
1539
+ function windBegin() {
1540
+ const v = videoEl;
1541
+ if (!v || windPhase === 'fading') return;
1542
+ windPhase = 'fading';
1543
+ windEl = v;
1544
+ windSavedVolume = v.volume;
1545
+ windFadeStart = Date.now();
1546
+ const hint = windDownHint('fading', windSource ?? 'page');
1547
+ flashOsd(hint, 6000);
1548
+ announce(hint);
1549
+ stopWindFade();
1550
+ windFadeTimer = setInterval(windStep, 50);
1551
+ windStep();
1552
+ }
1553
+
1554
+ function windStep() {
1555
+ const v = videoEl;
1556
+ if (!v || windPhase !== 'fading') return stopWindFade();
1557
+ const p = windDownProgress(Date.now() - windFadeStart);
1558
+ const lv = windDownLevels(p);
1559
+ v.volume = Math.max(0, Math.min(1, windSavedVolume * lv.volume));
1560
+ windProgress = p;
1561
+ if (p < 1) return;
1562
+ // Pause, hand the volume back for next time, and leave the picture dark
1563
+ // until something happens. The phase flips BEFORE pause() so the pause
1564
+ // handler doesn't read our own pause as the user cancelling.
1565
+ stopWindFade();
1566
+ windPhase = 'paused';
1567
+ v.pause();
1568
+ v.volume = windSavedVolume;
1569
+ const hint = windDownHint('paused', windSource ?? 'page');
1570
+ flashOsd(hint, 8000);
1571
+ announce(hint);
1572
+ }
1573
+
1574
+ /** Anything that says you're here: restore, and (mid-fade) playback simply carries on. */
1575
+ function windCancel() {
1576
+ if (windPhase === 'fading') {
1577
+ stopWindFade();
1578
+ if (windEl) windEl.volume = windSavedVolume;
1579
+ flashOsd('', 0);
1580
+ }
1581
+ windEl = null;
1582
+ if (windPhase === 'fading' || windPhase === 'paused') windPhase = windOn ? 'watching' : 'off';
1583
+ windProgress = 0;
1584
+ }
1585
+
1586
+ function windProbe() {
1587
+ const v = videoEl;
1588
+ if (!windOn || !v) return;
1589
+ const begin = shouldBeginWindDown({
1590
+ armed: windArmed(),
1591
+ playing: !v.paused && !v.ended,
1592
+ active: windPhase === 'fading',
1593
+ source: windSource ?? 'page',
1594
+ desktop: desktopSignal,
1595
+ quietMs: Date.now() - lastInputAt,
1596
+ afterMs: windDownAfterMs(windDownAfter),
1597
+ hidden: typeof document !== 'undefined' && document.hidden
1598
+ });
1599
+ if (begin) windBegin();
1600
+ }
1601
+
1602
+ function onDesktopSignal(sig: DesktopSignal | null) {
1603
+ desktopSignal = sig;
1604
+ if (desktopPresent(sig)) {
1605
+ if (windPhase === 'fading') windCancel();
1606
+ } else if (sig) {
1607
+ windProbe();
1608
+ }
1609
+ }
1610
+
1611
+ function startDesktop() {
1612
+ desktopWatch?.stop();
1613
+ const w = startDesktopWatch({ thresholdMs: windDownAfterMs(windDownAfter), onChange: onDesktopSignal });
1614
+ desktopWatch = w;
1615
+ void w.ready.then((ok) => {
1616
+ if (desktopWatch !== w) return;
1617
+ windSource = ok ? 'desktop' : 'page';
1618
+ if (!ok) desktopSignal = null;
1619
+ });
1620
+ }
1621
+
1622
+ function stopDesktop() {
1623
+ desktopWatch?.stop();
1624
+ desktopWatch = null;
1625
+ desktopSignal = null;
1626
+ }
1627
+
1628
+ // The only path to a permission prompt: the host opted in AND this is a
1629
+ // user gesture inside the player. Called synchronously from the handler,
1630
+ // so the browser still sees the activation.
1631
+ //
1632
+ // Wired to pointerdown, pointerup, click and keydown: which of them grants
1633
+ // activation depends on the input (a touch pointerdown grants none; its
1634
+ // pointerup does). An ask the browser refused for want of activation
1635
+ // ('unsupported' while the permission still reads 'prompt') re-arms for
1636
+ // the next gesture; a 'denied' is an answer and is never re-asked.
1637
+ let idleAskInFlight = false;
1638
+ function maybeAskIdle() {
1639
+ if (!idleAskPending || idleAskInFlight || !windOn || idleDetection !== true) return;
1640
+ idleAskPending = false;
1641
+ idleAskInFlight = true;
1642
+ void requestIdleDetection().then(async (r) => {
1643
+ idleAskInFlight = false;
1644
+ if (!windOn || idleDetection !== true) return;
1645
+ if (r === 'granted') startDesktop();
1646
+ else if (r === 'unsupported' && (await idleDetectionPermission()) === 'prompt') idleAskPending = true;
1647
+ });
1648
+ }
1649
+
1650
+ // Feature lifecycle: on ⇒ page listeners + the probe; off ⇒ every trace
1651
+ // removed (a fade in progress is cancelled and the volume handed back).
1652
+ $effect(() => {
1653
+ if (!windOn) {
1654
+ untrack(() => {
1655
+ windCancel();
1656
+ windPhase = 'off';
1657
+ windSource = null;
1658
+ });
1659
+ return;
1660
+ }
1661
+ untrack(() => {
1662
+ if (windPhase === 'off') windPhase = 'watching';
1663
+ if (windSource === null) windSource = 'page';
1664
+ lastInputAt = Date.now();
1665
+ });
1666
+ // Page input only ever CANCELS a fade (and resets the page-presence
1667
+ // clock); for the desktop source it never postpones the probe.
1668
+ const onInput = () => {
1669
+ lastInputAt = Date.now();
1670
+ if (windPhase === 'fading' || windPhase === 'paused') windCancel();
1671
+ };
1672
+ const evs = ['pointermove', 'pointerdown', 'keydown', 'wheel', 'touchstart'] as const;
1673
+ for (const ev of evs) window.addEventListener(ev, onInput, { passive: true, capture: true });
1674
+ // Coming back to the tab is presence too: the page clock restarts, so a
1675
+ // long stretch spent in another tab can't wind down the moment you return.
1676
+ const onVisible = () => {
1677
+ if (document.visibilityState === 'visible') lastInputAt = Date.now();
1678
+ };
1679
+ document.addEventListener('visibilitychange', onVisible);
1680
+ const probe = setInterval(windProbe, WIND_DOWN_PROBE_MS);
1681
+ return () => {
1682
+ for (const ev of evs) window.removeEventListener(ev, onInput, { capture: true });
1683
+ document.removeEventListener('visibilitychange', onVisible);
1684
+ clearInterval(probe);
1685
+ // Off or unmounted mid-fade: nothing may outlive the feature.
1686
+ windCancel();
1687
+ };
1688
+ });
1689
+
1690
+ // The desktop source: only with the explicit opt-in, never prompting here.
1691
+ $effect(() => {
1692
+ const on = windOn && idleDetection === true;
1693
+ void windDownAfter; // the detector's threshold — restart on change
1694
+ if (!on) return;
1695
+ let cancelled = false;
1696
+ untrack(() => {
1697
+ void idleDetectionPermission().then((perm) => {
1698
+ if (cancelled) return;
1699
+ if (perm === 'granted') startDesktop();
1700
+ else if (perm === 'prompt') idleAskPending = true;
1701
+ else windSource = 'page';
1702
+ });
1703
+ });
1704
+ return () => {
1705
+ cancelled = true;
1706
+ idleAskPending = false;
1707
+ stopDesktop();
1708
+ if (windOn) windSource = 'page';
1709
+ };
1710
+ });
1711
+
1712
+ // ── Settings session + panel (mos-5lzns.35) ─────────────────────────────
1713
+ // No store and no panel ⇒ no session: the player behaves exactly as
1714
+ // before. The session reads the host's store once per store identity,
1715
+ // coerces every value, and lets a choice made on screen beat a late read.
1716
+ let settingsSession: SettingsSession | null = null;
1717
+ let settingsOpen = $state(false);
1718
+ let settingsBtnEl: HTMLButtonElement | undefined = $state();
1719
+ let rootEl: HTMLDivElement | undefined = $state();
1720
+
1721
+ /** Land one coerced value: bindables are written, everything else overlays its prop. */
1722
+ function applySetting(id: string, v: SettingValue) {
1723
+ if (id === 'night.mode') {
1724
+ if (night !== undefined && night !== v) {
1725
+ night = v as NightMode;
1726
+ onNightModeChange?.(night);
1727
+ }
1728
+ return;
1729
+ }
1730
+ if (id === 'night.depth') {
1731
+ if (night !== undefined) nightDepth = v as number;
1732
+ return;
1733
+ }
1734
+ userSettings[id] = v;
1735
+ }
1736
+
1737
+ $effect(() => {
1738
+ const store = settingsStore;
1739
+ if (!store && !settingsPanel) return;
1740
+ const session = untrack(() =>
1741
+ createSettingsSession({
1742
+ store,
1743
+ onValue: (id, v, source) => {
1744
+ applySetting(id, v);
1745
+ if (source === 'user') onSettingChange?.(id, v);
1746
+ }
1747
+ })
1748
+ );
1749
+ settingsSession = session;
1750
+ return () => {
1751
+ session.close();
1752
+ if (settingsSession === session) settingsSession = null;
1753
+ };
1754
+ });
1755
+
1756
+ /** A viewer choice from outside the panel (N, the moon wheel) — saved like a panel change. */
1757
+ function rememberSetting(id: string, v: SettingValue) {
1758
+ settingsSession?.change(id, v);
1759
+ }
1760
+
1761
+ /** Rows exist only for features this player has: host opted in AND the data is here. */
1762
+ const settingsAvailable = $derived<SettingAvailability>({
1763
+ waveform: hasWave,
1764
+ speakers: sortedTurns.length > 0 && sortedSegments.length > 0,
1765
+ ads: normalizeAdSpans(adSpans, speakers).length > 0,
1766
+ loudness: loudnessProp !== undefined,
1767
+ quiet: quietListeningProp !== undefined,
1768
+ limiter: limiterProp !== undefined,
1769
+ night: nightPresent,
1770
+ nightLuma: nightPresent && lumaSeries != null,
1771
+ flashes: nightFlashes.length > 0,
1772
+ windDown: windDownProp !== undefined,
1773
+ idle: windDownProp !== undefined && idleDetectionSupported(),
1774
+ resume: resumeKey != null,
1775
+ open: openFrom !== undefined
1776
+ });
1777
+ const settingsGroups = $derived(settingsOpen ? panelGroups(settingsAvailable) : []);
1778
+ /** What the player is doing right now, per id — the panel shows exactly this. */
1779
+ const settingsValues = $derived.by(() => {
1780
+ const byProp: Record<string, SettingValue | undefined> = {
1781
+ night,
1782
+ nightDepth,
1783
+ biasLight,
1784
+ predictive,
1785
+ strobeGuard,
1786
+ loudness,
1787
+ loudnessTarget,
1788
+ quietListening,
1789
+ limiter,
1790
+ smartSpeed,
1791
+ speakerPace,
1792
+ skipAds,
1793
+ windDown,
1794
+ windDownAfter,
1795
+ windDownNightOnly,
1796
+ idleDetection,
1797
+ resumeSnap,
1798
+ openMotion,
1799
+ oneAxis
1800
+ };
1801
+ const out: Record<string, SettingValue> = {};
1802
+ for (const spec of VIDEO_SETTINGS) {
1803
+ const v = byProp[spec.prop];
1804
+ out[spec.id] = v === undefined || v === null ? spec.default : v;
1805
+ }
1806
+ return out;
1807
+ });
1808
+
1809
+ function openSettings() {
1810
+ if (!settingsPanel) return;
1811
+ keysOpen = false;
1812
+ settingsOpen = true;
1813
+ }
1814
+ function closeSettings() {
1815
+ settingsOpen = false;
1816
+ settingsSession?.flush();
1817
+ // Focus goes back where it came from (the gear), else the player —
1818
+ // never to a detached node (the gear is gone when the host turned
1819
+ // the panel off).
1820
+ queueMicrotask(() => (settingsBtnEl?.isConnected ? settingsBtnEl : rootEl)?.focus());
1821
+ }
1822
+ // The host turned the panel off while it was open: close it properly, so
1823
+ // focus is handed back instead of being dropped with the dialog.
1824
+ $effect(() => {
1825
+ if (!settingsPanel && untrack(() => settingsOpen)) untrack(closeSettings);
1826
+ });
1827
+
1828
+ function desktopWanted(): boolean {
1829
+ return windOn && idleDetection === true;
1830
+ }
1831
+
1832
+ function onPanelChange(id: string, v: SettingValue) {
1833
+ // Turning the desktop signal on is the ONE place the panel may lead to a
1834
+ // permission prompt: an explicit opt-in, asked synchronously inside the
1835
+ // click that made it (the API needs the gesture). Never on load.
1836
+ if (id === 'sleep.desktopIdle' && v === true && idleDetection !== true && !idleAskInFlight) {
1837
+ // In flight: the gesture listeners must not ask again while this
1838
+ // prompt is still unanswered (maybeAskIdle checks the same flag).
1839
+ idleAskInFlight = true;
1840
+ idleAskPending = false;
1841
+ void requestIdleDetection().then((r) => {
1842
+ idleAskInFlight = false;
1843
+ // Read at resolve time: by then the change below has landed.
1844
+ if (r === 'granted' && desktopWanted()) {
1845
+ idleAskPending = false;
1846
+ startDesktop();
1847
+ } else if (r === 'denied') {
1848
+ idleAskPending = false;
1849
+ }
1850
+ });
1851
+ }
1852
+ settingsSession?.change(id, v);
1853
+ }
1854
+
1855
+ // A new source is a new video: nothing carries over.
1856
+ $effect(() => {
1857
+ void src;
1858
+ untrack(() => {
1859
+ if (windPhase === 'fading' || windPhase === 'paused') windCancel();
1860
+ });
1861
+ });
1862
+
1863
+ let lastWindReport = '';
1864
+ $effect(() => {
1865
+ const state: WindDownState = { phase: windPhase, source: windOn ? windSource : null };
1866
+ const sig = state.phase + '|' + state.source;
1867
+ if (sig === lastWindReport) return;
1868
+ const first = lastWindReport === '';
1869
+ lastWindReport = sig;
1870
+ // Absent feature: never report (it doesn't exist on this player).
1871
+ if (first && state.phase === 'off') return;
1872
+ untrack(() => onWindDown?.(state));
1873
+ });
1874
+
1875
+ // Resume. The stored point is applied once per key, after metadata, unless
1876
+ // an explicit startAt / ?t= says otherwise or you've already moved; the
1877
+ // snap (advisors/resume-point via presence/resume.ts) waits for the
1878
+ // boundaries to land. Nothing is written until the stored point has been
1879
+ // read AND applied — closing a video before its metadata loads must never
1880
+ // clobber the good point with a 0.
1881
+ const fallbackResumeStore = localResumeStore();
1882
+ const resumeOn = $derived(typeof resumeKey === 'string' && resumeKey.length > 0);
1883
+ const activeResumeStore = $derived(resumeStore ?? fallbackResumeStore);
1884
+ const resumeCtx = $derived(resumeOn ? toResumeCtx(transcript, speakers, chapters) : null);
1885
+ let resumeActiveKey: string | null = null;
1886
+ let resumeLoaded = false;
1887
+ let resumeFrom: number | null = null;
1888
+ let pendingSnap: number | null = null;
1889
+ let resumeLastSave = 0;
1890
+ // The element whose position is being remembered — kept so the final write
1891
+ // on unmount still lands after bind:this has let go of it.
1892
+ let resumeEl: HTMLVideoElement | null = null;
1893
+
1894
+ function applyStoredResume() {
1895
+ const v = videoEl;
1896
+ if (resumeFrom == null || !v) return;
1897
+ if (startAt != null || urlStartAt != null) {
1898
+ resumeFrom = null; // the host's / the link's intent wins
1899
+ return;
1900
+ }
1901
+ if (v.readyState < 1) return; // onLoadedMetadata calls again
1902
+ const at = resumeFrom;
1903
+ resumeFrom = null;
1904
+ if (v.currentTime > RESUME_WHEEL_SEC) return; // you already moved — leave it
1905
+ seekTo(at);
1906
+ onResume?.(at, { at, reason: 'exact', label: '' });
1907
+ if (resumeSnap) {
1908
+ pendingSnap = at;
1909
+ trySnap();
1910
+ }
1911
+ }
1912
+
1913
+ function trySnap() {
1914
+ const v = videoEl;
1915
+ if (pendingSnap == null || !v || !resumeCtx) return;
1916
+ const plan = planResumeSnap(pendingSnap, v.currentTime, resumeCtx);
1917
+ if (plan.kind === 'wait') return;
1918
+ const from = pendingSnap;
1919
+ pendingSnap = null;
1920
+ if (plan.kind !== 'seek') return;
1921
+ seekTo(plan.point.at);
1922
+ if (plan.point.label) {
1923
+ flashOsd(plan.point.label, 5000);
1924
+ announce(plan.point.label);
1925
+ }
1926
+ onResume?.(from, plan.point);
1927
+ }
1928
+
1929
+ function saveResume(force: boolean) {
1930
+ const key = resumeActiveKey;
1931
+ const v = videoEl ?? resumeEl;
1932
+ if (videoEl) resumeEl = videoEl;
1933
+ if (!key || !resumeLoaded || resumeFrom != null || !v || v.readyState < 1) return;
1934
+ const now = Date.now();
1935
+ if (!force && now - resumeLastSave < RESUME_SAVE_MS) return;
1936
+ const sec = resumeToSave(v.currentTime, effectiveDuration);
1937
+ if (sec === undefined) return;
1938
+ resumeLastSave = now;
1939
+ try {
1940
+ void Promise.resolve(activeResumeStore.save(key, sec)).catch(() => {});
1941
+ } catch {
1942
+ /* a host store that throws is a missed save, never a broken player */
1943
+ }
1944
+ }
1945
+
1946
+ $effect(() => {
1947
+ const key = resumeOn ? (resumeKey as string) : null;
1948
+ const store = activeResumeStore;
1949
+ untrack(() => {
1950
+ if (key === resumeActiveKey) return;
1951
+ saveResume(true); // the outgoing key's last position, if still readable
1952
+ resumeActiveKey = key;
1953
+ resumeLoaded = false;
1954
+ resumeFrom = null;
1955
+ pendingSnap = null;
1956
+ resumeLastSave = 0;
1957
+ if (!key) return;
1958
+ let loaded: ReturnType<VideoResumeStore['load']>;
1959
+ try {
1960
+ loaded = store.load(key);
1961
+ } catch {
1962
+ loaded = null;
1963
+ }
1964
+ Promise.resolve(loaded).then(
1965
+ (val) => {
1966
+ if (resumeActiveKey !== key) return;
1967
+ resumeLoaded = true;
1968
+ resumeFrom = coerceResumeSec(val);
1969
+ applyStoredResume();
1970
+ },
1971
+ () => {
1972
+ if (resumeActiveKey === key) resumeLoaded = true;
1973
+ }
1974
+ );
1975
+ });
1976
+ });
1977
+
1978
+ // The snap runs when the boundaries land, not at open.
1979
+ $effect(() => {
1980
+ void resumeCtx;
1981
+ untrack(trySnap);
1982
+ });
1983
+
1984
+ // A hidden tab may never come back — write the position on the way out.
1985
+ $effect(() => {
1986
+ if (!resumeOn) return;
1987
+ const flush = () => saveResume(true);
1988
+ const onVis = () => {
1989
+ if (document.visibilityState === 'hidden') flush();
1990
+ };
1991
+ window.addEventListener('pagehide', flush);
1992
+ document.addEventListener('visibilitychange', onVis);
1993
+ return () => {
1994
+ flush();
1995
+ window.removeEventListener('pagehide', flush);
1996
+ document.removeEventListener('visibilitychange', onVis);
1997
+ };
1998
+ });
1999
+
2000
+ // FLIP open. Read once, at open — this is about how the player ARRIVES.
2001
+ // The poster paints on every path and clears on the first decoded frame.
2002
+ let stageWrapEl: HTMLDivElement | undefined = $state();
2003
+ let openPosterOn = $state(false);
2004
+ let openDone = false;
2005
+ const openPosterImage = $derived(posterBackground(openPoster ?? poster));
2006
+ $effect(() => {
2007
+ const shell = stageWrapEl;
2008
+ if (!shell || openDone) return;
2009
+ untrack(() => {
2010
+ openDone = true;
2011
+ if (openFrom === undefined) return;
2012
+ openPosterOn = !!openPosterImage && !(videoEl && videoEl.readyState >= 2);
2013
+ void flipOpen({ from: openMotion ? openFrom : null, shell, hasPoster: openPosterOn });
2014
+ });
2015
+ });
2016
+
1296
2017
  // ── Band pointer handlers ───────────────────────────────────────────────
1297
2018
  function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
1298
2019
  const canvas = canvasEl;
@@ -1560,6 +2281,10 @@
1560
2281
  const v = videoEl;
1561
2282
  if (!v) return;
1562
2283
 
2284
+ // The settings panel stops its own keys; anything that still arrives
2285
+ // while it is open (focus on the backdrop) must not drive the player.
2286
+ if (settingsOpen) return;
2287
+
1563
2288
  // The help overlay takes over Escape while it's open.
1564
2289
  if (keysOpen) {
1565
2290
  if (e.key === 'Escape' || e.key === '?') {
@@ -1574,6 +2299,11 @@
1574
2299
  e.preventDefault();
1575
2300
  keysOpen = true;
1576
2301
  return;
2302
+ case ',':
2303
+ if (!settingsPanel) break;
2304
+ e.preventDefault();
2305
+ openSettings();
2306
+ return;
1577
2307
  case ' ':
1578
2308
  case 'k':
1579
2309
  v.paused ? v.play() : v.pause();
@@ -1698,6 +2428,7 @@
1698
2428
  seekTo(start);
1699
2429
  }
1700
2430
  }
2431
+ applyStoredResume();
1701
2432
  }
1702
2433
 
1703
2434
  /**
@@ -1728,6 +2459,7 @@
1728
2459
  viewStart = follow.start;
1729
2460
  viewEnd = follow.end;
1730
2461
  }
2462
+ if (resumeOn) saveResume(false);
1731
2463
  if (videoEl) onTimeUpdate?.(videoEl);
1732
2464
  }
1733
2465
 
@@ -1768,6 +2500,7 @@
1768
2500
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
1769
2501
  <div
1770
2502
  class="vp-root {className}"
2503
+ bind:this={rootEl}
1771
2504
  class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
1772
2505
  class:vp-one-axis={nativeBarHidden}
1773
2506
  class:vp-night-capable={nightPresent}
@@ -1778,9 +2511,13 @@
1778
2511
  aria-label="Video player"
1779
2512
  tabindex="-1"
1780
2513
  onkeydown={onRootKeyDown}
2514
+ onpointerdowncapture={maybeAskIdle}
2515
+ onpointerupcapture={maybeAskIdle}
2516
+ onclickcapture={maybeAskIdle}
2517
+ onkeydowncapture={maybeAskIdle}
1781
2518
  >
1782
2519
  <div class="vp-main">
1783
- <div class="vp-stage-wrap">
2520
+ <div class="vp-stage-wrap" bind:this={stageWrapEl}>
1784
2521
  {#if nightPresent}
1785
2522
  <!-- Bias light: the frame's own colours, blurred, spilled behind the
1786
2523
  picture. Earlier in the DOM than the (positioned) stage, so the
@@ -1804,6 +2541,7 @@
1804
2541
  style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
1805
2542
  style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
1806
2543
  style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
2544
+ style:--vp-wind-down={windProgress > 0 ? windProgress.toFixed(3) : undefined}
1807
2545
  onwheel={onStageWheel}
1808
2546
  >
1809
2547
  <video
@@ -1818,8 +2556,19 @@
1818
2556
  }}
1819
2557
  onplay={() => {
1820
2558
  if (nightPresent || guardPresent) nightTick();
2559
+ if (windPhase === 'paused') windCancel();
2560
+ if (windOn) windProbe();
2561
+ }}
2562
+ onpause={() => {
2563
+ syncUrlNow();
2564
+ // You paused mid-fade: that's an answer too — hand the volume back.
2565
+ if (windPhase === 'fading') windCancel();
2566
+ if (resumeOn) saveResume(true);
2567
+ }}
2568
+ onended={() => {
2569
+ if (resumeOn) saveResume(true);
1821
2570
  }}
1822
- onpause={syncUrlNow}
2571
+ onloadeddata={() => (openPosterOn = false)}
1823
2572
  {src}
1824
2573
  {loop}
1825
2574
  crossorigin={crossorigin ?? undefined}
@@ -1837,6 +2586,13 @@
1837
2586
  menu but isn't painted on top of the frames' own text (jat-abwim). -->
1838
2587
  <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
1839
2588
  </video>
2589
+ {#if openPosterOn}
2590
+ <!-- Open-like-an-object poster (mos-5lzns.34): the card's own
2591
+ thumbnail OVER the video's opaque letterbox until the first frame
2592
+ decodes — a layer behind it would be hidden by the very box it
2593
+ exists to cover. Below the night veils and the OSD by DOM order. -->
2594
+ <div class="vp-open-poster" style:--vp-open-poster={openPosterImage} aria-hidden="true"></div>
2595
+ {/if}
1840
2596
  {#if nightPresent}
1841
2597
  <!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
1842
2598
  point and fog the picture; multiplying leaves blacks black. At
@@ -1844,6 +2600,15 @@
1844
2600
  <div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
1845
2601
  <div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
1846
2602
  {/if}
2603
+ {#if windProgress > 0}
2604
+ <!-- Wind-down veil (mos-5lzns.34): DARKENS toward neutral over the
2605
+ picture and the night veils. The video itself is never made
2606
+ translucent — whatever sits behind it (the page, the bias
2607
+ light) would show through and the fade would brighten. A DOM
2608
+ layer, so the night meter still reads raw frames. Under the
2609
+ OSD, which carries the hint. Gone the instant progress is 0. -->
2610
+ <div class="vp-wind-veil" aria-hidden="true"></div>
2611
+ {/if}
1847
2612
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
1848
2613
  </div>
1849
2614
  </div>
@@ -1989,6 +2754,25 @@
1989
2754
  </svg>
1990
2755
  </button>
1991
2756
  {/if}
2757
+ {#if settingsPanel}
2758
+ <button
2759
+ type="button"
2760
+ class="vp-night-btn vp-settings-btn"
2761
+ class:active={settingsOpen}
2762
+ aria-label="Settings"
2763
+ aria-haspopup="dialog"
2764
+ aria-expanded={settingsOpen}
2765
+ title="Settings · ,"
2766
+ bind:this={settingsBtnEl}
2767
+ onclick={() => (settingsOpen ? closeSettings() : openSettings())}
2768
+ >
2769
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
2770
+ <path d="M4 7h10M18 7h2M4 17h4M12 17h8" />
2771
+ <circle cx="16" cy="7" r="2" />
2772
+ <circle cx="10" cy="17" r="2" />
2773
+ </svg>
2774
+ </button>
2775
+ {/if}
1992
2776
  </span>
1993
2777
  <span>{formatTime(effectiveDuration, useHours)}</span>
1994
2778
  </div>
@@ -2127,6 +2911,17 @@
2127
2911
  </div>
2128
2912
  </div>
2129
2913
  {/if}
2914
+
2915
+ {#if settingsOpen}
2916
+ <SettingsPanel
2917
+ groups={settingsGroups}
2918
+ values={settingsValues}
2919
+ onchange={onPanelChange}
2920
+ onclose={closeSettings}
2921
+ helpHref={settingsHelpHref}
2922
+ persisted={!!settingsStore}
2923
+ />
2924
+ {/if}
2130
2925
  </div>
2131
2926
 
2132
2927
  <style>
@@ -2786,6 +3581,35 @@
2786
3581
  position: relative;
2787
3582
  }
2788
3583
 
3584
+ /* ── Presence (mos-5lzns.34) ──────────────────────────────────────────
3585
+ Wind-down: a neutral veil driven frame by frame from script, so no
3586
+ transition to fight.
3587
+ Its own variable, never folded into --vp-night-bright: "this is bright"
3588
+ and "you are not here" are different statements, and one must not be
3589
+ able to cancel the other out. Unset ⇒ opacity 1, no pixel changed. */
3590
+ .vp-wind-veil {
3591
+ position: absolute;
3592
+ inset: 0;
3593
+ pointer-events: none;
3594
+ border-radius: inherit;
3595
+ background: var(--color-neutral);
3596
+ opacity: calc(0.92 * var(--vp-wind-down, 0));
3597
+ transition: none;
3598
+ }
3599
+
3600
+ /* The open poster: a stand-in for the first frame, so it sits exactly
3601
+ where the video will put its picture (contain, not cover). */
3602
+ .vp-open-poster {
3603
+ position: absolute;
3604
+ inset: 0;
3605
+ pointer-events: none;
3606
+ border-radius: inherit;
3607
+ background-image: var(--vp-open-poster, none);
3608
+ background-repeat: no-repeat;
3609
+ background-position: center;
3610
+ background-size: contain;
3611
+ }
3612
+
2789
3613
  /* Bias light: peripheral light, so the blur is large on purpose — any
2790
3614
  detail in it reads as a distracting second image. */
2791
3615
  .vp-bias {