@joewinke/jatui 0.15.0 → 0.17.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/Button.svelte +13 -1
- package/dist/components/Button.svelte.d.ts +7 -0
- package/dist/components/VideoPlayer.svelte +279 -17
- package/dist/components/VideoPlayer.svelte.d.ts +29 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/video/README.md +1 -1
- package/dist/video/keys.d.ts +5 -5
- package/dist/video/keys.js +5 -4
- 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 -98
- package/package.json +1 -1
|
@@ -24,6 +24,13 @@
|
|
|
24
24
|
* Error auto-returns to idle after the shake animation completes.
|
|
25
25
|
*/
|
|
26
26
|
state?: SubmitState;
|
|
27
|
+
/**
|
|
28
|
+
* Toggle-button semantics. When provided, renders `aria-pressed="true"|"false"`
|
|
29
|
+
* so assistive tech announces an on/off toggle (like, subscribe, bold).
|
|
30
|
+
* Omit it for an ordinary button — no `aria-pressed` is rendered.
|
|
31
|
+
* Purely semantic: it adds no styling; pick `variant` from the same state.
|
|
32
|
+
*/
|
|
33
|
+
pressed?: boolean;
|
|
27
34
|
class?: string;
|
|
28
35
|
onclick?: (e: MouseEvent) => void;
|
|
29
36
|
children?: import('svelte').Snippet;
|
|
@@ -38,6 +45,7 @@
|
|
|
38
45
|
loading = false,
|
|
39
46
|
success = false,
|
|
40
47
|
state: submitStateProp = 'idle',
|
|
48
|
+
pressed,
|
|
41
49
|
class: className = '',
|
|
42
50
|
onclick,
|
|
43
51
|
children
|
|
@@ -54,6 +62,9 @@
|
|
|
54
62
|
const isSuccess = $derived(effectiveState === 'success');
|
|
55
63
|
const isError = $derived(effectiveState === 'error');
|
|
56
64
|
|
|
65
|
+
// Only a provided boolean becomes aria-pressed; undefined omits the attribute.
|
|
66
|
+
const ariaPressed = $derived(pressed === undefined ? undefined : pressed ? 'true' : 'false');
|
|
67
|
+
|
|
57
68
|
const variantClass = $derived(
|
|
58
69
|
{
|
|
59
70
|
primary: 'btn-primary',
|
|
@@ -117,7 +128,7 @@
|
|
|
117
128
|
</script>
|
|
118
129
|
|
|
119
130
|
{#if href}
|
|
120
|
-
<a {href} class={buttonClass} role="button" tabindex="0">
|
|
131
|
+
<a {href} class={buttonClass} role="button" tabindex="0" aria-pressed={ariaPressed}>
|
|
121
132
|
{#if children}{@render children()}{/if}
|
|
122
133
|
</a>
|
|
123
134
|
{:else}
|
|
@@ -126,6 +137,7 @@
|
|
|
126
137
|
{type}
|
|
127
138
|
disabled={disabled || isLoading}
|
|
128
139
|
aria-busy={isLoading || undefined}
|
|
140
|
+
aria-pressed={ariaPressed}
|
|
129
141
|
class={buttonClass}
|
|
130
142
|
{onclick}
|
|
131
143
|
onanimationend={onShakeEnd}
|
|
@@ -21,6 +21,13 @@ interface Props {
|
|
|
21
21
|
* Error auto-returns to idle after the shake animation completes.
|
|
22
22
|
*/
|
|
23
23
|
state?: SubmitState;
|
|
24
|
+
/**
|
|
25
|
+
* Toggle-button semantics. When provided, renders `aria-pressed="true"|"false"`
|
|
26
|
+
* so assistive tech announces an on/off toggle (like, subscribe, bold).
|
|
27
|
+
* Omit it for an ordinary button — no `aria-pressed` is rendered.
|
|
28
|
+
* Purely semantic: it adds no styling; pick `variant` from the same state.
|
|
29
|
+
*/
|
|
30
|
+
pressed?: boolean;
|
|
24
31
|
class?: string;
|
|
25
32
|
onclick?: (e: MouseEvent) => void;
|
|
26
33
|
children?: import('svelte').Snippet;
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// mos-5lzns.34 — presence (wind-down, resume-at-sentence, FLIP open).
|
|
23
23
|
export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
|
|
24
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';
|
|
25
27
|
</script>
|
|
26
28
|
|
|
27
29
|
<script lang="ts">
|
|
@@ -219,6 +221,17 @@
|
|
|
219
221
|
type WindDownState
|
|
220
222
|
} from '../video/presence';
|
|
221
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';
|
|
222
235
|
|
|
223
236
|
let {
|
|
224
237
|
src,
|
|
@@ -262,7 +275,7 @@
|
|
|
262
275
|
syncUrlTime = false,
|
|
263
276
|
luma = null,
|
|
264
277
|
markers = [],
|
|
265
|
-
oneAxis = true,
|
|
278
|
+
oneAxis: oneAxisProp = true,
|
|
266
279
|
onBandChange = undefined,
|
|
267
280
|
onMarkerClick = undefined,
|
|
268
281
|
onTimeUpdate = undefined,
|
|
@@ -271,35 +284,39 @@
|
|
|
271
284
|
night = $bindable(),
|
|
272
285
|
nightDepth = $bindable(NIGHT_DEPTH_DEFAULT),
|
|
273
286
|
nightWindow = null,
|
|
274
|
-
biasLight = true,
|
|
275
|
-
predictive = true,
|
|
276
|
-
strobeGuard = true,
|
|
287
|
+
biasLight: biasLightProp = true,
|
|
288
|
+
predictive: predictiveProp = true,
|
|
289
|
+
strobeGuard: strobeGuardProp = true,
|
|
277
290
|
onNightModeChange = undefined,
|
|
278
291
|
onNightBlind = undefined,
|
|
279
|
-
loudness = undefined,
|
|
280
|
-
loudnessTarget = AUDIO_TARGET_DEFAULT,
|
|
281
|
-
quietListening = undefined,
|
|
282
|
-
limiter = undefined,
|
|
292
|
+
loudness: loudnessProp = undefined,
|
|
293
|
+
loudnessTarget: loudnessTargetProp = AUDIO_TARGET_DEFAULT,
|
|
294
|
+
quietListening: quietListeningProp = undefined,
|
|
295
|
+
limiter: limiterProp = undefined,
|
|
283
296
|
onAudioState = undefined,
|
|
284
297
|
onAudioBroken = undefined,
|
|
285
|
-
smartSpeed = false,
|
|
286
|
-
speakerPace = false,
|
|
298
|
+
smartSpeed: smartSpeedProp = false,
|
|
299
|
+
speakerPace: speakerPaceProp = false,
|
|
287
300
|
speakerRates = null,
|
|
288
301
|
adSpans = null,
|
|
289
|
-
skipAds = false,
|
|
302
|
+
skipAds: skipAdsProp = false,
|
|
290
303
|
onRateChange = undefined,
|
|
291
|
-
windDown = undefined,
|
|
292
|
-
windDownAfter = WIND_DOWN_AFTER_DEFAULT,
|
|
293
|
-
windDownNightOnly = true,
|
|
294
|
-
idleDetection = false,
|
|
304
|
+
windDown: windDownProp = undefined,
|
|
305
|
+
windDownAfter: windDownAfterProp = WIND_DOWN_AFTER_DEFAULT,
|
|
306
|
+
windDownNightOnly: windDownNightOnlyProp = true,
|
|
307
|
+
idleDetection: idleDetectionProp = false,
|
|
295
308
|
onWindDown = undefined,
|
|
296
309
|
resumeKey = null,
|
|
297
310
|
resumeStore = undefined,
|
|
298
|
-
resumeSnap = true,
|
|
311
|
+
resumeSnap: resumeSnapProp = true,
|
|
299
312
|
onResume = undefined,
|
|
300
313
|
openFrom = undefined,
|
|
301
314
|
openPoster = null,
|
|
302
|
-
openMotion = true,
|
|
315
|
+
openMotion: openMotionProp = true,
|
|
316
|
+
settingsStore = undefined,
|
|
317
|
+
settingsPanel = false,
|
|
318
|
+
settingsHelpHref = null,
|
|
319
|
+
onSettingChange = undefined,
|
|
303
320
|
class: className = ''
|
|
304
321
|
}: {
|
|
305
322
|
src: string;
|
|
@@ -578,9 +595,68 @@
|
|
|
578
595
|
openPoster?: string | null;
|
|
579
596
|
/** Run the motion half of the open (default true). Off ⇒ poster only. */
|
|
580
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;
|
|
581
625
|
class?: string;
|
|
582
626
|
} = $props();
|
|
583
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
|
+
|
|
584
660
|
// prefers-reduced-motion gate for autoplay: an autoplaying clip IS motion, so
|
|
585
661
|
// under the reduce preference the video holds on its poster instead. Tracked
|
|
586
662
|
// live (media query listener) so a mid-session preference flip is honoured, and
|
|
@@ -778,6 +854,7 @@
|
|
|
778
854
|
if (row.when === 'transcript') return sortedSegments.length > 0;
|
|
779
855
|
if (row.when === 'comments') return socialMarkers.comments.length > 0;
|
|
780
856
|
if (row.when === 'night') return nightPresent;
|
|
857
|
+
if (row.when === 'settings') return settingsPanel;
|
|
781
858
|
return true;
|
|
782
859
|
})
|
|
783
860
|
);
|
|
@@ -1254,6 +1331,7 @@
|
|
|
1254
1331
|
function setNightMode(mode: NightMode) {
|
|
1255
1332
|
night = mode;
|
|
1256
1333
|
onNightModeChange?.(mode);
|
|
1334
|
+
rememberSetting('night.mode', mode);
|
|
1257
1335
|
}
|
|
1258
1336
|
function cycleNight() {
|
|
1259
1337
|
if (night === undefined) return;
|
|
@@ -1268,6 +1346,7 @@
|
|
|
1268
1346
|
e.stopPropagation();
|
|
1269
1347
|
if (!nightArmedNow) setNightMode('on');
|
|
1270
1348
|
nightDepth = nudgeDepth(nightDepth, e.deltaY < 0 ? 1 : -1);
|
|
1349
|
+
rememberSetting('night.depth', nightDepth);
|
|
1271
1350
|
flashOsd(`Night ${Math.round(clampDepth(nightDepth) * 100)}%`);
|
|
1272
1351
|
}
|
|
1273
1352
|
|
|
@@ -1630,6 +1709,149 @@
|
|
|
1630
1709
|
};
|
|
1631
1710
|
});
|
|
1632
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
|
+
|
|
1633
1855
|
// A new source is a new video: nothing carries over.
|
|
1634
1856
|
$effect(() => {
|
|
1635
1857
|
void src;
|
|
@@ -2059,6 +2281,10 @@
|
|
|
2059
2281
|
const v = videoEl;
|
|
2060
2282
|
if (!v) return;
|
|
2061
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
|
+
|
|
2062
2288
|
// The help overlay takes over Escape while it's open.
|
|
2063
2289
|
if (keysOpen) {
|
|
2064
2290
|
if (e.key === 'Escape' || e.key === '?') {
|
|
@@ -2073,6 +2299,11 @@
|
|
|
2073
2299
|
e.preventDefault();
|
|
2074
2300
|
keysOpen = true;
|
|
2075
2301
|
return;
|
|
2302
|
+
case ',':
|
|
2303
|
+
if (!settingsPanel) break;
|
|
2304
|
+
e.preventDefault();
|
|
2305
|
+
openSettings();
|
|
2306
|
+
return;
|
|
2076
2307
|
case ' ':
|
|
2077
2308
|
case 'k':
|
|
2078
2309
|
v.paused ? v.play() : v.pause();
|
|
@@ -2269,6 +2500,7 @@
|
|
|
2269
2500
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
2270
2501
|
<div
|
|
2271
2502
|
class="vp-root {className}"
|
|
2503
|
+
bind:this={rootEl}
|
|
2272
2504
|
class:vp-side={transcriptPosition === 'side' && sortedSegments.length > 0 && transcriptVisible}
|
|
2273
2505
|
class:vp-one-axis={nativeBarHidden}
|
|
2274
2506
|
class:vp-night-capable={nightPresent}
|
|
@@ -2522,6 +2754,25 @@
|
|
|
2522
2754
|
</svg>
|
|
2523
2755
|
</button>
|
|
2524
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}
|
|
2525
2776
|
</span>
|
|
2526
2777
|
<span>{formatTime(effectiveDuration, useHours)}</span>
|
|
2527
2778
|
</div>
|
|
@@ -2660,6 +2911,17 @@
|
|
|
2660
2911
|
</div>
|
|
2661
2912
|
</div>
|
|
2662
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}
|
|
2663
2925
|
</div>
|
|
2664
2926
|
|
|
2665
2927
|
<style>
|
|
@@ -7,6 +7,7 @@ export type { AudioState, AudioStatus, AudioBrokenReason } from '../video/audio/
|
|
|
7
7
|
export type { VideoAdSpan, RateReport } from '../video/rate';
|
|
8
8
|
export type { WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource } from '../video/presence';
|
|
9
9
|
export type { ResumePoint } from '../video/advisors/resume-point';
|
|
10
|
+
export type { VideoSettingsStore, SettingValue, SettingSpec } from '../video/settings';
|
|
10
11
|
import type { VideoBookmark, VideoChapter, VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment, VideoWaveform } from '../video/types';
|
|
11
12
|
import { type BandState } from '../video/band/axis';
|
|
12
13
|
import { type VideoLumaTrack } from '../video/band/luma';
|
|
@@ -16,6 +17,7 @@ import { type NightMode, type NightWindow } from '../video/night/clock';
|
|
|
16
17
|
import { type AudioBrokenReason, type AudioState } from '../video/audio';
|
|
17
18
|
import { type FlipSource, type VideoResumeStore, type WindDownState } from '../video/presence';
|
|
18
19
|
import type { ResumePoint } from '../video/advisors/resume-point';
|
|
20
|
+
import { type SettingValue, type VideoSettingsStore } from '../video/settings';
|
|
19
21
|
type $$ComponentProps = {
|
|
20
22
|
src: string;
|
|
21
23
|
poster?: string | null;
|
|
@@ -288,6 +290,33 @@ type $$ComponentProps = {
|
|
|
288
290
|
openPoster?: string | null;
|
|
289
291
|
/** Run the motion half of the open (default true). Off ⇒ poster only. */
|
|
290
292
|
openMotion?: boolean;
|
|
293
|
+
/**
|
|
294
|
+
* Settings (mos-5lzns.35): where the viewer's standing choices live —
|
|
295
|
+
* `{ get(id), set(id, value) }`, either may be async, keyed by the ids
|
|
296
|
+
* in `VIDEO_SETTINGS`. jatui never persists on its own (no
|
|
297
|
+
* localStorage, no API): a host with per-user state passes its own
|
|
298
|
+
* adapter. Every stored value is coerced; junk reads as nothing.
|
|
299
|
+
*
|
|
300
|
+
* A stored choice overrides the matching prop — the prop is the
|
|
301
|
+
* host's DEFAULT, the stored value is the viewer's decision. It never
|
|
302
|
+
* creates a feature the host left out: a stored `audio.level` does
|
|
303
|
+
* nothing on a player with no `loudness` prop. `night.mode` /
|
|
304
|
+
* `night.depth` are written into the bindable `night` / `nightDepth`,
|
|
305
|
+
* and `N` / the moon wheel are saved back through the store.
|
|
306
|
+
*/
|
|
307
|
+
settingsStore?: VideoSettingsStore;
|
|
308
|
+
/**
|
|
309
|
+
* Show the settings panel (default off — inert): a gear in the control
|
|
310
|
+
* strip and the `,` key open it. The panel is generated from the
|
|
311
|
+
* registry and shows only rows for features THIS player has (host
|
|
312
|
+
* opted in and the data exists). Without a `settingsStore`, choices
|
|
313
|
+
* last while the player is mounted.
|
|
314
|
+
*/
|
|
315
|
+
settingsPanel?: boolean;
|
|
316
|
+
/** A host page explaining the settings — linked from the panel's footer. */
|
|
317
|
+
settingsHelpHref?: string | null;
|
|
318
|
+
/** The viewer changed a setting (panel, `N`, the moon wheel). Coerced value. */
|
|
319
|
+
onSettingChange?: (id: string, value: SettingValue) => void;
|
|
291
320
|
class?: string;
|
|
292
321
|
};
|
|
293
322
|
declare const VideoPlayer: import("svelte").Component<$$ComponentProps, {
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { default as VideoPlayer } from './components/VideoPlayer.svelte';
|
|
|
15
15
|
export type { VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment, VideoBookmark, VideoWaveform, VideoSpeakerTalkTime, VideoLumaTrack, VideoMarker, BandState, BandAxis, NightMode, NightWindow, AudioState, AudioStatus, AudioBrokenReason, VideoAdSpan, RateReport, WindDownState, WindDownPhase, PresenceSource, VideoResumeStore, FlipSource, } from './components/VideoPlayer.svelte';
|
|
16
16
|
export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
|
|
17
17
|
export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster, type IdlePermission } from './video/presence';
|
|
18
|
+
export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue, type VideoSettingsStore, type SettingSpec as VideoSettingSpec, type SettingValue as VideoSettingValue, type SettingOption as VideoSettingOption, type SettingWhen as VideoSettingWhen, type SettingAvailability as VideoSettingAvailability } from './video/settings';
|
|
18
19
|
export { buildSilenceMap, boostAt, estimateSaving, type SilenceMap, type SilenceWave, type SilenceOpts, type BoostOpts } from './video/advisors/smart-speed';
|
|
19
20
|
export { speakerPace, suggestRates, rateAt, describeRates, type SpeakerPace, type SpeakerPaceRow, type RateMap, type RateTurn, type RateSegment, type SuggestOpts } from './video/advisors/speaker-rate';
|
|
20
21
|
export { snapResume, resumeLabel, type ResumePoint, type ResumeReason, type ResumeCtx, type ResumeOpts } from './video/advisors/resume-point';
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,11 @@ export { speakerRateMap, toRateTurns, toRateSegments } from './video/rate';
|
|
|
22
22
|
// the default resume store, and the FLIP pair for a host animating its own
|
|
23
23
|
// theater shell (e.g. flipClose back onto the card).
|
|
24
24
|
export { idleDetectionSupported, idleDetectionPermission, requestIdleDetection, localResumeStore, flipOpen, flipClose, clearPoster } from './video/presence';
|
|
25
|
+
// Settings (mos-5lzns.35): the registry is the single source for the
|
|
26
|
+
// player's panel AND a host's help page (labels + hints), the store adapter
|
|
27
|
+
// type a host implements for per-user persistence, and `coerce` for a host
|
|
28
|
+
// that reads the same values elsewhere. jatui never persists settings itself.
|
|
29
|
+
export { VIDEO_SETTINGS, SETTING_GROUPS, settingSpec, settingValue, coerce as coerceVideoSetting, memorySettingsStore, panelGroups as videoSettingsGroups, formatSettingValue } from './video/settings';
|
|
25
30
|
// Pure advisors (mos-5lzns.27): the extension-library self-test modules,
|
|
26
31
|
// ported verbatim. They measure and advise; the player's feature tasks
|
|
27
32
|
// (.31 audio / .32 rate / .34 presence) consume them. qrCanvas is the one
|
package/dist/video/README.md
CHANGED
|
@@ -19,7 +19,7 @@ props + callback surfaces.
|
|
|
19
19
|
| `keys.ts` | .28 (done) | The keyboard map — `PLAYER_KEYS` (the `?` overlay renders it), `chapterJump`, `FRAME_STEP_SEC`. Every key that exists is in the map; a key not in the map doesn't exist. |
|
|
20
20
|
| `rate.ts` + `rate/` | .28 contract — **.32** (done) | `rate.ts` is the barrel. `compose.ts` — the contract (`composeRate`, clamp [0.0625, 16], `shouldWriteRate`'s 0.001 deadband with an exact return to base), the `SPEED_STEPS` staircase, `formatSpeedLabel`. `speaker.ts` — props → `advisors/speaker-rate`; manual rates keyed by NAME always beat measured. `silence.ts` — waveform → `advisors/smart-speed` map + boost + the OSD saving claim. `ads.ts` — `adSpans` ∪ `ad: true` turns; skip only when PLAYBACK runs into an ad, never when a seek lands in one. `tick.ts` — the component's own 120ms loop's one-tick decision. None of them write `playbackRate`; they advise. |
|
|
21
21
|
| `deepLink.ts` | .28 (done) | `?t=` parse/format/read/write (replaceState, throttled, other params preserved). |
|
|
22
|
-
| `settings.ts` |
|
|
22
|
+
| `settings.ts` + `settings/` | **.35** (done) — knobs added by .30–.34 | `settings.ts` is the barrel. `registry.ts` — `VIDEO_SETTINGS`: id, group, label, **mandatory hint**, the prop it drives, the availability gate (`when`); `coerce` never throws (hostile values, throwing `valueOf`, Symbols → undefined). Labels/hints are the single source for the panel AND a host help page. `store.ts` — the host-supplied `VideoSettingsStore` (`get`/`set`, either async; jatui never persists on its own) and `createSettingsSession`: coerced reads, a choice made on screen beats a late read, per-id debounced writes flushed on close. `panel.ts` — `panelGroups` (rows only for features this player has — omitted, never disabled), value formatting, radio stepping. `SettingsPanel.svelte` — the generated dialog (switch / radio group / range, focus trap, Esc/`,` close, focus handed back). In the component a stored choice overlays its prop but never creates a presence-gated feature; `night`/`nightDepth` are written through their bindings and `N`/the moon wheel are saved back. The one permission ask is `sleep.desktopIdle` turned on, from that click. A knob lands WITH its reader. |
|
|
23
23
|
| `audio.ts` + `audio/` | **.31** (done) | `audio.ts` is the barrel. `rider.ts` — pure: RMS dBFS (silence → null), the 12 dB relative-gated loud estimate (rise 0.08 / forget 0.01), want-gain clamped 0.3–1.35, the asymmetric ride (down 0.15 / up 0.02), limiter constants + `compressorMakeupDb`/`limiterTrim` (DynamicsCompressor's automatic makeup, cancelled by a trim node), `routeSafety` (only blob:/data: or a `crossorigin` load — a same-origin URL may redirect cross-origin and route to SILENCE). `graph.ts` — `createAudioSession`: source → gain → quiet chain → limiter → trim → destination, analyser on the source. Context first, route last: the element is taken only when a feature is on, the source is route-safe and `ctx.state === 'running'` (gestures resume + retry; never navigator.userActivation). After routing it watches: an unsafe src or bit-exact zeros while audibly playing ⇒ `broken` (`unsafe-src` / `silent`) via `onBroken`. A construction throw is a permanent `build-failed` (context closed if the element wasn't taken; kept with source → destination if it was). Level off cancels before it sets; limiter moves are ramped. The element never enters the module — the component passes a `route` closure. |
|
|
24
24
|
| `night.ts` + `night/` | **.33** (done) | `night.ts` is the barrel. `meter.ts` — 32×18 frame bytes → Rec.709 luma + blown share, asymmetric smoothing, bias-light cells from the same bytes. `levels.ts` — luma → brightness/saturation/warmth/vignette off `NIGHT_FLOOR` 0.34, blind fixed level, the light-theme surround. `clock.ts` — local-clock `auto` window + the `N` auto/on/off cycle (no hyprsunset). `track.ts` — prediction (only ever adds dimming), flash intervals, `composeNight` (darker at once, brighter released per tick — never a jump). |
|
|
25
25
|
| `presence.ts` + `presence/` | **.34** (done) | `presence.ts` is the barrel. `winddown.ts` — pure: `shouldBeginWindDown` (a locked screen skips the wait; a desktop source with no reading holds — never guess), the 20s linear fade levels (volume `1−p`, picture `1−0.92p`), the hint (the page source says it is guessing from less). `idle.ts` — Chromium's Idle Detection API, feature-detected (`IdleDetector` + secure context); `startDesktopWatch` never prompts (start() rejects unless already granted ⇒ page fallback), `requestIdleDetection` is the ONE prompting call and the player makes it only with `idleDetection` on, from a gesture inside it. `resume.ts` — the host-supplied `VideoResumeStore` (localStorage default; jatui never calls an API), the data → advisor mapping, and `planResumeSnap` (wait for boundaries, drop once the user moved > 2.5s, skip `exact`/`start`); the snapping itself is `advisors/resume-point`, not re-derived. `flip.ts` — `open-transition.js` ported: FLIP over WAAPI, two keyframes, origin restated in both, poster on every path, a no-op under prefers-reduced-motion except the poster, `flipClose` holds and the next open releases on every path. |
|
package/dist/video/keys.d.ts
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
|
+
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
12
|
+
* and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
|
|
13
|
+
* the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
export interface PlayerKeyRow {
|
|
16
16
|
/** Keycap label as shown in the help overlay — `/`-separated alternates. */
|
|
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
|
|
|
20
20
|
* Availability gate: the row is only advertised (and the key only bound)
|
|
21
21
|
* when the feature's data exists. Undefined ⇒ always active.
|
|
22
22
|
*/
|
|
23
|
-
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night';
|
|
23
|
+
when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments' | 'night' | 'settings';
|
|
24
24
|
}
|
|
25
25
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
26
26
|
export declare const PLAYER_KEYS: PlayerKeyRow[];
|
package/dist/video/keys.js
CHANGED
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
* Space play/pause, ←/→ seek, ↑/↓ volume, M mute, −/= speed, Backspace reset
|
|
8
8
|
* speed, </> frame step, [/] chapter jumps, B bookmark, T transcript, ?
|
|
9
9
|
* shortcuts, Home/End. Deliberately NOT ported: F/M (favourite/file-under —
|
|
10
|
-
* library-app verbs, not player verbs),
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* library-app verbs, not player verbs), `/` (transcript search — the pane
|
|
11
|
+
* has none yet). N (night cycle) landed with the night module (mos-5lzns.33)
|
|
12
|
+
* and `,` (settings) with the settings panel (mos-5lzns.35), each gated on
|
|
13
|
+
* the host opting in.
|
|
14
14
|
*/
|
|
15
15
|
/** Order follows the help overlay: transport, seek, volume, speed, structure. */
|
|
16
16
|
export const PLAYER_KEYS = [
|
|
@@ -30,6 +30,7 @@ export const PLAYER_KEYS = [
|
|
|
30
30
|
{ keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
|
|
31
31
|
{ keys: 'N', description: 'Night dimming: auto / on / off', when: 'night' },
|
|
32
32
|
{ keys: 'Home / End', description: 'Jump to start / end' },
|
|
33
|
+
{ keys: ',', description: 'Settings', when: 'settings' },
|
|
33
34
|
{ keys: '?', description: 'Toggle this help' }
|
|
34
35
|
];
|
|
35
36
|
/** Band-local keys (band focused): advertised by the band's aria, not the overlay. */
|