feat: custom achievement sound and volume changing)

This commit is contained in:
Moyasee
2025-11-07 17:48:56 +02:00
parent 754e9c14b8
commit a6cbaf6dc1
21 changed files with 583 additions and 46 deletions

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { Sidebar, BottomPanel, Header, Toast } from "@renderer/components";
import {
@@ -25,7 +24,7 @@ import { UserFriendModal } from "./pages/shared-modals/user-friend-modal";
import { useSubscription } from "./hooks/use-subscription";
import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-modal";
import { injectCustomCss, removeCustomCss } from "./helpers";
import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "./helpers";
import "./app.scss";
export interface AppProps {
@@ -216,9 +215,11 @@ export function App() {
return () => unsubscribe();
}, [loadAndApplyTheme]);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.2;
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);

View File

@@ -410,6 +410,17 @@ declare global {
getCustomThemeById: (themeId: string) => Promise<Theme | null>;
getActiveCustomTheme: () => Promise<Theme | null>;
toggleCustomTheme: (themeId: string, isActive: boolean) => Promise<void>;
copyThemeAchievementSound: (
themeId: string,
sourcePath: string
) => Promise<void>;
removeThemeAchievementSound: (themeId: string) => Promise<void>;
getThemeSoundPath: (themeId: string) => Promise<string | null>;
importThemeSoundFromStore: (
themeId: string,
themeName: string,
storeUrl: string
) => Promise<void>;
/* Editor */
openEditorWindow: (themeId: string) => Promise<void>;

View File

@@ -121,3 +121,32 @@ export const formatNumber = (num: number): string => {
export const generateUUID = (): string => {
return uuidv4();
};
export const getAchievementSoundUrl = async (): Promise<string> => {
const defaultSound = (await import("@renderer/assets/audio/achievement.wav")).default;
try {
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme?.hasCustomSound) {
const soundPath = await window.electron.getThemeSoundPath(activeTheme.id);
if (soundPath) {
return `file://${soundPath}`;
}
}
} catch (error) {
console.error("Failed to get theme sound", error);
}
return defaultSound;
};
export const getAchievementSoundVolume = async (): Promise<number> => {
try {
const prefs = await window.electron.getUserPreferences();
return prefs?.achievementSoundVolume ?? 0.15;
} catch (error) {
console.error("Failed to get sound volume", error);
return 0.15;
}
};

View File

@@ -1,11 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react";
import achievementSound from "@renderer/assets/audio/achievement.wav";
import { useTranslation } from "react-i18next";
import {
AchievementCustomNotificationPosition,
AchievementNotificationInfo,
} from "@types";
import { injectCustomCss, removeCustomCss } from "@renderer/helpers";
import { injectCustomCss, removeCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import app from "../../../app.scss?inline";
import styles from "../../../components/achievements/notification/achievement-notification.scss?inline";
@@ -33,9 +32,11 @@ export function AchievementNotification() {
const [shadowRootRef, setShadowRootRef] = useState<HTMLElement | null>(null);
const playAudio = useCallback(() => {
const audio = new Audio(achievementSound);
audio.volume = 0.1;
const playAudio = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);

View File

@@ -51,6 +51,16 @@ export const ImportThemeModal = ({
if (!currentTheme) return;
try {
await window.electron.importThemeSoundFromStore(
theme.id,
themeName,
THEME_WEB_STORE_URL
);
} catch (soundError) {
logger.error("Failed to import theme sound", soundError);
}
const activeTheme = await window.electron.getActiveCustomTheme();
if (activeTheme) {

View File

@@ -17,4 +17,100 @@
&__test-achievement-notification-button {
align-self: flex-start;
}
&__volume-control {
display: flex;
flex-direction: column;
gap: 8px;
label {
font-size: 14px;
color: globals.$muted-color;
}
}
&__volume-input-wrapper {
display: flex;
align-items: center;
gap: 4px;
}
&__volume-input-container {
position: relative;
display: flex;
align-items: center;
background: globals.$dark-background-color;
border: 1px solid globals.$border-color;
padding: 8px 8px;
border-radius: 4px;
transition: border-color 0.2s;
&:focus-within {
border-color: rgba(255, 255, 255, 0.5);
color: globals.$muted-color;
}
input[type="number"] {
width: 30px;
background: transparent;
border: none;
color: globals.$muted-color;
font-size: 14px;
text-align: center;
&:focus {
outline: none;
}
&::-webkit-inner-spin-button,
&::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
appearance: textfield;
-moz-appearance: textfield;
}
}
&__volume-input-unit {
font-size: 14px;
color: globals.$muted-color;
pointer-events: none;
user-select: none;
}
&__volume-input-buttons {
display: flex;
flex-direction: column;
gap: 2px;
button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 16px;
background: globals.$dark-background-color;
border: 1px solid globals.$border-color;
border-radius: 2px;
color: globals.$muted-color;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: globals.$muted-color;
border-color: rgba(255, 255, 255, 0.5);
}
&:active {
background: globals.$background-color;
}
svg {
width: 12px;
height: 12px;
}
}
}
}

View File

@@ -1,4 +1,4 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { useContext, useEffect, useMemo, useState, useCallback, useRef } from "react";
import {
TextField,
Button,
@@ -43,6 +43,7 @@ export function SettingsGeneral() {
achievementCustomNotificationsEnabled: true,
achievementCustomNotificationPosition:
"top-left" as AchievementCustomNotificationPosition,
achievementSoundVolume: 15,
language: "",
customStyles: window.localStorage.getItem("customStyles") || "",
});
@@ -50,6 +51,8 @@ export function SettingsGeneral() {
const [languageOptions, setLanguageOptions] = useState<LanguageOption[]>([]);
const [defaultDownloadsPath, setDefaultDownloadsPath] = useState("");
const volumeUpdateTimeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
window.electron.getDefaultDownloadsPath().then((path) => {
@@ -81,6 +84,9 @@ export function SettingsGeneral() {
return () => {
clearInterval(interval);
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
};
}, []);
@@ -110,6 +116,7 @@ export function SettingsGeneral() {
userPreferences.achievementCustomNotificationsEnabled ?? true,
achievementCustomNotificationPosition:
userPreferences.achievementCustomNotificationPosition ?? "top-left",
achievementSoundVolume: Math.round((userPreferences.achievementSoundVolume ?? 0.15) * 100),
friendRequestNotificationsEnabled:
userPreferences.friendRequestNotificationsEnabled ?? false,
friendStartGameNotificationsEnabled:
@@ -148,6 +155,18 @@ export function SettingsGeneral() {
await updateUserPreferences(values);
};
const handleVolumeChange = useCallback((newVolume: number) => {
setForm((prev) => ({ ...prev, achievementSoundVolume: newVolume }));
if (volumeUpdateTimeoutRef.current) {
clearTimeout(volumeUpdateTimeoutRef.current);
}
volumeUpdateTimeoutRef.current = setTimeout(() => {
updateUserPreferences({ achievementSoundVolume: newVolume / 100 });
}, 300);
}, [updateUserPreferences]);
const handleChangeAchievementCustomNotificationPosition = async (
event: React.ChangeEvent<HTMLSelectElement>
) => {
@@ -309,6 +328,68 @@ export function SettingsGeneral() {
</>
)}
{form.achievementNotificationsEnabled && (
<div className="settings-general__volume-control">
<label htmlFor="achievement-volume">
{t("achievement_sound_volume")}
</label>
<div className="settings-general__volume-input-wrapper">
<div className="settings-general__volume-input-container">
<input
id="achievement-volume"
type="number"
min="0"
max="100"
value={form.achievementSoundVolume}
onChange={(e) => {
const value = e.target.value;
if (value === "") {
handleVolumeChange(0);
return;
}
const volumePercent = Math.min(100, Math.max(0, parseInt(value, 10)));
if (!isNaN(volumePercent)) {
handleVolumeChange(volumePercent);
}
}}
onBlur={(e) => {
if (e.target.value === "" || isNaN(parseInt(e.target.value, 10))) {
handleVolumeChange(0);
}
}}
/>
<span className="settings-general__volume-input-unit">%</span>
</div>
<div className="settings-general__volume-input-buttons">
<button
type="button"
onClick={(e) => {
e.preventDefault();
const newVolume = Math.min(100, form.achievementSoundVolume + 1);
handleVolumeChange(newVolume);
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 4l4 4H2l4-4z" />
</svg>
</button>
<button
type="button"
onClick={(e) => {
e.preventDefault();
const newVolume = Math.max(0, form.achievementSoundVolume - 1);
handleVolumeChange(newVolume);
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 8L2 4h8L6 8z" />
</svg>
</button>
</div>
</div>
</div>
)}
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description">

View File

@@ -93,12 +93,25 @@
&__notification-preview {
padding-top: 12px;
display: flex;
flex-direction: row;
align-items: center;
flex-direction: column;
gap: 16px;
&__select-variation {
flex: inherit;
}
}
&__notification-controls {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
}
&__sound-controls {
display: flex;
flex-direction: row;
gap: 8px;
flex-wrap: wrap;
}
}

View File

@@ -4,10 +4,10 @@ import Editor from "@monaco-editor/react";
import { AchievementCustomNotificationPosition, Theme } from "@types";
import { useSearchParams } from "react-router-dom";
import { Button, SelectField } from "@renderer/components";
import { CheckIcon } from "@primer/octicons-react";
import { CheckIcon, UploadIcon, TrashIcon, PlayIcon } from "@primer/octicons-react";
import { useTranslation } from "react-i18next";
import cn from "classnames";
import { injectCustomCss } from "@renderer/helpers";
import { injectCustomCss, getAchievementSoundUrl, getAchievementSoundVolume } from "@renderer/helpers";
import { AchievementNotificationItem } from "@renderer/components/achievements/notification/achievement-notification";
import { generateAchievementCustomNotificationTest } from "@shared";
import { CollapsedMenu } from "@renderer/components/collapsed-menu/collapsed-menu";
@@ -107,6 +107,46 @@ export default function ThemeEditor() {
}
};
const handleSelectSound = useCallback(async () => {
if (!theme) return;
const { filePaths } = await window.electron.showOpenDialog({
properties: ["openFile"],
filters: [
{
name: "Audio",
extensions: ["wav", "mp3", "ogg", "m4a"],
},
],
});
if (filePaths && filePaths.length > 0) {
await window.electron.copyThemeAchievementSound(theme.id, filePaths[0]);
const updatedTheme = await window.electron.getCustomThemeById(theme.id);
if (updatedTheme) {
setTheme(updatedTheme);
}
}
}, [theme]);
const handleRemoveSound = useCallback(async () => {
if (!theme) return;
await window.electron.removeThemeAchievementSound(theme.id);
const updatedTheme = await window.electron.getCustomThemeById(theme.id);
if (updatedTheme) {
setTheme(updatedTheme);
}
}, [theme]);
const handlePreviewSound = useCallback(async () => {
const soundUrl = await getAchievementSoundUrl();
const volume = await getAchievementSoundVolume();
const audio = new Audio(soundUrl);
audio.volume = volume;
audio.play();
}, []);
const achievementCustomNotificationPositionOptions = useMemo(() => {
return [
"top-left",
@@ -164,35 +204,58 @@ export default function ThemeEditor() {
<div className="theme-editor__footer">
<CollapsedMenu title={t("notification_preview")}>
<div className="theme-editor__notification-preview">
<SelectField
className="theme-editor__notification-preview__select-variation"
label={t("variation")}
options={Object.values(notificationVariations).map(
(variation) => {
return {
key: variation,
value: variation,
label: t(variation),
};
<div className="theme-editor__notification-controls">
<SelectField
className="theme-editor__notification-preview__select-variation"
label={t("variation")}
options={Object.values(notificationVariations).map(
(variation) => {
return {
key: variation,
value: variation,
label: t(variation),
};
}
)}
onChange={(value) =>
setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
}
)}
onChange={(value) =>
setNotificationVariation(
value.target.value as keyof typeof notificationVariations
)
}
/>
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
<SelectField
label={t("alignment")}
value={notificationAlignment}
onChange={(e) =>
setNotificationAlignment(
e.target.value as AchievementCustomNotificationPosition
)
}
options={achievementCustomNotificationPositionOptions}
/>
</div>
<div className="theme-editor__sound-controls">
<Button theme="outline" onClick={handleSelectSound}>
<UploadIcon />
{theme?.hasCustomSound
? t("change_achievement_sound")
: t("select_achievement_sound")}
</Button>
{theme?.hasCustomSound && (
<Button theme="outline" onClick={handleRemoveSound}>
<TrashIcon />
{t("remove_achievement_sound")}
</Button>
)}
<Button theme="outline" onClick={handlePreviewSound}>
<PlayIcon />
{t("preview_sound")}
</Button>
</div>
<div className="theme-editor__notification-preview-wrapper">
<root.div>