From a6cbaf6dc11b8cd031b8129eb729c91b43e12192 Mon Sep 17 00:00:00 2001 From: Moyasee Date: Fri, 7 Nov 2025 17:48:56 +0200 Subject: [PATCH] feat: custom achievement sound and volume changing) --- src/locales/en/translation.json | 5 + src/main/constants.ts | 4 + src/main/events/index.ts | 4 + .../themes/copy-theme-achievement-sound.ts | 40 ++++++ .../events/themes/get-theme-sound-path.ts | 12 ++ .../themes/import-theme-sound-from-store.ts | 57 +++++++++ .../themes/remove-theme-achievement-sound.ts | 39 ++++++ src/main/helpers/index.ts | 24 ++++ src/main/services/notifications/index.ts | 45 ++++++- src/preload/index.ts | 8 ++ src/renderer/src/app.tsx | 11 +- src/renderer/src/declaration.d.ts | 11 ++ src/renderer/src/helpers.ts | 29 +++++ .../notification/achievement-notification.tsx | 11 +- .../aparence/modals/import-theme-modal.tsx | 10 ++ .../src/pages/settings/settings-general.scss | 96 ++++++++++++++ .../src/pages/settings/settings-general.tsx | 83 +++++++++++- .../src/pages/theme-editor/theme-editor.scss | 17 ++- .../src/pages/theme-editor/theme-editor.tsx | 121 +++++++++++++----- src/types/level.types.ts | 1 + src/types/theme.types.ts | 1 + 21 files changed, 583 insertions(+), 46 deletions(-) create mode 100644 src/main/events/themes/copy-theme-achievement-sound.ts create mode 100644 src/main/events/themes/get-theme-sound-path.ts create mode 100644 src/main/events/themes/import-theme-sound-from-store.ts create mode 100644 src/main/events/themes/remove-theme-achievement-sound.ts diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 9989f153..30b165ae 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -557,6 +557,11 @@ "platinum": "Platinum", "hidden": "Hidden", "test_notification": "Test notification", + "achievement_sound_volume": "Achievement sound volume", + "select_achievement_sound": "Select achievement sound", + "change_achievement_sound": "Change achievement sound", + "remove_achievement_sound": "Remove achievement sound", + "preview_sound": "Preview sound", "notification_preview": "Achievement Notification Preview", "enable_friend_start_game_notifications": "When a friend starts playing a game", "autoplay_trailers_on_game_page": "Automatically start playing trailers on game page", diff --git a/src/main/constants.ts b/src/main/constants.ts index 82b99b2a..3c4c10e5 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -41,8 +41,12 @@ export const appVersion = app.getVersion() + (isStaging ? "-staging" : ""); export const ASSETS_PATH = path.join(SystemPath.getPath("userData"), "Assets"); +export const THEMES_PATH = path.join(SystemPath.getPath("userData"), "themes"); + export const MAIN_LOOP_INTERVAL = 2000; +export const DEFAULT_ACHIEVEMENT_SOUND_VOLUME = 0.15; + export const DECKY_PLUGINS_LOCATION = path.join( SystemPath.getPath("home"), "homebrew", diff --git a/src/main/events/index.ts b/src/main/events/index.ts index aaac89dd..89dd01f5 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -92,6 +92,10 @@ import "./themes/get-custom-theme-by-id"; import "./themes/get-active-custom-theme"; import "./themes/close-editor-window"; import "./themes/toggle-custom-theme"; +import "./themes/copy-theme-achievement-sound"; +import "./themes/remove-theme-achievement-sound"; +import "./themes/get-theme-sound-path"; +import "./themes/import-theme-sound-from-store"; import "./download-sources/remove-download-source"; import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; diff --git a/src/main/events/themes/copy-theme-achievement-sound.ts b/src/main/events/themes/copy-theme-achievement-sound.ts new file mode 100644 index 00000000..72ec0c79 --- /dev/null +++ b/src/main/events/themes/copy-theme-achievement-sound.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const copyThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + sourcePath: string +): Promise => { + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error("Source file does not exist"); + } + + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const fileExtension = path.extname(sourcePath); + const destinationPath = path.join(themeDir, `achievement${fileExtension}`); + + await fs.promises.copyFile(sourcePath, destinationPath); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); +}; + +registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); + diff --git a/src/main/events/themes/get-theme-sound-path.ts b/src/main/events/themes/get-theme-sound-path.ts new file mode 100644 index 00000000..5dccbd4e --- /dev/null +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -0,0 +1,12 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; + +const getThemeSoundPathEvent = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + return getThemeSoundPath(themeId); +}; + +registerEvent("getThemeSoundPath", getThemeSoundPathEvent); + diff --git a/src/main/events/themes/import-theme-sound-from-store.ts b/src/main/events/themes/import-theme-sound-from-store.ts new file mode 100644 index 00000000..cd4c6fcd --- /dev/null +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -0,0 +1,57 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import path from "node:path"; +import axios from "axios"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { logger } from "@main/services"; + +const importThemeSoundFromStore = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string, + themeName: string, + storeUrl: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + try { + const soundUrl = `${storeUrl}/themes/${themeName.toLowerCase()}/achievement.${format}`; + + const response = await axios.get(soundUrl, { + responseType: "arraybuffer", + timeout: 10000, + }); + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + fs.mkdirSync(themeDir, { recursive: true }); + } + + const destinationPath = path.join(themeDir, `achievement.${format}`); + await fs.promises.writeFile(destinationPath, response.data); + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: true, + updatedAt: new Date(), + }); + + logger.log(`Successfully imported sound for theme ${themeName}`); + return; + } catch (error) { + continue; + } + } + + logger.log(`No sound file found for theme ${themeName} in store`); +}; + +registerEvent("importThemeSoundFromStore", importThemeSoundFromStore); + diff --git a/src/main/events/themes/remove-theme-achievement-sound.ts b/src/main/events/themes/remove-theme-achievement-sound.ts new file mode 100644 index 00000000..adb17a57 --- /dev/null +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -0,0 +1,39 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import path from "node:path"; + +const removeThemeAchievementSound = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + if (!theme) { + throw new Error("Theme not found"); + } + + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + return; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(themeDir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: false, + updatedAt: new Date(), + }); +}; + +registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); + diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 2da49a1c..ae19fbdb 100644 --- a/src/main/helpers/index.ts +++ b/src/main/helpers/index.ts @@ -2,6 +2,8 @@ import axios from "axios"; import { JSDOM } from "jsdom"; import UserAgent from "user-agents"; import path from "node:path"; +import fs from "node:fs"; +import { THEMES_PATH } from "@main/constants"; export const getFileBuffer = async (url: string) => fetch(url, { method: "GET" }).then((response) => @@ -36,4 +38,26 @@ export const normalizePath = (str: string) => export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; +export const getThemePath = (themeId: string) => + path.join(THEMES_PATH, themeId); + +export const getThemeSoundPath = (themeId: string): string | null => { + const themeDir = getThemePath(themeId); + + if (!fs.existsSync(themeDir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(themeDir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } + + return null; +}; + export * from "./reg-parser"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d28c3cd7..d78a4d3f 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -5,15 +5,16 @@ import fs from "node:fs"; import axios from "axios"; import path from "node:path"; import sound from "sound-play"; -import { achievementSoundPath } from "@main/constants"; +import { achievementSoundPath, DEFAULT_ACHIEVEMENT_SOUND_VOLUME } from "@main/constants"; import icon from "@resources/icon.png?asset"; import { NotificationOptions, toXmlString } from "./xml"; import { logger } from "../logger"; import { WindowManager } from "../window-manager"; import type { Game, UserPreferences, UserProfile } from "@types"; -import { db, levelKeys } from "@main/level"; +import { db, levelKeys, themesSublevel } from "@main/level"; import { restartAndInstallUpdate } from "@main/events/autoupdater/restart-and-install-update"; import { SystemPath } from "../system-path"; +import { getThemeSoundPath } from "@main/helpers"; async function downloadImage(url: string | null) { if (!url) return undefined; @@ -40,6 +41,40 @@ async function downloadImage(url: string | null) { }); } +async function getAchievementSoundPath(): Promise { + try { + const allThemes = await themesSublevel.values().all(); + const activeTheme = allThemes.find((theme) => theme.isActive); + + if (activeTheme) { + const themeSoundPath = getThemeSoundPath(activeTheme.id); + if (themeSoundPath) { + return themeSoundPath; + } + } + } catch (error) { + logger.error("Failed to get theme sound path", error); + } + + return achievementSoundPath; +} + +async function getAchievementSoundVolume(): Promise { + try { + const userPreferences = await db.get( + levelKeys.userPreferences, + { + valueEncoding: "json", + } + ); + + return userPreferences?.achievementSoundVolume ?? DEFAULT_ACHIEVEMENT_SOUND_VOLUME; + } catch (error) { + logger.error("Failed to get achievement sound volume", error); + return DEFAULT_ACHIEVEMENT_SOUND_VOLUME; + } +} + export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, @@ -145,7 +180,8 @@ export const publishCombinedNewAchievementNotification = async ( if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; @@ -205,6 +241,7 @@ export const publishNewAchievementNotification = async (info: { if (WindowManager.mainWindow) { WindowManager.mainWindow.webContents.send("on-achievement-unlocked"); } else if (process.platform !== "linux") { - sound.play(achievementSoundPath); + const soundPath = await getAchievementSoundPath(); + sound.play(soundPath); } }; diff --git a/src/preload/index.ts b/src/preload/index.ts index fc588a30..67ac0c73 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -571,6 +571,14 @@ contextBridge.exposeInMainWorld("electron", { getActiveCustomTheme: () => ipcRenderer.invoke("getActiveCustomTheme"), toggleCustomTheme: (themeId: string, isActive: boolean) => ipcRenderer.invoke("toggleCustomTheme", themeId, isActive), + copyThemeAchievementSound: (themeId: string, sourcePath: string) => + ipcRenderer.invoke("copyThemeAchievementSound", themeId, sourcePath), + removeThemeAchievementSound: (themeId: string) => + ipcRenderer.invoke("removeThemeAchievementSound", themeId), + getThemeSoundPath: (themeId: string) => + ipcRenderer.invoke("getThemeSoundPath", themeId), + importThemeSoundFromStore: (themeId: string, themeName: string, storeUrl: string) => + ipcRenderer.invoke("importThemeSoundFromStore", themeId, themeName, storeUrl), /* Editor */ openEditorWindow: (themeId: string) => diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index 1ab76381..fd1c2735 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -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(); }, []); diff --git a/src/renderer/src/declaration.d.ts b/src/renderer/src/declaration.d.ts index 65f2ce9e..44696872 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -410,6 +410,17 @@ declare global { getCustomThemeById: (themeId: string) => Promise; getActiveCustomTheme: () => Promise; toggleCustomTheme: (themeId: string, isActive: boolean) => Promise; + copyThemeAchievementSound: ( + themeId: string, + sourcePath: string + ) => Promise; + removeThemeAchievementSound: (themeId: string) => Promise; + getThemeSoundPath: (themeId: string) => Promise; + importThemeSoundFromStore: ( + themeId: string, + themeName: string, + storeUrl: string + ) => Promise; /* Editor */ openEditorWindow: (themeId: string) => Promise; diff --git a/src/renderer/src/helpers.ts b/src/renderer/src/helpers.ts index f09cec84..3ee04805 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -121,3 +121,32 @@ export const formatNumber = (num: number): string => { export const generateUUID = (): string => { return uuidv4(); }; + +export const getAchievementSoundUrl = async (): Promise => { + 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 => { + 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; + } +}; diff --git a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx index c5c37933..02023f1d 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -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(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(); }, []); diff --git a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx index 516f320f..93baf1cd 100644 --- a/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx +++ b/src/renderer/src/pages/settings/aparence/modals/import-theme-modal.tsx @@ -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) { diff --git a/src/renderer/src/pages/settings/settings-general.scss b/src/renderer/src/pages/settings/settings-general.scss index 302effa3..58004362 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -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; + } + } + } } diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index c698440d..6d81f763 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -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([]); const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); + + const volumeUpdateTimeoutRef = useRef(); 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 ) => { @@ -309,6 +328,68 @@ export function SettingsGeneral() { )} + {form.achievementNotificationsEnabled && ( +
+ +
+
+ { + 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); + } + }} + /> + % +
+
+ + +
+
+
+ )} +

