@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.
- package/dist/components/VideoPlayer.svelte +843 -19
- package/dist/components/VideoPlayer.svelte.d.ts +103 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +10 -0
- package/dist/video/README.md +2 -2
- package/dist/video/keys.d.ts +5 -5
- package/dist/video/keys.js +5 -4
- package/dist/video/presence/flip.d.ts +145 -0
- package/dist/video/presence/flip.js +253 -0
- package/dist/video/presence/idle.d.ts +82 -0
- package/dist/video/presence/idle.js +145 -0
- package/dist/video/presence/resume.d.ts +76 -0
- package/dist/video/presence/resume.js +139 -0
- package/dist/video/presence/winddown.d.ts +106 -0
- package/dist/video/presence/winddown.js +104 -0
- package/dist/video/presence.d.ts +25 -21
- package/dist/video/presence.js +25 -21
- package/dist/video/settings/SettingsPanel.svelte +414 -0
- package/dist/video/settings/SettingsPanel.svelte.d.ts +16 -0
- package/dist/video/settings/panel.d.ts +24 -0
- package/dist/video/settings/panel.js +58 -0
- package/dist/video/settings/registry.d.ts +82 -0
- package/dist/video/settings/registry.js +331 -0
- package/dist/video/settings/store.d.ts +55 -0
- package/dist/video/settings/store.js +116 -0
- package/dist/video/settings.d.ts +15 -46
- package/dist/video/settings.js +15 -82
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|