@joewinke/jatui 0.13.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.
@@ -17,6 +17,11 @@
17
17
  export type { NightMode, NightWindow } from '../video/night/clock';
18
18
  // mos-5lzns.31 — audio graph.
19
19
  export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/graph';
20
+ // mos-5lzns.32 — rate composer (smart speed, speaker pace, skip ad reads).
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';
20
25
  </script>
21
26
 
22
27
  <script lang="ts">
@@ -137,7 +142,25 @@
137
142
  import { dragCancel, dragEnd, dragMove, dragStart, type DragState } from '../video/band/drag';
138
143
  import { createBandLayerCache } from '../video/band/memo';
139
144
  import { BAND_KEYS, PLAYER_KEYS, chapterJump, FRAME_STEP_SEC } from '../video/keys';
140
- import { composeRate, formatSpeedLabel, stepRate } from '../video/rate';
145
+ import {
146
+ adMaxStep,
147
+ adSkipTarget,
148
+ adviseRate,
149
+ advisorsActive,
150
+ composeRate,
151
+ formatSpeedLabel,
152
+ normalizeAdSpans,
153
+ RATE_TICK_MS,
154
+ shouldWriteRate,
155
+ silenceMapFor,
156
+ smartSpeedLabel,
157
+ speakerRateMap,
158
+ stepRate,
159
+ toRateSegments,
160
+ toRateTurns,
161
+ type RateReport,
162
+ type VideoAdSpan
163
+ } from '../video/rate';
141
164
  import { readTimeParam, WRITE_THROTTLE_MS, writeTimeParam } from '../video/deepLink';
142
165
  import {
143
166
  biasCells,
@@ -166,6 +189,36 @@
166
189
  type AudioBrokenReason,
167
190
  type AudioState
168
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';
169
222
 
170
223
  let {
171
224
  src,
@@ -229,6 +282,24 @@
229
282
  limiter = undefined,
230
283
  onAudioState = undefined,
231
284
  onAudioBroken = undefined,
285
+ smartSpeed = false,
286
+ speakerPace = false,
287
+ speakerRates = null,
288
+ adSpans = null,
289
+ skipAds = false,
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,
232
303
  class: className = ''
233
304
  }: {
234
305
  src: string;
@@ -405,6 +476,108 @@
405
476
  onAudioState?: (state: AudioState) => void;
406
477
  /** Entered `broken`: 'build-failed' | 'unsafe-src' | 'silent' (see above). */
407
478
  onAudioBroken?: (reason: AudioBrokenReason) => void;
479
+ /**
480
+ * Rate composer (mos-5lzns.32): `rate = base × speaker × silence`. The
481
+ * speed readout only ever reports `base` — the rate a human chose — so it
482
+ * keeps saying `1×` while a gap is taken at 2.2×. Every advisor is off by
483
+ * default and collapses to 1 until its data arrives: off, the element
484
+ * plays at exactly `base`.
485
+ *
486
+ * Smart speed: run the silences between sentences at 2.2×, found off the
487
+ * `waveform` envelope. Speech stays at `base`.
488
+ */
489
+ smartSpeed?: boolean;
490
+ /**
491
+ * Per-speaker pace: level everyone to the median words-per-minute,
492
+ * measured from the diarised `transcript` + `speakers`. Ad reads and
493
+ * gaps between turns play at `base`.
494
+ */
495
+ speakerPace?: boolean;
496
+ /**
497
+ * Hand-set speaker rates keyed by speaker NAME (the legend's display
498
+ * name). They always beat measured ones, and like them apply only while
499
+ * `speakerPace` is on — off means off. Range 0.0625–16.
500
+ */
501
+ speakerRates?: Record<string, number> | null;
502
+ /** Host-detected ad reads. Merged with any `ad: true` speaker turns. */
503
+ adSpans?: VideoAdSpan[] | null;
504
+ /**
505
+ * Skip ad reads: when playback runs into an ad span it jumps to its end
506
+ * (the OSD says so). A seek that lands inside an ad is honoured.
507
+ */
508
+ skipAds?: boolean;
509
+ /** The composed rate changed (real change only). `base` is what the UI shows. */
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;
408
581
  class?: string;
409
582
  } = $props();
410
583
 
@@ -640,11 +813,15 @@
640
813
  zoomTimer = setTimeout(() => (zoomLabelOn = false), 900);
641
814
  }
642
815
 
643
- 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
+ }
644
822
  osd = text;
645
823
  osdOn = true;
646
- if (osdTimer) clearTimeout(osdTimer);
647
- osdTimer = setTimeout(() => (osdOn = false), 900);
824
+ osdTimer = setTimeout(() => (osdOn = false), ms);
648
825
  }
