@joewinke/jatui 0.15.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.
@@ -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
@@ -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` | shared — knobs added by .30–.34 | `coerce` (never throws, never junk) + `VIDEO_SETTINGS` registry. A knob lands WITH its reader. |
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. |
@@ -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), `,` (settings —
11
- * jatui is prop-driven; a settings module arrives with the features that need
12
- * one), `/` (transcript search — the pane has none yet). N (night cycle)
13
- * landed with the night module (mos-5lzns.33), gated on the host opting in.
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[];
@@ -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), `,` (settings —
11
- * jatui is prop-driven; a settings module arrives with the features that need
12
- * one), `/` (transcript search — the pane has none yet). N (night cycle)
13
- * landed with the night module (mos-5lzns.33), gated on the host opting in.
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. */