diff --git a/README.md b/README.md index 8cc92a2..014b367 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ · Explore Loremaster · Choose a layout · 4K scaling + · Texture upgrades

@@ -237,6 +238,12 @@ Choose the exact profile for your display when possible, or the nearest validate Download SpinFOURKAYYY separately from its [latest GitHub release](https://github.com/itsspin/SPINFOURKAYYY/releases/latest), extract the complete ZIP into a normal user-writable folder, choose the percentage **before** launching, click **Start EverQuest for me**, and keep the scaler open until the game exits. EverQuest, SpinFOURKAYYY, and companion overlays should run at the same Windows privilege level. +## Sharper world textures with SpinTexture + +[**SpinTexture**](https://itsspin.github.io/spintexture/) is a separate companion project for improving EverQuest Legends textures. SpinUI modernizes the native interface while SpinTexture enhances the world around it, so players can use either project independently or combine them for a more complete visual upgrade. + +Visit the [SpinTexture project site](https://itsspin.github.io/spintexture/) for previews, downloads, and installation guidance. + ## A compact tour of the rest | Feature | Distinguishing behavior | diff --git a/loremaster-desktop/README.md b/loremaster-desktop/README.md index 81e76cc..2612057 100644 --- a/loremaster-desktop/README.md +++ b/loremaster-desktop/README.md @@ -24,6 +24,34 @@ health. The parser engine remains authoritative for combat attribution, charmed pets, mez/lull evidence and D0–D4 weekly raid events. The narrow preload exposes only versioned state, folder selection, reset, and window commands. +## Native Loremaster themes + +Loremaster includes two complete presentation systems using the same parser, +layout and accessibility behavior: + +- **Vellum & Ember** is the default and matches `spinui_reloaded` with oiled + leather surfaces, parchment text, brass edges and restrained spirit-blue + selections. +- **Midnight Frost Glass** matches `spinui_glass` with deep translucent panes, + ice-blue edges, mint actions and violet selections. + +The theme picker in Settings applies immediately to the Seed, expanded HUD, +Settings, alerts, crowd-control timers and Combat Archive. The selection is +stored with the other desktop settings and restored before windows are shown, +so changing themes never restarts the parser or moves an overlay. + +Alert Sound Studio includes four locally generated cues plus Silent, with a +separate selection for charm breaks, tells, summons, deaths, big hits, name +calls, mez warnings and lull warnings. Each event may instead use a local WAV, +MP3, OGG or M4A file selected through the native file picker. Custom audio is +validated and size-limited by Electron's main process; the sandboxed renderer +never receives general filesystem access. + +Weekly D0–D4 progress comes from explicit raid difficulty plus combat-log boss +evidence, with a confirmation prompt when the difficulty is not known and a +manual correction grid. Loremaster deliberately does not scrape EverQuest's +Instance Information window or reserve a global lockout-screen hotkey. + The Gear Path surface imports EQ Legends Tools' version-1 character-sheet JSON and EverQuest's `/outputfile inventory` TXT locally. It identifies goal items already equipped or held in bags/bank and groups missing pieces by source zone. diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index d03abb3..df0b177 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, dialog, globalShortcut, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, screen, shell, Tray } from "electron"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { existsSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; @@ -62,6 +62,10 @@ const screenshotControls = [ }, ] as const; type AlertAnchor = "auto" | "above" | "below" | "left" | "right"; +type AlertSoundKind = "default" | "charmBreak" | "tell" | "summon" | "death" | "bigHit" | "nameCalled" | "mez" | "lull"; +type AlertSoundPreset = "rune" | "crystal" | "ember" | "bell" | "custom" | "silent"; +interface AlertSoundProfile { preset: AlertSoundPreset; customPath: string } +type AlertSoundProfiles = Record; export interface AlertSettings { alertsEnabled: boolean; @@ -81,6 +85,7 @@ export interface AlertSettings { lullTimersEnabled: boolean; lullTimerSound: boolean; lullWarningSeconds: number; + soundProfiles: AlertSoundProfiles; } interface DesktopSettings { @@ -88,6 +93,7 @@ interface DesktopSettings { raidDifficulty: number | null; bisBuildPath: string; inventoryPath: string; + uiTheme: "vellum" | "glass"; alwaysOnTop: boolean; fontScale: number; composition: string; @@ -115,6 +121,35 @@ const defaultHealth: EngineHealth = { server: "?", }; +const ALERT_SOUND_KINDS: readonly AlertSoundKind[] = [ + "default", "charmBreak", "tell", "summon", "death", "bigHit", "nameCalled", "mez", "lull", +]; +const ALERT_SOUND_PRESETS: readonly AlertSoundPreset[] = ["rune", "crystal", "ember", "bell", "custom", "silent"]; +const defaultSoundProfiles: AlertSoundProfiles = { + default: { preset: "rune", customPath: "" }, + charmBreak: { preset: "ember", customPath: "" }, + tell: { preset: "crystal", customPath: "" }, + summon: { preset: "ember", customPath: "" }, + death: { preset: "ember", customPath: "" }, + bigHit: { preset: "rune", customPath: "" }, + nameCalled: { preset: "crystal", customPath: "" }, + mez: { preset: "rune", customPath: "" }, + lull: { preset: "bell", customPath: "" }, +}; + +function normalizeSoundProfiles(value: unknown): AlertSoundProfiles { + const source = value && typeof value === "object" ? value as Record : {}; + return Object.fromEntries(ALERT_SOUND_KINDS.map((kind) => { + const candidate = source[kind] && typeof source[kind] === "object" + ? source[kind] as Partial : {}; + const preset = ALERT_SOUND_PRESETS.includes(candidate.preset as AlertSoundPreset) + ? candidate.preset as AlertSoundPreset : defaultSoundProfiles[kind].preset; + const customPath = typeof candidate.customPath === "string" && candidate.customPath.length <= 4096 + ? candidate.customPath : ""; + return [kind, { preset, customPath }]; + })) as AlertSoundProfiles; +} + const defaultAlertSettings: AlertSettings = { alertsEnabled: true, alertSound: true, @@ -133,6 +168,7 @@ const defaultAlertSettings: AlertSettings = { lullTimersEnabled: true, lullTimerSound: false, lullWarningSeconds: 12, + soundProfiles: defaultSoundProfiles, }; const defaultSettings: DesktopSettings = { @@ -140,6 +176,7 @@ const defaultSettings: DesktopSettings = { raidDifficulty: null, bisBuildPath: "", inventoryPath: "", + uiTheme: "vellum", alwaysOnTop: true, fontScale: 1.15, composition: "", @@ -178,6 +215,7 @@ function readSettings(): DesktopSettings { raidDifficulty, bisBuildPath: typeof value.bisBuildPath === "string" ? value.bisBuildPath : "", inventoryPath: typeof value.inventoryPath === "string" ? value.inventoryPath : "", + uiTheme: value.uiTheme === "glass" ? "glass" : "vellum", alwaysOnTop: boolean(value.alwaysOnTop, true), fontScale: clampInteger(value.fontScale === undefined ? 115 : Number(value.fontScale) * 100, 115, 90, 160) / 100, composition: typeof value.composition === "string" ? value.composition.slice(0, 48) : "", @@ -202,10 +240,11 @@ function readSettings(): DesktopSettings { lullTimersEnabled: boolean(alertValue.lullTimersEnabled, true), lullTimerSound: boolean(alertValue.lullTimerSound, false), lullWarningSeconds: clampInteger(alertValue.lullWarningSeconds, 12, 3, 30), + soundProfiles: normalizeSoundProfiles(alertValue.soundProfiles), }, }; } catch { - return { ...defaultSettings, alerts: { ...defaultAlertSettings } }; + return { ...defaultSettings, alerts: { ...defaultAlertSettings, soundProfiles: normalizeSoundProfiles(null) } }; } } @@ -445,10 +484,16 @@ class EngineSupervisor { this.send({ type: "engine.set-raid-difficulty", raidDifficulty }); } - updateDesktopSettings(patch: Partial> & { + updateDesktopSettings(patch: Partial> & { alerts?: Partial; }): DesktopSettings { - const nextAlerts = patch.alerts ? { ...this.settings.alerts, ...patch.alerts } : this.settings.alerts; + const nextAlerts = patch.alerts ? { + ...this.settings.alerts, + ...patch.alerts, + soundProfiles: patch.alerts.soundProfiles + ? normalizeSoundProfiles(patch.alerts.soundProfiles) + : this.settings.alerts.soundProfiles, + } : this.settings.alerts; const previousScale = this.settings.fontScale; const nextScale = typeof patch.fontScale === "number" ? clamp(patch.fontScale, 0.9, 1.6) : previousScale; const nextSeedPosition = nextScale !== previousScale @@ -456,6 +501,7 @@ class EngineSupervisor { : this.settings.seedPosition; this.settings = { ...this.settings, + ...(patch.uiTheme === "vellum" || patch.uiTheme === "glass" ? { uiTheme: patch.uiTheme } : {}), ...(typeof patch.alwaysOnTop === "boolean" ? { alwaysOnTop: patch.alwaysOnTop } : {}), fontScale: nextScale, seedPosition: nextSeedPosition, @@ -488,10 +534,6 @@ class EngineSupervisor { this.send({ type: "engine.set-raid-completion", target, difficulty, completed }); } - scanAltZLockouts(): void { - this.send({ type: "engine.scan-alt-z-lockouts" }); - } - private catalogCachePath(): string { return path.join(app.getPath("userData"), "eq-legends-tools-gear-cache.json"); } @@ -1041,6 +1083,12 @@ function setAnalysisMode(active: boolean, preserveAnchor = false): void { setImmediate(() => { movingWindowProgrammatically = false; }); } +function rendererUrl(base: string, query: Record): string { + const url = new URL(base); + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value); + return url.toString(); +} + function createAlertWindow(): void { const settings = engine?.getState().settings ?? defaultSettings; const alertSize = scaledSize(ALERT_SIZE, settings.fontScale); @@ -1066,9 +1114,10 @@ function createAlertWindow(): void { alertWindow.setIgnoreMouseEvents(true); applyAlwaysOnTop(settings.alwaysOnTop); const developmentUrl = process.env.VITE_DEV_SERVER_URL; + const rendererQuery = { alert: "1", theme: settings.uiTheme }; const rendererReady = developmentUrl - ? alertWindow.loadURL(`${developmentUrl}?alert=1`) - : alertWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: { alert: "1" } }); + ? alertWindow.loadURL(rendererUrl(developmentUrl, rendererQuery)) + : alertWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery }); void rendererReady.then(() => { alertWindow?.webContents.setZoomFactor(settings.fontScale); positionAlertWindow(); @@ -1117,9 +1166,10 @@ function createControlWindow(): void { controlWindow.setIgnoreMouseEvents(true); applyAlwaysOnTop(settings.alwaysOnTop); const developmentUrl = process.env.VITE_DEV_SERVER_URL; + const rendererQuery = { controls: "1", theme: settings.uiTheme }; const rendererReady = developmentUrl - ? controlWindow.loadURL(`${developmentUrl}?controls=1`) - : controlWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: { controls: "1" } }); + ? controlWindow.loadURL(rendererUrl(developmentUrl, rendererQuery)) + : controlWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery }); void rendererReady.then(() => { controlWindow?.webContents.setZoomFactor(settings.fontScale); syncControlWindow(); @@ -1202,9 +1252,10 @@ function createWindow(): void { }); const developmentUrl = process.env.VITE_DEV_SERVER_URL; + const rendererQuery = { theme: settings.uiTheme }; const rendererReady = developmentUrl - ? mainWindow.loadURL(developmentUrl) - : mainWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html")); + ? mainWindow.loadURL(rendererUrl(developmentUrl, rendererQuery)) + : mainWindow.loadFile(path.join(app.getAppPath(), "dist", "index.html"), { query: rendererQuery }); void rendererReady.then(() => { mainWindow?.webContents.on("will-navigate", (event) => event.preventDefault()); mainWindow?.webContents.setZoomFactor(settings.fontScale); @@ -1289,9 +1340,9 @@ function createWindow(): void { ? mainWindow?.webContents.executeJavaScript( "document.querySelector('.rune-seed')?.classList.add('attacking')") : undefined) - .then(() => screenshotView === "settings" + .then(() => screenshotView === "settings" || screenshotView?.startsWith("sounds") ? mainWindow?.webContents.executeJavaScript( - "document.querySelector('.masthead-actions button')?.click()") + "document.querySelector('button[aria-label=\"Open settings\"]')?.click()") : screenshotView === "analysis" ? mainWindow?.webContents.executeJavaScript( "document.querySelector('button[aria-label=\"Open full combat breakdown\"]')?.click()") @@ -1303,6 +1354,15 @@ function createWindow(): void { `) : undefined) .then(() => new Promise((resolve) => setTimeout(resolve, 250))) + .then(() => screenshotView?.startsWith("sounds") + ? mainWindow?.webContents.executeJavaScript( + "document.querySelector('.sound-studio')?.scrollIntoView({ block: 'start' })") + : undefined) + .then(() => screenshotView === "sounds-menu" + ? mainWindow?.webContents.executeJavaScript( + "document.querySelector('.sound-preset-trigger')?.click()") + : undefined) + .then(() => new Promise((resolve) => setTimeout(resolve, screenshotView?.startsWith("sounds") ? 180 : 0))) .then(() => mainWindow?.webContents.capturePage()) .then((image) => { if (image) writeFileSync(screenshotPath, image.toPNG()); @@ -1386,10 +1446,67 @@ ipcMain.on("alerts:test", () => { }); }); +const CUSTOM_SOUND_EXTENSIONS = new Set([".wav", ".mp3", ".ogg", ".m4a"]); +const CUSTOM_SOUND_MAX_BYTES = 8 * 1024 * 1024; + +function validatedCustomSoundPath(kind: AlertSoundKind): string | null { + const profile = engine?.getState().settings.alerts.soundProfiles[kind]; + if (!profile?.customPath) return null; + const candidate = path.resolve(profile.customPath); + try { + const stats = statSync(candidate); + return stats.isFile() + && stats.size > 0 + && stats.size <= CUSTOM_SOUND_MAX_BYTES + && CUSTOM_SOUND_EXTENSIONS.has(path.extname(candidate).toLowerCase()) + ? candidate : null; + } catch { + return null; + } +} + +ipcMain.handle("alerts:choose-sound", async (_event, value: unknown) => { + if (!mainWindow || !engine || !ALERT_SOUND_KINDS.includes(value as AlertSoundKind)) return null; + const kind = value as AlertSoundKind; + const result = await dialog.showOpenDialog(mainWindow, { + title: "Choose a Loremaster alert sound", + properties: ["openFile"], + filters: [{ name: "Audio", extensions: ["wav", "mp3", "ogg", "m4a"] }], + }); + if (result.canceled || result.filePaths.length !== 1) return null; + const candidate = path.resolve(result.filePaths[0]); + try { + const stats = statSync(candidate); + if (!stats.isFile() || stats.size <= 0 || stats.size > CUSTOM_SOUND_MAX_BYTES + || !CUSTOM_SOUND_EXTENSIONS.has(path.extname(candidate).toLowerCase())) return null; + } catch { + return null; + } + const profiles = normalizeSoundProfiles(engine.getState().settings.alerts.soundProfiles); + profiles[kind] = { preset: "custom", customPath: candidate }; + return engine.updateDesktopSettings({ alerts: { soundProfiles: profiles } }); +}); + +ipcMain.handle("alerts:read-sound", (_event, value: unknown) => { + if (!ALERT_SOUND_KINDS.includes(value as AlertSoundKind)) return null; + const candidate = validatedCustomSoundPath(value as AlertSoundKind); + if (!candidate) return null; + try { + return { + bytes: readFileSync(candidate), + extension: path.extname(candidate).toLowerCase(), + name: path.basename(candidate), + }; + } catch { + return null; + } +}); + ipcMain.handle("settings:update", (_event, value: unknown) => { if (!value || typeof value !== "object" || !engine) return null; const raw = value as Record; const patch: Parameters[0] = {}; + if (raw.uiTheme === "vellum" || raw.uiTheme === "glass") patch.uiTheme = raw.uiTheme; if (typeof raw.alwaysOnTop === "boolean") patch.alwaysOnTop = raw.alwaysOnTop; if (Number.isFinite(Number(raw.fontScale))) patch.fontScale = clamp(Number(raw.fontScale), 0.9, 1.6); if (typeof raw.composition === "string") patch.composition = raw.composition.slice(0, 48); @@ -1404,6 +1521,9 @@ ipcMain.handle("settings:update", (_event, value: unknown) => { "lullTimersEnabled", "lullTimerSound", ]; for (const key of booleanKeys) if (typeof candidate[key] === "boolean") Object.assign(alerts, { [key]: candidate[key] }); + if (candidate.soundProfiles && typeof candidate.soundProfiles === "object") { + alerts.soundProfiles = normalizeSoundProfiles(candidate.soundProfiles); + } if (["auto", "above", "below", "left", "right"].includes(String(candidate.alertAnchor))) { alerts.alertAnchor = candidate.alertAnchor as AlertAnchor; } @@ -1506,9 +1626,6 @@ app.whenReady().then(() => { engine.start(); createWindow(); ensureTray(); - if (!globalShortcut.register("CommandOrControl+Shift+Z", () => engine?.scanAltZLockouts())) { - console.error("Could not register the Ctrl+Shift+Z Alt+Z lockout scan hotkey"); - } startTopmostHeartbeat(); screen.on("display-metrics-changed", scheduleTopmostReassertion); const smokeExitMs = Number(process.env.LOREMASTER_SMOKE_EXIT_MS || 0); @@ -1517,7 +1634,6 @@ app.whenReady().then(() => { } }); app.on("before-quit", () => { - globalShortcut.unregisterAll(); clearTopmostReassertions(); if (topmostHeartbeatTimer) clearInterval(topmostHeartbeatTimer); topmostHeartbeatTimer = null; diff --git a/loremaster-desktop/electron/preload.ts b/loremaster-desktop/electron/preload.ts index 8764922..c9cc4b1 100644 --- a/loremaster-desktop/electron/preload.ts +++ b/loremaster-desktop/electron/preload.ts @@ -16,6 +16,8 @@ contextBridge.exposeInMainWorld("loremasterDesktop", { checkForUpdates: () => ipcRenderer.invoke("updates:check"), resetEngine: () => ipcRenderer.send("engine:reset"), testAlert: () => ipcRenderer.send("alerts:test"), + chooseAlertSound: (kind: string) => ipcRenderer.invoke("alerts:choose-sound", kind), + readAlertSound: (kind: string) => ipcRenderer.invoke("alerts:read-sound", kind), onSnapshot: (callback: (event: unknown) => void) => { const listener = (_event: Electron.IpcRendererEvent, value: unknown) => callback(value); ipcRenderer.on("engine:snapshot", listener); diff --git a/loremaster-desktop/index.html b/loremaster-desktop/index.html index 9e8a0f5..61d43d4 100644 --- a/loremaster-desktop/index.html +++ b/loremaster-desktop/index.html @@ -1,10 +1,10 @@ - + diff --git a/loremaster-desktop/src/App.tsx b/loremaster-desktop/src/App.tsx index bde456b..6131512 100644 --- a/loremaster-desktop/src/App.tsx +++ b/loremaster-desktop/src/App.tsx @@ -12,6 +12,9 @@ import { type EngineHealth, type EngineSnapshotEvent, type GearPlanView, + type LoremasterTheme, + type AlertSoundKind, + type AlertSoundPreset, } from "./protocol"; import { CombatArchive } from "./CombatArchive"; @@ -20,9 +23,46 @@ const raidDifficulties = [0, 1, 2, 3, 4] as const; const cogSource = "./loremaster-cog.png"; const eqToolsUrl = "https://eqlegendstools.com/"; const eqToolsCharSheetUrl = "https://eqlegendstools.com/char-sheet/"; +const soundKinds: readonly { id: AlertSoundKind; label: string; detail: string }[] = [ + { id: "default", label: "General alerts", detail: "Raid prompts and alert previews" }, + { id: "charmBreak", label: "Charm breaks", detail: "Urgent recharm warning" }, + { id: "tell", label: "Incoming tells", detail: "Direct player messages" }, + { id: "summon", label: "Summoned", detail: "Boss summon warning" }, + { id: "death", label: "Death", detail: "Character death" }, + { id: "bigHit", label: "Big hits", detail: "Damage threshold warning" }, + { id: "nameCalled", label: "Name called", detail: "Group, raid, or guild mention" }, + { id: "mez", label: "Mez urgent", detail: "Safe window closing" }, + { id: "lull", label: "Lull urgent", detail: "Safe window closing" }, +]; +const soundPresets: readonly { id: AlertSoundPreset; label: string }[] = [ + { id: "rune", label: "Rune Pulse" }, + { id: "crystal", label: "Crystal Chime" }, + { id: "ember", label: "Ember Alarm" }, + { id: "bell", label: "Temple Bell" }, + { id: "custom", label: "Custom File" }, + { id: "silent", label: "Silent" }, +]; +const defaultSoundProfiles: DesktopSettings["alerts"]["soundProfiles"] = { + default: { preset: "rune", customPath: "" }, charmBreak: { preset: "ember", customPath: "" }, + tell: { preset: "crystal", customPath: "" }, summon: { preset: "ember", customPath: "" }, + death: { preset: "ember", customPath: "" }, bigHit: { preset: "rune", customPath: "" }, + nameCalled: { preset: "crystal", customPath: "" }, mez: { preset: "rune", customPath: "" }, + lull: { preset: "bell", customPath: "" }, +}; + +function normalizeTheme(value: unknown): LoremasterTheme { + return value === "glass" ? "glass" : "vellum"; +} + +function applyTheme(value: unknown): LoremasterTheme { + const theme = normalizeTheme(value); + document.documentElement.dataset.theme = theme; + return theme; +} const defaultDesktopSettings: DesktopSettings = { logPath: "", raidDifficulty: null, bisBuildPath: "", inventoryPath: "", + uiTheme: "vellum", alwaysOnTop: true, fontScale: 1.15, composition: "", splitCharmedPetDps: false, stanceAdvisorEnabled: false, seedPosition: null, alerts: { @@ -31,6 +71,7 @@ const defaultDesktopSettings: DesktopSettings = { alertBigHit: true, alertNameCalled: true, bigHitThreshold: 800, mezTimersEnabled: true, mezTimerSound: false, mezWarningSeconds: 10, lullTimersEnabled: true, lullTimerSound: false, lullWarningSeconds: 12, + soundProfiles: defaultSoundProfiles, }, }; @@ -86,16 +127,6 @@ function formatDuration(value: number): string { return minutes > 0 ? `${minutes}:${remainder}` : `${seconds}s`; } -function formatLockout(value: number): string { - const seconds = Math.max(0, Math.floor(value)); - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (days > 0) return `${days}d ${hours}h ${minutes}m`; - if (hours > 0) return `${hours}h ${minutes}m`; - return `${minutes}m ${seconds % 60}s`; -} - function spellLabel(control: ControlTimerView): string { const rank = control.rank > 0 ? roman[control.rank] ?? String(control.rank) : ""; return `${control.spell}${rank ? ` ${rank}` : ""}`; @@ -197,13 +228,18 @@ function SeedControlSurface() { if (!desktop) return () => document.body.classList.remove("control-window"); void desktop.getEngineState().then((state) => { if (isEngineSnapshotEvent(state.snapshot)) setEvent(state.snapshot); + applyTheme(state.settings.uiTheme); setSettings(state.settings); }); const removeSnapshot = desktop.onSnapshot((value) => { if (isEngineSnapshotEvent(value)) setEvent(value); }); const removeSettings = desktop.onSettings((value) => { - if (value && typeof value === "object") setSettings(value as DesktopSettings); + if (value && typeof value === "object") { + const next = value as DesktopSettings; + applyTheme(next.uiTheme); + setSettings(next); + } }); return () => { removeSnapshot(); @@ -263,8 +299,17 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif }) { const [manualPath, setManualPath] = useState(health.configuredPath); const [draft, setDraft] = useState(settings); + const [activeSoundMenu, setActiveSoundMenu] = useState(null); const [updateInfo, setUpdateInfo] = useState["checkForUpdates"]>> | null>(null); const [checkingUpdate, setCheckingUpdate] = useState(false); + useEffect(() => { + if (!activeSoundMenu) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") setActiveSoundMenu(null); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [activeSoundMenu]); const chooseFolder = async () => { const selected = await window.loremasterDesktop?.chooseLogFolder(); if (selected) setManualPath(selected); @@ -286,8 +331,37 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif const patchAlerts = (value: Partial) => setDraft((current) => ({ ...current, alerts: { ...current.alerts, ...value }, })); + const patchSoundProfile = (kind: AlertSoundKind, preset: AlertSoundPreset) => { + setDraft((current) => ({ + ...current, + alerts: { + ...current.alerts, + soundProfiles: { + ...current.alerts.soundProfiles, + [kind]: { ...current.alerts.soundProfiles[kind], preset }, + }, + }, + })); + }; + const chooseCustomSound = async (kind: AlertSoundKind) => { + const synchronized = await window.loremasterDesktop?.updateSettings({ alerts: draft.alerts }); + if (synchronized) setDraft(synchronized); + const saved = await window.loremasterDesktop?.chooseAlertSound(kind); + if (!saved) return; + setDraft(saved); + onSettings(saved); + }; + const selectSoundPreset = async (kind: AlertSoundKind, preset: AlertSoundPreset) => { + setActiveSoundMenu(null); + if (preset === "custom" && !draft.alerts.soundProfiles[kind].customPath) { + await chooseCustomSound(kind); + return; + } + patchSoundProfile(kind, preset); + }; const savePreferences = async () => { const saved = await window.loremasterDesktop?.updateSettings({ + uiTheme: draft.uiTheme, alwaysOnTop: draft.alwaysOnTop, fontScale: draft.fontScale, composition: draft.composition, @@ -297,6 +371,16 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif }); if (saved) onSettings(saved); }; + const selectTheme = async (uiTheme: LoremasterTheme) => { + applyTheme(uiTheme); + patchDraft({ uiTheme }); + const saved = await window.loremasterDesktop?.updateSettings({ uiTheme }); + if (saved) { + setDraft(saved); + applyTheme(saved.uiTheme); + onSettings(saved); + } + }; const changeFontScale = async (delta: number) => { const fontScale = Math.max(0.9, Math.min(1.6, Math.round((draft.fontScale + delta) * 20) / 20)); setDraft((current) => ({ ...current, fontScale })); @@ -306,6 +390,27 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif return (

CONFIGURATION

ENGINE + LOGS

+
+ +

Match Loremaster to your active SpinUI skin. The choice applies immediately to the HUD, Rune Seed, timers, and alerts.

+
+ {([ + { id: "vellum", name: "VELLUM & EMBER", detail: "Matches SpinUI Reloaded" }, + { id: "glass", name: "MIDNIGHT FROST GLASS", detail: "Matches SpinUI Glass" }, + ] as const).map((option) => )} +
+

Choose the game folder or its Logs folder. Loremaster automatically follows the newest character log.

@@ -387,6 +492,41 @@ function SettingsPanel({ health, raidDifficulty, settings, onSettings, onRaidDif
+
+ +

Give each alert a distinct cue. Presets are generated locally; custom WAV, MP3, OGG, or M4A files stay on this computer.

+
+ {soundKinds.map((kind) => { + const profile = draft.alerts.soundProfiles[kind.id]; + const customName = profile.customPath.split(/[\\/]/).pop() || "Choose an audio file"; + const presetLabel = soundPresets.find((preset) => preset.id === profile.preset)?.label ?? "Rune Pulse"; + const menuOpen = activeSoundMenu === kind.id; + return
+ {kind.label}{kind.detail} +