649
826
 
650
827
  // ── Seeking ─────────────────────────────────────────────────────────────
@@ -655,25 +832,168 @@
655
832
  if (videoEl) videoEl.currentTime = t;
656
833
  }
657
834
 
658
- // ── Speed ───────────────────────────────────────────────────────────────
835
+ // ── Speed + rate composer (video/rate/*, mos-5lzns.32) ──────────────────
836
+ // rate = base × speaker × silence. `base` is the only rate a human chose
837
+ // and the only one the OSD reports; the advisors multiply into the APPLIED
838
+ // rate from their own 120ms loop, which runs only while one of them (or ad
839
+ // skipping) can act. Off, every factor is 1 and the element plays at
840
+ // exactly `base` — the contract in video/rate/compose.ts.
841
+ const rateTurns = $derived(toRateTurns(speakers));
842
+ const rateSegments = $derived(toRateSegments(transcript));
843
+ const rateAdvisors = $derived({
844
+ speakerRates: speakerPace
845
+ ? speakerRateMap({ turns: rateTurns, segments: rateSegments, manual: speakerRates, measure: true })
846
+ : null,
847
+ turns: rateTurns,
848
+ silence: smartSpeed ? silenceMapFor(waveform, effectiveDuration) : null
849
+ });
850
+ const adRanges = $derived(skipAds ? normalizeAdSpans(adSpans, speakers) : []);
851
+ const rateLoopOn = $derived(advisorsActive(rateAdvisors) || adRanges.length > 0);
852
+ /** Playing in a visible tab — the only time the 120ms loop has anything to do. */
853
+ let rateRunning = $state(false);
854
+ // Plain (non-reactive): written and read inside one tick.
855
+ let rateSpeaker = 1;
856
+ let rateSilence = 1;
659
857
  /**
660
- * Apply the base rate. No advisors exist yet (mos-5lzns.32 fills the
661
- * speaker/silence ones) — composeRate collapses them to 1, so today this is
662
- * exactly `base`. When they land, the composer multiplies and this keeps
663
- * reporting `base` on the OSD — the contract in video/rate.ts.
858
+ * `ratechange` events this component still owes itself: every write that
859
+ * changes the value fires exactly one. Anything beyond them is a human (the
860
+ * native speed menu) — even one that picks the very value we last wrote.
664
861
  */
862
+ let rateSelfWrites = 0;
863
+ let reportedBase = Number.NaN;
864
+ let reportedRate = Number.NaN;
865
+ /** Last playhead sample while playing; null after a seek/pause ⇒ never an ad skip. */
866
+ let adPrev: number | null = null;
867
+ let adPrevAt = 0;
868
+
665
869
  function applyRate() {
666
870
  const v = videoEl;
667
871
  if (!v) return;
668
- const { rate } = composeRate(baseRate, 1, 1);
872
+ const { rate } = composeRate(baseRate, rateSpeaker, rateSilence);
669
873
  // Only real changes are written — playbackRate is a live audio parameter.
670
- if (v.playbackRate !== rate) v.playbackRate = rate;
874
+ if (shouldWriteRate(v.playbackRate, rate, baseRate)) {
875
+ rateSelfWrites++;
876
+ v.playbackRate = rate;
877
+ }
878
+ if (baseRate !== reportedBase || rate !== reportedRate) {
879
+ reportedBase = baseRate;
880
+ reportedRate = rate;
881
+ onRateChange?.({ base: baseRate, speaker: rateSpeaker, silence: rateSilence, rate });
882
+ }
671
883
  }
672
884
  function stepSpeed(direction: 1 | -1) {
673
885
  baseRate = stepRate(baseRate, direction);
674
886
  applyRate();
675
887
  flashOsd(formatSpeedLabel(baseRate));
676
888
  }
