@djangocfg/ui-core 2.1.380 → 2.1.382

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,172 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Generic notification-sound bus.
5
+ *
6
+ * Lifted from `ui-tools/Chat/core/audio/audioBus` and generalised over an
7
+ * arbitrary event-key type so any feature (chat, presence, alerts, …)
8
+ * can reuse the same Safari unlock + multi-fire-safe playback infra.
9
+ *
10
+ * Pitfalls this addresses:
11
+ * - Safari needs a user-gesture transaction to unlock playback. We
12
+ * pre-allocate an `<audio>` per cached URL and play() each (muted)
13
+ * during the unlock event so the whole bus lifts at once.
14
+ * - Rapid play() calls on the same element cancel each other — we
15
+ * clone a fresh `HTMLAudioElement` per fire (HTTP cache reuses the
16
+ * underlying bytes for free).
17
+ * - SSR safety: all DOM access is gated; module imports never touch
18
+ * `window`.
19
+ * - `play()` returns a Promise — we attach `.catch()` everywhere so
20
+ * blocked-autoplay warnings don't reach the console.
21
+ */
22
+
23
+ export interface SoundBusOptions<E extends string> {
24
+ /** Map an event key to a sound URL. `false`/missing silences the event. */
25
+ sounds: Partial<Record<E, string | false>>;
26
+ /**
27
+ * Volume 0..1 used for the next `play()` call. Receives the event so
28
+ * callers can scale per-event (e.g. quieter error sounds).
29
+ */
30
+ getVolume: (event?: E) => number;
31
+ /** Master mute. Read on every play. */
32
+ getMuted: () => boolean;
33
+ /** Per-event predicate. Return `false` to suppress one event. */
34
+ isEnabled: (event: E) => boolean;
35
+ }
36
+
37
+ export interface SoundBus<E extends string> {
38
+ play: (event: E) => void;
39
+ preload: (event: E) => void;
40
+ unlock: () => void;
41
+ isUnlocked: () => boolean;
42
+ subscribeUnlock: (cb: (unlocked: boolean) => void) => () => void;
43
+ setSounds: (sounds: Partial<Record<E, string | false>>) => void;
44
+ dispose: () => void;
45
+ }
46
+
47
+ // One unlock state per tab — first gesture inside ANY bus unlocks every
48
+ // bus. Matches AudioPlayer's ADR-004 "global per tab" rule.
49
+ let unlocked = false;
50
+ const unlockListeners = new Set<(v: boolean) => void>();
51
+
52
+ function setUnlocked(value: boolean) {
53
+ if (unlocked === value) return;
54
+ unlocked = value;
55
+ for (const cb of unlockListeners) cb(value);
56
+ }
57
+
58
+ /** Test helper — resets unlock state between tests. */
59
+ export function _resetUnlockForTesting(): void {
60
+ unlocked = false;
61
+ unlockListeners.clear();
62
+ }
63
+
64
+ export function createSoundBus<E extends string>(
65
+ options: SoundBusOptions<E>,
66
+ ): SoundBus<E> {
67
+ if (typeof window === 'undefined') return noopBus<E>();
68
+
69
+ let sounds = options.sounds;
70
+ const cache = new Map<string, HTMLAudioElement>();
71
+
72
+ const getOrCreate = (url: string): HTMLAudioElement => {
73
+ const hit = cache.get(url);
74
+ if (hit) return hit;
75
+ const el = new Audio(url);
76
+ el.preload = 'auto';
77
+ el.crossOrigin = 'anonymous';
78
+ cache.set(url, el);
79
+ return el;
80
+ };
81
+
82
+ const resolveUrl = (event: E): string | null => {
83
+ const v = sounds[event];
84
+ if (!v) return null;
85
+ return v;
86
+ };
87
+
88
+ const play = (event: E) => {
89
+ if (options.getMuted()) return;
90
+ if (!options.isEnabled(event)) return;
91
+ const url = resolveUrl(event);
92
+ if (!url) return;
93
+
94
+ getOrCreate(url);
95
+ const fresh = new Audio(url);
96
+ fresh.preload = 'auto';
97
+ fresh.volume = clamp01(options.getVolume(event));
98
+ const p = fresh.play();
99
+ if (p && typeof p.catch === 'function') {
100
+ p.catch(() => {
101
+ // Browser blocked playback (no gesture yet) — ignore.
102
+ });
103
+ }
104
+ };
105
+
106
+ const preload = (event: E) => {
107
+ const url = resolveUrl(event);
108
+ if (!url) return;
109
+ const el = getOrCreate(url);
110
+ try {
111
+ el.load();
112
+ } catch {
113
+ // ignore
114
+ }
115
+ };
116
+
117
+ const unlock = () => {
118
+ if (unlocked) return;
119
+ for (const el of cache.values()) {
120
+ const wasMuted = el.muted;
121
+ el.muted = true;
122
+ const p = el.play();
123
+ if (p && typeof p.then === 'function') {
124
+ p.then(() => {
125
+ el.pause();
126
+ el.currentTime = 0;
127
+ el.muted = wasMuted;
128
+ }).catch(() => {
129
+ el.muted = wasMuted;
130
+ });
131
+ } else {
132
+ el.pause();
133
+ el.muted = wasMuted;
134
+ }
135
+ }
136
+ setUnlocked(true);
137
+ };
138
+
139
+ return {
140
+ play,
141
+ preload,
142
+ unlock,
143
+ isUnlocked: () => unlocked,
144
+ subscribeUnlock(cb) {
145
+ unlockListeners.add(cb);
146
+ return () => unlockListeners.delete(cb);
147
+ },
148
+ setSounds(next) {
149
+ sounds = next;
150
+ },
151
+ dispose() {
152
+ cache.clear();
153
+ },
154
+ };
155
+ }
156
+
157
+ function clamp01(v: number): number {
158
+ if (!Number.isFinite(v)) return 1;
159
+ return v < 0 ? 0 : v > 1 ? 1 : v;
160
+ }
161
+
162
+ function noopBus<E extends string>(): SoundBus<E> {
163
+ return {
164
+ play: () => undefined,
165
+ preload: () => undefined,
166
+ unlock: () => undefined,
167
+ isUnlocked: () => false,
168
+ subscribeUnlock: () => () => undefined,
169
+ setSounds: () => undefined,
170
+ dispose: () => undefined,
171
+ };
172
+ }
@@ -0,0 +1,21 @@
1
+ export {
2
+ createSoundBus,
3
+ _resetUnlockForTesting,
4
+ type SoundBus,
5
+ type SoundBusOptions,
6
+ } from './createSoundBus';
7
+ export {
8
+ createAudioPrefsStore,
9
+ useAudioPrefs,
10
+ type AudioPrefsState,
11
+ } from './useAudioPrefs';
12
+ export {
13
+ useNotificationSounds,
14
+ type NotificationSoundsConfig,
15
+ type NotificationSoundsApi,
16
+ } from './useNotificationSounds';
17
+ export {
18
+ useSoundEffect,
19
+ type SoundEffectOptions,
20
+ type SoundEffectApi,
21
+ } from './useSoundEffect';
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Persistent audio preferences (volume / mute / per-event toggles).
5
+ *
6
+ * One zustand store per `storageKey` — instances with different keys
7
+ * persist independently (chat / alerts / per-product). Cross-tab sync
8
+ * via the `storage` event ships with zustand's persist middleware.
9
+ */
10
+
11
+ import { create, type StoreApi, type UseBoundStore } from 'zustand';
12
+ import { persist, createJSONStorage } from 'zustand/middleware';
13
+
14
+ export interface AudioPrefsState<E extends string = string> {
15
+ /** 0..1 master volume. */
16
+ volume: number;
17
+ /** Master mute (overrides per-event toggles). */
18
+ muted: boolean;
19
+ /** Per-event opt-out — `false` silences a single trigger. */
20
+ enabled: Partial<Record<E, boolean>>;
21
+ setVolume: (v: number) => void;
22
+ setMuted: (m: boolean) => void;
23
+ setEventEnabled: (event: E, enabled: boolean) => void;
24
+ }
25
+
26
+ const clamp01 = (v: number): number => {
27
+ if (!Number.isFinite(v)) return 1;
28
+ return v < 0 ? 0 : v > 1 ? 1 : v;
29
+ };
30
+
31
+ /**
32
+ * Per-key registry so repeated calls with the same `storageKey` return
33
+ * the same store instance — necessary for cross-component state sharing
34
+ * (e.g. ChatHeader audio toggle + ChatLauncher bus reading the same key).
35
+ */
36
+ const registry = new Map<string, UseBoundStore<StoreApi<AudioPrefsState<string>>>>();
37
+
38
+ export function createAudioPrefsStore<E extends string = string>(
39
+ storageKey: string,
40
+ ): UseBoundStore<StoreApi<AudioPrefsState<E>>> {
41
+ const cached = registry.get(storageKey);
42
+ if (cached) return cached as unknown as UseBoundStore<StoreApi<AudioPrefsState<E>>>;
43
+
44
+ const store = create<AudioPrefsState<E>>()(
45
+ persist(
46
+ (set) => ({
47
+ volume: 1,
48
+ muted: false,
49
+ enabled: {},
50
+ setVolume: (v) => set({ volume: clamp01(v) }),
51
+ setMuted: (m) => set({ muted: !!m }),
52
+ setEventEnabled: (event, enabled) =>
53
+ set((s) => ({ enabled: { ...s.enabled, [event]: enabled } })),
54
+ }),
55
+ {
56
+ name: storageKey,
57
+ storage: createJSONStorage(() => {
58
+ if (typeof window === 'undefined') {
59
+ return {
60
+ getItem: () => null,
61
+ setItem: () => undefined,
62
+ removeItem: () => undefined,
63
+ };
64
+ }
65
+ return window.localStorage;
66
+ }),
67
+ partialize: (s) => ({ volume: s.volume, muted: s.muted, enabled: s.enabled }),
68
+ version: 1,
69
+ },
70
+ ),
71
+ );
72
+
73
+ registry.set(storageKey, store as unknown as UseBoundStore<StoreApi<AudioPrefsState<string>>>);
74
+ return store;
75
+ }
76
+
77
+ /**
78
+ * React hook helper — returns the persisted prefs store hook for a given
79
+ * `storageKey`. Callers can use it as a normal zustand hook with selectors.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * const usePrefs = useAudioPrefs<'sent' | 'received'>('myapp.audio');
84
+ * const muted = usePrefs((s) => s.muted);
85
+ * ```
86
+ */
87
+ export function useAudioPrefs<E extends string = string>(
88
+ storageKey: string,
89
+ ): UseBoundStore<StoreApi<AudioPrefsState<E>>> {
90
+ return createAudioPrefsStore<E>(storageKey);
91
+ }
@@ -0,0 +1,271 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
4
+
5
+ import { createSoundBus, type SoundBus } from './createSoundBus';
6
+ import { useAudioPrefs } from './useAudioPrefs';
7
+
8
+ export interface NotificationSoundsConfig<E extends string> {
9
+ /**
10
+ * Persistence key for the mute / volume / per-event toggles. Use one
11
+ * key per product surface (e.g. `'chat.audio'`, `'alerts.audio'`).
12
+ */
13
+ storageKey: string;
14
+ /** Event → URL map. `false`/missing silences the event. */
15
+ sounds?: Partial<Record<E, string | false>>;
16
+ /** Master volume override. When set, the persisted value is ignored. */
17
+ volume?: number;
18
+ /**
19
+ * Per-event volume multipliers (0..1). Final per-play volume is
20
+ * `master × eventVolumes[event] ?? 1`. Use this to dim error / status
21
+ * sounds so they don't feel jarring next to message dings. Slack /
22
+ * Linear / Intercom-style defaults: error ≈ 0.25, mention ≈ 1.0,
23
+ * received/sent ≈ 0.6.
24
+ */
25
+ eventVolumes?: Partial<Record<E, number>>;
26
+ /** Master mute override. When set, the persisted value is ignored. */
27
+ muted?: boolean;
28
+ /** Custom predicate — return `false` to suppress one event. */
29
+ shouldPlay?: (event: E) => boolean;
30
+ /**
31
+ * Skip all playback when the user prefers reduced motion. @default true
32
+ */
33
+ respectReducedMotion?: boolean;
34
+ /**
35
+ * Skip all playback when the user prefers reduced data. @default true
36
+ */
37
+ respectReducedData?: boolean;
38
+ /**
39
+ * Skip all playback when the tab is hidden. @default true
40
+ */
41
+ muteWhenHidden?: boolean;
42
+ /**
43
+ * Disable the hook entirely — bus is not created, prefs are not read,
44
+ * `play()` is a no-op. Useful when the surrounding host plays sounds
45
+ * itself (e.g. Electron / Wails backend) and the React layer should
46
+ * stay silent.
47
+ */
48
+ silenced?: boolean;
49
+ /**
50
+ * Side-channel fired whenever `play()` would have triggered an event.
51
+ * Stays active even when `silenced=true`. Use to bridge into a native
52
+ * audio backend (cmdop_go, Tauri, …).
53
+ */
54
+ onSoundEvent?: (event: E) => void;
55
+ }
56
+
57
+ export interface NotificationSoundsApi<E extends string> {
58
+ play: (event: E) => void;
59
+ preload: (event: E) => void;
60
+ unlock: () => void;
61
+ isUnlocked: boolean;
62
+ /** Resolved mute state — combines persisted prefs + reduced-motion + hidden. */
63
+ muted: boolean;
64
+ setMuted: (m: boolean) => void;
65
+ toggleMute: () => void;
66
+ volume: number;
67
+ setVolume: (v: number) => void;
68
+ isEventEnabled: (event: E) => boolean;
69
+ setEventEnabled: (event: E, enabled: boolean) => void;
70
+ /** True when no sounds map is configured (or `silenced`). */
71
+ isSilent: boolean;
72
+ }
73
+
74
+ function readReducedMotion(): boolean {
75
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
76
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
77
+ }
78
+ function readReducedData(): boolean {
79
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
80
+ return window.matchMedia('(prefers-reduced-data: reduce)').matches;
81
+ }
82
+ function readVisibilityHidden(): boolean {
83
+ if (typeof document === 'undefined') return false;
84
+ return document.visibilityState === 'hidden';
85
+ }
86
+
87
+ /**
88
+ * Mid-level notification-sounds hook.
89
+ *
90
+ * Use when you need a small named map (`received`, `error`, `mention`)
91
+ * with persisted mute + Safari unlock + reduced-motion / hidden-tab
92
+ * guards. Pair with `useAudioPrefs(...)` if you need cross-component
93
+ * access to the same mute state.
94
+ *
95
+ * @example
96
+ * ```tsx
97
+ * type Event = 'received' | 'error';
98
+ * const sounds = useNotificationSounds<Event>({
99
+ * storageKey: 'myapp.audio',
100
+ * sounds: { received: '/r.mp3', error: '/e.mp3' },
101
+ * });
102
+ *
103
+ * onMessage = (m) => sounds.play('received');
104
+ * onMute = () => sounds.toggleMute();
105
+ * ```
106
+ */
107
+ export function useNotificationSounds<E extends string>(
108
+ config: NotificationSoundsConfig<E>,
109
+ ): NotificationSoundsApi<E> {
110
+ const {
111
+ storageKey,
112
+ sounds = {},
113
+ volume: volumeOverride,
114
+ eventVolumes,
115
+ muted: mutedOverride,
116
+ shouldPlay,
117
+ respectReducedMotion = true,
118
+ respectReducedData = true,
119
+ muteWhenHidden = true,
120
+ silenced = false,
121
+ onSoundEvent,
122
+ } = config;
123
+
124
+ const usePrefs = useAudioPrefs<E>(storageKey);
125
+ const volumeP = usePrefs((s) => s.volume);
126
+ const mutedP = usePrefs((s) => s.muted);
127
+ const enabledMap = usePrefs((s) => s.enabled);
128
+ const setVolumeP = usePrefs((s) => s.setVolume);
129
+ const setMutedP = usePrefs((s) => s.setMuted);
130
+ const setEventEnabledP = usePrefs((s) => s.setEventEnabled);
131
+
132
+ const volume = volumeOverride != null ? volumeOverride : volumeP;
133
+ const muted = mutedOverride != null ? mutedOverride : mutedP;
134
+
135
+ // Refs for stable getters inside the bus.
136
+ const volumeRef = useRef(volume);
137
+ volumeRef.current = volume;
138
+ const eventVolumesRef = useRef(eventVolumes);
139
+ eventVolumesRef.current = eventVolumes;
140
+ const mutedRef = useRef(muted);
141
+ mutedRef.current = muted;
142
+ const enabledRef = useRef(enabledMap);
143
+ enabledRef.current = enabledMap;
144
+ const reducedMotionRef = useRef(readReducedMotion());
145
+ const reducedDataRef = useRef(readReducedData());
146
+ const hiddenRef = useRef(readVisibilityHidden());
147
+ const shouldPlayRef = useRef(shouldPlay);
148
+ shouldPlayRef.current = shouldPlay;
149
+ const silencedRef = useRef(silenced);
150
+ silencedRef.current = silenced;
151
+ const onSoundEventRef = useRef(onSoundEvent);
152
+ onSoundEventRef.current = onSoundEvent;
153
+
154
+ // Watch reduced-motion / reduced-data preference changes.
155
+ useEffect(() => {
156
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
157
+ const mqMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
158
+ const mqData = window.matchMedia('(prefers-reduced-data: reduce)');
159
+ const onMotion = () => {
160
+ reducedMotionRef.current = mqMotion.matches;
161
+ };
162
+ const onData = () => {
163
+ reducedDataRef.current = mqData.matches;
164
+ };
165
+ mqMotion.addEventListener('change', onMotion);
166
+ mqData.addEventListener('change', onData);
167
+ return () => {
168
+ mqMotion.removeEventListener('change', onMotion);
169
+ mqData.removeEventListener('change', onData);
170
+ };
171
+ }, []);
172
+
173
+ // Visibility tracking — mute while tab is hidden.
174
+ useEffect(() => {
175
+ if (!muteWhenHidden || typeof document === 'undefined') return;
176
+ const onVis = () => {
177
+ hiddenRef.current = document.visibilityState === 'hidden';
178
+ };
179
+ document.addEventListener('visibilitychange', onVis);
180
+ return () => document.removeEventListener('visibilitychange', onVis);
181
+ }, [muteWhenHidden]);
182
+
183
+ const effectiveMuted = useCallback((): boolean => {
184
+ if (silencedRef.current) return true;
185
+ if (mutedRef.current) return true;
186
+ if (muteWhenHidden && hiddenRef.current) return true;
187
+ if (respectReducedMotion && reducedMotionRef.current) return true;
188
+ if (respectReducedData && reducedDataRef.current) return true;
189
+ return false;
190
+ }, [muteWhenHidden, respectReducedMotion, respectReducedData]);
191
+
192
+ const isEnabledImpl = useCallback((event: E): boolean => {
193
+ if (shouldPlayRef.current && shouldPlayRef.current(event) === false) return false;
194
+ const flag = enabledRef.current[event];
195
+ if (flag === false) return false;
196
+ return true;
197
+ }, []);
198
+
199
+ // Bus instance — created once per hook lifetime, sounds map hot-swapped.
200
+ const busRef = useRef<SoundBus<E> | null>(null);
201
+ if (busRef.current === null) {
202
+ busRef.current = createSoundBus<E>({
203
+ sounds,
204
+ getVolume: (event) => {
205
+ const master = volumeRef.current;
206
+ if (event === undefined) return master;
207
+ const scale = eventVolumesRef.current?.[event];
208
+ if (scale === undefined) return master;
209
+ return master * scale;
210
+ },
211
+ getMuted: () => effectiveMuted(),
212
+ isEnabled: (event) => isEnabledImpl(event),
213
+ });
214
+ }
215
+
216
+ useEffect(() => {
217
+ busRef.current?.setSounds(sounds);
218
+ }, [sounds]);
219
+
220
+ // Preload all listed sounds once.
221
+ useEffect(() => {
222
+ const bus = busRef.current;
223
+ if (!bus) return;
224
+ for (const ev of Object.keys(sounds) as E[]) bus.preload(ev);
225
+ }, [sounds]);
226
+
227
+ useEffect(() => {
228
+ const bus = busRef.current;
229
+ return () => bus?.dispose();
230
+ }, []);
231
+
232
+ const isUnlocked = useSyncExternalStore(
233
+ useCallback((cb) => busRef.current?.subscribeUnlock(cb) ?? (() => undefined), []),
234
+ () => busRef.current?.isUnlocked() ?? false,
235
+ () => false,
236
+ );
237
+
238
+ const play = useCallback(
239
+ (event: E) => {
240
+ onSoundEventRef.current?.(event);
241
+ busRef.current?.play(event);
242
+ },
243
+ [],
244
+ );
245
+ const preload = useCallback((event: E) => busRef.current?.preload(event), []);
246
+ const unlock = useCallback(() => busRef.current?.unlock(), []);
247
+
248
+ const toggleMute = useCallback(() => setMutedP(!mutedRef.current), [setMutedP]);
249
+
250
+ const isEventEnabled = useCallback((event: E) => isEnabledImpl(event), [isEnabledImpl]);
251
+
252
+ const isSilent = useMemo(() => {
253
+ if (silenced) return true;
254
+ return !sounds || Object.keys(sounds).length === 0;
255
+ }, [silenced, sounds]);
256
+
257
+ return {
258
+ play,
259
+ preload,
260
+ unlock,
261
+ isUnlocked,
262
+ muted,
263
+ setMuted: (m: boolean) => setMutedP(m),
264
+ toggleMute,
265
+ volume,
266
+ setVolume: (v: number) => setVolumeP(v),
267
+ isEventEnabled,
268
+ setEventEnabled: (event, enabled) => setEventEnabledP(event, enabled),
269
+ isSilent,
270
+ };
271
+ }
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+
5
+ export interface SoundEffectOptions {
6
+ /** 0..1 master volume. @default 1 */
7
+ volume?: number;
8
+ /** Set to true to skip play() entirely (e.g. user-prefs gating). @default false */
9
+ silenced?: boolean;
10
+ }
11
+
12
+ export interface SoundEffectApi {
13
+ /** Trigger playback. Fire-and-forget; cancel-safe. */
14
+ play: () => void;
15
+ /** Eagerly preload the asset. */
16
+ preload: () => void;
17
+ }
18
+
19
+ /**
20
+ * One-shot single-asset hook — the minimal "play a ding" helper.
21
+ *
22
+ * Lower level than `useNotificationSounds` (no persisted mute, no
23
+ * per-event toggles). Use when you have ONE asset, ONE trigger, and
24
+ * don't need cross-component sync.
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * const submitDing = useSoundEffect('/sfx/ding.mp3');
29
+ * <Button onClick={() => { submitDing.play(); doStuff(); }} />
30
+ * ```
31
+ */
32
+ export function useSoundEffect(
33
+ url: string | null | undefined,
34
+ options: SoundEffectOptions = {},
35
+ ): SoundEffectApi {
36
+ const { volume = 1, silenced = false } = options;
37
+ const templateRef = useRef<HTMLAudioElement | null>(null);
38
+ const volumeRef = useRef(volume);
39
+ volumeRef.current = volume;
40
+ const silencedRef = useRef(silenced);
41
+ silencedRef.current = silenced;
42
+ const urlRef = useRef(url);
43
+ urlRef.current = url;
44
+
45
+ useEffect(() => {
46
+ if (typeof window === 'undefined' || !url) return;
47
+ const el = new Audio(url);
48
+ el.preload = 'auto';
49
+ el.crossOrigin = 'anonymous';
50
+ templateRef.current = el;
51
+ return () => {
52
+ templateRef.current = null;
53
+ };
54
+ }, [url]);
55
+
56
+ const play = useCallback(() => {
57
+ if (silencedRef.current) return;
58
+ const u = urlRef.current;
59
+ if (!u || typeof window === 'undefined') return;
60
+ const fresh = new Audio(u);
61
+ fresh.preload = 'auto';
62
+ fresh.volume = volumeRef.current;
63
+ const p = fresh.play();
64
+ if (p && typeof p.catch === 'function') p.catch(() => undefined);
65
+ }, []);
66
+
67
+ const preload = useCallback(() => {
68
+ const el = templateRef.current;
69
+ if (!el) return;
70
+ try {
71
+ el.load();
72
+ } catch {
73
+ // ignore
74
+ }
75
+ }, []);
76
+
77
+ return { play, preload };
78
+ }