@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.
- package/dist/components/VideoPlayer.svelte +779 -16
- package/dist/components/VideoPlayer.svelte.d.ts +108 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +9 -0
- package/dist/video/README.md +3 -3
- package/dist/video/presence/flip.d.ts +145 -0
- package/dist/video/presence/flip.js +253 -0
- package/dist/video/presence/idle.d.ts +82 -0
- package/dist/video/presence/idle.js +145 -0
- package/dist/video/presence/resume.d.ts +76 -0
- package/dist/video/presence/resume.js +139 -0
- package/dist/video/presence/winddown.d.ts +106 -0
- package/dist/video/presence/winddown.js +104 -0
- package/dist/video/presence.d.ts +25 -21
- package/dist/video/presence.js +25 -21
- package/dist/video/rate/ads.d.ts +36 -0
- package/dist/video/rate/ads.js +59 -0
- package/dist/video/rate/compose.d.ts +82 -0
- package/dist/video/rate/compose.js +114 -0
- package/dist/video/rate/silence.d.ts +27 -0
- package/dist/video/rate/silence.js +48 -0
- package/dist/video/rate/speaker.d.ts +54 -0
- package/dist/video/rate/speaker.js +94 -0
- package/dist/video/rate/tick.d.ts +38 -0
- package/dist/video/rate/tick.js +15 -0
- package/dist/video/rate.d.ts +20 -63
- package/dist/video/rate.js +20 -82
- package/dist/video/settings.js +25 -2
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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,
|
|
872
|
+
const { rate } = composeRate(baseRate, rateSpeaker, rateSilence);
|
|
669
873
|
// Only real changes are written — playbackRate is a live audio parameter.
|
|
670
|
-
if (v.playbackRate
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|