mirror of
https://github.com/hydralauncher/hydra.git
synced 2026-01-15 16:33:02 -03:00
feat: custom achievement sound and volume changing)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
40
src/main/events/themes/copy-theme-achievement-sound.ts
Normal file
40
src/main/events/themes/copy-theme-achievement-sound.ts
Normal file
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
12
src/main/events/themes/get-theme-sound-path.ts
Normal file
12
src/main/events/themes/get-theme-sound-path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { registerEvent } from "../register-event";
|
||||
import { getThemeSoundPath } from "@main/helpers";
|
||||
|
||||
const getThemeSoundPathEvent = async (
|
||||
_event: Electron.IpcMainInvokeEvent,
|
||||
themeId: string
|
||||
): Promise<string | null> => {
|
||||
return getThemeSoundPath(themeId);
|
||||
};
|
||||
|
||||
registerEvent("getThemeSoundPath", getThemeSoundPathEvent);
|
||||
|
||||
57
src/main/events/themes/import-theme-sound-from-store.ts
Normal file
57
src/main/events/themes/import-theme-sound-from-store.ts
Normal file
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
39
src/main/events/themes/remove-theme-achievement-sound.ts
Normal file
39
src/main/events/themes/remove-theme-achievement-sound.ts
Normal file
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string> {
|
||||
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<number> {
|
||||
try {
|
||||
const userPreferences = await db.get<string, UserPreferences>(
|
||||
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<string, UserPreferences>(
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
|
||||
11
src/renderer/src/declaration.d.ts
vendored
11
src/renderer/src/declaration.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface UserPreferences {
|
||||
achievementNotificationsEnabled?: boolean;
|
||||
achievementCustomNotificationsEnabled?: boolean;
|
||||
achievementCustomNotificationPosition?: AchievementCustomNotificationPosition;
|
||||
achievementSoundVolume?: number;
|
||||
friendRequestNotificationsEnabled?: boolean;
|
||||
friendStartGameNotificationsEnabled?: boolean;
|
||||
showDownloadSpeedInMegabytes?: boolean;
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Theme {
|
||||
authorName?: string;
|
||||
isActive: boolean;
|
||||
code: string;
|
||||
hasCustomSound?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user