889
+ function rateTick() {
890
+ const v = videoEl;
891
+ if (!v) return;
892
+ const t = v.currentTime || 0;
893
+ if (v.paused || v.seeking || adRanges.length === 0) adPrev = null;
894
+ else {
895
+ const now = performance.now();
896
+ const hop = adSkipTarget(adPrev, t, adRanges, adMaxStep(now - adPrevAt, v.playbackRate));
897
+ adPrevAt = now;
898
+ adPrev = t;
899
+ if (hop) {
900
+ adPrev = null;
901
+ seekTo(hop.to);
902
+ flashOsd(`Skipped ad · ${formatDuration(hop.to - hop.from)}`);
903
+ return;
904
+ }
905
+ }
906
+ const a = adviseRate(baseRate, t, rateAdvisors);
907
+ rateSpeaker = a.speaker;
908
+ rateSilence = a.silence;
909
+ applyRate();
910
+ }
911
+ $effect(() => {
912
+ const v = videoEl;
913
+ if (!v) return;
914
+ rateSelfWrites = 0;
915
+ const syncRunning = () => {
916
+ const on = !v.paused && !v.ended && !document.hidden;
917
+ if (rateRunning !== on) rateRunning = on;
918
+ };
919
+ const onSeeking = () => (adPrev = null);
920
+ // A paused scrub re-advises once, so the frame you land on already
921
+ // carries its rate when play resumes (the loop itself is stopped).
922
+ const onSeeked = () => {
923
+ if (rateLoopOn && !rateRunning) rateTick();
924
+ };
925
+ // A rate this component didn't write came from a human (the native
926
+ // controls' speed menu): it becomes the new base, so the advisors keep
927
+ // multiplying on top of it instead of fighting it.
928
+ const onNativeRate = () => {
929
+ if (rateSelfWrites > 0) {
930
+ rateSelfWrites--;
931
+ return;
932
+ }
933
+ const r = v.playbackRate;
934
+ if (!Number.isFinite(r) || r < 0) return;
935
+ baseRate = r;
936
+ applyRate();
937
+ };
938
+ syncRunning();
939
+ for (const ev of ['play', 'pause', 'ended']) v.addEventListener(ev, syncRunning);
940
+ document.addEventListener('visibilitychange', syncRunning);
941
+ v.addEventListener('seeking', onSeeking);
942
+ v.addEventListener('seeked', onSeeked);
943
+ v.addEventListener('ratechange', onNativeRate);
944
+ return () => {
945
+ for (const ev of ['play', 'pause', 'ended']) v.removeEventListener(ev, syncRunning);
946
+ document.removeEventListener('visibilitychange', syncRunning);
947
+ v.removeEventListener('seeking', onSeeking);
948
+ v.removeEventListener('seeked', onSeeked);
949
+ v.removeEventListener('ratechange', onNativeRate);
950
+ };
951
+ });
952
+ // Armed: advise once now (so the first frame after play is already right),
953
+ // and off leaves no trace — every factor back to 1, the element to base.
954
+ $effect(() => {
955
+ if (!videoEl || !rateLoopOn) return;
956
+ untrack(rateTick);
957
+ return () => {
958
+ untrack(() => {
959
+ rateSpeaker = 1;
960
+ rateSilence = 1;
961
+ adPrev = null;
962
+ applyRate();
963
+ });
964
+ };
965
+ });
966
+ // The 120ms loop runs only while armed AND playing in a visible tab —
967
+ // paused, ended or hidden, there is no rate to steer.
968
+ $effect(() => {
969
+ if (!videoEl || !rateLoopOn || !rateRunning) return;
970
+ const id = setInterval(rateTick, RATE_TICK_MS);
971
+ untrack(rateTick);
972
+ return () => {
973
+ clearInterval(id);
974
+ adPrev = null;
975
+ };
976
+ });
977
+ // Nothing acts without something the user can see: an advisor announces
978
+ // itself once on the OSD when it arms (and again only after it disarms).
979
+ let rateAnnounced = { silence: false, speaker: false };
980
+ $effect(() => {
981
+ const { silence, speakerRates: table } = rateAdvisors;
982
+ const levelled = table ? Object.values(table).filter((r) => r !== 1).length : 0;
983
+ untrack(() => {
984
+ if (!silence) rateAnnounced.silence = false;
985
+ else if (!rateAnnounced.silence) {
986
+ rateAnnounced.silence = true;
987
+ const label = smartSpeedLabel(silence);
988
+ if (label) flashOsd(label);
989
+ }
990
+ if (!levelled) rateAnnounced.speaker = false;
991
+ else if (!rateAnnounced.speaker) {
992
+ rateAnnounced.speaker = true;
993
+ flashOsd(`Speaker pace · ${levelled} adjusted`);
994
+ }
995
+ });
996
+ });
677
997
 
678
998
  // ── Audio (video/audio/*) ───────────────────────────────────────────────
679
999
  // The session owns the graph and its one-way door; this block only hands
@@ -697,7 +1017,10 @@
697
1017
  };
698
1018
  return {
699
1019
  playing: !v.paused && !v.ended,
700
- 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',
701
1024
  audible: !v.muted && v.volume > 0,
702
1025
  time: v.currentTime,
703
1026
  audioDecoded: typeof m.webkitAudioDecodedByteCount === 'number' ? m.webkitAudioDecodedByteCount : null,
@@ -709,7 +1032,9 @@
709
1032
  onBroken: (reason) => onAudioBroken?.(reason)
710
1033
  });
711
1034
  audio = s;
