This commit is contained in:
@@ -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
43
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export { AudioProvider, useAudioSettings, usePlaySound } from "./AudioContext";
|
||||
export { useAudioStore } from "./store";
|
||||
export { SOUNDS } from "./sounds";
|
||||
export type { SoundKey } from "./sounds";
|
||||
|
||||
@@ -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
76
src/audio/store.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
11
src/main.tsx
11
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(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<AudioProvider>
|
||||
<LiftProvider>
|
||||
<RouterProvider router={router} />
|
||||
</LiftProvider>
|
||||
</AudioProvider>
|
||||
<LiftProvider>
|
||||
<RouterProvider router={router} />
|
||||
</LiftProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user