feat: use howler
Some checks failed
Deploy to VPS (dist) / deploy (push) Has been cancelled

This commit is contained in:
Hewston Fox
2026-03-22 17:37:19 +02:00
parent e5b3b97680
commit 5c08238dbd
15 changed files with 152 additions and 140 deletions

View File

@@ -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",

43
pnpm-lock.yaml generated
View File

@@ -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)

View File

@@ -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<Map<SoundKey, HTMLAudioElement>>;
};
const AudioCtx = createContext<AudioCtxValue | null>(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<Map<SoundKey, HTMLAudioElement>>(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 (
<AudioCtx
value={useMemo(
() =>
({
isEnabled,
setIsEnabled,
loopingRef,
}) as AudioCtxValue,
[isEnabled],
)}
>
{children}
</AudioCtx>
);
}

View File

@@ -1,3 +1,3 @@
export { AudioProvider, useAudioSettings, usePlaySound } from "./AudioContext";
export { useAudioStore } from "./store";
export { SOUNDS } from "./sounds";
export type { SoundKey } from "./sounds";

View File

@@ -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<SoundKey, Howl> = {} as Record<SoundKey, Howl>;
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 });
}
}

76
src/audio/store.ts Normal file
View File

@@ -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<void>;
play: (key: SoundKey, opts?: { mode?: "once" | "loop"; force?: boolean }) => void;
stopLoop: (key: SoundKey) => void;
stopAllLoops: () => void;
};
const looping = new Map<SoundKey, number>();
export const useAudioStore = create<AudioState>((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();
},
};
});

View File

@@ -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<Exclude<Props["variant"], undefined>, string>;
export default function Button({ className, variant = "blue", onClick, ...props }: Props) {
const play = usePlaySound();
const play = useAudioStore((s) => s.play);
return (
<motion.button

View File

@@ -10,7 +10,7 @@ import LightSurface from "@components/surface/LightSurface";
import BackIcon from "./icons/BackIcon";
import StartIcon from "./icons/StartIcon";
import classes from "./Pagination.module.css";
import { usePlaySound } from "@/audio";
import { useAudioStore } from "@/audio";
type Props = {
value: number;
@@ -21,7 +21,7 @@ type Props = {
export default function Pagination({ value, total, onChange, variant = "default" }: Props) {
const { t } = useTranslation();
const play = usePlaySound();
const play = useAudioStore((s) => s.play);
const isAtStart = value <= 1 || total <= 1;
const isAtEnd = value >= total || total <= 1;

View File

@@ -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<HTMLMotionProps<"div">, "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;

View File

@@ -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<HTMLMotionProps<"input">, "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<HTMLInputElement>(null);

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={new QueryClient()}>
<AudioProvider>
<LiftProvider>
<RouterProvider router={router} />
</LiftProvider>
</AudioProvider>
<LiftProvider>
<RouterProvider router={router} />
</LiftProvider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -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);

View File

@@ -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");

View File

@@ -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");

View File

@@ -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<number>(0);