diff --git a/server/src/index.ts b/server/src/index.ts index 84c34e3..90da983 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -47,13 +47,14 @@ fs.mkdirSync(SOUNDS_DIR, { recursive: true }); // Persistenter Zustand: Lautstärke/Plays + Kategorien type Category = { id: string; name: string; color?: string; sort?: number }; -type PersistedState = { + type PersistedState = { volumes: Record; plays: Record; totalPlays: number; categories?: Category[]; fileCategories?: Record; // relPath or fileName -> categoryIds[] fileBadges?: Record; // relPath or fileName -> custom badges (emoji/text) + selectedChannels?: Record; // guildId -> channelId (serverweite Auswahl) }; // Neuer, persistenter Speicherort direkt im Sounds-Volume const STATE_FILE_NEW = path.join(SOUNDS_DIR, 'state.json'); @@ -72,7 +73,8 @@ function readPersistedState(): PersistedState { totalPlays: parsed.totalPlays ?? 0, categories: Array.isArray(parsed.categories) ? parsed.categories : [], fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {} + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {} } as PersistedState; } // 2) Fallback: alten Speicherort lesen und sofort nach NEW migrieren @@ -85,7 +87,8 @@ function readPersistedState(): PersistedState { totalPlays: parsed.totalPlays ?? 0, categories: Array.isArray(parsed.categories) ? parsed.categories : [], fileCategories: parsed.fileCategories ?? {}, - fileBadges: parsed.fileBadges ?? {} + fileBadges: parsed.fileBadges ?? {}, + selectedChannels: parsed.selectedChannels ?? {} }; try { fs.mkdirSync(path.dirname(STATE_FILE_NEW), { recursive: true }); @@ -162,6 +165,23 @@ function sseBroadcast(payload: any) { } } +// Hilfsfunktionen für serverweit ausgewählten Channel pro Guild +function getSelectedChannelForGuild(guildId: string): string | undefined { + const id = String(guildId || ''); + if (!id) return undefined; + const sc = persistedState.selectedChannels ?? {}; + return sc[id]; +} +function setSelectedChannelForGuild(guildId: string, channelId: string): void { + const g = String(guildId || ''); + const c = String(channelId || ''); + if (!g || !c) return; + if (!persistedState.selectedChannels) persistedState.selectedChannels = {}; + persistedState.selectedChannels[g] = c; + writePersistedState(persistedState); + sseBroadcast({ type: 'channel', guildId: g, channelId: c }); +} + async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { const guild = client.guilds.cache.get(guildId); if (!guild) throw new Error('Guild nicht gefunden'); @@ -727,13 +747,14 @@ app.get('/api/channels', (_req: Request, res: Response) => { if (!client.isReady()) return res.status(503).json({ error: 'Bot noch nicht bereit' }); const allowed = new Set(ALLOWED_GUILD_IDS); - const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string }> = []; + const result: Array<{ guildId: string; guildName: string; channelId: string; channelName: string; selected?: boolean }> = []; for (const [, guild] of client.guilds.cache) { if (allowed.size > 0 && !allowed.has(guild.id)) continue; const channels = guild.channels.cache; for (const [, ch] of channels) { if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { - result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name }); + const sel = getSelectedChannelForGuild(guild.id); + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); } } } @@ -741,6 +762,36 @@ app.get('/api/channels', (_req: Request, res: Response) => { res.json(result); }); +// Globale Channel-Auswahl: auslesen (komplettes Mapping) +app.get('/api/selected-channels', (_req: Request, res: Response) => { + try { + res.json({ selected: persistedState.selectedChannels ?? {} }); + } catch (e: any) { + res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + +// Globale Channel-Auswahl: setzen (validiert Channel-Typ) +app.post('/api/selected-channel', async (req: Request, res: Response) => { + try { + const { guildId, channelId } = req.body as { guildId?: string; channelId?: string }; + const gid = String(guildId ?? ''); + const cid = String(channelId ?? ''); + if (!gid || !cid) return res.status(400).json({ error: 'guildId und channelId erforderlich' }); + const guild = client.guilds.cache.get(gid); + if (!guild) return res.status(404).json({ error: 'Guild nicht gefunden' }); + const ch = guild.channels.cache.get(cid); + if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { + return res.status(400).json({ error: 'Ungültiger Voice-Channel' }); + } + setSelectedChannelForGuild(gid, cid); + return res.json({ ok: true }); + } catch (e: any) { + console.error('selected-channel error', e); + return res.status(500).json({ error: e?.message ?? 'Unbekannter Fehler' }); + } +}); + app.post('/api/play', async (req: Request, res: Response) => { try { const { soundName, guildId, channelId, volume, folder, relativePath } = req.body as { @@ -1023,7 +1074,9 @@ app.get('/api/events', (req: Request, res: Response) => { res.flushHeaders?.(); // Snapshot senden - try { res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive) })}\n\n`); } catch {} + try { + res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {} })}\n\n`); + } catch {} // Ping, damit Proxies die Verbindung offen halten const ping = setInterval(() => { try { res.write(':\n\n'); } catch {} }, 15_000); diff --git a/web/src/App.tsx b/web/src/App.tsx index 0aa2b2f..57fefeb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; -import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents } from './api'; +import { fetchChannels, fetchSounds, playSound, setVolumeLive, getVolume, adminStatus, adminLogin, adminLogout, adminDelete, adminRename, playUrl, fetchCategories, createCategory, assignCategories, clearBadges, updateCategory, deleteCategory, partyStart, partyStop, subscribeEvents, getSelectedChannels, setSelectedChannel } from './api'; import type { VoiceChannelInfo, Sound, Category } from './types'; import { getCookie, setCookie } from './cookies'; @@ -59,14 +59,19 @@ export default function App() { useEffect(() => { (async () => { try { - const c = await fetchChannels(); + const [c, selectedMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); setChannels(c); - const stored = localStorage.getItem('selectedChannel'); - if (stored && c.find(x => `${x.guildId}:${x.channelId}` === stored)) { - setSelected(stored); - } else if (c[0]) { - setSelected(`${c[0].guildId}:${c[0].channelId}`); + let initial = ''; + if (c.length > 0) { + const firstGuild = c[0].guildId; + const serverCid = selectedMap[firstGuild]; + if (serverCid && c.find(x => x.guildId === firstGuild && x.channelId === serverCid)) { + initial = `${firstGuild}:${serverCid}`; + } else { + initial = `${c[0].guildId}:${c[0].channelId}`; + } } + if (initial) setSelected(initial); } catch (e: any) { setError(e?.message || 'Fehler beim Laden der Channels'); } @@ -90,6 +95,23 @@ export default function App() { }); } else if (msg?.type === 'snapshot') { setPartyActiveGuilds(Array.isArray(msg.party) ? msg.party : []); + try { + const sel = msg?.selected || {}; + const gid = selected ? selected.split(':')[0] : ''; + if (gid && sel[gid]) { + const newVal = `${gid}:${sel[gid]}`; + setSelected(newVal); + } + } catch {} + } else if (msg?.type === 'channel') { + try { + const gid = msg.guildId; + const cid = msg.channelId; + if (gid && cid) { + const curGid = selected ? selected.split(':')[0] : ''; + if (curGid === gid) setSelected(`${gid}:${cid}`); + } + } catch {} } }); return () => { try { unsub(); } catch {} }; @@ -358,7 +380,13 @@ export default function App() {
- + { + setSelected(v); + try { + const [gid, cid] = v.split(':'); + await setSelectedChannel(gid, cid); + } catch (e) { /* noop */ } + }} /> folder_special
diff --git a/web/src/api.ts b/web/src/api.ts index 28fe3e8..cd61520 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -79,6 +79,21 @@ export async function fetchChannels(): Promise { return res.json(); } +export async function getSelectedChannels(): Promise> { + const res = await fetch(`${API_BASE}/selected-channels`); + if (!res.ok) throw new Error('Fehler beim Laden der Channel-Auswahl'); + const data = await res.json(); + return data?.selected || {}; +} + +export async function setSelectedChannel(guildId: string, channelId: string): Promise { + const res = await fetch(`${API_BASE}/selected-channel`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId, channelId }) + }); + if (!res.ok) throw new Error('Channel-Auswahl setzen fehlgeschlagen'); +} + export async function playSound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise { const res = await fetch(`${API_BASE}/play`, { method: 'POST', diff --git a/web/src/types.ts b/web/src/types.ts index 40ab488..4cfbef3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -20,6 +20,7 @@ export type VoiceChannelInfo = { guildName: string; channelId: string; channelName: string; + selected?: boolean; }; export type Category = { id: string; name: string; color?: string; sort?: number };