712
- 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)));
713
1038
  const gesture = () => s.gesture();
714
1039
  const media = ['volumechange', 'loadstart', 'loadedmetadata'];
715
1040
  const gestures = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
@@ -730,7 +1055,8 @@
730
1055
  target: loudnessTarget,
731
1056
  limiter: limiter === true,
732
1057
  quiet: quietListening === true,
733
- volume: v.volume,
1058
+ // Quiet listening reads the volume the USER chose — not the fade's.
1059
+ volume: windPhase === 'fading' ? windSavedVolume : v.volume,
734
1060
  muted: v.muted,
735
1061
  night: nightArmedNow
736
1062
  };
@@ -1092,6 +1418,380 @@
1092
1418
  });
1093
1419
  });
1094
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
+
1095
1795
  // ── Band pointer handlers ───────────────────────────────────────────────
1096
1796
  function canvasSec(e: MouseEvent): { sec: number; x: number; width: number } | null {
1097
1797
  const canvas = canvasEl;
@@ -1497,6 +2197,7 @@
1497
2197
  seekTo(start);
1498
2198
  }
1499
2199
  }
2200
+ applyStoredResume();
1500
2201
  }
1501
2202
 
1502
2203
  /**
@@ -1527,6 +2228,7 @@
1527
2228
  viewStart = follow.start;
1528
2229
  viewEnd = follow.end;
1529
2230
  }
2231
+ if (resumeOn) saveResume(false);
1530
2232
  if (videoEl) onTimeUpdate?.(videoEl);
1531
2233
  }
1532
2234
 
@@ -1577,9 +2279,13 @@
1577
2279
  aria-label="Video player"
1578
2280
  tabindex="-1"
1579
2281
  onkeydown={onRootKeyDown}
2282
+ onpointerdowncapture={maybeAskIdle}
2283
+ onpointerupcapture={maybeAskIdle}
2284
+ onclickcapture={maybeAskIdle}
2285
+ onkeydowncapture={maybeAskIdle}
1580
2286
  >
1581
2287
  <div class="vp-main">
1582
- <div class="vp-stage-wrap">
2288
+ <div class="vp-stage-wrap" bind:this={stageWrapEl}>
1583
2289
  {#if nightPresent}
1584
2290
  <!-- Bias light: the frame's own colours, blurred, spilled behind the
1585
2291
  picture. Earlier in the DOM than the (positioned) stage, so the
@@ -1603,6 +2309,7 @@
1603
2309
  style:--vp-night-sat={nightFrame ? nightFrame.levels.sat.toFixed(3) : undefined}
1604
2310
  style:--vp-night-warm={nightLayersOn && nightFrame ? nightFrame.levels.warm.toFixed(3) : undefined}
1605
2311
  style:--vp-night-vig={nightLayersOn && nightFrame ? nightFrame.levels.vig.toFixed(3) : undefined}
2312
+ style:--vp-wind-down={windProgress > 0 ? windProgress.toFixed(3) : undefined}
1606
2313
  onwheel={onStageWheel}
1607
2314
  >
1608
2315
  <video
@@ -1617,8 +2324,19 @@
1617
2324
  }}
1618
2325
  onplay={() => {
1619
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);
1620
2338
  }}
1621
- onpause={syncUrlNow}
2339
+ onloadeddata={() => (openPosterOn = false)}
1622
2340
  {src}
1623
2341
  {loop}
1624
2342
  crossorigin={crossorigin ?? undefined}
@@ -1636,6 +2354,13 @@
1636
2354
  menu but isn't painted on top of the frames' own text (jat-abwim). -->
1637
2355
  <track kind="captions" src={captionsSrc ?? undefined} srclang={captionsLang} label={captionsLabel} default={!!captionsSrc && captionsDefault} />
1638
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}
1639
2364
  {#if nightPresent}
1640
2365
  <!-- Night veils. Both MULTIPLY: a grey scrim would raise the black
1641
2366
  point and fog the picture; multiplying leaves blacks black. At
@@ -1643,6 +2368,15 @@
1643
2368
  <div class="vp-night-layer vp-night-warm" aria-hidden="true"></div>
1644
2369
  <div class="vp-night-layer vp-night-vignette" aria-hidden="true"></div>
1645
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}
1646
2380
  <div class="vp-osd" class:on={osdOn} aria-hidden="true">{osd}</div>
1647
2381
  </div>
1648
2382
  </div>
@@ -2585,6 +3319,35 @@
2585
3319
  position: relative;
2586
3320
  }
2587
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
+
2588
3351
  /* Bias light: peripheral light, so the blur is large on purpose — any
2589
3352
  detail in it reads as a distracting second image. */
2590
3353
  .vp-bias {