diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index bcd774ea..8624cf08 100755 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -558,6 +558,15 @@ "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", + "select": "Select", + "preview": "Preview", + "remove": "Remove", + "no_sound_file_selected": "No sound file selected", "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/locales/es/translation.json b/src/locales/es/translation.json index ad08777a..12e7e7fe 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -543,6 +543,12 @@ "platinum": "Platino", "hidden": "Oculto", "test_notification": "Probar notificación", + "achievement_sound_volume": "Volumen del sonido de logro", + "select_achievement_sound": "Seleccionar sonido de logro", + "select": "Seleccionar", + "preview": "Vista previa", + "remove": "Remover", + "no_sound_file_selected": "No se seleccionó ningún archivo de sonido", "notification_preview": "Probar notificación de logro", "debrid": "Debrid", "debrid_description": "Los servicios Debrid son descargadores premium sin restricciones que te dejan descargar más rápido archivos alojados en servicios de alojamiento siendo que la única limitación es tu velocidad de internet.", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index 002ec720..fc0f4332 100755 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -542,6 +542,12 @@ "platinum": "Platina", "hidden": "Oculta", "test_notification": "Testar notificação", + "achievement_sound_volume": "Volume do som de conquista", + "select_achievement_sound": "Selecionar som de conquista", + "select": "Selecionar", + "preview": "Reproduzir", + "remove": "Remover", + "no_sound_file_selected": "Nenhum arquivo de som selecionado", "notification_preview": "Prévia da Notificação de Conquistas", "enable_friend_start_game_notifications": "Quando um amigo iniciar um jogo", "autoplay_trailers_on_game_page": "Reproduzir trailers automaticamente na página do jogo", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 02477701..c9527af8 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -556,6 +556,12 @@ "platinum": "Платиновый", "hidden": "Скрытый", "test_notification": "Тестовое уведомление", + "achievement_sound_volume": "Громкость звука достижения", + "select_achievement_sound": "Выбрать звук достижения", + "select": "Выбрать", + "preview": "Предпросмотр", + "remove": "Удалить", + "no_sound_file_selected": "Файл звука не выбран", "notification_preview": "Предварительный просмотр уведомления о достижении", "enable_friend_start_game_notifications": "Когда друг начинает играть в игру", "autoplay_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..834f47ba 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -92,6 +92,11 @@ 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/get-theme-sound-data-url"; +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..a52e6269 --- /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, theme.name); + + 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, + originalSoundPath: sourcePath, + updatedAt: new Date(), + }); +}; + +registerEvent("copyThemeAchievementSound", copyThemeAchievementSound); diff --git a/src/main/events/themes/get-theme-sound-data-url.ts b/src/main/events/themes/get-theme-sound-data-url.ts new file mode 100644 index 00000000..a93538dd --- /dev/null +++ b/src/main/events/themes/get-theme-sound-data-url.ts @@ -0,0 +1,40 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import fs from "node:fs"; +import path from "node:path"; +import { logger } from "@main/services"; + +const getThemeSoundDataUrl = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + try { + const theme = await themesSublevel.get(themeId); + const soundPath = getThemeSoundPath(themeId, theme?.name); + + if (!soundPath || !fs.existsSync(soundPath)) { + return null; + } + + const buffer = await fs.promises.readFile(soundPath); + const ext = path.extname(soundPath).toLowerCase().slice(1); + + const mimeTypes: Record = { + mp3: "audio/mpeg", + wav: "audio/wav", + ogg: "audio/ogg", + m4a: "audio/mp4", + }; + + const mimeType = mimeTypes[ext] || "audio/mpeg"; + const base64 = buffer.toString("base64"); + + return `data:${mimeType};base64,${base64}`; + } catch (error) { + logger.error("Failed to get theme sound data URL", error); + return null; + } +}; + +registerEvent("getThemeSoundDataUrl", getThemeSoundDataUrl); 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..11658c6a --- /dev/null +++ b/src/main/events/themes/get-theme-sound-path.ts @@ -0,0 +1,13 @@ +import { registerEvent } from "../register-event"; +import { getThemeSoundPath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; + +const getThemeSoundPathEvent = async ( + _event: Electron.IpcMainInvokeEvent, + themeId: string +): Promise => { + const theme = await themesSublevel.get(themeId); + return getThemeSoundPath(themeId, theme?.name); +}; + +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..66da6cb3 --- /dev/null +++ b/src/main/events/themes/import-theme-sound-from-store.ts @@ -0,0 +1,60 @@ +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, theme.name); + + 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) { + logger.error( + `Failed to import ${format} sound for theme ${themeName}`, + 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..a8603426 --- /dev/null +++ b/src/main/events/themes/remove-theme-achievement-sound.ts @@ -0,0 +1,48 @@ +import { registerEvent } from "../register-event"; +import fs from "node:fs"; +import { getThemePath } from "@main/helpers"; +import { themesSublevel } from "@main/level"; +import { THEMES_PATH } from "@main/constants"; +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, theme.name); + const legacyThemeDir = path.join(THEMES_PATH, themeId); + + const removeFromDir = async (dir: string) => { + if (!fs.existsSync(dir)) { + return; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + await fs.promises.unlink(soundPath); + } + } + }; + + await removeFromDir(themeDir); + if (themeDir !== legacyThemeDir) { + await removeFromDir(legacyThemeDir); + } + + await themesSublevel.put(themeId, { + ...theme, + hasCustomSound: false, + originalSoundPath: undefined, + updatedAt: new Date(), + }); +}; + +registerEvent("removeThemeAchievementSound", removeThemeAchievementSound); diff --git a/src/main/helpers/index.ts b/src/main/helpers/index.ts index 2da49a1c..664dbd78 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) => @@ -31,9 +33,64 @@ export const isPortableVersion = () => { }; export const normalizePath = (str: string) => - path.posix.normalize(str).replace(/\\/g, "/"); + path.posix.normalize(str).replaceAll("\\", "/"); export const addTrailingSlash = (str: string) => str.endsWith("/") ? str : `${str}/`; +const sanitizeFolderName = (name: string): string => { + return name + .toLowerCase() + .replaceAll(/[^a-z0-9-_\s]/g, "") + .replaceAll(/\s+/g, "-") + .replaceAll(/-+/g, "-") + .replaceAll(/(^-|-$)/g, ""); +}; + +export const getThemePath = (themeId: string, themeName?: string): string => { + if (themeName) { + const sanitizedName = sanitizeFolderName(themeName); + if (sanitizedName) { + return path.join(THEMES_PATH, sanitizedName); + } + } + return path.join(THEMES_PATH, themeId); +}; + +export const getThemeSoundPath = ( + themeId: string, + themeName?: string +): string | null => { + const themeDir = getThemePath(themeId, themeName); + const legacyThemeDir = themeName ? path.join(THEMES_PATH, themeId) : null; + + const checkDir = (dir: string): string | null => { + if (!fs.existsSync(dir)) { + return null; + } + + const formats = ["wav", "mp3", "ogg", "m4a"]; + + for (const format of formats) { + const soundPath = path.join(dir, `achievement.${format}`); + if (fs.existsSync(soundPath)) { + return soundPath; + } + } + + return null; + }; + + const soundPath = checkDir(themeDir); + if (soundPath) { + return soundPath; + } + + if (legacyThemeDir) { + return checkDir(legacyThemeDir); + } + + return null; +}; + export * from "./reg-parser"; diff --git a/src/main/services/notifications/index.ts b/src/main/services/notifications/index.ts index d28c3cd7..b8ff480c 100644 --- a/src/main/services/notifications/index.ts +++ b/src/main/services/notifications/index.ts @@ -11,9 +11,10 @@ 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,27 @@ 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?.hasCustomSound) { + const themeSoundPath = getThemeSoundPath( + activeTheme.id, + activeTheme.name + ); + if (themeSoundPath) { + return themeSoundPath; + } + } + } catch (error) { + logger.error("Failed to get theme sound path", error); + } + + return achievementSoundPath; +} + export const publishDownloadCompleteNotification = async (game: Game) => { const userPreferences = await db.get( levelKeys.userPreferences, @@ -145,7 +167,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 +228,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..bfb1de6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -571,6 +571,25 @@ 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), + getThemeSoundDataUrl: (themeId: string) => + ipcRenderer.invoke("getThemeSoundDataUrl", 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..272551ad 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,12 @@ 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 +220,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..9eefb477 100644 --- a/src/renderer/src/declaration.d.ts +++ b/src/renderer/src/declaration.d.ts @@ -410,6 +410,18 @@ 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; + getThemeSoundDataUrl: (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..e16aa7a4 100644 --- a/src/renderer/src/helpers.ts +++ b/src/renderer/src/helpers.ts @@ -121,3 +121,35 @@ 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 soundDataUrl = await window.electron.getThemeSoundDataUrl( + activeTheme.id + ); + if (soundDataUrl) { + return soundDataUrl; + } + } + } 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..38b2443b 100644 --- a/src/renderer/src/pages/achievements/notification/achievement-notification.tsx +++ b/src/renderer/src/pages/achievements/notification/achievement-notification.tsx @@ -1,11 +1,15 @@ 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 +37,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..8a6a0ac1 100644 --- a/src/renderer/src/pages/settings/settings-general.scss +++ b/src/renderer/src/pages/settings/settings-general.scss @@ -17,4 +17,159 @@ &__test-achievement-notification-button { align-self: flex-start; } + + &__volume-control { + display: flex; + flex-direction: column; + gap: 12px; + + label { + font-size: 14px; + color: globals.$muted-color; + } + } + + &__volume-slider-wrapper { + display: flex; + align-items: center; + gap: 8px; + width: 200px; + position: relative; + --volume-percent: 0%; + } + + &__volume-icon { + color: globals.$muted-color; + flex-shrink: 0; + } + + &__volume-value { + font-size: 14px; + color: globals.$body-color; + font-weight: 500; + min-width: 40px; + text-align: right; + flex-shrink: 0; + } + + &__volume-slider { + flex: 1; + height: 6px; + border-radius: 3px; + background: globals.$dark-background-color; + outline: none; + -webkit-appearance: none; + appearance: none; + cursor: pointer; + transition: background 0.2s; + + &::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; + cursor: pointer; + border: 2px solid globals.$background-color; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: all 0.2s; + margin-top: -6px; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4); + } + + &:active { + transform: scale(1.05); + } + } + + &::-moz-range-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; + cursor: pointer; + border: 2px solid globals.$background-color; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + transition: all 0.2s; + margin-top: -6px; + + &:hover { + transform: scale(1.1); + box-shadow: 0 2px 6px rgba(255, 255, 255, 0.4); + } + + &:active { + transform: scale(1.05); + } + } + + &::-webkit-slider-runnable-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: linear-gradient( + to right, + globals.$muted-color 0%, + globals.$muted-color var(--volume-percent), + globals.$dark-background-color var(--volume-percent), + globals.$dark-background-color 100% + ); + } + + &::-moz-range-track { + width: 100%; + height: 6px; + border-radius: 3px; + background: globals.$dark-background-color; + } + + &::-moz-range-progress { + height: 6px; + border-radius: 3px; + background: globals.$muted-color; + } + + &:focus { + outline: none; + + &::-webkit-slider-thumb { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } + + &::-moz-range-thumb { + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2); + } + } + + &::-ms-thumb { + width: 18px; + height: 18px; + border-radius: 50%; + background: globals.$muted-color; + cursor: pointer; + border: 2px solid globals.$background-color; + } + + &::-ms-track { + width: 100%; + height: 6px; + background: transparent; + border-color: transparent; + color: transparent; + } + + &::-ms-fill-lower { + background: globals.$muted-color; + border-radius: 3px; + } + + &::-ms-fill-upper { + background: globals.$dark-background-color; + border-radius: 3px; + } + } } diff --git a/src/renderer/src/pages/settings/settings-general.tsx b/src/renderer/src/pages/settings/settings-general.tsx index c698440d..c81ced7d 100644 --- a/src/renderer/src/pages/settings/settings-general.tsx +++ b/src/renderer/src/pages/settings/settings-general.tsx @@ -1,4 +1,11 @@ -import { useContext, useEffect, useMemo, useState } from "react"; +import { + useContext, + useEffect, + useMemo, + useState, + useCallback, + useRef, +} from "react"; import { TextField, Button, @@ -12,7 +19,7 @@ import languageResources from "@locales"; import { orderBy } from "lodash-es"; import { settingsContext } from "@renderer/context"; import "./settings-general.scss"; -import { DesktopDownloadIcon } from "@primer/octicons-react"; +import { DesktopDownloadIcon, UnmuteIcon } from "@primer/octicons-react"; import { logger } from "@renderer/logger"; import { AchievementCustomNotificationPosition } from "@types"; @@ -43,6 +50,7 @@ export function SettingsGeneral() { achievementCustomNotificationsEnabled: true, achievementCustomNotificationPosition: "top-left" as AchievementCustomNotificationPosition, + achievementSoundVolume: 15, language: "", customStyles: window.localStorage.getItem("customStyles") || "", }); @@ -51,6 +59,8 @@ export function SettingsGeneral() { const [defaultDownloadsPath, setDefaultDownloadsPath] = useState(""); + const volumeUpdateTimeoutRef = useRef(); + useEffect(() => { window.electron.getDefaultDownloadsPath().then((path) => { setDefaultDownloadsPath(path); @@ -81,6 +91,9 @@ export function SettingsGeneral() { return () => { clearInterval(interval); + if (volumeUpdateTimeoutRef.current) { + clearTimeout(volumeUpdateTimeoutRef.current); + } }; }, []); @@ -110,6 +123,9 @@ 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 +164,21 @@ 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 +340,39 @@ export function SettingsGeneral() { )} + {form.achievementNotificationsEnabled && ( +
+ +
+ + { + const volumePercent = parseInt(e.target.value, 10); + if (!isNaN(volumePercent)) { + handleVolumeChange(volumePercent); + } + }} + className="settings-general__volume-slider" + style={ + { + "--volume-percent": `${form.achievementSoundVolume}%`, + } as React.CSSProperties + } + /> + + {form.achievementSoundVolume}% + +
+
+ )} +

