diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index ce55f55..4a4440a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -2,6 +2,7 @@ import "../src/styles/theme.css"; import { definePreview } from "@storybook/react-vite"; import { AnimationProvider } from "@/animation/provider"; import Section from "@/section"; +import { defaultSoundTheme } from "@/sound/default-theme"; import { SoundProvider } from "@/sound/provider"; export default definePreview({ @@ -9,7 +10,7 @@ export default definePreview({ decorators: [ (Story) => ( - +
diff --git a/package.json b/package.json index 2be9704..a2f7b01 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "./lib/utils": "./dist/lib/utils.js", "./preload-reload": "./dist/preload-reload.js", "./sound": "./dist/sound/index.js", + "./sound/default-theme": "./dist/sound/default-theme.js", "./richtext": "./dist/richtext/index.js", "./richtext/converters": "./dist/richtext/converters/index.js", "./richtext/converters/headings": "./dist/richtext/converters/headings.js", @@ -98,4 +99,4 @@ "motion-dom": "12.38.0", "motion-utils": "12.36.0" } -} \ No newline at end of file +} diff --git a/src/sound/defaults.ts b/src/sound/defaults.ts deleted file mode 100644 index e589055..0000000 --- a/src/sound/defaults.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { SoundDef, SoundTheme } from "./types"; - -const buttonsSrc = new URL( - "../assets/sounds/762132__ienba__ui-buttons.wav", - import.meta.url, -).href; -const uiSetSrc = new URL( - "../assets/sounds/842498__newlocknew__uimvmt_game-user-interface-sound-set.mp3", - import.meta.url, -).href; - -const buttonsSpriteMap = { - click1: [0, 1000], - click2: [2750, 1000], - click3: [5200, 1000], - click4: [7700, 1000], -} satisfies Record; - -const uiSetSpriteMap = { - smooth1: [0, 1200], - smooth2: [7800, 1400], - lobbyCreated: [53800, 3500], - gameStart: [62000, 4000], -} satisfies Record; - -const fromButtons = ( - sprite: keyof typeof buttonsSpriteMap, - extra?: Partial, -): SoundDef => ({ - src: buttonsSrc, - sprite, - spriteMap: buttonsSpriteMap, - ...extra, -}); - -const fromUiSet = ( - sprite: keyof typeof uiSetSpriteMap, - extra?: Partial, -): SoundDef => ({ - src: uiSetSrc, - sprite, - spriteMap: uiSetSpriteMap, - ...extra, -}); - -export const defaultSoundTheme: SoundTheme = { - volume: 1, - muted: false, - custom: {}, - tokens: { - click: fromButtons("click1", { pool: 4, interrupt: true }), - hover: fromButtons("click2", { pool: 4, interrupt: true, volume: 0.6 }), - toggle: fromButtons("click2", { pool: 2 }), - focus: fromButtons("click2", { pool: 1, volume: 0.5 }), - selectOpen: fromButtons("click3"), - selectClose: fromButtons("click4"), - lobbyCreated: fromUiSet("lobbyCreated"), - gameStart: fromUiSet("gameStart"), - gameWon: fromUiSet("gameStart"), - roundSuccess: fromUiSet("smooth1"), - userJoined: fromUiSet("smooth1", { volume: 0.7 }), - userLeft: fromUiSet("smooth2", { volume: 0.7 }), - error: fromButtons("click4", { volume: 0.8 }), - submit: fromButtons("click3"), - vote: fromButtons("click2"), - }, -}; diff --git a/src/sound/index.ts b/src/sound/index.ts index f775a0c..1d0f9a0 100644 --- a/src/sound/index.ts +++ b/src/sound/index.ts @@ -1,4 +1,6 @@ -export { defaultSoundTheme } from "./defaults"; +// NOTE: `defaultSoundTheme` is deliberately NOT re-exported here — it is the +// only module referencing the (multi-MB) audio assets and must stay behind the +// `@unom/ui/sound/default-theme` subpath so component imports stay asset-free. export { mergeTheme } from "./merge"; export { SoundProvider, @@ -8,6 +10,7 @@ export { useSoundSettings, useSoundTheme, } from "./provider"; +export { silentSoundTheme } from "./silent"; export { type AnySoundKey, type CoreSoundKey, diff --git a/src/sound/merge.ts b/src/sound/merge.ts index 66f65c3..c221d74 100644 --- a/src/sound/merge.ts +++ b/src/sound/merge.ts @@ -4,11 +4,10 @@ export function mergeTheme( base: SoundTheme, override: PartialSoundTheme, ): SoundTheme { - const tokens = { ...base.tokens }; + // Both sides may be sparse — missing keys simply stay silent. + const tokens = { ...(base.tokens ?? {}) }; if (override.tokens) { - for (const key of Object.keys( - override.tokens, - ) as (keyof typeof tokens)[]) { + for (const key of Object.keys(override.tokens) as (keyof typeof tokens)[]) { const next = override.tokens[key]; // `undefined` inherits from parent; explicit `null` silences this token. if (next !== undefined) tokens[key] = next; @@ -18,6 +17,6 @@ export function mergeTheme( tokens, volume: override.volume ?? base.volume, muted: override.muted ?? base.muted, - custom: { ...base.custom, ...(override.custom ?? {}) }, + custom: { ...(base.custom ?? {}), ...(override.custom ?? {}) }, }; } diff --git a/src/sound/provider.tsx b/src/sound/provider.tsx index 66e5cd3..97fa2d4 100644 --- a/src/sound/provider.tsx +++ b/src/sound/provider.tsx @@ -8,9 +8,14 @@ import { useRef, useState, } from "react"; -import { defaultSoundTheme } from "./defaults"; import { mergeTheme } from "./merge"; -import { getUrlHowl, playDef, stopAll as stopAllPlayers, stopDef } from "./player"; +import { + getUrlHowl, + playDef, + stopAll as stopAllPlayers, + stopDef, +} from "./player"; +import { silentSoundTheme } from "./silent"; import type { AnySoundKey, CoreSoundKey, @@ -22,7 +27,10 @@ import type { SoundTheme, } from "./types"; -const SoundContext = createContext(defaultSoundTheme); +// Silent by default: the default theme (and its audio assets) must only ever +// be pulled in through the `@unom/ui/sound/default-theme` subpath, never by +// merely importing a sound-aware component. +const SoundContext = createContext(silentSoundTheme); const ControllerContext = createContext(null); type PersistedSettings = { volume?: number; muted?: boolean }; @@ -50,7 +58,22 @@ function writePersisted(key: string, value: PersistedSettings) { } export type SoundProviderProps = { - theme: PartialSoundTheme; + theme?: PartialSoundTheme; + /** + * Lazily loaded theme, resolved once after mount and merged OVER `theme`. + * Lets apps defer even the theme's JS (and its audio asset references): + * + * ```tsx + * + * import("@unom/ui/sound/default-theme").then((m) => m.defaultSoundTheme) + * } + * > + * ``` + * + * Read once on mount, so an inline arrow function is fine. + */ + loadTheme?: () => Promise; /** When set, volume/mute persist to localStorage under this key. */ persistKey?: string; children: ReactNode; @@ -58,6 +81,7 @@ export type SoundProviderProps = { export function SoundProvider({ theme, + loadTheme, persistKey, children, }: SoundProviderProps) { @@ -72,6 +96,30 @@ export function SoundProvider({ Pick >({}); const [hydrated, setHydrated] = useState(false); + const [loadedTheme, setLoadedTheme] = useState( + null, + ); + + // `loadTheme` is intentionally read once on mount (via ref) so consumers can + // pass an inline arrow without re-triggering the load on every render. + const loadThemeRef = useRef(loadTheme); + loadThemeRef.current = loadTheme; + + useEffect(() => { + const load = loadThemeRef.current; + if (!load) return; + let cancelled = false; + load() + .then((t) => { + if (!cancelled) setLoadedTheme(t); + }) + .catch(() => { + // Failed/aborted chunk load — stay silent rather than crash. + }); + return () => { + cancelled = true; + }; + }, []); useEffect(() => { if (persistKey) { @@ -90,10 +138,12 @@ export function SoundProvider({ }); }, [persistKey, hydrated, overrides.volume, overrides.muted]); - const merged = useMemo( - () => mergeTheme(parent, { ...theme, ...overrides }), - [parent, theme, overrides], - ); + const merged = useMemo(() => { + // parent ← theme ← loadTheme() result ← persisted volume/mute overrides + let next = mergeTheme(parent, theme ?? {}); + if (loadedTheme) next = mergeTheme(next, loadedTheme); + return mergeTheme(next, overrides); + }, [parent, theme, loadedTheme, overrides]); // Stable themeRef so controller callbacks never need to re-create. const themeRef = useRef(merged); @@ -102,7 +152,8 @@ export function SoundProvider({ const controller = useMemo(() => { const resolve = (key: string): SoundDef | null => { const t = themeRef.current; - if (key in t.tokens) return t.tokens[key as CoreSoundKey]; + // Sparse tokens: a missing key falls through to `custom`, then silence. + if (key in t.tokens) return t.tokens[key as CoreSoundKey] ?? null; return t.custom[key] ?? null; }; return { diff --git a/src/sound/silent.ts b/src/sound/silent.ts new file mode 100644 index 0000000..14b904f --- /dev/null +++ b/src/sound/silent.ts @@ -0,0 +1,14 @@ +import type { SoundTheme } from "./types"; + +/** + * The zero-asset theme. Used as the context default so that importing any + * sound-aware component (e.g. Button) never drags audio assets into a + * consumer's bundle. Mount a with a real theme (or + * `loadTheme`) to opt into sound. + */ +export const silentSoundTheme: SoundTheme = { + tokens: {}, + volume: 1, + muted: false, + custom: {}, +}; diff --git a/src/sound/types.ts b/src/sound/types.ts index 7afba97..12bde65 100644 --- a/src/sound/types.ts +++ b/src/sound/types.ts @@ -33,11 +33,13 @@ export type CoreSoundKey = | "vote"; /** Games augment this via `declare module "@unom/ui/sound"` to register custom token names. */ +// biome-ignore lint/suspicious/noEmptyInterface: must stay an interface — consumers extend it via declaration merging. export interface SoundCustomTokens {} export type CustomSoundKey = Extract; export type SoundTheme = { - tokens: Record; + /** Sparse: missing keys are simply silent. `null` explicitly silences a token. */ + tokens: Partial>; volume: number; muted: boolean; custom: Record;