refactor: extract persisted ui state
This commit is contained in:
@@ -1,26 +1,14 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { createChildLogger } from "../logger";
|
import type { SharedUIState, SharedUIStatePatch } from "../state/uiState";
|
||||||
|
|
||||||
const logger = createChildLogger("ui-state-routes");
|
export { SharedUIState, SharedUIStatePatch };
|
||||||
|
|
||||||
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<SharedUIState> & {
|
|
||||||
selectedGuild?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface UIStateRouteOptions {
|
export interface UIStateRouteOptions {
|
||||||
getSharedUIState: () => SharedUIState;
|
getSharedUIState: () => SharedUIState;
|
||||||
patchSharedUIState: (patch: SharedUIStatePatch) => SharedUIState;
|
patchSharedUIState: (
|
||||||
|
patch: SharedUIStatePatch,
|
||||||
|
) => Promise<SharedUIState> | SharedUIState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
||||||
@@ -38,10 +26,10 @@ export function createUIStateRoutes(options: UIStateRouteOptions): Router {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/ui-state - Update UI state
|
// POST /api/ui-state - Update UI state
|
||||||
router.post("/ui-state", (req, res, next) => {
|
router.post("/ui-state", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const patch = req.body as SharedUIStatePatch;
|
const patch = req.body as SharedUIStatePatch;
|
||||||
const updated = patchSharedUIState(patch);
|
const updated = await patchSharedUIState(patch);
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const logger = createChildLogger("voice-routes");
|
|||||||
|
|
||||||
export interface VoiceRouteOptions {
|
export interface VoiceRouteOptions {
|
||||||
voiceController: VoiceController;
|
voiceController: VoiceController;
|
||||||
patchSharedUIState: (patch: Partial<SharedUIState>) => SharedUIState;
|
patchSharedUIState: (
|
||||||
|
patch: Partial<SharedUIState>,
|
||||||
|
) => Promise<SharedUIState> | SharedUIState;
|
||||||
broadcaster: ModerationBroadcaster;
|
broadcaster: ModerationBroadcaster;
|
||||||
adminPassword?: string;
|
adminPassword?: string;
|
||||||
}
|
}
|
||||||
@@ -23,7 +25,9 @@ export function createVoiceRoutes(
|
|||||||
// Support both old signature (VoiceController) and new signature (options object)
|
// Support both old signature (VoiceController) and new signature (options object)
|
||||||
let voiceController: VoiceController;
|
let voiceController: VoiceController;
|
||||||
let patchSharedUIState:
|
let patchSharedUIState:
|
||||||
| ((patch: Partial<SharedUIState>) => SharedUIState)
|
| ((
|
||||||
|
patch: Partial<SharedUIState>,
|
||||||
|
) => Promise<SharedUIState> | SharedUIState)
|
||||||
| undefined;
|
| undefined;
|
||||||
let broadcaster: ModerationBroadcaster | undefined;
|
let broadcaster: ModerationBroadcaster | undefined;
|
||||||
let adminPassword: string | undefined;
|
let adminPassword: string | undefined;
|
||||||
@@ -137,7 +141,7 @@ export function createVoiceRoutes(
|
|||||||
|
|
||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = await patchSharedUIState({
|
||||||
selectedVoiceGuild: guildId,
|
selectedVoiceGuild: guildId,
|
||||||
selectedVoiceChannel: channelId,
|
selectedVoiceChannel: channelId,
|
||||||
});
|
});
|
||||||
@@ -163,7 +167,7 @@ export function createVoiceRoutes(
|
|||||||
|
|
||||||
// Update UI state and broadcast to connected clients
|
// Update UI state and broadcast to connected clients
|
||||||
if (patchSharedUIState && broadcaster) {
|
if (patchSharedUIState && broadcaster) {
|
||||||
const updatedState = patchSharedUIState({
|
const updatedState = await patchSharedUIState({
|
||||||
selectedVoiceGuild: "",
|
selectedVoiceGuild: "",
|
||||||
selectedVoiceChannel: "",
|
selectedVoiceChannel: "",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<SharedUIState> & {
|
||||||
|
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<SharedUIState> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
+3
-96
@@ -30,6 +30,7 @@ import { createRecordingsRoutes } from "./routes/recordingsRoutes";
|
|||||||
import { createSyncRoutes } from "./routes/syncRoutes";
|
import { createSyncRoutes } from "./routes/syncRoutes";
|
||||||
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
import { createUIStateRoutes } from "./routes/uiStateRoutes";
|
||||||
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
import { createVoiceRoutes } from "./routes/voiceRoutes";
|
||||||
|
import { createSharedUIStateStore } from "./state/uiState";
|
||||||
import { Streamer } from "./streaming";
|
import { Streamer } from "./streaming";
|
||||||
import type { VoiceController } from "./voiceController";
|
import type { VoiceController } from "./voiceController";
|
||||||
|
|
||||||
@@ -53,65 +54,14 @@ type VoiceGlobals = typeof globalThis & {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SharedUIState {
|
|
||||||
selectedVoiceGuild: string;
|
|
||||||
selectedVoiceChannel: string;
|
|
||||||
selectedTextGuild: string;
|
|
||||||
selectedTextChannel: string;
|
|
||||||
activeTab: "voice" | "messages" | "media" | "review" | "recordings";
|
|
||||||
isListening: boolean;
|
|
||||||
isStreaming: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MediaSettings {
|
interface MediaSettings {
|
||||||
musicVolume: number;
|
musicVolume: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SharedUIStatePatch = Partial<SharedUIState> & {
|
|
||||||
selectedGuild?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultSharedUIState: SharedUIState = {
|
|
||||||
selectedVoiceGuild: "",
|
|
||||||
selectedVoiceChannel: "",
|
|
||||||
selectedTextGuild: "",
|
|
||||||
selectedTextChannel: "",
|
|
||||||
activeTab: "voice",
|
|
||||||
isListening: false,
|
|
||||||
isStreaming: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultMediaSettings: MediaSettings = {
|
const defaultMediaSettings: MediaSettings = {
|
||||||
musicVolume: 1,
|
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<MediaSettings> {
|
async function initializeMediaSettings(): Promise<MediaSettings> {
|
||||||
const stored = await getPersistedValue(
|
const stored = await getPersistedValue(
|
||||||
"media-settings",
|
"media-settings",
|
||||||
@@ -123,56 +73,13 @@ async function initializeMediaSettings(): Promise<MediaSettings> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export async function startWebserver(
|
||||||
port: number = 3000,
|
port: number = 3000,
|
||||||
_client: Client,
|
_client: Client,
|
||||||
voiceController: VoiceController,
|
voiceController: VoiceController,
|
||||||
) {
|
) {
|
||||||
await initializeSharedUIState();
|
const { getSharedUIState, patchSharedUIState } = await createSharedUIStateStore();
|
||||||
let mediaSettings = await initializeMediaSettings();
|
let mediaSettings = await initializeMediaSettings();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -206,7 +113,7 @@ export async function startWebserver(
|
|||||||
|
|
||||||
const mediaController = new MediaController({
|
const mediaController = new MediaController({
|
||||||
isVoiceConnected: () => voiceController.getStatus().connected,
|
isVoiceConnected: () => voiceController.getStatus().connected,
|
||||||
isBrowserStreaming: () => sharedUIState.isStreaming,
|
isBrowserStreaming: () => getSharedUIState().isStreaming,
|
||||||
screenController,
|
screenController,
|
||||||
onStateChange: (state) => broadcaster.mediaState(state),
|
onStateChange: (state) => broadcaster.mediaState(state),
|
||||||
initialMusicVolume: mediaSettings.musicVolume,
|
initialMusicVolume: mediaSettings.musicVolume,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user