{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..486f694c 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.scss +++ b/src/renderer/src/pages/theme-editor/theme-editor.scss @@ -47,6 +47,8 @@ position: relative; border: 1px solid globals.$muted-color; border-radius: 2px; + flex: 1; + min-width: 0; } &__footer { @@ -80,7 +82,7 @@ } &__info { - padding: 16px; + padding: 8px; p { font-size: 16px; @@ -93,12 +95,39 @@ &__notification-preview { padding-top: 12px; display: flex; - flex-direction: row; - align-items: center; + flex-direction: column; gap: 16px; &__select-variation { flex: inherit; } } + + &__notification-preview-controls { + display: flex; + flex-direction: column; + gap: 16px; + flex-shrink: 0; + } + + &__notification-controls { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + + &__sound-actions { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } + + &__sound-actions-row { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + } } diff --git a/src/renderer/src/pages/theme-editor/theme-editor.tsx b/src/renderer/src/pages/theme-editor/theme-editor.tsx index 9df3e9f4..3f0be9cf 100644 --- a/src/renderer/src/pages/theme-editor/theme-editor.tsx +++ b/src/renderer/src/pages/theme-editor/theme-editor.tsx @@ -3,11 +3,16 @@ import "./theme-editor.scss"; 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 { Button, SelectField, TextField } from "@renderer/components"; +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, 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"; @@ -27,6 +32,7 @@ export default function ThemeEditor() { const [theme, setTheme] = useState(null); const [code, setCode] = useState(""); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [soundPath, setSoundPath] = useState(""); const [isClosingNotifications, setIsClosingNotifications] = useState(false); @@ -62,6 +68,9 @@ export default function ThemeEditor() { if (loadedTheme) { setTheme(loadedTheme); setCode(loadedTheme.code); + if (loadedTheme.originalSoundPath) { + setSoundPath(loadedTheme.originalSoundPath); + } if (shadowRootRef) { injectCustomCss(loadedTheme.code, shadowRootRef); } @@ -107,6 +116,73 @@ 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) { + const originalPath = filePaths[0]; + await window.electron.copyThemeAchievementSound(theme.id, originalPath); + const updatedTheme = await window.electron.getCustomThemeById(theme.id); + if (updatedTheme) { + setTheme(updatedTheme); + if (updatedTheme.originalSoundPath) { + setSoundPath(updatedTheme.originalSoundPath); + } + } + } + }, [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); + } + setSoundPath(""); + }, [theme]); + + const handlePreviewSound = useCallback(async () => { + if (!theme) return; + + let soundUrl: string; + + if (theme.hasCustomSound) { + const themeSoundUrl = await window.electron.getThemeSoundDataUrl( + theme.id + ); + if (themeSoundUrl) { + soundUrl = themeSoundUrl; + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + } else { + const defaultSound = ( + await import("@renderer/assets/audio/achievement.wav") + ).default; + soundUrl = defaultSound; + } + + const volume = await getAchievementSoundVolume(); + const audio = new Audio(soundUrl); + audio.volume = volume; + audio.play(); + }, [theme]); + const achievementCustomNotificationPositionOptions = useMemo(() => { return [ "top-left", @@ -164,35 +240,66 @@ export default function ThemeEditor() {

- { - return { - key: variation, - value: variation, - label: t(variation), - }; - } - )} - onChange={(value) => - setNotificationVariation( - value.target.value as keyof typeof notificationVariations - ) +
+
+ { + return { + key: variation, + value: variation, + label: t(variation), + }; + } + )} + onChange={(value) => + setNotificationVariation( + value.target.value as keyof typeof notificationVariations + ) + } + /> + + + setNotificationAlignment( + e.target.value as AchievementCustomNotificationPosition + ) + } + options={achievementCustomNotificationPositionOptions} + /> +
+
+ + + + {t("select")} + } /> - - 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..80976ec0 100644 --- a/src/types/theme.types.ts +++ b/src/types/theme.types.ts @@ -5,6 +5,8 @@ export interface Theme { authorName?: string; isActive: boolean; code: string; + hasCustomSound?: boolean; + originalSoundPath?: string; createdAt: Date; updatedAt: Date; }