From a1b85d8ac36ba0fef7866e3a7cd1d92aa941ccc5 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 19 May 2026 14:11:09 +0700 Subject: [PATCH] refactor: extract persisted ui state --- src/routes/uiStateRoutes.ts | 26 +++------ src/routes/voiceRoutes.ts | 12 +++-- src/state/uiState.ts | 102 ++++++++++++++++++++++++++++++++++++ src/webserver.ts | 99 ++-------------------------------- tests/state/uiState.test.ts | 44 ++++++++++++++++ 5 files changed, 164 insertions(+), 119 deletions(-) create mode 100644 src/state/uiState.ts create mode 100644 tests/state/uiState.test.ts diff --git a/src/routes/uiStateRoutes.ts b/src/routes/uiStateRoutes.ts index 2b79948..ac1d59c 100644 --- a/src/routes/uiStateRoutes.ts +++ b/src/routes/uiStateRoutes.ts @@ -1,26 +1,14 @@ import type { Router } from "express"; import express from "express"; -import { createChildLogger } from "../logger"; +import type { SharedUIState, SharedUIStatePatch } from "../state/uiState"; -const logger = createChildLogger("ui-state-routes"); - -export interface SharedUIState { - selectedVoiceGuild: string; - selectedVoiceChannel: string; - selectedTextGuild: string; - selectedTextChannel: string; - activeTab: "voice" | "messages" | "media" | "review" | "recordings"; - isListening: boolean; - isStreaming: boolean; -} - -export type SharedUIStatePatch = Partial & { - selectedGuild?: string; -}; +export { SharedUIState, SharedUIStatePatch }; export interface UIStateRouteOptions { getSharedUIState: () => SharedUIState; - patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState; + patchSharedUIState: ( + patch: SharedUIStatePatch, + ) => Promise | SharedUIState; } export function createUIStateRoutes(options: UIStateRouteOptions): Router { @@ -38,10 +26,10 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router { }); // POST /api/ui-state - Update UI state - router.post("/ui-state", (req, res, next) => { + router.post("/ui-state", async (req, res, next) => { try { const patch = req.body as SharedUIStatePatch; - const updated = patchSharedUIState(patch); + const updated = await patchSharedUIState(patch); res.json(updated); } catch (error) { next(error); diff --git a/src/routes/voiceRoutes.ts b/src/routes/voiceRoutes.ts index 801ab26..9c49db9 100644 --- a/src/routes/voiceRoutes.ts +++ b/src/routes/voiceRoutes.ts @@ -10,7 +10,9 @@ const logger = createChildLogger("voice-routes"); export interface VoiceRouteOptions { voiceController: VoiceController; - patchSharedUIState: (patch: Partial) => SharedUIState; + patchSharedUIState: ( + patch: Partial, + ) => Promise | SharedUIState; broadcaster: ModerationBroadcaster; adminPassword?: string; } @@ -23,7 +25,9 @@ export function createVoiceRoutes( // Support both old signature (VoiceController) and new signature (options object) let voiceController: VoiceController; let patchSharedUIState: - | ((patch: Partial) => SharedUIState) + | (( + patch: Partial, + ) => Promise | SharedUIState) | undefined; let broadcaster: ModerationBroadcaster | undefined; let adminPassword: string | undefined; @@ -137,7 +141,7 @@ export function createVoiceRoutes( // Update UI state and broadcast to connected clients if (patchSharedUIState && broadcaster) { - const updatedState = patchSharedUIState({ + const updatedState = await patchSharedUIState({ selectedVoiceGuild: guildId, selectedVoiceChannel: channelId, }); @@ -163,7 +167,7 @@ export function createVoiceRoutes( // Update UI state and broadcast to connected clients if (patchSharedUIState && broadcaster) { - const updatedState = patchSharedUIState({ + const updatedState = await patchSharedUIState({ selectedVoiceGuild: "", selectedVoiceChannel: "", }); diff --git a/src/state/uiState.ts b/src/state/uiState.ts new file mode 100644 index 0000000..ac2b99d --- /dev/null +++ b/src/state/uiState.ts @@ -0,0 +1,102 @@ +import { getPersistedValue, setPersistedValue } from "../muxer-queue"; + +export type ActiveTab = + | "voice" + | "messages" + | "media" + | "review" + | "recordings"; + +export interface SharedUIState { + selectedVoiceGuild: string; + selectedVoiceChannel: string; + selectedTextGuild: string; + selectedTextChannel: string; + activeTab: ActiveTab; + isListening: boolean; + isStreaming: boolean; +} + +export type SharedUIStatePatch = Partial & { + selectedGuild?: string; +}; + +const activeTabs: ActiveTab[] = [ + "voice", + "messages", + "media", + "review", + "recordings", +]; + +export const defaultSharedUIState: SharedUIState = { + selectedVoiceGuild: "", + selectedVoiceChannel: "", + selectedTextGuild: "", + selectedTextChannel: "", + activeTab: "voice", + isListening: false, + isStreaming: false, +}; + +export function normalizeSharedUIState( + value: SharedUIStatePatch, +): SharedUIState { + const guild = value.selectedGuild ?? ""; + return { + selectedVoiceGuild: value.selectedVoiceGuild ?? guild, + selectedVoiceChannel: value.selectedVoiceChannel ?? "", + selectedTextGuild: value.selectedTextGuild ?? guild, + selectedTextChannel: value.selectedTextChannel ?? "", + activeTab: activeTabs.includes(value.activeTab as ActiveTab) + ? (value.activeTab as ActiveTab) + : "voice", + isListening: value.isListening ?? false, + isStreaming: value.isStreaming ?? false, + }; +} + +export async function createSharedUIStateStore() { + let sharedUIState = normalizeSharedUIState( + await getPersistedValue("web-ui-state", defaultSharedUIState), + ); + + function getSharedUIState(): SharedUIState { + return { ...sharedUIState }; + } + + async function patchSharedUIState( + patch: SharedUIStatePatch, + ): Promise { + if (typeof patch.selectedGuild === "string") { + sharedUIState.selectedVoiceGuild = patch.selectedGuild; + sharedUIState.selectedTextGuild = patch.selectedGuild; + } + if (typeof patch.selectedVoiceGuild === "string") { + sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild; + } + if (typeof patch.selectedVoiceChannel === "string") { + sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel; + } + if (typeof patch.selectedTextGuild === "string") { + sharedUIState.selectedTextGuild = patch.selectedTextGuild; + } + if (typeof patch.selectedTextChannel === "string") { + sharedUIState.selectedTextChannel = patch.selectedTextChannel; + } + if (activeTabs.includes(patch.activeTab as ActiveTab)) { + sharedUIState.activeTab = patch.activeTab as ActiveTab; + } + if (typeof patch.isListening === "boolean") { + sharedUIState.isListening = patch.isListening; + } + if (typeof patch.isStreaming === "boolean") { + sharedUIState.isStreaming = patch.isStreaming; + } + + await setPersistedValue("web-ui-state", sharedUIState); + return getSharedUIState(); + } + + return { getSharedUIState, patchSharedUIState }; +} diff --git a/src/webserver.ts b/src/webserver.ts index e657325..05da6c8 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -30,6 +30,7 @@ import { createRecordingsRoutes } from "./routes/recordingsRoutes"; import { createSyncRoutes } from "./routes/syncRoutes"; import { createUIStateRoutes } from "./routes/uiStateRoutes"; import { createVoiceRoutes } from "./routes/voiceRoutes"; +import { createSharedUIStateStore } from "./state/uiState"; import { Streamer } from "./streaming"; import type { VoiceController } from "./voiceController"; @@ -53,65 +54,14 @@ type VoiceGlobals = typeof globalThis & { ) => void; }; -interface SharedUIState { - selectedVoiceGuild: string; - selectedVoiceChannel: string; - selectedTextGuild: string; - selectedTextChannel: string; - activeTab: "voice" | "messages" | "media" | "review" | "recordings"; - isListening: boolean; - isStreaming: boolean; -} - interface MediaSettings { musicVolume: number; } -type SharedUIStatePatch = Partial & { - selectedGuild?: string; -}; - -const defaultSharedUIState: SharedUIState = { - selectedVoiceGuild: "", - selectedVoiceChannel: "", - selectedTextGuild: "", - selectedTextChannel: "", - activeTab: "voice", - isListening: false, - isStreaming: false, -}; - const defaultMediaSettings: MediaSettings = { musicVolume: 1, }; -let sharedUIState: SharedUIState = { ...defaultSharedUIState }; - -export function normalizeSharedUIState( - value: SharedUIStatePatch, -): SharedUIState { - const guild = value.selectedGuild ?? ""; - return { - selectedVoiceGuild: value.selectedVoiceGuild ?? guild, - selectedVoiceChannel: value.selectedVoiceChannel ?? "", - selectedTextGuild: value.selectedTextGuild ?? guild, - selectedTextChannel: value.selectedTextChannel ?? "", - activeTab: (["voice", "messages", "media", "review", "recordings"].includes( - value.activeTab ?? "", - ) - ? value.activeTab - : "voice") as "voice" | "messages" | "media" | "review" | "recordings", - isListening: value.isListening ?? false, - isStreaming: value.isStreaming ?? false, - }; -} - -async function initializeSharedUIState() { - sharedUIState = normalizeSharedUIState( - await getPersistedValue("web-ui-state", defaultSharedUIState), - ); -} - async function initializeMediaSettings(): Promise { const stored = await getPersistedValue( "media-settings", @@ -123,56 +73,13 @@ async function initializeMediaSettings(): Promise { }; } -function getSharedUIState(): SharedUIState { - return { ...sharedUIState }; -} - -function patchSharedUIState(patch: SharedUIStatePatch) { - if (typeof patch.selectedGuild === "string") { - sharedUIState.selectedVoiceGuild = patch.selectedGuild; - sharedUIState.selectedTextGuild = patch.selectedGuild; - } - if (typeof patch.selectedVoiceGuild === "string") { - sharedUIState.selectedVoiceGuild = patch.selectedVoiceGuild; - } - if (typeof patch.selectedVoiceChannel === "string") { - sharedUIState.selectedVoiceChannel = patch.selectedVoiceChannel; - } - if (typeof patch.selectedTextGuild === "string") { - sharedUIState.selectedTextGuild = patch.selectedTextGuild; - } - if (typeof patch.selectedTextChannel === "string") { - sharedUIState.selectedTextChannel = patch.selectedTextChannel; - } - if ( - ["voice", "messages", "media", "review", "recordings"].includes( - patch.activeTab ?? "", - ) - ) { - sharedUIState.activeTab = patch.activeTab as - | "voice" - | "messages" - | "media" - | "review" - | "recordings"; - } - if (typeof patch.isListening === "boolean") { - sharedUIState.isListening = patch.isListening; - } - if (typeof patch.isStreaming === "boolean") { - sharedUIState.isStreaming = patch.isStreaming; - } - setPersistedValue("web-ui-state", sharedUIState); - return getSharedUIState(); -} - export async function startWebserver( port: number = 3000, _client: Client, voiceController: VoiceController, ) { - await initializeSharedUIState(); + const { getSharedUIState, patchSharedUIState } = await createSharedUIStateStore(); let mediaSettings = await initializeMediaSettings(); const app = express(); @@ -206,7 +113,7 @@ export async function startWebserver( const mediaController = new MediaController({ isVoiceConnected: () => voiceController.getStatus().connected, - isBrowserStreaming: () => sharedUIState.isStreaming, + isBrowserStreaming: () => getSharedUIState().isStreaming, screenController, onStateChange: (state) => broadcaster.mediaState(state), initialMusicVolume: mediaSettings.musicVolume, diff --git a/tests/state/uiState.test.ts b/tests/state/uiState.test.ts new file mode 100644 index 0000000..1e88c9f --- /dev/null +++ b/tests/state/uiState.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { normalizeSharedUIState } from "../../src/state/uiState"; + +describe("normalizeSharedUIState", () => { + it("maps legacy selectedGuild into voice and text guilds", () => { + expect(normalizeSharedUIState({ selectedGuild: "guild-1" })).toEqual({ + selectedVoiceGuild: "guild-1", + selectedVoiceChannel: "", + selectedTextGuild: "guild-1", + selectedTextChannel: "", + activeTab: "voice", + isListening: false, + isStreaming: false, + }); + }); + + it("keeps valid explicit values", () => { + expect( + normalizeSharedUIState({ + selectedVoiceGuild: "voice-guild", + selectedVoiceChannel: "voice-channel", + selectedTextGuild: "text-guild", + selectedTextChannel: "text-channel", + activeTab: "media", + isListening: true, + isStreaming: true, + }), + ).toEqual({ + selectedVoiceGuild: "voice-guild", + selectedVoiceChannel: "voice-channel", + selectedTextGuild: "text-guild", + selectedTextChannel: "text-channel", + activeTab: "media", + isListening: true, + isStreaming: true, + }); + }); + + it("falls back to voice tab for invalid activeTab", () => { + expect(normalizeSharedUIState({ activeTab: "bad" as never })).toMatchObject({ + activeTab: "voice", + }); + }); +});