From 5c08238dbd5261616e88e45fec27ff39273c149e Mon Sep 17 00:00:00 2001 From: Hewston Fox Date: Sun, 22 Mar 2026 17:37:19 +0200 Subject: [PATCH] feat: use howler --- package.json | 5 +- pnpm-lock.yaml | 43 +++++++ src/audio/AudioContext.tsx | 110 ------------------ src/audio/index.ts | 2 +- src/audio/sounds.ts | 12 +- src/audio/store.ts | 76 ++++++++++++ src/components/atoms/Button/Button.tsx | 4 +- .../atoms/Pagination/Pagination.tsx | 4 +- .../atoms/TabSelector/TabSelector.tsx | 4 +- .../form/NumberInput/NumberInput.tsx | 4 +- src/main.tsx | 11 +- .../Header/components/Settings/Settings.tsx | 4 +- .../LanguageModal/LanguageModal.tsx | 4 +- .../SettingsModal/SettingsModal.tsx | 5 +- .../components/Navigation/Navigation.tsx | 4 +- 15 files changed, 152 insertions(+), 140 deletions(-) delete mode 100644 src/audio/AudioContext.tsx create mode 100644 src/audio/store.ts diff --git a/package.json b/package.json index 14c701d..5c1ca2f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "axios": "^1.13.6", "clsx": "^2.1.1", "eruda": "^3.4.3", + "howler": "^2.2.4", "i18next": "^25.8.17", "i18next-http-backend": "^3.0.2", "motion": "^12.35.1", @@ -34,10 +35,12 @@ "react-dom": "^19.2.4", "react-i18next": "^16.5.6", "tailwindcss": "^4.2.1", - "xstate": "^5.28.0" + "xstate": "^5.28.0", + "zustand": "^5.0.12" }, "devDependencies": { "@tanstack/router-plugin": "^1.166.3", + "@types/howler": "^2.2.12", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05c93b2..b514262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: eruda: specifier: ^3.4.3 version: 3.4.3 + howler: + specifier: ^2.2.4 + version: 2.2.4 i18next: specifier: ^25.8.17 version: 25.8.17(typescript@5.9.3) @@ -71,10 +74,16 @@ importers: xstate: specifier: ^5.28.0 version: 5.28.0 + zustand: + specifier: ^5.0.12 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) devDependencies: '@tanstack/router-plugin': specifier: ^1.166.3 version: 1.166.3(@tanstack/react-router@1.166.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass-embedded@1.97.3)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + '@types/howler': + specifier: ^2.2.12 + version: 2.2.12 '@types/node': specifier: ^24.10.1 version: 24.12.0 @@ -1226,6 +1235,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/howler@2.2.12': + resolution: {integrity: sha512-hy769UICzOSdK0Kn1FBk4gN+lswcj1EKRkmiDtMkUGvFfYJzgaDXmVXkSShS2m89ERAatGIPnTUlp2HhfkVo5g==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} @@ -1567,6 +1579,9 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + howler@2.2.4: + resolution: {integrity: sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -2268,6 +2283,24 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@ark/schema@0.56.0': @@ -3145,6 +3178,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/howler@2.2.12': {} + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -3481,6 +3516,8 @@ snapshots: dependencies: function-bind: 1.1.2 + howler@2.2.4: {} + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -4096,3 +4133,9 @@ snapshots: yaml@2.8.2: {} zod@3.25.76: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/src/audio/AudioContext.tsx b/src/audio/AudioContext.tsx deleted file mode 100644 index 4ff667c..0000000 --- a/src/audio/AudioContext.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { type ReactNode, type RefObject, useMemo } from "react"; -import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; -import tg, { STORAGE_KEYS } from "@/tg"; -import type { SoundKey } from "./sounds"; -import { SOUNDS, preloadSounds } from "./sounds"; - -type AudioCtxValue = { - isEnabled: boolean; - setIsEnabled: (enabled: boolean) => void; - loopingRef: RefObject>; -}; - -const AudioCtx = createContext(null); - -export function useAudioSettings(): { - isEnabled: boolean; - setIsEnabled: (enabled: boolean) => void; -} { - const ctx = useContext(AudioCtx); - if (!ctx) throw new Error("useAudioSettings must be used within AudioProvider"); - return { isEnabled: ctx.isEnabled, setIsEnabled: ctx.setIsEnabled }; -} - -export function usePlaySound(): ( - key: SoundKey, - { mode, force }?: { mode?: "once" | "loop"; force: boolean }, -) => void { - const ctx = useContext(AudioCtx); - if (!ctx) throw new Error("usePlaySound must be used within AudioProvider"); - - const { isEnabled, loopingRef } = ctx; - - return useCallback( - (key: SoundKey, { mode, force }: { mode?: "once" | "loop"; force?: boolean } = {}) => { - if (!isEnabled && !force) return; - - if (mode === "loop" && loopingRef.current.has(key)) return; - - const audio = new Audio(SOUNDS[key]); - - if (mode === "loop") { - audio.loop = true; - loopingRef.current.set(key, audio); - } else { - audio.addEventListener( - "ended", - () => { - audio.src = ""; - }, - { once: true }, - ); - } - - audio.play().catch(() => { - if (mode === "loop") loopingRef.current.delete(key); - }); - }, - [isEnabled, loopingRef], - ); -} - -export function AudioProvider({ children }: { children: ReactNode }) { - const [isEnabled, setIsEnabled] = useState(true); - const hydratedRef = useRef(false); - const loopingRef = useRef>(null); - if (!loopingRef.current) { - loopingRef.current = new Map(); - preloadSounds(); - } - - useEffect(() => { - tg.storage.getItem(STORAGE_KEYS.soundEnabled).then((value) => { - if (value !== "") { - setIsEnabled(value !== "false"); - } - hydratedRef.current = true; - }); - }, []); - - useEffect(() => { - if (!hydratedRef.current) return; - tg.storage.setItem(STORAGE_KEYS.soundEnabled, String(isEnabled)); - }, [isEnabled]); - - useEffect(() => { - if (!isEnabled) { - for (const audio of loopingRef.current!.values()) { - audio.pause(); - audio.src = ""; - } - loopingRef.current!.clear(); - } - }, [isEnabled]); - - return ( - - ({ - isEnabled, - setIsEnabled, - loopingRef, - }) as AudioCtxValue, - [isEnabled], - )} - > - {children} - - ); -} diff --git a/src/audio/index.ts b/src/audio/index.ts index b1c7266..06d8356 100644 --- a/src/audio/index.ts +++ b/src/audio/index.ts @@ -1,3 +1,3 @@ -export { AudioProvider, useAudioSettings, usePlaySound } from "./AudioContext"; +export { useAudioStore } from "./store"; export { SOUNDS } from "./sounds"; export type { SoundKey } from "./sounds"; diff --git a/src/audio/sounds.ts b/src/audio/sounds.ts index 314b5d6..8291396 100644 --- a/src/audio/sounds.ts +++ b/src/audio/sounds.ts @@ -1,15 +1,17 @@ -import { prefetch } from "@/helpers/dom"; +import { Howl } from "howler"; import click from "./assets/click.mp3"; -export const SOUNDS = { +const SOUND_SOURCES = { click, } as const; -export type SoundKey = keyof typeof SOUNDS; +export type SoundKey = keyof typeof SOUND_SOURCES; + +export const SOUNDS: Record = {} as Record; export function preloadSounds() { - for (const src of Object.values(SOUNDS)) { - prefetch(src); + for (const [key, src] of Object.entries(SOUND_SOURCES)) { + SOUNDS[key as SoundKey] = new Howl({ src: [src], preload: true }); } } diff --git a/src/audio/store.ts b/src/audio/store.ts new file mode 100644 index 0000000..0e2ac5e --- /dev/null +++ b/src/audio/store.ts @@ -0,0 +1,76 @@ +import { create } from "zustand"; +import tg, { STORAGE_KEYS } from "@/tg"; +import type { SoundKey } from "./sounds"; +import { SOUNDS, preloadSounds } from "./sounds"; + +type AudioState = { + isEnabled: boolean; + _hydrated: boolean; + setIsEnabled: (enabled: boolean) => void; + hydrate: () => Promise; + play: (key: SoundKey, opts?: { mode?: "once" | "loop"; force?: boolean }) => void; + stopLoop: (key: SoundKey) => void; + stopAllLoops: () => void; +}; + +const looping = new Map(); + +export const useAudioStore = create((set, get) => { + preloadSounds(); + + return { + isEnabled: true, + _hydrated: false, + + setIsEnabled(enabled: boolean) { + set({ isEnabled: enabled }); + if (get()._hydrated) { + tg.storage.setItem(STORAGE_KEYS.soundEnabled, String(enabled)); + } + if (!enabled) { + get().stopAllLoops(); + } + }, + + async hydrate() { + const value = await tg.storage.getItem(STORAGE_KEYS.soundEnabled); + if (value !== "") { + set({ isEnabled: value !== "false" }); + } + set({ _hydrated: true }); + }, + + play(key: SoundKey, opts?: { mode?: "once" | "loop"; force?: boolean }) { + const { isEnabled } = get(); + if (!isEnabled && !opts?.force) return; + + const howl = SOUNDS[key]; + if (!howl) return; + + if (opts?.mode === "loop") { + if (looping.has(key)) return; + howl.loop(true); + const id = howl.play(); + looping.set(key, id); + } else { + howl.loop(false); + howl.play(); + } + }, + + stopLoop(key: SoundKey) { + const id = looping.get(key); + if (id != null) { + SOUNDS[key]?.stop(id); + looping.delete(key); + } + }, + + stopAllLoops() { + for (const [key, id] of looping) { + SOUNDS[key]?.stop(id); + } + looping.clear(); + }, + }; +}); diff --git a/src/components/atoms/Button/Button.tsx b/src/components/atoms/Button/Button.tsx index a80f52f..dd64701 100644 --- a/src/components/atoms/Button/Button.tsx +++ b/src/components/atoms/Button/Button.tsx @@ -1,7 +1,7 @@ import { motion, type HTMLMotionProps } from "motion/react"; import clsx, { type ClassValue } from "clsx"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; import tg from "@/tg"; import classes from "./Button.module.css"; @@ -19,7 +19,7 @@ const VARIANTS_MAP = { } satisfies Record, string>; export default function Button({ className, variant = "blue", onClick, ...props }: Props) { - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); return ( s.play); const isAtStart = value <= 1 || total <= 1; const isAtEnd = value >= total || total <= 1; diff --git a/src/components/atoms/TabSelector/TabSelector.tsx b/src/components/atoms/TabSelector/TabSelector.tsx index d3cce4d..6e83101 100644 --- a/src/components/atoms/TabSelector/TabSelector.tsx +++ b/src/components/atoms/TabSelector/TabSelector.tsx @@ -4,7 +4,7 @@ import DarkSurface from "@components/surface/DarkSurface"; import { motion, type HTMLMotionProps } from "motion/react"; import classes from "./TabSelector.module.css"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; import tg from "@/tg"; type Tab = { @@ -20,7 +20,7 @@ type Props = Omit, "className" | "onChange"> & { }; export default function TabSelector({ tabs, value, onChange, className, ...props }: Props) { - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); const selectedIndex = value != null ? tabs.findIndex((tab) => tab.key === value) : -1; diff --git a/src/components/form/NumberInput/NumberInput.tsx b/src/components/form/NumberInput/NumberInput.tsx index a18f58a..92fed9c 100644 --- a/src/components/form/NumberInput/NumberInput.tsx +++ b/src/components/form/NumberInput/NumberInput.tsx @@ -4,7 +4,7 @@ import { type ReactNode, useRef, useState, type ChangeEvent, useId } from "react import KeyboardIcon from "@components/icons/KeyboardIcon"; import classes from "./NumberInput.module.css"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; import tg from "@/tg"; type Props = Omit, "className" | "type" | "onChange"> & { @@ -31,7 +31,7 @@ export default function NumberInput({ onChange, ...props }: Props) { - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); const stableId = useId(); const inputRef = useRef(null); diff --git a/src/main.tsx b/src/main.tsx index 1f5cdee..12365bb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -14,7 +14,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import i18n from "@/i18n"; import tg from "@/tg"; import { routeTree } from "./routeTree.gen"; -import { AudioProvider } from "@/audio"; +import { useAudioStore } from "@/audio"; import { LiftProvider } from "@components/lift"; import "./styles/index.css"; @@ -30,15 +30,14 @@ declare module "@tanstack/react-router" { tg.init(); i18n.init(); +useAudioStore.getState().hydrate(); ReactDOM.createRoot(document.getElementById("root")!).render( - - - - - + + + , ); diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx index 6f39d40..e91392b 100644 --- a/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx +++ b/src/routes/-/RootLayout/components/Header/components/Settings/Settings.tsx @@ -13,10 +13,10 @@ const TransactionsHistoryModal = lazy(() => import("./components/TransactionsHis const LanguageModal = lazy(() => import("./components/LanguageModal")); import classes from "./Settings.module.css"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; export default function Settings() { - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); const [state, send] = useMachine(settingsMachine); diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx index f81aa12..fe83a26 100644 --- a/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/LanguageModal/LanguageModal.tsx @@ -5,7 +5,7 @@ import Modal from "@components/modals/Modal"; import ContentSurface from "@components/surface/ContentSurface"; import DarkSurface from "@components/surface/DarkSurface"; import { useLanguages } from "@/i18n/useLanguages"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; import tg from "@/tg"; import classes from "./LanguageModal.module.css"; @@ -21,7 +21,7 @@ type Props = { export default function LanguageModal({ open, onClose, onBack }: Props) { const { t } = useTranslation(); const { languages, current, setLanguage } = useLanguages(); - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); const handleSelect = (key: string) => { play("click"); diff --git a/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx index f57fc74..6a66e8d 100644 --- a/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx +++ b/src/routes/-/RootLayout/components/Header/components/Settings/components/SettingsModal/SettingsModal.tsx @@ -5,7 +5,7 @@ import Modal from "@components/modals/Modal"; import ContentSurface from "@components/surface/ContentSurface"; import LightSurface from "@components/surface/LightSurface"; import SwitchInput from "@components/form/SwitchInput"; -import { useAudioSettings, usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; import tg from "@/tg"; import classes from "./SettingsModal.module.css"; @@ -23,8 +23,7 @@ type Props = { export default function SettingsModal({ open, onClose, onNavigate, liftIds }: Props) { const { t } = useTranslation(); const { current } = useLanguages(); - const { isEnabled, setIsEnabled } = useAudioSettings(); - const play = usePlaySound(); + const { isEnabled, setIsEnabled, play } = useAudioStore(); const handleNavigate = (modal: SettingsModalId) => { play("click"); diff --git a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx index c85a10d..272faf4 100644 --- a/src/routes/-/RootLayout/components/Navigation/Navigation.tsx +++ b/src/routes/-/RootLayout/components/Navigation/Navigation.tsx @@ -23,7 +23,7 @@ import rouletteIcon from "./assets/roulette.svg"; import tasksIcon from "./assets/tasks.svg"; import earningsIcon from "./assets/earnings.svg"; import tg, { useTelegramViewportValue } from "@/tg"; -import { usePlaySound } from "@/audio"; +import { useAudioStore } from "@/audio"; const ANIMATION_DURATION = 0.2; const SPRING_ANIMATION = { @@ -193,7 +193,7 @@ function MenuBar({ labelKey, icon, delay, active, onClick }: MenuBarProps) { } export default function Navigation() { - const play = usePlaySound(); + const play = useAudioStore((s) => s.play); const matchRoute = useMatchRoute(); const navigate = useNavigate(); const [menuOpen, setMenuOpen] = useState(0);