{t("common_redist")}

diff --git a/src/renderer/src/pages/theme-editor/theme-editor.scss b/src/renderer/src/pages/theme-editor/theme-editor.scss index 38061c88..b34217f9 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -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; + } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 9df3e9f4..8ae86f63 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -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() {

- { - return { - key: variation, - value: variation, - label: t(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 - ) - } - /> + /> - - setNotificationAlignment( - e.target.value as AchievementCustomNotificationPosition - ) - } - options={achievementCustomNotificationPositionOptions} - /> + + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
+ +
+ + + {theme?.hasCustomSound && ( + + )} + + +
diff --git a/src/types/level.types.ts b/src/types/level.types.ts index 1df55b9e..c3f799fa 100644 --- a/src/types/level.types.ts +++ b/src/types/level.types.ts @@ -113,6 +113,7 @@ export interface UserPreferences { achievementNotificationsEnabled?: boolean; achievementCustomNotificationsEnabled?: boolean; achievementCustomNotificationPosition?: AchievementCustomNotificationPosition; + achievementSoundVolume?: number; friendRequestNotificationsEnabled?: boolean; friendStartGameNotificationsEnabled?: boolean; showDownloadSpeedInMegabytes?: boolean; diff --git a/src/types/theme.types.ts b/src/types/theme.types.ts index abba8fc1..94285d9c 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -5,6 +5,7 @@ export interface Theme { authorName?: string; isActive: boolean; code: string; + hasCustomSound?: boolean; createdAt: Date; updatedAt: Date; }