@joewinke/jatui 0.27.1 → 0.28.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.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Semantic sound names — what an app SAYS ("confirm", "incoming-call"), not
3
+ * which file it plays. Each name carries the policy facts the preferences
4
+ * layer needs: its category, a base volume, whether it is essential (survives
5
+ * the quiet/reduced-motion preference), and how it loops.
6
+ *
7
+ * Hosts can re-point any name at their own file (`player.register('incoming-call',
8
+ * '/ringer.mp3')`) and can register names of their own; unknown names fall back
9
+ * to `CUSTOM_SOUND_SPEC` (a non-essential UI sound).
10
+ */
11
+ export const SOUND_CATEGORIES = ['ui', 'notifications', 'calls'];
12
+ export const SOUND_NAMES = [
13
+ 'confirm',
14
+ 'error',
15
+ 'notify',
16
+ 'message',
17
+ 'send',
18
+ 'toggle',
19
+ 'tap',
20
+ 'incoming-call'
21
+ ];
22
+ export const SOUND_SPECS = {
23
+ confirm: {
24
+ category: 'ui',
25
+ volume: 0.15,
26
+ essential: false,
27
+ loop: false,
28
+ loopGapMs: 0,
29
+ description: 'An important action succeeded (saved, paid, published)'
30
+ },
31
+ error: {
32
+ category: 'ui',
33
+ volume: 0.2,
34
+ essential: true,
35
+ loop: false,
36
+ loopGapMs: 0,
37
+ description: 'An action failed'
38
+ },
39
+ notify: {
40
+ category: 'notifications',
41
+ volume: 0.3,
42
+ essential: false,
43
+ loop: false,
44
+ loopGapMs: 0,
45
+ description: 'Something happened the user did not cause'
46
+ },
47
+ message: {
48
+ category: 'notifications',
49
+ volume: 0.3,
50
+ essential: false,
51
+ loop: false,
52
+ loopGapMs: 0,
53
+ description: 'A message arrived from another person'
54
+ },
55
+ send: {
56
+ category: 'ui',
57
+ volume: 0.15,
58
+ essential: false,
59
+ loop: false,
60
+ loopGapMs: 0,
61
+ description: 'The user sent a message or submitted a form'
62
+ },
63
+ toggle: {
64
+ category: 'ui',
65
+ volume: 0.12,
66
+ essential: false,
67
+ loop: false,
68
+ loopGapMs: 0,
69
+ description: 'A switch changed state'
70
+ },
71
+ tap: {
72
+ category: 'ui',
73
+ volume: 0.1,
74
+ essential: false,
75
+ loop: false,
76
+ loopGapMs: 0,
77
+ description: 'A deliberate, low-frequency press'
78
+ },
79
+ 'incoming-call': {
80
+ category: 'calls',
81
+ volume: 0.5,
82
+ essential: true,
83
+ loop: true,
84
+ loopGapMs: 1200,
85
+ description: 'Someone is calling — rings until answered or declined'
86
+ }
87
+ };
88
+ /** Spec for a host-registered name that is not one of the semantic names. */
89
+ export const CUSTOM_SOUND_SPEC = {
90
+ category: 'ui',
91
+ volume: 0.15,
92
+ essential: false,
93
+ loop: false,
94
+ loopGapMs: 0,
95
+ description: 'Host-registered sound'
96
+ };
97
+ /** Pre-semantic clip names (utils/sound.ts era) mapped to their semantic spec. */
98
+ const LEGACY_ALIASES = { success: 'confirm', pop: 'tap' };
99
+ export function isSoundName(name) {
100
+ return SOUND_NAMES.includes(name);
101
+ }
102
+ export function soundSpecFor(name) {
103
+ if (isSoundName(name))
104
+ return SOUND_SPECS[name];
105
+ const alias = LEGACY_ALIASES[name];
106
+ return alias ? SOUND_SPECS[alias] : CUSTOM_SOUND_SPEC;
107
+ }
108
+ /**
109
+ * The bundled CC0 clip for each semantic name, loaded on first use so apps
110
+ * that never play a sound never ship the base64 payloads in their entry chunk.
111
+ */
112
+ export const BUILTIN_CLIPS = {
113
+ confirm: () => import('../assets/sounds/success').then((m) => m.successSound),
114
+ error: () => import('../assets/sounds/error').then((m) => m.errorSound),
115
+ notify: () => import('../assets/sounds/notify').then((m) => m.notifySound),
116
+ message: () => import('../assets/sounds/notify').then((m) => m.notifySound),
117
+ send: () => import('../assets/sounds/send').then((m) => m.sendSound),
118
+ toggle: () => import('../assets/sounds/toggle').then((m) => m.toggleSound),
119
+ tap: () => import('../assets/sounds/pop').then((m) => m.popSound),
120
+ 'incoming-call': () => import('../assets/sounds/notify').then((m) => m.notifySound)
121
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The sound player — semantic names in, policy-gated audio out.
3
+ *
4
+ * const sound = getSoundPlayer()
5
+ * sound.play('confirm')
6
+ * await sound.register('incoming-call', '/sounds/ringer.mp3', { loopGapMs: 0 })
7
+ * const ring = sound.play('incoming-call') // loops
8
+ * ring.stop() // answered / declined
9
+ *
10
+ * `play` is synchronous and never throws: a sound that is blocked, missing or
11
+ * still decoding returns a handle anyway (`handle.blocked` says why it is
12
+ * silent). Looping sounds are re-checked whenever preferences change, so
13
+ * turning sound off or muting "calls" silences a ringing call immediately.
14
+ */
15
+ import { type SoundBackend } from './backend';
16
+ import { type SoundBlockReason, type SoundEnvironment } from './policy';
17
+ import { type SoundPreferencesStore } from './preferences';
18
+ import { type SoundCategory, type SoundName, type SoundSpec } from './names';
19
+ /** A semantic name, or any name the host registered itself. */
20
+ export type SoundId = SoundName | (string & {});
21
+ export interface PlaySoundOptions {
22
+ /** Base gain 0–1, replacing the spec's (still scaled by master volume). */
23
+ volume?: number;
24
+ category?: SoundCategory;
25
+ essential?: boolean;
26
+ loop?: boolean;
27
+ /**
28
+ * Explicit user test (a "Preview" button in settings): bypasses every gate
29
+ * except master volume. Never use it for app-triggered sounds.
30
+ */
31
+ preview?: boolean;
32
+ }
33
+ export interface SoundHandle {
34
+ stop(): void;
35
+ /** True while audible or still decoding toward audible. */
36
+ readonly playing: boolean;
37
+ /** Why it is silent, when the policy blocked it. */
38
+ readonly blocked: SoundBlockReason | null;
39
+ }
40
+ export interface SoundPlayer {
41
+ /** Point a name at a clip (data URI or URL) and decode it. */
42
+ register(name: SoundId, src: string, spec?: Partial<SoundSpec>): Promise<void>;
43
+ play(name: SoundId, options?: PlaySoundOptions): SoundHandle;
44
+ /** Decode clips ahead of time (defaults to every semantic name). */
45
+ preload(names?: readonly SoundId[]): Promise<void>;
46
+ stopAll(): void;
47
+ destroy(): void;
48
+ }
49
+ export interface CreateSoundPlayerOptions {
50
+ preferences?: SoundPreferencesStore;
51
+ environment?: () => SoundEnvironment;
52
+ backend?: SoundBackend;
53
+ }
54
+ export declare function createSoundPlayer(options?: CreateSoundPlayerOptions): SoundPlayer;
55
+ /** The app-wide player, bound to the shared preferences store. */
56
+ export declare function getSoundPlayer(): SoundPlayer;
57
+ /** Test seam: replace (or with no argument, drop) the shared player. */
58
+ export declare function setSoundPlayer(player: SoundPlayer | null): void;
59
+ /** Shorthand for `getSoundPlayer().play(name, options)`. */
60
+ export declare function playSound(name: SoundId, options?: PlaySoundOptions): SoundHandle;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The sound player — semantic names in, policy-gated audio out.
3
+ *
4
+ * const sound = getSoundPlayer()
5
+ * sound.play('confirm')
6
+ * await sound.register('incoming-call', '/sounds/ringer.mp3', { loopGapMs: 0 })
7
+ * const ring = sound.play('incoming-call') // loops
8
+ * ring.stop() // answered / declined
9
+ *
10
+ * `play` is synchronous and never throws: a sound that is blocked, missing or
11
+ * still decoding returns a handle anyway (`handle.blocked` says why it is
12
+ * silent). Looping sounds are re-checked whenever preferences change, so
13
+ * turning sound off or muting "calls" silences a ringing call immediately.
14
+ */
15
+ import { createWebAudioBackend } from './backend';
16
+ import { decideSound, readSoundEnvironment } from './policy';
17
+ import { getSoundPreferences } from './preferences';
18
+ import { BUILTIN_CLIPS, isSoundName, SOUND_NAMES, soundSpecFor } from './names';
19
+ export function createSoundPlayer(options = {}) {
20
+ const prefs = options.preferences ?? getSoundPreferences();
21
+ const environment = options.environment ?? readSoundEnvironment;
22
+ let backend = options.backend ?? null;
23
+ const entries = new Map();
24
+ const active = new Set();
25
+ const audio = () => (backend ??= createWebAudioBackend());
26
+ function clipFor(name) {
27
+ let entry = entries.get(name);
28
+ if (!entry)
29
+ entries.set(name, (entry = { spec: {} }));
30
+ if (!entry.clip) {
31
+ const src = entry.src
32
+ ? Promise.resolve(entry.src)
33
+ : isSoundName(name)
34
+ ? BUILTIN_CLIPS[name]().catch(() => null)
35
+ : Promise.resolve(null);
36
+ entry.clip = src.then((s) => (s ? audio().load(s) : null));
37
+ }
38
+ return entry.clip;
39
+ }
40
+ // Looping voices (ringers) obey preference changes while they play.
41
+ const unsubscribe = prefs.subscribe((p) => {
42
+ for (const a of [...active]) {
43
+ if (!a.spec.loop)
44
+ continue;
45
+ if (!decideSound(p, a.spec, environment()).play)
46
+ a.handle.stop();
47
+ }
48
+ });
49
+ function silent(reason) {
50
+ return { stop() { }, playing: false, blocked: reason };
51
+ }
52
+ return {
53
+ async register(name, src, spec = {}) {
54
+ entries.set(name, { spec, src });
55
+ await clipFor(name);
56
+ },
57
+ play(name, opts = {}) {
58
+ const base = { ...soundSpecFor(name), ...(entries.get(name)?.spec ?? {}) };
59
+ const spec = {
60
+ ...base,
61
+ volume: opts.volume ?? base.volume,
62
+ category: opts.category ?? base.category,
63
+ essential: opts.essential ?? base.essential,
64
+ loop: opts.loop ?? base.loop
65
+ };
66
+ const p = prefs.get();
67
+ let gain;
68
+ if (opts.preview) {
69
+ gain = Math.min(1, Math.max(0, spec.volume)) * p.volume;
70
+ if (gain <= 0)
71
+ return silent('silent');
72
+ }
73
+ else {
74
+ const d = decideSound(p, spec, environment());
75
+ if (!d.play)
76
+ return silent(d.reason);
77
+ gain = d.gain;
78
+ }
79
+ let voice = null;
80
+ let playing = true;
81
+ const record = {
82
+ spec,
83
+ handle: {
84
+ stop() {
85
+ if (!playing)
86
+ return;
87
+ playing = false;
88
+ active.delete(record);
89
+ voice?.stop();
90
+ },
91
+ get playing() {
92
+ return playing;
93
+ },
94
+ blocked: null
95
+ }
96
+ };
97
+ active.add(record);
98
+ clipFor(name).then((clip) => {
99
+ if (!playing)
100
+ return;
101
+ voice = clip
102
+ ? audio().start(clip, {
103
+ gain,
104
+ loop: spec.loop,
105
+ loopGapMs: spec.loopGapMs,
106
+ onEnded: () => record.handle.stop()
107
+ })
108
+ : null;
109
+ if (!voice)
110
+ record.handle.stop();
111
+ });
112
+ return record.handle;
113
+ },
114
+ async preload(names = SOUND_NAMES) {
115
+ await Promise.all(names.map((n) => clipFor(n)));
116
+ },
117
+ stopAll() {
118
+ for (const a of [...active])
119
+ a.handle.stop();
120
+ },
121
+ destroy() {
122
+ unsubscribe();
123
+ for (const a of [...active])
124
+ a.handle.stop();
125
+ }
126
+ };
127
+ }
128
+ let shared = null;
129
+ /** The app-wide player, bound to the shared preferences store. */
130
+ export function getSoundPlayer() {
131
+ return (shared ??= createSoundPlayer());
132
+ }
133
+ /** Test seam: replace (or with no argument, drop) the shared player. */
134
+ export function setSoundPlayer(player) {
135
+ shared?.destroy();
136
+ shared = player;
137
+ }
138
+ /** Shorthand for `getSoundPlayer().play(name, options)`. */
139
+ export function playSound(name, options) {
140
+ return getSoundPlayer().play(name, options);
141
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The sound policy — a pure function from (preferences, sound, environment) to
3
+ * "play at this gain" or "don't, because …". Every play goes through it, and
4
+ * because it is pure the whole discipline is unit-testable without audio.
5
+ */
6
+ import type { SoundCategory } from './names';
7
+ import type { QuietHours, SoundPreferences } from './preferences';
8
+ export interface SoundEnvironment {
9
+ /** The document is hidden (background tab, minimized window). */
10
+ hidden: boolean;
11
+ /** `prefers-reduced-motion: reduce` is set at the OS level. */
12
+ reducedMotion: boolean;
13
+ now: Date;
14
+ }
15
+ export interface SoundRequest {
16
+ category: SoundCategory;
17
+ essential: boolean;
18
+ /** Base gain 0–1 (the spec volume or the caller's override). */
19
+ volume: number;
20
+ }
21
+ export type SoundBlockReason = 'disabled' | 'category-muted' | 'hidden' | 'quiet-hours' | 'quiet' | 'silent';
22
+ export type SoundDecision = {
23
+ play: true;
24
+ gain: number;
25
+ } | {
26
+ play: false;
27
+ reason: SoundBlockReason;
28
+ gain: 0;
29
+ };
30
+ /** Is `now` inside the window? `start > end` wraps midnight; `start == end` is empty. */
31
+ export declare function inQuietHours(quietHours: QuietHours | null, now: Date): boolean;
32
+ export declare function decideSound(prefs: SoundPreferences, req: SoundRequest, env: SoundEnvironment): SoundDecision;
33
+ /** Read the live browser environment; on the server everything is "quiet". */
34
+ export declare function readSoundEnvironment(): SoundEnvironment;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The sound policy — a pure function from (preferences, sound, environment) to
3
+ * "play at this gain" or "don't, because …". Every play goes through it, and
4
+ * because it is pure the whole discipline is unit-testable without audio.
5
+ */
6
+ function minutes(hhmm) {
7
+ const [h, m] = hhmm.split(':').map(Number);
8
+ return h * 60 + m;
9
+ }
10
+ /** Is `now` inside the window? `start > end` wraps midnight; `start == end` is empty. */
11
+ export function inQuietHours(quietHours, now) {
12
+ if (!quietHours)
13
+ return false;
14
+ const s = minutes(quietHours.start);
15
+ const e = minutes(quietHours.end);
16
+ const t = now.getHours() * 60 + now.getMinutes();
17
+ if (s === e)
18
+ return false;
19
+ return s < e ? t >= s && t < e : t >= s || t < e;
20
+ }
21
+ export function decideSound(prefs, req, env) {
22
+ const no = (reason) => ({ play: false, reason, gain: 0 });
23
+ // Calls are opt-out; everything else is opt-in behind the master switch.
24
+ // A ringer that is silent out of the box is a missed call.
25
+ if (req.category !== 'calls' && !prefs.enabled)
26
+ return no('disabled');
27
+ if (!prefs.categories[req.category])
28
+ return no('category-muted');
29
+ // Calls are the one category that must reach a user who is looking elsewhere.
30
+ if (req.category !== 'calls') {
31
+ if (env.hidden)
32
+ return no('hidden');
33
+ if (inQuietHours(prefs.quietHours, env.now))
34
+ return no('quiet-hours');
35
+ }
36
+ if (req.category === 'ui' && !req.essential) {
37
+ if (prefs.quiet === 'on' || (prefs.quiet === 'auto' && env.reducedMotion))
38
+ return no('quiet');
39
+ }
40
+ const gain = Math.min(1, Math.max(0, req.volume)) * prefs.volume;
41
+ return gain > 0 ? { play: true, gain } : no('silent');
42
+ }
43
+ /** Read the live browser environment; on the server everything is "quiet". */
44
+ export function readSoundEnvironment() {
45
+ const now = new Date();
46
+ if (typeof document === 'undefined')
47
+ return { hidden: true, reducedMotion: false, now };
48
+ let reducedMotion = false;
49
+ try {
50
+ reducedMotion = !!globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
51
+ }
52
+ catch {
53
+ // matchMedia unavailable — treat as no preference
54
+ }
55
+ return { hidden: document.visibilityState === 'hidden', reducedMotion, now };
56
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Sound preferences — the ONE store every app sound goes through.
3
+ *
4
+ * Framework-free (subscribe/get/set) so it works in plain TS, node tests and
5
+ * Svelte alike; `SoundSettings.svelte` is a thin view over it. Persisted per
6
+ * device in localStorage, SSR-safe (no storage → in-memory defaults), synced
7
+ * across tabs via the `storage` event, and tolerant of corrupt/partial JSON.
8
+ *
9
+ * UI and notification sounds are OFF by default (opt-in); calls are ON by
10
+ * default (opt-out) so an incoming call rings out of the box. A device that set the pre-0.28 boolean
11
+ * (`jatui-sound-enabled`) is migrated on first read.
12
+ */
13
+ import { type SoundCategory } from './names';
14
+ /** Local wall-clock window, 'HH:MM' 24h. `start > end` wraps midnight. */
15
+ export interface QuietHours {
16
+ start: string;
17
+ end: string;
18
+ }
19
+ /**
20
+ * Non-essential UI sounds (taps, toggles, confirms):
21
+ * - `auto` — silenced when the OS asks for reduced motion
22
+ * - `on` — always silenced
23
+ * - `off` — never silenced by this preference
24
+ */
25
+ export type SoundQuietMode = 'auto' | 'on' | 'off';
26
+ export interface SoundPreferences {
27
+ /** Master switch for `ui` + `notifications` (opt-in). Calls ignore it — mute them via `categories.calls`. */
28
+ enabled: boolean;
29
+ /** Master volume 0–1, multiplied into every sound's base gain. */
30
+ volume: number;
31
+ /** Per-category switch — `false` mutes that category. */
32
+ categories: Record<SoundCategory, boolean>;
33
+ /** Everything except calls is silent inside this window. `null` = off. */
34
+ quietHours: QuietHours | null;
35
+ quiet: SoundQuietMode;
36
+ }
37
+ export declare const SOUND_PREFS_STORAGE_KEY = "jatui-sound-prefs";
38
+ /** The pre-semantic boolean key from utils/sound.ts — read once to migrate. */
39
+ export declare const LEGACY_SOUND_ENABLED_KEY = "jatui-sound-enabled";
40
+ export declare const DEFAULT_SOUND_PREFERENCES: SoundPreferences;
41
+ /** Coerce anything (parsed JSON, a partial patch) into a complete, valid object. */
42
+ export declare function normalizeSoundPreferences(raw: unknown, base?: SoundPreferences): SoundPreferences;
43
+ export type SoundPreferencesPatch = Partial<Omit<SoundPreferences, 'categories'>> & {
44
+ categories?: Partial<Record<SoundCategory, boolean>>;
45
+ };
46
+ export interface SoundPreferencesStore {
47
+ get(): SoundPreferences;
48
+ /** Merge a patch (categories merge per key) and persist. */
49
+ set(patch: SoundPreferencesPatch | ((current: SoundPreferences) => SoundPreferencesPatch)): void;
50
+ setCategory(category: SoundCategory, on: boolean): void;
51
+ reset(): void;
52
+ /** Calls `fn` immediately and on every change; returns the unsubscribe. */
53
+ subscribe(fn: (prefs: SoundPreferences) => void): () => void;
54
+ /** Drop the cross-tab listener. */
55
+ destroy(): void;
56
+ }
57
+ /** Minimal Storage surface — `localStorage`, `sessionStorage` or a test fake. */
58
+ export type SoundPreferencesStorage = Pick<Storage, 'getItem' | 'setItem'>;
59
+ export interface CreateSoundPreferencesOptions {
60
+ /** Defaults to `globalThis.localStorage` when present; `null` = memory only. */
61
+ storage?: SoundPreferencesStorage | null;
62
+ key?: string;
63
+ /** Overrides applied over the library defaults for a device with nothing saved. */
64
+ defaults?: SoundPreferencesPatch;
65
+ }
66
+ export declare function createSoundPreferences(options?: CreateSoundPreferencesOptions): SoundPreferencesStore;
67
+ /** The app-wide store (lazy, so SSR imports never touch storage). */
68
+ export declare function getSoundPreferences(): SoundPreferencesStore;
69
+ /** Test seam: replace (or with no argument, drop) the shared store. */
70
+ export declare function setSoundPreferences(store: SoundPreferencesStore | null): void;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Sound preferences — the ONE store every app sound goes through.
3
+ *
4
+ * Framework-free (subscribe/get/set) so it works in plain TS, node tests and
5
+ * Svelte alike; `SoundSettings.svelte` is a thin view over it. Persisted per
6
+ * device in localStorage, SSR-safe (no storage → in-memory defaults), synced
7
+ * across tabs via the `storage` event, and tolerant of corrupt/partial JSON.
8
+ *
9
+ * UI and notification sounds are OFF by default (opt-in); calls are ON by
10
+ * default (opt-out) so an incoming call rings out of the box. A device that set the pre-0.28 boolean
11
+ * (`jatui-sound-enabled`) is migrated on first read.
12
+ */
13
+ import { SOUND_CATEGORIES } from './names';
14
+ export const SOUND_PREFS_STORAGE_KEY = 'jatui-sound-prefs';
15
+ /** The pre-semantic boolean key from utils/sound.ts — read once to migrate. */
16
+ export const LEGACY_SOUND_ENABLED_KEY = 'jatui-sound-enabled';
17
+ export const DEFAULT_SOUND_PREFERENCES = Object.freeze({
18
+ enabled: false,
19
+ volume: 1,
20
+ categories: Object.freeze({ ui: true, notifications: true, calls: true }),
21
+ quietHours: null,
22
+ quiet: 'auto'
23
+ });
24
+ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
25
+ function clampVolume(v, fallback) {
26
+ const n = typeof v === 'number' ? v : Number.NaN;
27
+ if (!Number.isFinite(n))
28
+ return fallback;
29
+ return Math.min(1, Math.max(0, n));
30
+ }
31
+ /** Coerce anything (parsed JSON, a partial patch) into a complete, valid object. */
32
+ export function normalizeSoundPreferences(raw, base = DEFAULT_SOUND_PREFERENCES) {
33
+ const r = (raw && typeof raw === 'object' ? raw : {});
34
+ const cats = (r.categories && typeof r.categories === 'object' ? r.categories : {});
35
+ const categories = {};
36
+ for (const c of SOUND_CATEGORIES) {
37
+ categories[c] = typeof cats[c] === 'boolean' ? cats[c] : base.categories[c];
38
+ }
39
+ let quietHours = base.quietHours;
40
+ if (r.quietHours === null)
41
+ quietHours = null;
42
+ else if (r.quietHours && typeof r.quietHours === 'object') {
43
+ const q = r.quietHours;
44
+ if (typeof q.start === 'string' && typeof q.end === 'string' && TIME_RE.test(q.start) && TIME_RE.test(q.end)) {
45
+ quietHours = { start: q.start, end: q.end };
46
+ }
47
+ }
48
+ return {
49
+ enabled: typeof r.enabled === 'boolean' ? r.enabled : base.enabled,
50
+ volume: clampVolume(r.volume, base.volume),
51
+ categories,
52
+ quietHours,
53
+ quiet: r.quiet === 'auto' || r.quiet === 'on' || r.quiet === 'off' ? r.quiet : base.quiet
54
+ };
55
+ }
56
+ function defaultStorage() {
57
+ try {
58
+ return typeof localStorage === 'undefined' ? null : localStorage;
59
+ }
60
+ catch {
61
+ return null; // access can throw (sandboxed iframe, blocked cookies)
62
+ }
63
+ }
64
+ function merge(current, patch) {
65
+ return normalizeSoundPreferences({ ...current, ...patch, categories: { ...current.categories, ...(patch.categories ?? {}) } }, current);
66
+ }
67
+ export function createSoundPreferences(options = {}) {
68
+ const storage = options.storage === undefined ? defaultStorage() : options.storage;
69
+ const key = options.key ?? SOUND_PREFS_STORAGE_KEY;
70
+ const defaults = merge(DEFAULT_SOUND_PREFERENCES, options.defaults ?? {});
71
+ const listeners = new Set();
72
+ function read() {
73
+ if (!storage)
74
+ return defaults;
75
+ try {
76
+ const raw = storage.getItem(key);
77
+ if (raw != null)
78
+ return normalizeSoundPreferences(JSON.parse(raw), defaults);
79
+ if (storage.getItem(LEGACY_SOUND_ENABLED_KEY) === 'true')
80
+ return { ...defaults, enabled: true };
81
+ }
82
+ catch {
83
+ // corrupt JSON or a throwing storage — fall back to defaults
84
+ }
85
+ return defaults;
86
+ }
87
+ let current = read();
88
+ function emit() {
89
+ for (const fn of listeners)
90
+ fn(current);
91
+ }
92
+ function commit(next) {
93
+ current = next;
94
+ if (storage) {
95
+ try {
96
+ storage.setItem(key, JSON.stringify(next));
97
+ }
98
+ catch {
99
+ // quota / private mode — keep the in-memory value
100
+ }
101
+ }
102
+ emit();
103
+ }
104
+ const onStorage = (e) => {
105
+ if (e.key !== key)
106
+ return;
107
+ current = read();
108
+ emit();
109
+ };
110
+ const crossTab = storage != null && typeof window !== 'undefined' && storage === defaultStorage();
111
+ if (crossTab)
112
+ window.addEventListener('storage', onStorage);
113
+ return {
114
+ get: () => current,
115
+ set(patch) {
116
+ commit(merge(current, typeof patch === 'function' ? patch(current) : patch));
117
+ },
118
+ setCategory(category, on) {
119
+ commit(merge(current, { categories: { [category]: on } }));
120
+ },
121
+ reset() {
122
+ commit(defaults);
123
+ },
124
+ subscribe(fn) {
125
+ listeners.add(fn);
126
+ fn(current);
127
+ return () => listeners.delete(fn);
128
+ },
129
+ destroy() {
130
+ listeners.clear();
131
+ if (crossTab)
132
+ window.removeEventListener('storage', onStorage);
133
+ }
134
+ };
135
+ }
136
+ let shared = null;
137
+ /** The app-wide store (lazy, so SSR imports never touch storage). */
138
+ export function getSoundPreferences() {
139
+ return (shared ??= createSoundPreferences());
140
+ }
141
+ /** Test seam: replace (or with no argument, drop) the shared store. */
142
+ export function setSoundPreferences(store) {
143
+ shared?.destroy();
144
+ shared = store;
145
+ }