From 901dfe54be239d063a462fd9c98bb7d293fde556 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 00:51:07 +0100 Subject: [PATCH] feat: Soundboard Plugin + Radio Globe Fixes - Add Soundboard plugin (full Jukebox port): server + frontend + CSS - Fix Radio Globe: swap geo coords (API returns [lng,lat] not [lat,lng]) - Fix Radio stations showing "Unbekannt": use item.page.title + fix channel ID regex - Add DirectMessages + MessageContent intents for DM commands - Register SoundboardTab in App.tsx with scoped theme/card-size CSS vars Co-Authored-By: Claude Opus 4.6 --- server/package.json | 18 +- server/src/core/discord.ts | 5 +- server/src/index.ts | 2 + server/src/plugins/radio/api.ts | 6 +- server/src/plugins/soundboard/index.ts | 973 +++++++++ web/src/App.tsx | 2 + web/src/plugins/radio/RadioTab.tsx | 8 +- web/src/plugins/soundboard/SoundboardTab.tsx | 1516 +++++++++++++ web/src/plugins/soundboard/soundboard.css | 2029 ++++++++++++++++++ 9 files changed, 4545 insertions(+), 14 deletions(-) create mode 100644 server/src/plugins/soundboard/index.ts create mode 100644 web/src/plugins/soundboard/SoundboardTab.tsx create mode 100644 web/src/plugins/soundboard/soundboard.css diff --git a/server/package.json b/server/package.json index e71ea6b..79e9f35 100644 --- a/server/package.json +++ b/server/package.json @@ -8,17 +8,21 @@ "start": "node dist/index.js" }, "dependencies": { - "discord.js": "^14.18.0", - "@discordjs/voice": "^0.18.0", - "@discordjs/opus": "^0.9.0", - "sodium-native": "^4.3.1", - "express": "^5.0.0", + "discord.js": "^14.25.1", + "@discordjs/voice": "^0.19.0", + "@discordjs/opus": "^0.10.0", + "sodium-native": "^5.0.10", + "libsodium-wrappers": "^0.8.2", + "tweetnacl": "^1.0.3", + "express": "^5.2.1", + "multer": "^2.0.0", "prism-media": "^1.3.5" }, "devDependencies": { - "typescript": "^5.7.0", + "typescript": "^5.9.3", "@types/node": "^24.0.0", - "@types/express": "^5.0.0", + "@types/express": "^5.0.6", + "@types/multer": "^2.0.0", "tsx": "^4.19.0" } } diff --git a/server/src/core/discord.ts b/server/src/core/discord.ts index 9078fa5..1feb2f9 100644 --- a/server/src/core/discord.ts +++ b/server/src/core/discord.ts @@ -1,10 +1,13 @@ -import { Client, GatewayIntentBits } from 'discord.js'; +import { Client, GatewayIntentBits, Partials } from 'discord.js'; const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, ], + partials: [Partials.Channel], }); export default client; diff --git a/server/src/index.ts b/server/src/index.ts index 7801321..b5d9a41 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -5,6 +5,7 @@ import { addSSEClient, removeSSEClient, sseBroadcast, getSSEClientCount } from ' import { loadState, getFullState } from './core/persistence.js'; import { getPlugins, registerPlugin, PluginContext } from './core/plugin.js'; import radioPlugin from './plugins/radio/index.js'; +import soundboardPlugin from './plugins/soundboard/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -101,6 +102,7 @@ client.once('ready', async () => { async function boot(): Promise { // ── Register plugins ── registerPlugin(radioPlugin); + registerPlugin(soundboardPlugin); // Init all plugins for (const p of getPlugins()) { diff --git a/server/src/plugins/radio/api.ts b/server/src/plugins/radio/api.ts index 1111058..2d593c8 100644 --- a/server/src/plugins/radio/api.ts +++ b/server/src/plugins/radio/api.ts @@ -64,10 +64,10 @@ export async function fetchPlaceChannels(placeId: string): Promise; + plays: Record; + totalPlays: number; + categories?: Category[]; + fileCategories?: Record; + fileBadges?: Record; + selectedChannels?: Record; + entranceSounds?: Record; + exitSounds?: Record; +}; +type ListedSound = { fileName: string; name: string; folder: string; relativePath: string }; +type GuildAudioState = { + connection: VoiceConnection; + player: ReturnType; + guildId: string; + channelId: string; + currentResource?: AudioResource; + currentVolume: number; +}; + +// ── Persisted State ── +const STATE_FILE = path.join(SOUNDS_DIR, 'state.json'); +const STATE_FILE_OLD = path.join(path.resolve(SOUNDS_DIR, '..'), 'state.json'); + +function readPersistedState(): PersistedState { + try { + if (fs.existsSync(STATE_FILE)) { + const p = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); + return { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0, + categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {}, + fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {}, + entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} }; + } + if (fs.existsSync(STATE_FILE_OLD)) { + const p = JSON.parse(fs.readFileSync(STATE_FILE_OLD, 'utf8')); + const m: PersistedState = { volumes: p.volumes ?? {}, plays: p.plays ?? {}, totalPlays: p.totalPlays ?? 0, + categories: Array.isArray(p.categories) ? p.categories : [], fileCategories: p.fileCategories ?? {}, + fileBadges: p.fileBadges ?? {}, selectedChannels: p.selectedChannels ?? {}, + entranceSounds: p.entranceSounds ?? {}, exitSounds: p.exitSounds ?? {} }; + try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(m, null, 2), 'utf8'); } catch {} + return m; + } + } catch {} + return { volumes: {}, plays: {}, totalPlays: 0 }; +} + +let persistedState: PersistedState; +let _writeTimer: ReturnType | null = null; + +function writeState(): void { + try { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(persistedState, null, 2), 'utf8'); } catch (e) { console.warn('[Soundboard] state write error:', e); } +} +function writeStateDebounced(): void { + if (_writeTimer) return; + _writeTimer = setTimeout(() => { _writeTimer = null; writeState(); }, 2000); +} + +function getPersistedVolume(guildId: string): number { + const v = persistedState.volumes[guildId]; + return typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1; +} + +function safeSoundsPath(rel: string): string | null { + const resolved = path.resolve(SOUNDS_DIR, rel); + if (!resolved.startsWith(path.resolve(SOUNDS_DIR) + path.sep) && resolved !== path.resolve(SOUNDS_DIR)) return null; + return resolved; +} + +function incrementPlaysFor(relativePath: string) { + try { const key = relativePath.replace(/\\/g, '/'); + persistedState.plays[key] = (persistedState.plays[key] ?? 0) + 1; + persistedState.totalPlays = (persistedState.totalPlays ?? 0) + 1; + writeStateDebounced(); + } catch {} +} + +// ── Loudnorm Cache ── +const NORM_CACHE_DIR = path.join(SOUNDS_DIR, '.norm-cache'); + +function normCacheKey(filePath: string): string { + const rel = path.relative(SOUNDS_DIR, filePath).replace(/\\/g, '/'); + return rel.replace(/[/\\:*?"<>|]/g, '_') + '.pcm'; +} + +function getNormCachePath(filePath: string): string | null { + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + if (!fs.existsSync(cacheFile)) return null; + try { + const srcMtime = fs.statSync(filePath).mtimeMs; + const cacheMtime = fs.statSync(cacheFile).mtimeMs; + if (srcMtime > cacheMtime) { try { fs.unlinkSync(cacheFile); } catch {} invalidatePcmMemory(cacheFile); return null; } + } catch { return null; } + return cacheFile; +} + +function normalizeToCache(filePath: string): Promise { + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + return new Promise((resolve, reject) => { + const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', filePath, + '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, + '-f', 's16le', '-ar', '48000', '-ac', '2', cacheFile]); + ff.on('error', reject); + ff.on('close', (code) => { if (code === 0) resolve(cacheFile); else reject(new Error(`ffmpeg exit ${code}`)); }); + }); +} + +// ── PCM Memory Cache ── +const pcmMemoryCache = new Map(); +let pcmMemoryCacheBytes = 0; + +function getPcmFromMemory(cachedPath: string): Buffer | null { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) return buf; + try { + const stat = fs.statSync(cachedPath); + if (stat.size > PCM_PER_FILE_MAX_MB * 1024 * 1024) return null; + if (pcmMemoryCacheBytes + stat.size > PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) return null; + const data = fs.readFileSync(cachedPath); + pcmMemoryCache.set(cachedPath, data); + pcmMemoryCacheBytes += data.byteLength; + return data; + } catch { return null; } +} + +function invalidatePcmMemory(cachedPath: string): void { + const buf = pcmMemoryCache.get(cachedPath); + if (buf) { pcmMemoryCacheBytes -= buf.byteLength; pcmMemoryCache.delete(cachedPath); } +} + +// ── Sound listing ── +function listAllSounds(): ListedSound[] { + const rootEntries = fs.readdirSync(SOUNDS_DIR, { withFileTypes: true }); + const rootFiles: ListedSound[] = rootEntries + .filter(d => d.isFile() && /\.(mp3|wav)$/i.test(d.name)) + .map(d => ({ fileName: d.name, name: path.parse(d.name).name, folder: '', relativePath: d.name })); + + const folderItems: ListedSound[] = []; + for (const dirent of rootEntries.filter(d => d.isDirectory() && d.name !== '.norm-cache')) { + const folderPath = path.join(SOUNDS_DIR, dirent.name); + for (const e of fs.readdirSync(folderPath, { withFileTypes: true })) { + if (!e.isFile() || !/\.(mp3|wav)$/i.test(e.name)) continue; + folderItems.push({ fileName: e.name, name: path.parse(e.name).name, folder: dirent.name, + relativePath: path.join(dirent.name, e.name) }); + } + } + return [...rootFiles, ...folderItems].sort((a, b) => a.name.localeCompare(b.name)); +} + +// ── Norm cache sync ── +async function syncNormCache(): Promise { + if (!NORMALIZE_ENABLE) return; + const t0 = Date.now(); + const allSounds = listAllSounds(); + const expectedKeys = new Set(); + const toProcess: string[] = []; + + for (const s of allSounds) { + const fp = path.join(SOUNDS_DIR, s.relativePath); + const key = normCacheKey(fp); + expectedKeys.add(key); + if (!fs.existsSync(fp)) continue; + if (getNormCachePath(fp)) continue; + toProcess.push(fp); + } + + let created = 0, failed = 0; + const skipped = allSounds.length - toProcess.length; + const queue = [...toProcess]; + async function worker(): Promise { + while (queue.length > 0) { + const fp = queue.shift()!; + try { await normalizeToCache(fp); created++; } + catch (e) { failed++; console.warn(`[Soundboard] norm-cache failed: ${path.relative(SOUNDS_DIR, fp)}`, e); } + } + } + await Promise.all(Array.from({ length: Math.min(NORM_CONCURRENCY, toProcess.length || 1) }, worker)); + + let cleaned = 0; + try { + for (const f of fs.readdirSync(NORM_CACHE_DIR)) { + if (!expectedKeys.has(f)) { try { fs.unlinkSync(path.join(NORM_CACHE_DIR, f)); cleaned++; } catch {} } + } + } catch {} + + console.log(`[Soundboard] Norm-cache sync (${((Date.now() - t0) / 1000).toFixed(1)}s): ${created} new, ${skipped} cached, ${failed} failed, ${cleaned} orphans`); +} + +// ── Audio State ── +const guildAudioState = new Map(); +const partyTimers = new Map(); +const partyActive = new Set(); +const nowPlaying = new Map(); +const connectedSince = new Map(); + +// ── Voice Lifecycle ── +async function ensureConnectionReady(connection: VoiceConnection, channelId: string, guildId: string, guild: any): Promise { + try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); return connection; } catch {} + try { connection.rejoin({ channelId, selfDeaf: false, selfMute: false }); await entersState(connection, VoiceConnectionStatus.Ready, 15_000); return connection; } catch {} + try { connection.destroy(); } catch {} + guildAudioState.delete(guildId); + const newConn = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + try { await entersState(newConn, VoiceConnectionStatus.Ready, 15_000); return newConn; } + catch { try { newConn.destroy(); } catch {} guildAudioState.delete(guildId); throw new Error('Voice connection failed after 3 attempts'); } +} + +function attachVoiceLifecycle(state: GuildAudioState, guild: any) { + const { connection } = state; + if ((connection as any).__lifecycleAttached) return; + try { (connection as any).setMaxListeners?.(0); } catch {} + let reconnectAttempts = 0; + const MAX_RECONNECT_ATTEMPTS = 3; + let isReconnecting = false; + + connection.on('stateChange', async (oldS: any, newS: any) => { + if (newS.status === VoiceConnectionStatus.Ready) { + reconnectAttempts = 0; isReconnecting = false; + if (!connectedSince.has(state.guildId)) connectedSince.set(state.guildId, new Date().toISOString()); + return; + } + if (isReconnecting) return; + try { + if (newS.status === VoiceConnectionStatus.Disconnected) { + try { await Promise.race([entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(connection, VoiceConnectionStatus.Connecting, 5_000)]); } + catch { + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { reconnectAttempts++; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } + else { reconnectAttempts = 0; try { connection.destroy(); } catch {} + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } + } + } else if (newS.status === VoiceConnectionStatus.Destroyed) { + connectedSince.delete(state.guildId); + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); + } else if (newS.status === VoiceConnectionStatus.Connecting || newS.status === VoiceConnectionStatus.Signalling) { + isReconnecting = true; + try { await entersState(connection, VoiceConnectionStatus.Ready, 15_000); } + catch { + reconnectAttempts++; + if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) { await new Promise(r => setTimeout(r, reconnectAttempts * 2000)); isReconnecting = false; connection.rejoin({ channelId: state.channelId, selfDeaf: false, selfMute: false }); } + else { reconnectAttempts = 0; isReconnecting = false; try { connection.destroy(); } catch {} + const nc = joinVoiceChannel({ channelId: state.channelId, guildId: state.guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + state.connection = nc; nc.subscribe(state.player); attachVoiceLifecycle(state, guild); } + } + } + } catch { isReconnecting = false; } + }); + (connection as any).__lifecycleAttached = true; +} + +// ── Playback ── +let _pluginCtx: PluginContext | null = null; + +async function playFilePath(guildId: string, channelId: string, filePath: string, volume?: number, relativeKey?: string): Promise { + const ctx = _pluginCtx!; + const guild = ctx.client.guilds.cache.get(guildId); + if (!guild) throw new Error('Guild nicht gefunden'); + + let state = guildAudioState.get(guildId); + if (!state) { + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + const player = createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); + connection.subscribe(player); + state = { connection, player, guildId, channelId, currentVolume: getPersistedVolume(guildId) }; + guildAudioState.set(guildId, state); + state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); + attachVoiceLifecycle(state, guild); + } + + // Channel-Wechsel + try { + const current = getVoiceConnection(guildId); + if (current && current.joinConfig?.channelId !== channelId) { + current.destroy(); + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + const player = state.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); + connection.subscribe(player); + state = { connection, player, guildId, channelId, currentVolume: state.currentVolume ?? getPersistedVolume(guildId) }; + guildAudioState.set(guildId, state); + state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); + attachVoiceLifecycle(state, guild); + } + } catch {} + + if (!getVoiceConnection(guildId)) { + const connection = joinVoiceChannel({ channelId, guildId, adapterCreator: guild.voiceAdapterCreator as any, selfMute: false, selfDeaf: false }); + const player = state?.player ?? createAudioPlayer({ behaviors: { noSubscriber: NoSubscriberBehavior.Play } }); + connection.subscribe(player); + state = { connection, player, guildId, channelId, currentVolume: state?.currentVolume ?? getPersistedVolume(guildId) }; + guildAudioState.set(guildId, state); + state.connection = await ensureConnectionReady(connection, channelId, guildId, guild); + attachVoiceLifecycle(state, guild); + } + + const useVolume = typeof volume === 'number' && Number.isFinite(volume) + ? Math.max(0, Math.min(1, volume)) + : (state.currentVolume ?? 1); + + let resource: AudioResource; + if (NORMALIZE_ENABLE) { + const cachedPath = getNormCachePath(filePath); + if (cachedPath) { + const pcmBuf = getPcmFromMemory(cachedPath); + if (pcmBuf) { + resource = createAudioResource(Readable.from(pcmBuf), { inlineVolume: useVolume !== 1, inputType: StreamType.Raw }); + } else { + resource = createAudioResource(fs.createReadStream(cachedPath, { highWaterMark: 256 * 1024 }), { inlineVolume: true, inputType: StreamType.Raw }); + } + } else { + const cacheFile = path.join(NORM_CACHE_DIR, normCacheKey(filePath)); + const ff = child_process.spawn('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', filePath, + '-af', `loudnorm=I=${NORMALIZE_I}:LRA=${NORMALIZE_LRA}:TP=${NORMALIZE_TP}`, + '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1']); + const playerStream = new PassThrough(); + const cacheWrite = fs.createWriteStream(cacheFile); + ff.stdout.on('data', (chunk: Buffer) => { playerStream.write(chunk); cacheWrite.write(chunk); }); + ff.stdout.on('end', () => { + playerStream.end(); cacheWrite.end(); + try { const buf = fs.readFileSync(cacheFile); + if (pcmMemoryCacheBytes + buf.byteLength <= PCM_MEMORY_CACHE_MAX_MB * 1024 * 1024) { pcmMemoryCache.set(cacheFile, buf); pcmMemoryCacheBytes += buf.byteLength; } + } catch {} + }); + ff.on('error', () => { try { fs.unlinkSync(cacheFile); } catch {} }); + ff.on('close', (code) => { if (code !== 0) { try { fs.unlinkSync(cacheFile); } catch {} } }); + resource = createAudioResource(playerStream, { inlineVolume: true, inputType: StreamType.Raw }); + } + } else { + resource = createAudioResource(filePath, { inlineVolume: true }); + } + + if (resource.volume) resource.volume.setVolume(useVolume); + state.player.stop(); + state.player.play(resource); + state.currentResource = resource; + state.currentVolume = useVolume; + + const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name; + nowPlaying.set(guildId, soundLabel); + sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: soundLabel }); + if (relativeKey) incrementPlaysFor(relativeKey); +} + +// ── Admin Auth (JWT-like with HMAC) ── +type AdminPayload = { iat: number; exp: number }; +function b64url(input: Buffer | string): string { + return Buffer.from(input).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +} +function signAdminToken(adminPwd: string, payload: AdminPayload): string { + const body = b64url(JSON.stringify(payload)); + const sig = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + return `${body}.${sig}`; +} +function verifyAdminToken(adminPwd: string, token: string | undefined): boolean { + if (!token || !adminPwd) return false; + const [body, sig] = token.split('.'); + if (!body || !sig) return false; + const expected = crypto.createHmac('sha256', adminPwd).update(body).digest('base64url'); + if (expected !== sig) return false; + try { + const payload = JSON.parse(Buffer.from(body, 'base64').toString('utf8')) as AdminPayload; + return typeof payload.exp === 'number' && Date.now() < payload.exp; + } catch { return false; } +} +function readCookie(req: express.Request, key: string): string | undefined { + const c = req.headers.cookie; + if (!c) return undefined; + for (const part of c.split(';')) { const [k, v] = part.trim().split('='); if (k === key) return decodeURIComponent(v || ''); } + return undefined; +} + +// ── Party Mode ── +function schedulePartyPlayback(guildId: string, channelId: string) { + const doPlay = async () => { + try { + const all = listAllSounds(); + if (all.length === 0) return; + const pick = all[Math.floor(Math.random() * all.length)]; + await playFilePath(guildId, channelId, path.join(SOUNDS_DIR, pick.relativePath)); + } catch (e) { console.error('[Soundboard] party play error:', e); } + }; + const loop = async () => { + if (!partyActive.has(guildId)) return; + await doPlay(); + if (!partyActive.has(guildId)) return; + const delay = 30_000 + Math.floor(Math.random() * 60_000); + partyTimers.set(guildId, setTimeout(loop, delay)); + }; + partyActive.add(guildId); + void loop(); + sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: true, channelId }); +} + +// ── Discord Commands (DM) ── +async function handleCommand(message: Message, content: string) { + const reply = async (txt: string) => { try { await message.author.send?.(txt); } catch { await message.reply(txt); } }; + const parts = content.split(/\s+/); + const cmd = parts[0].toLowerCase(); + + if (cmd === '?help') { + await reply('Soundboard Commands:\n?help - Hilfe\n?list - Sounds\n?entrance | remove\n?exit | remove'); + return; + } + if (cmd === '?list') { + const files = listAllSounds().map(s => s.relativePath); + await reply(files.length ? files.join('\n') : 'Keine Dateien.'); + return; + } + if (cmd === '?entrance' || cmd === '?exit') { + const isEntrance = cmd === '?entrance'; + const map = isEntrance ? 'entranceSounds' : 'exitSounds'; + const [, fileNameRaw] = parts; + const userId = message.author?.id ?? ''; + if (!userId) { await reply('Kein Benutzer erkannt.'); return; } + const fileName = fileNameRaw?.trim(); + if (!fileName) { await reply(`Verwendung: ${cmd} | remove`); return; } + if (/^(remove|clear|delete)$/i.test(fileName)) { + persistedState[map] = persistedState[map] ?? {}; + delete (persistedState[map] as Record)[userId]; + writeState(); + await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound entfernt.`); + return; + } + if (!/\.(mp3|wav)$/i.test(fileName)) { await reply('Nur .mp3 oder .wav'); return; } + const resolve = (() => { + try { + if (fs.existsSync(path.join(SOUNDS_DIR, fileName))) return fileName; + for (const d of fs.readdirSync(SOUNDS_DIR, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + if (fs.existsSync(path.join(SOUNDS_DIR, d.name, fileName))) return `${d.name}/${fileName}`; + } + return ''; + } catch { return ''; } + })(); + if (!resolve) { await reply('Datei nicht gefunden. Nutze ?list.'); return; } + persistedState[map] = persistedState[map] ?? {}; + (persistedState[map] as Record)[userId] = resolve; + writeState(); + await reply(`${isEntrance ? 'Entrance' : 'Exit'}-Sound gesetzt: ${resolve}`); + return; + } + await reply('Unbekannter Command. Nutze ?help.'); +} + +// ── The Plugin ── +const soundboardPlugin: Plugin = { + name: 'soundboard', + version: '1.0.0', + description: 'Discord Soundboard – MP3/WAV Sounds im Voice-Channel abspielen', + + async init(ctx) { + _pluginCtx = ctx; + fs.mkdirSync(SOUNDS_DIR, { recursive: true }); + fs.mkdirSync(NORM_CACHE_DIR, { recursive: true }); + persistedState = readPersistedState(); + console.log(`[Soundboard] ${listAllSounds().length} sounds, ${persistedState.totalPlays ?? 0} total plays`); + }, + + async onReady(ctx) { + // Entrance/Exit Sounds + ctx.client.on(Events.VoiceStateUpdate, async (oldState: VoiceState, newState: VoiceState) => { + try { + const userId = (newState.id || oldState.id) as string; + if (!userId || userId === ctx.client.user?.id) return; + const guildId = (newState.guild?.id || oldState.guild?.id) as string; + if (!guildId) return; + const before = oldState.channelId; + const after = newState.channelId; + + if (after && before !== after) { + const file = persistedState.entranceSounds?.[userId]; + if (file) { + const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/')); + if (fs.existsSync(abs)) { try { await playFilePath(guildId, after, abs, undefined, file); } catch {} } + } + } + if (before && !after) { + const file = persistedState.exitSounds?.[userId]; + if (file) { + const abs = path.join(SOUNDS_DIR, file.replace(/\\/g, '/')); + if (fs.existsSync(abs)) { try { await playFilePath(guildId, before, abs, undefined, file); } catch {} } + } + } + } catch {} + }); + + // DM Commands + ctx.client.on(Events.MessageCreate, async (message: Message) => { + try { + if (message.author?.bot) return; + const content = (message.content || '').trim(); + if (content.startsWith('?')) { await handleCommand(message, content); return; } + if (!message.channel?.isDMBased?.()) return; + if (message.attachments.size === 0) return; + for (const [, attachment] of message.attachments) { + const name = attachment.name ?? 'upload'; + if (!/\.(mp3|wav)$/i.test(name)) continue; + const safeName = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + let targetPath = path.join(SOUNDS_DIR, safeName); + let i = 2; + while (fs.existsSync(targetPath)) { const { name: n, ext } = path.parse(safeName); targetPath = path.join(SOUNDS_DIR, `${n}-${i}${ext}`); i++; } + const res = await fetch(attachment.url); + if (!res.ok) continue; + fs.writeFileSync(targetPath, Buffer.from(await res.arrayBuffer())); + if (NORMALIZE_ENABLE) normalizeToCache(targetPath).catch(() => {}); + await message.author.send?.(`Sound gespeichert: ${path.basename(targetPath)}`); + } + } catch {} + }); + + // Norm-Cache Sync + syncNormCache(); + + // Voice stats broadcast + setInterval(() => { + if (guildAudioState.size === 0) return; + for (const [gId, st] of guildAudioState) { + const status = st.connection.state?.status ?? 'unknown'; + if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString()); + const ch = ctx.client.channels.cache.get(st.channelId); + sseBroadcast({ type: 'soundboard_voicestats', plugin: 'soundboard', guildId: gId, + voicePing: (st.connection.ping as any)?.ws ?? null, gatewayPing: ctx.client.ws.ping, + status, channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null }); + } + }, 5_000); + }, + + registerRoutes(app: express.Application, ctx: PluginContext) { + const requireAdmin = (req: express.Request, res: express.Response, next: () => void) => { + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + if (!verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin'))) { res.status(401).json({ error: 'Nicht eingeloggt' }); return; } + next(); + }; + + // ── Admin Auth ── + app.post('/api/soundboard/admin/login', (req, res) => { + if (!ctx.adminPwd) { res.status(503).json({ error: 'Admin nicht konfiguriert' }); return; } + const { password } = req.body ?? {}; + if (!password || password !== ctx.adminPwd) { res.status(401).json({ error: 'Falsches Passwort' }); return; } + const token = signAdminToken(ctx.adminPwd, { iat: Date.now(), exp: Date.now() + 7 * 24 * 3600 * 1000 }); + res.setHeader('Set-Cookie', `admin=${encodeURIComponent(token)}; HttpOnly; Path=/; Max-Age=${7 * 24 * 3600}; SameSite=Lax`); + res.json({ ok: true }); + }); + app.post('/api/soundboard/admin/logout', (_req, res) => { + res.setHeader('Set-Cookie', 'admin=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax'); + res.json({ ok: true }); + }); + app.get('/api/soundboard/admin/status', (req, res) => { + res.json({ authenticated: verifyAdminToken(ctx.adminPwd, readCookie(req, 'admin')) }); + }); + + // ── Sounds ── + app.get('/api/soundboard/sounds', (req, res) => { + const q = String(req.query.q ?? '').toLowerCase(); + const folderFilter = typeof req.query.folder === 'string' ? req.query.folder : '__all__'; + const categoryFilter = typeof req.query.categoryId === 'string' ? String(req.query.categoryId) : undefined; + const useFuzzy = String(req.query.fuzzy ?? '0') === '1'; + + const allItems = listAllSounds(); + const folderCounts = new Map(); + for (const it of allItems) { if (it.folder) folderCounts.set(it.folder, (folderCounts.get(it.folder) ?? 0) + 1); } + const folders: Array<{ key: string; name: string; count: number }> = []; + for (const [key, count] of folderCounts) folders.push({ key, name: key, count }); + + const allWithTime = allItems.map(it => { + try { return { ...it, mtimeMs: fs.statSync(path.join(SOUNDS_DIR, it.relativePath)).mtimeMs }; } + catch { return { ...it, mtimeMs: 0 }; } + }); + const sortedByNewest = [...allWithTime].sort((a, b) => b.mtimeMs - a.mtimeMs); + const recentTop10 = sortedByNewest.slice(0, 10); + const recentTop5Set = new Set(recentTop10.slice(0, 5).map(x => x.relativePath)); + + let itemsByFolder = allItems as ListedSound[]; + if (folderFilter !== '__all__') { + if (folderFilter === '__recent__') itemsByFolder = recentTop10; + else itemsByFolder = allItems.filter(it => folderFilter === '' ? it.folder === '' : it.folder === folderFilter); + } + + function fuzzyScore(text: string, pattern: string): number { + if (!pattern) return 1; + if (text === pattern) return 2000; + const idx = text.indexOf(pattern); + if (idx !== -1) return 1000 + (idx === 0 ? 200 : 0) - idx * 2; + let tI = 0, pI = 0, score = 0, last = -1, gaps = 0, first = -1; + while (tI < text.length && pI < pattern.length) { + if (text[tI] === pattern[pI]) { if (first === -1) first = tI; if (last === tI - 1) score += 5; last = tI; pI++; } + else if (first !== -1) gaps++; + tI++; + } + if (pI !== pattern.length) return 0; + return score + Math.max(0, 300 - first * 2) + Math.max(0, 100 - gaps * 10); + } + + let filteredItems = itemsByFolder; + if (q) { + if (useFuzzy) { + filteredItems = itemsByFolder.map(it => ({ it, score: fuzzyScore(it.name.toLowerCase(), q) })) + .filter(x => x.score > 0).sort((a, b) => b.score - a.score || a.it.name.localeCompare(b.it.name)) + .map(x => x.it); + } else { + filteredItems = itemsByFolder.filter(s => s.name.toLowerCase().includes(q)); + } + } + + const playsEntries = Object.entries(persistedState.plays || {}); + const top3 = playsEntries.sort((a, b) => (b[1] as number) - (a[1] as number)).slice(0, 3) + .map(([rel, count]) => { const it = allItems.find(i => i.relativePath === rel || i.fileName === rel); + return it ? { key: `__top__:${rel}`, name: `${it.name} (${count})`, count: 1 } : null; }) + .filter(Boolean) as Array<{ key: string; name: string; count: number }>; + + const foldersOut = [ + { key: '__all__', name: 'Alle', count: allItems.length }, + { key: '__recent__', name: 'Neu', count: Math.min(10, allItems.length) }, + ...(top3.length ? [{ key: '__top3__', name: 'Most Played (3)', count: top3.length }] : []), + ...folders + ]; + + let result = filteredItems; + if (categoryFilter) { + const fc = persistedState.fileCategories ?? {}; + result = result.filter(it => (fc[it.relativePath ?? it.fileName] ?? []).includes(categoryFilter)); + } + if (folderFilter === '__top3__') { + const keys = new Set(top3.map(t => t.key.split(':')[1])); + result = allItems.filter(i => keys.has(i.relativePath ?? i.fileName)); + } + + const top3Set = new Set(top3.map(t => t.key.split(':')[1])); + const customBadges = persistedState.fileBadges ?? {}; + const withBadges = result.map(it => { + const key = it.relativePath ?? it.fileName; + const badges: string[] = []; + if (recentTop5Set.has(key)) badges.push('new'); + if (top3Set.has(key)) badges.push('rocket'); + for (const b of (customBadges[key] ?? [])) badges.push(b); + return { ...it, isRecent: recentTop5Set.has(key), badges }; + }); + + res.json({ items: withBadges, total: allItems.length, folders: foldersOut, + categories: persistedState.categories ?? [], fileCategories: persistedState.fileCategories ?? {} }); + }); + + // ── Analytics ── + app.get('/api/soundboard/analytics', (_req, res) => { + const allItems = listAllSounds(); + const byKey = new Map(); + for (const it of allItems) { byKey.set(it.relativePath, it); if (!byKey.has(it.fileName)) byKey.set(it.fileName, it); } + const mostPlayed = Object.entries(persistedState.plays ?? {}) + .map(([rel, count]) => { const it = byKey.get(rel); return it ? { name: it.name, relativePath: it.relativePath, count: Number(count) || 0 } : null; }) + .filter((x): x is { name: string; relativePath: string; count: number } => !!x) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)).slice(0, 10); + res.json({ totalSounds: allItems.length, totalPlays: persistedState.totalPlays ?? 0, mostPlayed }); + }); + + // ── Channels ── + app.get('/api/soundboard/channels', (_req, res) => { + if (!ctx.client.isReady()) { res.status(503).json({ error: 'Bot noch nicht bereit' }); return; } + const allowed = new Set(ctx.allowedGuildIds); + const result: any[] = []; + for (const [, guild] of ctx.client.guilds.cache) { + if (allowed.size > 0 && !allowed.has(guild.id)) continue; + for (const [, ch] of guild.channels.cache) { + if (ch?.type === ChannelType.GuildVoice || ch?.type === ChannelType.GuildStageVoice) { + const sel = persistedState.selectedChannels?.[guild.id]; + result.push({ guildId: guild.id, guildName: guild.name, channelId: ch.id, channelName: ch.name, selected: sel === ch.id }); + } + } + } + result.sort((a: any, b: any) => a.guildName.localeCompare(b.guildName) || a.channelName.localeCompare(b.channelName)); + res.json(result); + }); + + app.get('/api/soundboard/selected-channels', (_req, res) => { + res.json({ selected: persistedState.selectedChannels ?? {} }); + }); + + app.post('/api/soundboard/selected-channel', async (req, res) => { + const { guildId, channelId } = req.body ?? {}; + if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; } + const guild = ctx.client.guilds.cache.get(guildId); + if (!guild) { res.status(404).json({ error: 'Guild nicht gefunden' }); return; } + const ch = guild.channels.cache.get(channelId); + if (!ch || (ch.type !== ChannelType.GuildVoice && ch.type !== ChannelType.GuildStageVoice)) { res.status(400).json({ error: 'Ungültiger Voice-Channel' }); return; } + if (!persistedState.selectedChannels) persistedState.selectedChannels = {}; + persistedState.selectedChannels[guildId] = channelId; + writeState(); + sseBroadcast({ type: 'soundboard_channel', plugin: 'soundboard', guildId, channelId }); + res.json({ ok: true }); + }); + + // ── Play ── + app.post('/api/soundboard/play', async (req, res) => { + try { + const { soundName, guildId, channelId, volume, folder, relativePath } = req.body ?? {}; + if (!soundName || !guildId || !channelId) { res.status(400).json({ error: 'soundName, guildId, channelId erforderlich' }); return; } + let filePath: string; + if (relativePath) filePath = path.join(SOUNDS_DIR, relativePath); + else if (folder) { const mp3 = path.join(SOUNDS_DIR, folder, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, folder, `${soundName}.wav`); } + else { const mp3 = path.join(SOUNDS_DIR, `${soundName}.mp3`); filePath = fs.existsSync(mp3) ? mp3 : path.join(SOUNDS_DIR, `${soundName}.wav`); } + if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'Sound nicht gefunden' }); return; } + const relKey = relativePath || (folder ? `${folder}/${soundName}` : soundName); + await playFilePath(guildId, channelId, filePath, volume, relKey); + res.json({ ok: true }); + } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } + }); + + app.post('/api/soundboard/play-url', async (req, res) => { + try { + const { url, guildId, channelId, volume } = req.body ?? {}; + if (!url || !guildId || !channelId) { res.status(400).json({ error: 'url, guildId, channelId erforderlich' }); return; } + let parsed: URL; + try { parsed = new URL(url); } catch { res.status(400).json({ error: 'Ungültige URL' }); return; } + if (!parsed.pathname.toLowerCase().endsWith('.mp3')) { res.status(400).json({ error: 'Nur MP3-Links' }); return; } + const dest = path.join(SOUNDS_DIR, path.basename(parsed.pathname)); + const r = await fetch(url); + if (!r.ok) { res.status(400).json({ error: 'Download fehlgeschlagen' }); return; } + fs.writeFileSync(dest, Buffer.from(await r.arrayBuffer())); + if (NORMALIZE_ENABLE) { try { await normalizeToCache(dest); } catch {} } + try { await playFilePath(guildId, channelId, dest, volume, path.basename(dest)); } + catch { res.status(500).json({ error: 'Abspielen fehlgeschlagen' }); return; } + res.json({ ok: true, saved: path.basename(dest) }); + } catch (e: any) { res.status(500).json({ error: e?.message ?? 'Fehler' }); } + }); + + // ── Volume ── + app.post('/api/soundboard/volume', (req, res) => { + const { guildId, volume } = req.body ?? {}; + if (!guildId || typeof volume !== 'number') { res.status(400).json({ error: 'guildId und volume erforderlich' }); return; } + const safeVol = Math.max(0, Math.min(1, volume)); + const state = guildAudioState.get(guildId); + if (state) { + state.currentVolume = safeVol; + if (state.currentResource?.volume) state.currentResource.volume.setVolume(safeVol); + } + persistedState.volumes[guildId] = safeVol; + writeState(); + sseBroadcast({ type: 'soundboard_volume', plugin: 'soundboard', guildId, volume: safeVol }); + res.json({ ok: true, volume: safeVol }); + }); + + app.get('/api/soundboard/volume', (req, res) => { + const guildId = String(req.query.guildId ?? ''); + if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } + const state = guildAudioState.get(guildId); + res.json({ volume: state?.currentVolume ?? getPersistedVolume(guildId) }); + }); + + // ── Stop ── + app.post('/api/soundboard/stop', (req, res) => { + const guildId = String((req.query.guildId || req.body?.guildId) ?? ''); + if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } + const state = guildAudioState.get(guildId); + if (!state) { res.status(404).json({ error: 'Kein aktiver Player' }); return; } + state.player.stop(true); + nowPlaying.delete(guildId); + sseBroadcast({ type: 'soundboard_nowplaying', plugin: 'soundboard', guildId, name: '' }); + const t = partyTimers.get(guildId); if (t) clearTimeout(t); + partyTimers.delete(guildId); partyActive.delete(guildId); + sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false }); + res.json({ ok: true }); + }); + + // ── Party ── + app.post('/api/soundboard/party/start', (req, res) => { + const { guildId, channelId } = req.body ?? {}; + if (!guildId || !channelId) { res.status(400).json({ error: 'guildId und channelId erforderlich' }); return; } + const old = partyTimers.get(guildId); if (old) clearTimeout(old); partyTimers.delete(guildId); + schedulePartyPlayback(guildId, channelId); + res.json({ ok: true }); + }); + app.post('/api/soundboard/party/stop', (req, res) => { + const guildId = String(req.body?.guildId ?? ''); + if (!guildId) { res.status(400).json({ error: 'guildId erforderlich' }); return; } + const t = partyTimers.get(guildId); if (t) clearTimeout(t); + partyTimers.delete(guildId); partyActive.delete(guildId); + sseBroadcast({ type: 'soundboard_party', plugin: 'soundboard', guildId, active: false }); + res.json({ ok: true }); + }); + + // ── Categories ── + app.get('/api/soundboard/categories', (_req, res) => { res.json({ categories: persistedState.categories ?? [] }); }); + app.post('/api/soundboard/categories', requireAdmin, (req, res) => { + const { name, color, sort } = req.body ?? {}; + if (!name?.trim()) { res.status(400).json({ error: 'name erforderlich' }); return; } + const cat = { id: crypto.randomUUID(), name: name.trim(), color, sort }; + persistedState.categories = [...(persistedState.categories ?? []), cat]; + writeState(); res.json({ ok: true, category: cat }); + }); + app.patch('/api/soundboard/categories/:id', requireAdmin, (req, res) => { + const cats = persistedState.categories ?? []; + const idx = cats.findIndex(c => c.id === req.params.id); + if (idx === -1) { res.status(404).json({ error: 'Nicht gefunden' }); return; } + const { name, color, sort } = req.body ?? {}; + if (typeof name === 'string') cats[idx].name = name; + if (typeof color === 'string') cats[idx].color = color; + if (typeof sort === 'number') cats[idx].sort = sort; + writeState(); res.json({ ok: true, category: cats[idx] }); + }); + app.delete('/api/soundboard/categories/:id', requireAdmin, (req, res) => { + const cats = persistedState.categories ?? []; + if (!cats.find(c => c.id === req.params.id)) { res.status(404).json({ error: 'Nicht gefunden' }); return; } + persistedState.categories = cats.filter(c => c.id !== req.params.id); + const fc = persistedState.fileCategories ?? {}; + for (const k of Object.keys(fc)) fc[k] = (fc[k] ?? []).filter(x => x !== req.params.id); + writeState(); res.json({ ok: true }); + }); + app.post('/api/soundboard/categories/assign', requireAdmin, (req, res) => { + const { files, add, remove } = req.body ?? {}; + if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } + const validCats = new Set((persistedState.categories ?? []).map(c => c.id)); + const fc = persistedState.fileCategories ?? {}; + for (const rel of files) { + const old = new Set(fc[rel] ?? []); + for (const a of (add ?? []).filter((id: string) => validCats.has(id))) old.add(a); + for (const r of (remove ?? []).filter((id: string) => validCats.has(id))) old.delete(r); + fc[rel] = Array.from(old); + } + persistedState.fileCategories = fc; writeState(); res.json({ ok: true, fileCategories: fc }); + }); + + // ── Badges ── + app.post('/api/soundboard/badges/assign', requireAdmin, (req, res) => { + const { files, add, remove } = req.body ?? {}; + if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } + const fb = persistedState.fileBadges ?? {}; + for (const rel of files) { const old = new Set(fb[rel] ?? []); + for (const a of (add ?? [])) old.add(a); for (const r of (remove ?? [])) old.delete(r); fb[rel] = Array.from(old); } + persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb }); + }); + app.post('/api/soundboard/badges/clear', requireAdmin, (req, res) => { + const { files } = req.body ?? {}; + if (!Array.isArray(files) || !files.length) { res.status(400).json({ error: 'files[] erforderlich' }); return; } + const fb = persistedState.fileBadges ?? {}; + for (const rel of files) delete fb[rel]; + persistedState.fileBadges = fb; writeState(); res.json({ ok: true, fileBadges: fb }); + }); + + // ── Admin: Delete & Rename ── + app.post('/api/soundboard/admin/sounds/delete', requireAdmin, (req, res) => { + const { paths: pathsList } = req.body ?? {}; + if (!Array.isArray(pathsList) || !pathsList.length) { res.status(400).json({ error: 'paths[] erforderlich' }); return; } + const results: any[] = []; + for (const rel of pathsList) { + const full = safeSoundsPath(rel); + if (!full) { results.push({ path: rel, ok: false, error: 'Ungültig' }); continue; } + try { + if (fs.existsSync(full) && fs.statSync(full).isFile()) { + fs.unlinkSync(full); + try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(full)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} + results.push({ path: rel, ok: true }); + } else results.push({ path: rel, ok: false, error: 'nicht gefunden' }); + } catch (e: any) { results.push({ path: rel, ok: false, error: e?.message }); } + } + res.json({ ok: true, results }); + }); + + app.post('/api/soundboard/admin/sounds/rename', requireAdmin, (req, res) => { + const { from, to } = req.body ?? {}; + if (!from || !to) { res.status(400).json({ error: 'from und to erforderlich' }); return; } + const src = safeSoundsPath(from); + if (!src) { res.status(400).json({ error: 'Ungültiger Quellpfad' }); return; } + const parsed = path.parse(from); + const sanitizedName = to.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + const dstRel = path.join(parsed.dir || '', `${sanitizedName}.mp3`); + const dst = safeSoundsPath(dstRel); + if (!dst) { res.status(400).json({ error: 'Ungültiger Zielpfad' }); return; } + if (!fs.existsSync(src)) { res.status(404).json({ error: 'Quelle nicht gefunden' }); return; } + if (fs.existsSync(dst)) { res.status(409).json({ error: 'Ziel existiert bereits' }); return; } + fs.renameSync(src, dst); + try { const cf = path.join(NORM_CACHE_DIR, normCacheKey(src)); if (fs.existsSync(cf)) fs.unlinkSync(cf); } catch {} + res.json({ ok: true, from, to: dstRel }); + }); + + // ── Upload ── + const uploadStorage = multer.diskStorage({ + destination: (_req: any, _file: any, cb: (e: null, dest: string) => void) => cb(null, SOUNDS_DIR), + filename: (_req: any, file: { originalname: string }, cb: (e: null, name: string) => void) => { + const safe = file.originalname.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_'); + const { name, ext } = path.parse(safe); + let finalName = safe; let i = 2; + while (fs.existsSync(path.join(SOUNDS_DIR, finalName))) { finalName = `${name}-${i}${ext}`; i++; } + cb(null, finalName); + }, + }); + const uploadMulter = multer({ storage: uploadStorage, + fileFilter: (_req: any, file: { originalname: string }, cb: (e: null, ok: boolean) => void) => { + cb(null, /\.(mp3|wav)$/i.test(file.originalname)); + }, limits: { fileSize: 50 * 1024 * 1024, files: 20 } }); + + app.post('/api/soundboard/upload', requireAdmin, (req, res) => { + uploadMulter.array('files', 20)(req, res, async (err: any) => { + if (err) { res.status(400).json({ error: err?.message ?? 'Upload fehlgeschlagen' }); return; } + const files = (req as any).files as any[] | undefined; + if (!files?.length) { res.status(400).json({ error: 'Keine gültigen Dateien' }); return; } + if (NORMALIZE_ENABLE) { for (const f of files) normalizeToCache(f.path).catch(() => {}); } + res.json({ ok: true, files: files.map(f => ({ name: f.filename, size: f.size })) }); + }); + }); + + // ── Health ── + app.get('/api/soundboard/health', (_req, res) => { + res.json({ ok: true, totalPlays: persistedState.totalPlays ?? 0, categories: (persistedState.categories ?? []).length, sounds: listAllSounds().length }); + }); + + // ── SSE Events (soundboard-specific data in main SSE stream) ── + // The main hub SSE already exists at /api/events, snapshot data is provided via getSnapshot() + }, + + getSnapshot() { + const statsSnap: Record = {}; + for (const [gId, st] of guildAudioState) { + const status = st.connection.state?.status ?? 'unknown'; + if (status === 'ready' && !connectedSince.has(gId)) connectedSince.set(gId, new Date().toISOString()); + const ch = _pluginCtx?.client.channels.cache.get(st.channelId); + statsSnap[gId] = { voicePing: (st.connection.ping as any)?.ws ?? null, + gatewayPing: _pluginCtx?.client.ws.ping, status, + channelName: ch && 'name' in ch ? (ch as any).name : null, + connectedSince: connectedSince.get(gId) ?? null }; + } + return { + soundboard: { + party: Array.from(partyActive), + selected: persistedState?.selectedChannels ?? {}, + volumes: persistedState?.volumes ?? {}, + nowplaying: Object.fromEntries(nowPlaying), + voicestats: statsSnap, + }, + }; + }, + + async destroy() { + for (const t of partyTimers.values()) clearTimeout(t); + partyTimers.clear(); partyActive.clear(); + for (const [gId, state] of guildAudioState) { + try { state.player.stop(true); } catch {} + try { state.connection.destroy(); } catch {} + } + guildAudioState.clear(); + if (_writeTimer) { clearTimeout(_writeTimer); writeState(); } + console.log('[Soundboard] Destroyed'); + }, +}; + +export default soundboardPlugin; diff --git a/web/src/App.tsx b/web/src/App.tsx index a55c400..f382196 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import RadioTab from './plugins/radio/RadioTab'; +import SoundboardTab from './plugins/soundboard/SoundboardTab'; interface PluginInfo { name: string; @@ -10,6 +11,7 @@ interface PluginInfo { // Plugin tab components const tabComponents: Record> = { radio: RadioTab, + soundboard: SoundboardTab, }; export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index 95e5e10..93e33bf 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -100,7 +100,8 @@ export default function RadioTab({ data }: { data: any }) { setStationsLoading(true); setStations([]); if (globeRef.current) { - globeRef.current.pointOfView({ lat: point.geo[0], lng: point.geo[1], altitude: 0.4 }, 800); + // Radio Garden geo format: [lng, lat] + globeRef.current.pointOfView({ lat: point.geo[1], lng: point.geo[0], altitude: 0.4 }, 800); } fetch(`/api/radio/place/${point.id}/channels`) .then(r => r.json()) @@ -123,8 +124,9 @@ export default function RadioTab({ data }: { data: any }) { .atmosphereColor('rgba(230, 126, 34, 0.25)') .atmosphereAltitude(0.12) .pointsData(places) - .pointLat((d: any) => d.geo[0]) - .pointLng((d: any) => d.geo[1]) + // Radio Garden geo format: [lng, lat] + .pointLat((d: any) => d.geo[1]) + .pointLng((d: any) => d.geo[0]) .pointColor(() => 'rgba(230, 126, 34, 0.85)') .pointRadius((d: any) => Math.max(0.12, Math.min(0.45, 0.06 + (d.size ?? 1) * 0.005))) .pointAltitude(0.003) diff --git a/web/src/plugins/soundboard/SoundboardTab.tsx b/web/src/plugins/soundboard/SoundboardTab.tsx new file mode 100644 index 0000000..b3ad866 --- /dev/null +++ b/web/src/plugins/soundboard/SoundboardTab.tsx @@ -0,0 +1,1516 @@ +import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; +import './soundboard.css'; + +/* ══════════════════════════════════════════════════════════════════ + INLINED TYPES (from original types.ts) + ══════════════════════════════════════════════════════════════════ */ + +type Sound = { + fileName: string; + name: string; + folder?: string; + relativePath?: string; + isRecent?: boolean; + badges?: string[]; +}; + +type SoundsResponse = { + items: Sound[]; + total: number; + folders: Array<{ key: string; name: string; count: number }>; + categories?: Category[]; + fileCategories?: Record; +}; + +type VoiceChannelInfo = { + guildId: string; + guildName: string; + channelId: string; + channelName: string; + selected?: boolean; +}; + +type Category = { id: string; name: string; color?: string; sort?: number }; + +type AnalyticsItem = { + name: string; + relativePath: string; + count: number; +}; + +type AnalyticsResponse = { + totalSounds: number; + totalPlays: number; + mostPlayed: AnalyticsItem[]; +}; + +/* ══════════════════════════════════════════════════════════════════ + INLINED COOKIE HELPERS (from original cookies.ts) + ══════════════════════════════════════════════════════════════════ */ + +function setCookie(name: string, value: string, days = 365): void { + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); + document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; +} + +function getCookie(name: string): string | null { + const key = `${encodeURIComponent(name)}=`; + const parts = document.cookie.split(';'); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith(key)) { + return decodeURIComponent(trimmed.slice(key.length)); + } + } + return null; +} + +/* ══════════════════════════════════════════════════════════════════ + INLINED API FUNCTIONS (from original api.ts) + All endpoints prefixed with /api/soundboard/ instead of /api/ + ══════════════════════════════════════════════════════════════════ */ + +const API_BASE = '/api/soundboard'; + +async function fetchSounds(q?: string, folderKey?: string, categoryId?: string, fuzzy?: boolean): Promise { + const url = new URL(`${API_BASE}/sounds`, window.location.origin); + if (q) url.searchParams.set('q', q); + if (folderKey !== undefined) url.searchParams.set('folder', folderKey); + if (categoryId) url.searchParams.set('categoryId', categoryId); + if (typeof fuzzy === 'boolean') url.searchParams.set('fuzzy', fuzzy ? '1' : '0'); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error('Fehler beim Laden der Sounds'); + return res.json(); +} + +async function fetchAnalytics(): Promise { + const res = await fetch(`${API_BASE}/analytics`); + if (!res.ok) throw new Error('Fehler beim Laden der Analytics'); + return res.json(); +} + +async function fetchCategories() { + const res = await fetch(`${API_BASE}/categories`, { credentials: 'include' }); + if (!res.ok) throw new Error('Fehler beim Laden der Kategorien'); + return res.json(); +} + +async function fetchChannels(): Promise { + const res = await fetch(`${API_BASE}/channels`); + if (!res.ok) throw new Error('Fehler beim Laden der Channels'); + return res.json(); +} + +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 || {}; +} + +async function apiSetSelectedChannel(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'); +} + +async function apiPlaySound(soundName: string, guildId: string, channelId: string, volume: number, relativePath?: string): Promise { + const res = await fetch(`${API_BASE}/play`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ soundName, guildId, channelId, volume, relativePath }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Play fehlgeschlagen'); + } +} + +async function apiPlayUrl(url: string, guildId: string, channelId: string, volume: number): Promise { + const res = await fetch(`${API_BASE}/play-url`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, guildId, channelId, volume }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Play-URL fehlgeschlagen'); + } +} + +async function apiPartyStart(guildId: string, channelId: string) { + const res = await fetch(`${API_BASE}/party/start`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId, channelId }) + }); + if (!res.ok) throw new Error('Partymode Start fehlgeschlagen'); +} + +async function apiPartyStop(guildId: string) { + const res = await fetch(`${API_BASE}/party/stop`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId }) + }); + if (!res.ok) throw new Error('Partymode Stop fehlgeschlagen'); +} + +async function apiSetVolumeLive(guildId: string, volume: number): Promise { + const res = await fetch(`${API_BASE}/volume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId, volume }) + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || 'Volume aendern fehlgeschlagen'); + } +} + +async function apiGetVolume(guildId: string): Promise { + const url = new URL(`${API_BASE}/volume`, window.location.origin); + url.searchParams.set('guildId', guildId); + const res = await fetch(url.toString()); + if (!res.ok) throw new Error('Fehler beim Laden der Lautstaerke'); + const data = await res.json(); + return typeof data?.volume === 'number' ? data.volume : 1; +} + +async function apiAdminStatus(): Promise { + const res = await fetch(`${API_BASE}/admin/status`, { credentials: 'include' }); + if (!res.ok) return false; + const data = await res.json(); + return !!data?.authenticated; +} + +async function apiAdminLogin(password: string): Promise { + const res = await fetch(`${API_BASE}/admin/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ password }) + }); + return res.ok; +} + +async function apiAdminLogout(): Promise { + await fetch(`${API_BASE}/admin/logout`, { method: 'POST', credentials: 'include' }); +} + +async function apiAdminDelete(paths: string[]): Promise { + const res = await fetch(`${API_BASE}/admin/sounds/delete`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ paths }) + }); + if (!res.ok) throw new Error('Loeschen fehlgeschlagen'); +} + +async function apiAdminRename(from: string, to: string): Promise { + const res = await fetch(`${API_BASE}/admin/sounds/rename`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + body: JSON.stringify({ from, to }) + }); + if (!res.ok) throw new Error('Umbenennen fehlgeschlagen'); + const data = await res.json(); + return data?.to as string; +} + +function apiUploadFile( + file: File, + onProgress: (pct: number) => void, +): Promise { + return new Promise((resolve, reject) => { + const form = new FormData(); + form.append('files', file); + const xhr = new XMLHttpRequest(); + xhr.open('POST', `${API_BASE}/upload`); + xhr.upload.onprogress = e => { + if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); + }; + xhr.onload = () => { + if (xhr.status === 200) { + try { + const data = JSON.parse(xhr.responseText); + resolve(data.files?.[0]?.name ?? file.name); + } catch { resolve(file.name); } + } else { + try { reject(new Error(JSON.parse(xhr.responseText).error)); } + catch { reject(new Error(`HTTP ${xhr.status}`)); } + } + }; + xhr.onerror = () => reject(new Error('Netzwerkfehler')); + xhr.send(form); + }); +} + +/* ══════════════════════════════════════════════════════════════════ + CONSTANTS + ══════════════════════════════════════════════════════════════════ */ + +const THEMES = [ + { id: 'default', color: '#5865f2', label: 'Discord' }, + { id: 'purple', color: '#9b59b6', label: 'Midnight' }, + { id: 'forest', color: '#2ecc71', label: 'Forest' }, + { id: 'sunset', color: '#e67e22', label: 'Sunset' }, + { id: 'ocean', color: '#3498db', label: 'Ocean' }, +]; + +const CAT_PALETTE = [ + '#3b82f6', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', + '#f97316', '#06b6d4', '#ef4444', '#a855f7', '#84cc16', + '#d946ef', '#0ea5e9', '#f43f5e', '#10b981', +]; + +type Tab = 'all' | 'favorites' | 'recent'; + +type UploadItem = { + id: string; + file: File; + status: 'waiting' | 'uploading' | 'done' | 'error'; + progress: number; + savedName?: string; + error?: string; +}; + +interface VoiceStats { + voicePing: number | null; + gatewayPing: number; + status: string; + channelName: string | null; + connectedSince: string | null; +} + +/* ══════════════════════════════════════════════════════════════════ + PROPS — receives SSE data from the Hub + ══════════════════════════════════════════════════════════════════ */ + +interface SoundboardTabProps { + data: any; +} + +/* ══════════════════════════════════════════════════════════════════ + COMPONENT + ══════════════════════════════════════════════════════════════════ */ + +export default function SoundboardTab({ data }: SoundboardTabProps) { + /* ── Data ── */ + const [sounds, setSounds] = useState([]); + const [total, setTotal] = useState(0); + const [folders, setFolders] = useState>([]); + const [categories, setCategories] = useState([]); + const [analytics, setAnalytics] = useState({ + totalSounds: 0, + totalPlays: 0, + mostPlayed: [], + }); + + /* ── Navigation ── */ + const [activeTab, setActiveTab] = useState('all'); + const [activeFolder, setActiveFolder] = useState(''); + const [query, setQuery] = useState(''); + const [importUrl, setImportUrl] = useState(''); + const [importBusy, setImportBusy] = useState(false); + + /* ── Channels ── */ + const [channels, setChannels] = useState([]); + const [selected, setSelected] = useState(''); + const selectedRef = useRef(''); + const [channelOpen, setChannelOpen] = useState(false); + + /* ── Playback ── */ + const [volume, setVolume] = useState(1); + const [lastPlayed, setLastPlayed] = useState(''); + + /* ── Preferences ── */ + const [favs, setFavs] = useState>({}); + const [theme, setTheme] = useState(() => localStorage.getItem('jb-theme') || 'default'); + const [cardSize, setCardSize] = useState(() => parseInt(localStorage.getItem('jb-card-size') || '110')); + + /* ── Party ── */ + const [chaosMode, setChaosMode] = useState(false); + const [partyActiveGuilds, setPartyActiveGuilds] = useState([]); + const chaosModeRef = useRef(false); + const volDebounceRef = useRef>(); + + /* ── Admin ── */ + const [isAdmin, setIsAdmin] = useState(false); + const [showAdmin, setShowAdmin] = useState(false); + const [adminPwd, setAdminPwd] = useState(''); + const [adminSounds, setAdminSounds] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); + const [adminQuery, setAdminQuery] = useState(''); + const [adminSelection, setAdminSelection] = useState>({}); + const [renameTarget, setRenameTarget] = useState(''); + const [renameValue, setRenameValue] = useState(''); + + /* ── Drag & Drop Upload ── */ + const [isDragging, setIsDragging] = useState(false); + const [uploads, setUploads] = useState([]); + const [showUploads, setShowUploads] = useState(false); + const dragCounterRef = useRef(0); + const uploadDismissRef = useRef>(); + + /* ── Voice Stats ── */ + const [voiceStats, setVoiceStats] = useState(null); + const [showConnModal, setShowConnModal] = useState(false); + + /* ── UI ── */ + const [notification, setNotification] = useState<{ msg: string; type: 'info' | 'error' } | null>(null); + const [clock, setClock] = useState(''); + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; sound: Sound } | null>(null); + const [refreshKey, setRefreshKey] = useState(0); + + /* ── Refs ── */ + useEffect(() => { chaosModeRef.current = chaosMode; }, [chaosMode]); + useEffect(() => { selectedRef.current = selected; }, [selected]); + + /* ── Drag & Drop: global window listeners ── */ + useEffect(() => { + const onDragEnter = (e: DragEvent) => { + if (Array.from(e.dataTransfer?.items ?? []).some(i => i.kind === 'file')) { + dragCounterRef.current++; + setIsDragging(true); + } + }; + const onDragLeave = () => { + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }; + const onDragOver = (e: DragEvent) => e.preventDefault(); + const onDrop = (e: DragEvent) => { + e.preventDefault(); + dragCounterRef.current = 0; + setIsDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []).filter(f => + /\.(mp3|wav)$/i.test(f.name) + ); + if (files.length) handleFileDrop(files); + }; + window.addEventListener('dragenter', onDragEnter); + window.addEventListener('dragleave', onDragLeave); + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + return () => { + window.removeEventListener('dragenter', onDragEnter); + window.removeEventListener('dragleave', onDragLeave); + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin]); + + /* ── Helpers ── */ + const notify = useCallback((msg: string, type: 'info' | 'error' = 'info') => { + setNotification({ msg, type }); + setTimeout(() => setNotification(null), 3000); + }, []); + const soundKey = useCallback((s: Sound) => s.relativePath ?? s.fileName, []); + const isMp3Url = useCallback((value: string) => { + try { + const parsed = new URL(value.trim()); + return parsed.pathname.toLowerCase().endsWith('.mp3'); + } catch { + return false; + } + }, []); + + const guildId = selected ? selected.split(':')[0] : ''; + const channelId = selected ? selected.split(':')[1] : ''; + + const selectedChannel = useMemo(() => + channels.find(c => `${c.guildId}:${c.channelId}` === selected), + [channels, selected]); + + /* ── Clock ── */ + useEffect(() => { + const update = () => { + const now = new Date(); + const h = String(now.getHours()).padStart(2, '0'); + const m = String(now.getMinutes()).padStart(2, '0'); + const s = String(now.getSeconds()).padStart(2, '0'); + setClock(`${h}:${m}:${s}`); + }; + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, []); + + /* ── Init ── */ + useEffect(() => { + (async () => { + try { + const [ch, selMap] = await Promise.all([fetchChannels(), getSelectedChannels()]); + setChannels(ch); + if (ch.length) { + const g = ch[0].guildId; + const serverCid = selMap[g]; + const match = serverCid && ch.find(x => x.guildId === g && x.channelId === serverCid); + setSelected(match ? `${g}:${serverCid}` : `${ch[0].guildId}:${ch[0].channelId}`); + } + } catch (e: any) { notify(e?.message || 'Channel-Fehler', 'error'); } + try { setIsAdmin(await apiAdminStatus()); } catch { } + try { const c = await fetchCategories(); setCategories(c.categories || []); } catch { } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /* ── Theme (persist only, data-theme is set on .sb-app div) ── */ + useEffect(() => { + localStorage.setItem('jb-theme', theme); + }, [theme]); + + /* ── Card size (scoped to .sb-app container) ── */ + const sbAppRef = useRef(null); + useEffect(() => { + const el = sbAppRef.current; + if (!el) return; + el.style.setProperty('--card-size', cardSize + 'px'); + const ratio = cardSize / 110; + el.style.setProperty('--card-emoji', Math.round(28 * ratio) + 'px'); + el.style.setProperty('--card-font', Math.max(9, Math.round(11 * ratio)) + 'px'); + localStorage.setItem('jb-card-size', String(cardSize)); + }, [cardSize]); + + /* ── SSE via props.data instead of own EventSource ── */ + useEffect(() => { + if (!data) return; + + // Handle snapshot data (initial load from hub SSE) + if (data.soundboard) { + const sb = data.soundboard; + if (Array.isArray(sb.party)) { + setPartyActiveGuilds(sb.party); + } + try { + const sel = sb.selected || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && sel[g]) setSelected(`${g}:${sel[g]}`); + } catch { } + try { + const vols = sb.volumes || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof vols[g] === 'number') setVolume(vols[g]); + } catch { } + try { + const np = sb.nowplaying || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && typeof np[g] === 'string') setLastPlayed(np[g]); + } catch { } + try { + const vs = sb.voicestats || {}; + const g = selectedRef.current?.split(':')[0]; + if (g && vs[g]) setVoiceStats(vs[g]); + } catch { } + } + + // Handle individual SSE event types + if (data.type === 'soundboard_party') { + setPartyActiveGuilds(prev => { + const s = new Set(prev); + if (data.active) s.add(data.guildId); else s.delete(data.guildId); + return Array.from(s); + }); + } else if (data.type === 'soundboard_channel') { + const g = selectedRef.current?.split(':')[0]; + if (data.guildId === g) setSelected(`${data.guildId}:${data.channelId}`); + } else if (data.type === 'soundboard_volume') { + const g = selectedRef.current?.split(':')[0]; + if (data.guildId === g && typeof data.volume === 'number') setVolume(data.volume); + } else if (data.type === 'soundboard_nowplaying') { + const g = selectedRef.current?.split(':')[0]; + if (data.guildId === g) setLastPlayed(data.name || ''); + } else if (data.type === 'soundboard_voicestats') { + const g = selectedRef.current?.split(':')[0]; + if (data.guildId === g) { + setVoiceStats({ + voicePing: data.voicePing, + gatewayPing: data.gatewayPing, + status: data.status, + channelName: data.channelName, + connectedSince: data.connectedSince, + }); + } + } + }, [data]); + + useEffect(() => { + setChaosMode(guildId ? partyActiveGuilds.includes(guildId) : false); + }, [selected, partyActiveGuilds, guildId]); + + /* ── Data Fetch ── */ + useEffect(() => { + (async () => { + try { + let folderParam = '__all__'; + if (activeTab === 'recent') folderParam = '__recent__'; + else if (activeFolder) folderParam = activeFolder; + const s = await fetchSounds(query, folderParam, undefined, false); + setSounds(s.items); + setTotal(s.total); + setFolders(s.folders); + } catch (e: any) { notify(e?.message || 'Sounds-Fehler', 'error'); } + })(); + }, [activeTab, activeFolder, query, refreshKey, notify]); + + useEffect(() => { + void loadAnalytics(); + }, [refreshKey]); + + /* ── Favs persistence ── */ + useEffect(() => { + const c = getCookie('favs'); + if (c) try { setFavs(JSON.parse(c)); } catch { } + }, []); + + useEffect(() => { + try { setCookie('favs', JSON.stringify(favs)); } catch { } + }, [favs]); + + /* ── Volume sync ── */ + useEffect(() => { + if (selected) { + (async () => { + try { const v = await apiGetVolume(guildId); setVolume(v); } catch { } + })(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selected]); + + /* ── Close dropdowns on outside click ── */ + useEffect(() => { + const handler = () => { setChannelOpen(false); setCtxMenu(null); }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, []); + + useEffect(() => { + if (showAdmin && isAdmin) { + void loadAdminSounds(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showAdmin, isAdmin]); + + /* ── Actions ── */ + async function loadAnalytics() { + try { + const d = await fetchAnalytics(); + setAnalytics(d); + } catch { } + } + + async function handlePlay(s: Sound) { + if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error'); + try { + await apiPlaySound(s.name, guildId, channelId, volume, s.relativePath); + setLastPlayed(s.name); + void loadAnalytics(); + } catch (e: any) { notify(e?.message || 'Play fehlgeschlagen', 'error'); } + } + + async function handleUrlImport() { + const trimmed = importUrl.trim(); + if (!trimmed) return notify('Bitte einen MP3-Link eingeben', 'error'); + if (!selected) return notify('Bitte einen Voice-Channel auswaehlen', 'error'); + if (!isMp3Url(trimmed)) return notify('Nur direkte MP3-Links sind erlaubt', 'error'); + setImportBusy(true); + try { + await apiPlayUrl(trimmed, guildId, channelId, volume); + setImportUrl(''); + notify('MP3 importiert und abgespielt'); + setRefreshKey(k => k + 1); + await loadAnalytics(); + } catch (e: any) { + notify(e?.message || 'URL-Import fehlgeschlagen', 'error'); + } finally { + setImportBusy(false); + } + } + + async function handleFileDrop(files: File[]) { + if (!isAdmin) { + notify('Admin-Login erforderlich zum Hochladen', 'error'); + return; + } + if (uploadDismissRef.current) clearTimeout(uploadDismissRef.current); + + const items: UploadItem[] = files.map(f => ({ + id: Math.random().toString(36).slice(2), + file: f, + status: 'waiting', + progress: 0, + })); + setUploads(items); + setShowUploads(true); + + const updated = [...items]; + for (let i = 0; i < updated.length; i++) { + updated[i] = { ...updated[i], status: 'uploading' }; + setUploads([...updated]); + try { + const savedName = await apiUploadFile( + updated[i].file, + pct => { + updated[i] = { ...updated[i], progress: pct }; + setUploads([...updated]); + }, + ); + updated[i] = { ...updated[i], status: 'done', progress: 100, savedName }; + } catch (e: any) { + updated[i] = { ...updated[i], status: 'error', error: e?.message ?? 'Fehler' }; + } + setUploads([...updated]); + } + + // Refresh sound list + setRefreshKey(k => k + 1); + void loadAnalytics(); + + // Auto-dismiss after 3.5s + uploadDismissRef.current = setTimeout(() => { + setShowUploads(false); + setUploads([]); + }, 3500); + } + + async function handleStop() { + if (!selected) return; + setLastPlayed(''); + try { await fetch(`${API_BASE}/stop?guildId=${encodeURIComponent(guildId)}`, { method: 'POST' }); } catch { } + } + + async function handleRandom() { + if (!displaySounds.length || !selected) return; + const rnd = displaySounds[Math.floor(Math.random() * displaySounds.length)]; + handlePlay(rnd); + } + + async function toggleParty() { + if (chaosMode) { + await handleStop(); + try { await apiPartyStop(guildId); } catch { } + } else { + if (!selected) return notify('Bitte einen Channel auswaehlen', 'error'); + try { await apiPartyStart(guildId, channelId); } catch { } + } + } + + async function handleChannelSelect(ch: VoiceChannelInfo) { + const v = `${ch.guildId}:${ch.channelId}`; + setSelected(v); + setChannelOpen(false); + try { await apiSetSelectedChannel(ch.guildId, ch.channelId); } catch { } + } + + function toggleFav(key: string) { + setFavs(prev => ({ ...prev, [key]: !prev[key] })); + } + + async function loadAdminSounds() { + setAdminLoading(true); + try { + const d = await fetchSounds('', '__all__', undefined, false); + setAdminSounds(d.items || []); + } catch (e: any) { + notify(e?.message || 'Admin-Sounds konnten nicht geladen werden', 'error'); + } finally { + setAdminLoading(false); + } + } + + function toggleAdminSelection(path: string) { + setAdminSelection(prev => ({ ...prev, [path]: !prev[path] })); + } + + function startRename(sound: Sound) { + setRenameTarget(soundKey(sound)); + setRenameValue(sound.name); + } + + function cancelRename() { + setRenameTarget(''); + setRenameValue(''); + } + + async function submitRename() { + if (!renameTarget) return; + const baseName = renameValue.trim().replace(/\.(mp3|wav)$/i, ''); + if (!baseName) { + notify('Bitte einen gueltigen Namen eingeben', 'error'); + return; + } + try { + await apiAdminRename(renameTarget, baseName); + notify('Sound umbenannt'); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Umbenennen fehlgeschlagen', 'error'); + } + } + + async function deleteAdminPaths(paths: string[]) { + if (paths.length === 0) return; + try { + await apiAdminDelete(paths); + notify(paths.length === 1 ? 'Sound geloescht' : `${paths.length} Sounds geloescht`); + setAdminSelection({}); + cancelRename(); + setRefreshKey(k => k + 1); + if (showAdmin) await loadAdminSounds(); + } catch (e: any) { + notify(e?.message || 'Loeschen fehlgeschlagen', 'error'); + } + } + + async function handleAdminLogin() { + try { + const ok = await apiAdminLogin(adminPwd); + if (ok) { + setIsAdmin(true); + setAdminPwd(''); + notify('Admin eingeloggt'); + } + else notify('Falsches Passwort', 'error'); + } catch { notify('Login fehlgeschlagen', 'error'); } + } + + async function handleAdminLogout() { + try { + await apiAdminLogout(); + setIsAdmin(false); + setAdminSelection({}); + cancelRename(); + notify('Ausgeloggt'); + } catch { } + } + + /* ── Computed ── */ + const displaySounds = useMemo(() => { + if (activeTab === 'favorites') return sounds.filter(s => favs[s.relativePath ?? s.fileName]); + return sounds; + }, [sounds, activeTab, favs]); + + const favCount = useMemo(() => Object.values(favs).filter(Boolean).length, [favs]); + + const visibleFolders = useMemo(() => + folders.filter(f => !['__all__', '__recent__', '__top3__'].includes(f.key)), + [folders]); + + const folderColorMap = useMemo(() => { + const m: Record = {}; + visibleFolders.forEach((f, i) => { m[f.key] = CAT_PALETTE[i % CAT_PALETTE.length]; }); + return m; + }, [visibleFolders]); + + const firstOfInitial = useMemo(() => { + const seen = new Set(); + const result = new Set(); + displaySounds.forEach((s, idx) => { + const ch = s.name.charAt(0).toUpperCase(); + if (!seen.has(ch)) { seen.add(ch); result.add(idx); } + }); + return result; + }, [displaySounds]); + + const channelsByGuild = useMemo(() => { + const groups: Record = {}; + channels.forEach(c => { + if (!groups[c.guildName]) groups[c.guildName] = []; + groups[c.guildName].push(c); + }); + return groups; + }, [channels]); + + const adminFilteredSounds = useMemo(() => { + const q = adminQuery.trim().toLowerCase(); + if (!q) return adminSounds; + return adminSounds.filter(s => { + const key = soundKey(s).toLowerCase(); + return s.name.toLowerCase().includes(q) + || (s.folder || '').toLowerCase().includes(q) + || key.includes(q); + }); + }, [adminQuery, adminSounds, soundKey]); + + const selectedAdminPaths = useMemo(() => + Object.keys(adminSelection).filter(k => adminSelection[k]), + [adminSelection]); + + const selectedVisibleCount = useMemo(() => + adminFilteredSounds.filter(s => !!adminSelection[soundKey(s)]).length, + [adminFilteredSounds, adminSelection, soundKey]); + + const allVisibleSelected = adminFilteredSounds.length > 0 && selectedVisibleCount === adminFilteredSounds.length; + const analyticsTop = analytics.mostPlayed.slice(0, 10); + const totalSoundsDisplay = analytics.totalSounds || total; + + const clockMain = clock.slice(0, 5); + const clockSec = clock.slice(5); + + /* ════════════════════════════════════════════ + RENDER + ════════════════════════════════════════════ */ + return ( +
+ {chaosMode &&
} + + {/* ═══ TOPBAR ═══ */} +
+
+
+ music_note +
+ Soundboard + + {/* Channel Dropdown */} +
e.stopPropagation()}> + + {channelOpen && ( +
+ {Object.entries(channelsByGuild).map(([guild, chs]) => ( + +
{guild}
+ {chs.map(ch => ( +
handleChannelSelect(ch)} + > + volume_up + {ch.channelName} +
+ ))} +
+ ))} + {channels.length === 0 && ( +
+ Keine Channels verfuegbar +
+ )} +
+ )} +
+
+ +
+
{clockMain}{clockSec}
+
+ +
+ {lastPlayed && ( +
+
+
+
+
+ Last Played: {lastPlayed} +
+ )} + {selected && ( +
setShowConnModal(true)} style={{cursor:'pointer'}} title="Verbindungsdetails"> + + Verbunden + {voiceStats?.voicePing != null && ( + {voiceStats.voicePing}ms + )} +
+ )} + +
+
+ + {/* ═══ TOOLBAR ═══ */} +
+
+ + + +
+ +
+ search + setQuery(e.target.value)} + /> + {query && ( + + )} +
+ +
+ link + setImportUrl(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') void handleUrlImport(); }} + /> + +
+ +
+ +
+ { + const newVol = volume > 0 ? 0 : 0.5; + setVolume(newVol); + if (guildId) apiSetVolumeLive(guildId, newVol).catch(() => {}); + }} + > + {volume === 0 ? 'volume_off' : volume < 0.5 ? 'volume_down' : 'volume_up'} + + { + const v = parseFloat(e.target.value); + setVolume(v); + if (guildId) { + if (volDebounceRef.current) clearTimeout(volDebounceRef.current); + volDebounceRef.current = setTimeout(() => { + apiSetVolumeLive(guildId, v).catch(() => {}); + }, 120); + } + }} + style={{ '--vol': `${Math.round(volume * 100)}%` } as React.CSSProperties} + /> + {Math.round(volume * 100)}% +
+ + + + + + + +
+ grid_view + setCardSize(parseInt(e.target.value))} + /> +
+ +
+ {THEMES.map(t => ( +
setTheme(t.id)} + /> + ))} +
+
+ +
+
+ library_music +
+ Sounds gesamt + {totalSoundsDisplay} +
+
+ +
+ leaderboard +
+ Most Played +
+ {analyticsTop.length === 0 ? ( + Noch keine Plays + ) : ( + analyticsTop.map((item, idx) => ( + + {idx + 1}. {item.name} ({item.count}) + + )) + )} +
+
+
+
+ + {/* ═══ FOLDER CHIPS ═══ */} + {activeTab === 'all' && visibleFolders.length > 0 && ( +
+ {visibleFolders.map(f => { + const color = folderColorMap[f.key] || '#888'; + const isActive = activeFolder === f.key; + return ( + + ); + })} +
+ )} + + {/* ═══ MAIN ═══ */} +
+ {displaySounds.length === 0 ? ( +
+
{activeTab === 'favorites' ? '\u2B50' : '\uD83D\uDD07'}
+
+ {activeTab === 'favorites' + ? 'Noch keine Favoriten' + : query + ? `Kein Sound fuer "${query}" gefunden` + : 'Keine Sounds vorhanden'} +
+
+ {activeTab === 'favorites' + ? 'Klick den Stern auf einem Sound!' + : 'Hier gibt\'s noch nichts zu hoeren.'} +
+
+ ) : ( +
+ {displaySounds.map((s, idx) => { + const key = s.relativePath ?? s.fileName; + const isFav = !!favs[key]; + const isPlaying = lastPlayed === s.name; + const isNew = s.isRecent || s.badges?.includes('new'); + const initial = s.name.charAt(0).toUpperCase(); + const showInitial = firstOfInitial.has(idx); + const folderColor = s.folder ? (folderColorMap[s.folder] || 'var(--accent)') : 'var(--accent)'; + + return ( +
{ + const card = e.currentTarget; + const rect = card.getBoundingClientRect(); + const ripple = document.createElement('div'); + ripple.className = 'ripple'; + const sz = Math.max(rect.width, rect.height); + ripple.style.width = ripple.style.height = sz + 'px'; + ripple.style.left = (e.clientX - rect.left - sz / 2) + 'px'; + ripple.style.top = (e.clientY - rect.top - sz / 2) + 'px'; + card.appendChild(ripple); + setTimeout(() => ripple.remove(), 500); + handlePlay(s); + }} + onContextMenu={e => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: Math.min(e.clientX, window.innerWidth - 170), + y: Math.min(e.clientY, window.innerHeight - 140), + sound: s, + }); + }} + title={`${s.name}${s.folder ? ` (${s.folder})` : ''}`} + > + {isNew && NEU} + { e.stopPropagation(); toggleFav(key); }} + > + {isFav ? 'star' : 'star_border'} + + {showInitial && {initial}} + {s.name} + {s.folder && {s.folder}} +
+
+
+
+
+ ); + })} +
+ )} +
+ + {/* ═══ CONTEXT MENU ═══ */} + {ctxMenu && ( +
e.stopPropagation()} + > +
{ handlePlay(ctxMenu.sound); setCtxMenu(null); }}> + play_arrow + Abspielen +
+
{ + toggleFav(ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName); + setCtxMenu(null); + }}> + + {favs[ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName] ? 'star' : 'star_border'} + + Favorit +
+ {isAdmin && ( + <> +
+
{ + const path = ctxMenu.sound.relativePath ?? ctxMenu.sound.fileName; + await deleteAdminPaths([path]); + setCtxMenu(null); + }}> + delete + Loeschen +
+ + )} +
+ )} + + {/* ═══ CONNECTION MODAL ═══ */} + {showConnModal && voiceStats && (() => { + const uptimeSec = voiceStats.connectedSince + ? Math.floor((Date.now() - new Date(voiceStats.connectedSince).getTime()) / 1000) + : 0; + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + const s = uptimeSec % 60; + const uptimeStr = h > 0 + ? `${h}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s` + : m > 0 + ? `${m}m ${String(s).padStart(2,'0')}s` + : `${s}s`; + const pingColor = (ms: number | null) => + ms == null ? 'var(--muted)' : ms < 80 ? 'var(--green)' : ms < 150 ? '#f0a830' : '#e04040'; + return ( +
setShowConnModal(false)}> +
e.stopPropagation()}> +
+ cell_tower + Verbindungsdetails + +
+
+
+ Voice Ping + + + {voiceStats.voicePing != null ? `${voiceStats.voicePing} ms` : '---'} + +
+
+ Gateway Ping + + + {voiceStats.gatewayPing >= 0 ? `${voiceStats.gatewayPing} ms` : '---'} + +
+
+ Status + + {voiceStats.status === 'ready' ? 'Verbunden' : voiceStats.status} + +
+
+ Kanal + {voiceStats.channelName || '---'} +
+
+ Verbunden seit + {uptimeStr} +
+
+
+
+ ); + })()} + + {/* ═══ TOAST ═══ */} + {notification && ( +
+ + {notification.type === 'error' ? 'error_outline' : 'check_circle'} + + {notification.msg} +
+ )} + + {/* ═══ ADMIN PANEL ═══ */} + {showAdmin && ( +
{ if (e.target === e.currentTarget) setShowAdmin(false); }}> +
+

+ Admin + +

+ {!isAdmin ? ( +
+
+ + setAdminPwd(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleAdminLogin()} + placeholder="Admin-Passwort..." + /> +
+ +
+ ) : ( +
+
+

Eingeloggt als Admin

+
+ + +
+
+ +
+ + setAdminQuery(e.target.value)} + placeholder="Nach Name, Ordner oder Pfad filtern..." + /> +
+ +
+ + + +
+ +
+ {adminLoading ? ( +
Lade Sounds...
+ ) : adminFilteredSounds.length === 0 ? ( +
Keine Sounds gefunden.
+ ) : ( +
+ {adminFilteredSounds.map(sound => { + const key = soundKey(sound); + const editing = renameTarget === key; + return ( +
+ + +
+
{sound.name}
+
+ {sound.folder ? `Ordner: ${sound.folder}` : 'Root'} + {' \u00B7 '} + {key} +
+ {editing && ( +
+ setRenameValue(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void submitRename(); + if (e.key === 'Escape') cancelRename(); + }} + placeholder="Neuer Name..." + /> + + +
+ )} +
+ + {!editing && ( +
+ + +
+ )} +
+ ); + })} +
+ )} +
+
+ )} +
+
+ )} + + {/* ── Drag & Drop Overlay ── */} + {isDragging && ( +
+
+ cloud_upload +
MP3 & WAV hier ablegen
+
Mehrere Dateien gleichzeitig moeglich
+
+
+ )} + + {/* ── Upload-Queue ── */} + {showUploads && uploads.length > 0 && ( +
+
+ upload + + {uploads.every(u => u.status === 'done' || u.status === 'error') + ? `${uploads.filter(u => u.status === 'done').length} von ${uploads.length} hochgeladen` + : `Lade hoch\u2026 (${uploads.filter(u => u.status === 'done').length}/${uploads.length})`} + + +
+
+ {uploads.map(u => ( +
+ audio_file +
+
+ {u.savedName ?? u.file.name} +
+
{(u.file.size / 1024).toFixed(0)} KB
+
+ {(u.status === 'waiting' || u.status === 'uploading') && ( +
+
+
+ )} + + {u.status === 'done' ? 'check_circle' : + u.status === 'error' ? 'error' : + u.status === 'uploading' ? 'sync' : 'schedule'} + + {u.status === 'error' &&
{u.error}
} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/web/src/plugins/soundboard/soundboard.css b/web/src/plugins/soundboard/soundboard.css new file mode 100644 index 0000000..f309daa --- /dev/null +++ b/web/src/plugins/soundboard/soundboard.css @@ -0,0 +1,2029 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap'); +@import url('https://fonts.googleapis.com/icon?family=Material+Icons'); +/* Soundboard Plugin — ported from Jukebox styles */ + +/* ──────────────────────────────────────────── + Theme Variables — Default (Discord Blurple) + ──────────────────────────────────────────── */ +.sb-app { + --bg-deep: #1a1b1e; + --bg-primary: #1e1f22; + --bg-secondary: #2b2d31; + --bg-tertiary: #313338; + --bg-modifier-hover: rgba(79, 84, 92, .16); + --bg-modifier-active: rgba(79, 84, 92, .24); + --bg-modifier-selected: rgba(79, 84, 92, .32); + + --text-normal: #dbdee1; + --text-muted: #949ba4; + --text-faint: #6d6f78; + + --accent: #5865f2; + --accent-rgb: 88, 101, 242; + --accent-hover: #4752c4; + --accent-glow: rgba(88, 101, 242, .45); + + --green: #23a55a; + --red: #f23f42; + --yellow: #f0b232; + --white: #ffffff; + + --font: 'DM Sans', 'Outfit', 'gg sans', 'Noto Sans', Whitney, 'Helvetica Neue', Helvetica, Arial, sans-serif; + --radius: 8px; + --radius-lg: 12px; + --shadow-low: 0 1px 3px rgba(0, 0, 0, .24); + --shadow-med: 0 4px 12px rgba(0, 0, 0, .32); + --shadow-high: 0 8px 24px rgba(0, 0, 0, .4); + --transition: 150ms cubic-bezier(.4, 0, .2, 1); + + --card-size: 110px; + --card-emoji: 28px; + --card-font: 11px; + + color-scheme: dark; +} + +/* ── Theme: Midnight Purple ── */ +.sb-app[data-theme="purple"] { + --bg-deep: #13111c; + --bg-primary: #1a1726; + --bg-secondary: #241f35; + --bg-tertiary: #2e2845; + --accent: #9b59b6; + --accent-rgb: 155, 89, 182; + --accent-hover: #8e44ad; + --accent-glow: rgba(155, 89, 182, .45); +} + +/* ── Theme: Forest ── */ +.sb-app[data-theme="forest"] { + --bg-deep: #0f1a14; + --bg-primary: #142119; + --bg-secondary: #1c2e22; + --bg-tertiary: #253a2c; + --accent: #2ecc71; + --accent-rgb: 46, 204, 113; + --accent-hover: #27ae60; + --accent-glow: rgba(46, 204, 113, .4); +} + +/* ── Theme: Sunset ── */ +.sb-app[data-theme="sunset"] { + --bg-deep: #1a1210; + --bg-primary: #231815; + --bg-secondary: #2f201c; + --bg-tertiary: #3d2a24; + --accent: #e67e22; + --accent-rgb: 230, 126, 34; + --accent-hover: #d35400; + --accent-glow: rgba(230, 126, 34, .4); +} + +/* ── Theme: Ocean ── */ +.sb-app[data-theme="ocean"] { + --bg-deep: #0a1628; + --bg-primary: #0f1e33; + --bg-secondary: #162a42; + --bg-tertiary: #1e3652; + --accent: #3498db; + --accent-rgb: 52, 152, 219; + --accent-hover: #2980b9; + --accent-glow: rgba(52, 152, 219, .4); +} + +/* ──────────────────────────────────────────── + App Layout + ──────────────────────────────────────────── */ +.sb-app { + display: flex; + flex-direction: column; + height: 100%; + position: relative; +} + +/* ──────────────────────────────────────────── + Top Bar + ──────────────────────────────────────────── */ +.topbar { + display: flex; + align-items: center; + padding: 0 20px; + height: 52px; + background: var(--bg-secondary); + border-bottom: 1px solid rgba(0, 0, 0, .24); + z-index: 10; + flex-shrink: 0; + gap: 16px; + transition: background .4s ease; +} + +.topbar-left { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.sb-app-logo { + width: 28px; + height: 28px; + background: var(--accent); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: background .4s ease; +} + +.sb-app-title { + font-size: 16px; + font-weight: 700; + color: var(--white); + letter-spacing: -.02em; +} + +/* ── Clock ── */ +.clock-wrap { + flex: 1; + display: flex; + justify-content: center; +} + +.clock { + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: .02em; + font-variant-numeric: tabular-nums; + opacity: .9; +} + +.clock-seconds { + font-size: 14px; + color: var(--text-faint); + font-weight: 500; +} + +.topbar-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* ── Channel Dropdown ── */ +.channel-dropdown { + position: relative; + flex-shrink: 0; +} + +.channel-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 12px 5px 10px; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.channel-btn:hover { + background: var(--bg-modifier-selected); + border-color: rgba(255, 255, 255, .12); +} + +.channel-btn.open { + border-color: var(--accent); +} + +.channel-btn .cb-icon { + font-size: 16px; + color: var(--text-muted); +} + +.channel-btn .chevron { + font-size: 12px; + color: var(--text-faint); + transition: transform var(--transition); + margin-left: 2px; +} + +.channel-btn.open .chevron { + transform: rotate(180deg); +} + +.channel-status { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--green); + flex-shrink: 0; +} + +.channel-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 220px; + background: var(--bg-deep); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: var(--radius); + box-shadow: var(--shadow-high); + padding: 6px; + z-index: 100; + animation: ctx-in 100ms ease-out; +} + +.channel-menu-header { + padding: 6px 8px 4px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .05em; + color: var(--text-faint); +} + +.channel-option { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 10px; + border-radius: 4px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; + transition: all var(--transition); +} + +.channel-option:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.channel-option.active { + background: var(--accent); + color: var(--white); +} + +.channel-option .co-icon { + font-size: 16px; + opacity: .7; +} + +/* ── Connection Indicator ── */ +.connection { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: rgba(35, 165, 90, .12); + font-size: 12px; + color: var(--green); + font-weight: 600; +} + +.conn-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--green); + box-shadow: 0 0 6px rgba(35, 165, 90, .6); + animation: pulse-dot 2s ease-in-out infinite; +} + +@keyframes pulse-dot { + 0%, 100% { box-shadow: 0 0 6px rgba(35, 165, 90, .5); } + 50% { box-shadow: 0 0 12px rgba(35, 165, 90, .8); } +} + +.conn-ping { + font-size: 10px; + opacity: .7; + margin-left: 2px; +} + +/* ── Connection Details Modal ── */ +.conn-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .55); + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(4px); + animation: fadeIn .15s ease; +} +.conn-modal { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 16px; + width: 340px; + box-shadow: 0 20px 60px rgba(0,0,0,.4); + overflow: hidden; + animation: slideUp .2s ease; +} +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +.conn-modal-header { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 16px; + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 14px; +} +.conn-modal-close { + margin-left: auto; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px; + border-radius: 6px; + display: flex; + transition: all .15s; +} +.conn-modal-close:hover { + background: rgba(255,255,255,.08); + color: var(--fg); +} +.conn-modal-body { + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; +} +.conn-stat { + display: flex; + justify-content: space-between; + align-items: center; +} +.conn-stat-label { + color: var(--muted); + font-size: 13px; +} +.conn-stat-value { + font-weight: 600; + font-size: 13px; + display: flex; + align-items: center; + gap: 6px; +} +.conn-ping-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* ── Admin Icon Button ── */ +.admin-btn-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-faint); + transition: all var(--transition); + font-size: 18px; +} + +.admin-btn-icon:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} + +.admin-btn-icon.active { + color: var(--accent); +} + +/* ──────────────────────────────────────────── + Toolbar + ──────────────────────────────────────────── */ +.toolbar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); + flex-shrink: 0; + flex-wrap: wrap; + transition: background .4s ease; +} + +/* ── Category Tabs ── */ +.cat-tabs { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.cat-tab { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-family: var(--font); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.cat-tab:hover { + background: var(--bg-modifier-selected); + color: var(--text-normal); +} + +.cat-tab.active { + background: var(--accent); + color: var(--white); +} + +.tab-count { + font-size: 10px; + font-weight: 700; + background: rgba(255, 255, 255, .15); + padding: 0 6px; + border-radius: 8px; + line-height: 1.6; +} + +/* ── Search ── */ +.search-wrap { + position: relative; + flex: 1; + max-width: 280px; + min-width: 140px; +} + +.search-wrap .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 15px; + color: var(--text-faint); + pointer-events: none; +} + +.search-input { + width: 100%; + height: 32px; + padding: 0 28px 0 32px; + border: 1px solid rgba(255, 255, 255, .06); + border-radius: 20px; + background: var(--bg-secondary); + color: var(--text-normal); + font-family: var(--font); + font-size: 13px; + outline: none; + transition: all var(--transition); +} + +.search-input::placeholder { + color: var(--text-faint); +} + +.search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.search-clear { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-faint); + transition: all var(--transition); +} + +.search-clear:hover { + background: var(--bg-tertiary); + color: var(--text-normal); +} + +.toolbar-spacer { + flex: 1; +} + +/* ── URL Import ── */ +.url-import-wrap { + display: flex; + align-items: center; + gap: 6px; + min-width: 240px; + max-width: 460px; + flex: 1; + padding: 4px 6px 4px 8px; + border-radius: 20px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); +} + +.url-import-icon { + font-size: 15px; + color: var(--text-faint); + flex-shrink: 0; +} + +.url-import-input { + flex: 1; + min-width: 0; + height: 26px; + border: none; + background: transparent; + color: var(--text-normal); + font-size: 12px; + font-family: var(--font); + outline: none; +} + +.url-import-input::placeholder { + color: var(--text-faint); +} + +.url-import-btn { + height: 24px; + padding: 0 10px; + border-radius: 14px; + border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .45); + background: rgba(var(--accent-rgb, 88, 101, 242), .12); + color: var(--accent); + font-size: 11px; + font-weight: 700; + white-space: nowrap; + transition: all var(--transition); +} + +.url-import-btn:hover { + background: var(--accent); + border-color: var(--accent); + color: var(--white); +} + +.url-import-btn:disabled { + opacity: .5; + pointer-events: none; +} + +/* ── Toolbar Buttons ── */ +.tb-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 20px; + background: var(--bg-tertiary); + color: var(--text-muted); + font-family: var(--font); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all var(--transition); + white-space: nowrap; +} + +.tb-btn:hover { + background: var(--bg-modifier-selected); + color: var(--text-normal); + border-color: rgba(255, 255, 255, .12); +} + +.tb-btn .tb-icon { + font-size: 15px; +} + +.tb-btn.random { + border-color: rgba(88, 101, 242, .3); + color: var(--accent); +} + +.tb-btn.random:hover { + background: var(--accent); + color: var(--white); + border-color: var(--accent); +} + +.tb-btn.party { + border-color: rgba(240, 178, 50, .3); + color: var(--yellow); +} + +.tb-btn.party:hover { + background: var(--yellow); + color: #1a1b1e; + border-color: var(--yellow); +} + +.tb-btn.party.active { + background: var(--yellow); + color: #1a1b1e; + border-color: var(--yellow); + animation: party-btn 600ms ease-in-out infinite alternate; +} + +@keyframes party-btn { + from { box-shadow: 0 0 8px rgba(240, 178, 50, .4); } + to { box-shadow: 0 0 20px rgba(240, 178, 50, .7); } +} + +.tb-btn.stop { + border-color: rgba(242, 63, 66, .3); + color: var(--red); +} + +.tb-btn.stop:hover { + background: var(--red); + color: var(--white); + border-color: var(--red); +} + +/* ── Size Slider ── */ +.size-control { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.size-control .sc-icon { + font-size: 14px; + color: var(--text-faint); +} + +.size-slider { + -webkit-appearance: none; + appearance: none; + width: 70px; + height: 3px; + border-radius: 2px; + background: var(--bg-modifier-selected); + outline: none; + cursor: pointer; +} + +.size-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + transition: transform var(--transition); +} + +.size-slider::-webkit-slider-thumb:hover { + transform: scale(1.3); +} + +.size-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: none; + cursor: pointer; +} + +/* ── Theme Selector ── */ +.theme-selector { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.theme-dot { + width: 16px; + height: 16px; + border-radius: 50%; + cursor: pointer; + transition: all var(--transition); + border: 2px solid transparent; +} + +.theme-dot:hover { + transform: scale(1.2); +} + +.theme-dot.active { + border-color: var(--white); + box-shadow: 0 0 6px rgba(255, 255, 255, .3); +} + +/* ── Analytics Strip ── */ +.analytics-strip { + display: flex; + align-items: stretch; + gap: 8px; + padding: 8px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); + flex-shrink: 0; +} + +.analytics-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 12px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); +} + +.analytics-card.analytics-wide { + flex: 1; + min-width: 0; +} + +.analytics-icon { + font-size: 18px; + color: var(--accent); + flex-shrink: 0; +} + +.analytics-copy { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.analytics-label { + font-size: 10px; + font-weight: 700; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-faint); +} + +.analytics-value { + font-size: 18px; + line-height: 1; + font-weight: 800; + color: var(--text-normal); +} + +.analytics-top-list { + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; +} + +.analytics-top-list::-webkit-scrollbar { + display: none; +} + +.analytics-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + background: rgba(var(--accent-rgb, 88, 101, 242), .15); + color: var(--accent); + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.analytics-muted { + color: var(--text-muted); + font-size: 12px; +} + +/* ──────────────────────────────────────────── + Category / Folder Strip + ──────────────────────────────────────────── */ +.category-strip { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 20px; + background: var(--bg-primary); + border-bottom: 1px solid rgba(0, 0, 0, .12); + overflow-x: auto; + flex-shrink: 0; + scrollbar-width: none; + transition: background .4s ease; +} + +.category-strip::-webkit-scrollbar { + display: none; +} + +.cat-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .06); + white-space: nowrap; + cursor: pointer; + transition: all var(--transition); + flex-shrink: 0; +} + +.cat-chip:hover { + border-color: rgba(255, 255, 255, .12); + color: var(--text-normal); + background: var(--bg-tertiary); +} + +.cat-chip.active { + background: rgba(88, 101, 242, .1); +} + +.cat-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.cat-count { + font-size: 10px; + font-weight: 700; + opacity: .5; +} + +/* ──────────────────────────────────────────── + Main Grid Area + ──────────────────────────────────────────── */ +.main { + flex: 1; + overflow-y: auto; + padding: 16px 20px; + background: var(--bg-primary); + transition: background .4s ease; +} + +.sound-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--card-size), 1fr)); + gap: 8px; +} + +/* ──────────────────────────────────────────── + Sound Card + ──────────────────────────────────────────── */ +.sound-card { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + padding: 12px 6px 8px; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + cursor: pointer; + transition: all var(--transition); + border: 2px solid transparent; + user-select: none; + overflow: hidden; + aspect-ratio: 1; + opacity: 0; + animation: card-enter 350ms ease-out forwards; +} + +.sound-card::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + opacity: 0; + transition: opacity var(--transition); + background: radial-gradient(ellipse at center, var(--accent-glow) 0%, transparent 70%); + pointer-events: none; +} + +.sound-card:hover { + background: var(--bg-tertiary); + transform: translateY(-3px); + box-shadow: var(--shadow-med), 0 0 20px var(--accent-glow); + border-color: rgba(88, 101, 242, .2); +} + +.sound-card:hover::before { + opacity: 1; +} + +.sound-card:active { + transform: translateY(0); + transition-duration: 50ms; +} + +.sound-card.playing { + border-color: var(--accent); + animation: card-enter 350ms ease-out forwards, playing-glow 1.2s ease-in-out infinite alternate; +} + +@keyframes playing-glow { + from { box-shadow: 0 0 4px var(--accent-glow); } + to { box-shadow: 0 0 16px var(--accent-glow); } +} + +@keyframes card-enter { + from { opacity: 0; transform: translateY(10px) scale(.97); } + to { opacity: 1; transform: translateY(0) scale(1); } +} + +/* ── Ripple Effect ── */ +.ripple { + position: absolute; + border-radius: 50%; + background: rgba(88, 101, 242, .3); + transform: scale(0); + animation: ripple-expand 500ms ease-out forwards; + pointer-events: none; +} + +@keyframes ripple-expand { + to { transform: scale(3); opacity: 0; } +} + +/* ── Sound Card Content ── */ +.sound-emoji { + font-size: var(--card-emoji); + font-weight: 800; + line-height: 1; + z-index: 1; + transition: transform var(--transition); + opacity: .7; + font-family: 'Syne', 'DM Sans', sans-serif; +} + +.sound-card:hover .sound-emoji { + transform: scale(1.15); + opacity: 1; +} + +.sound-card.playing .sound-emoji { + animation: emoji-bounce 400ms ease; + opacity: 1; +} + +@keyframes emoji-bounce { + 0%, 100% { transform: scale(1); } + 40% { transform: scale(1.3); } + 70% { transform: scale(.95); } +} + +.sound-name { + font-size: var(--card-font); + font-weight: 600; + text-align: center; + color: var(--text-normal); + z-index: 1; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0 4px; +} + +.sound-duration { + font-size: 9px; + color: var(--text-faint); + z-index: 1; + font-weight: 500; +} + +/* ── Favorite Star ── */ +.fav-star { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + transition: all var(--transition); + cursor: pointer; + z-index: 2; + color: var(--text-faint); + padding: 2px; + line-height: 1; +} + +.fav-star .fav-icon { + font-size: 14px; +} + +.sound-card:hover .fav-star { + opacity: .6; +} + +.fav-star:hover { + opacity: 1 !important; + color: var(--yellow); + transform: scale(1.2); +} + +.fav-star.active { + opacity: 1 !important; + color: var(--yellow); +} + +/* ── "New" Badge ── */ +.new-badge { + position: absolute; + top: 4px; + left: 4px; + font-size: 8px; + font-weight: 700; + background: var(--green); + color: white; + padding: 1px 5px; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: .03em; + z-index: 2; +} + +/* ── Playing Wave Indicator ── */ +.playing-indicator { + position: absolute; + bottom: 3px; + left: 50%; + transform: translateX(-50%); + display: none; + gap: 2px; + align-items: flex-end; + height: 10px; +} + +.sound-card.playing .playing-indicator { + display: flex; +} + +.wave-bar { + width: 2px; + background: var(--accent); + border-radius: 1px; + animation: wave 600ms ease-in-out infinite alternate; +} + +.wave-bar:nth-child(1) { height: 3px; animation-delay: 0ms; } +.wave-bar:nth-child(2) { height: 7px; animation-delay: 150ms; } +.wave-bar:nth-child(3) { height: 5px; animation-delay: 300ms; } +.wave-bar:nth-child(4) { height: 9px; animation-delay: 100ms; } + +@keyframes wave { + from { height: 2px; } + to { height: 10px; } +} + +/* ──────────────────────────────────────────── + Empty State + ──────────────────────────────────────────── */ +.empty-state { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 60px 20px; + text-align: center; +} + +.empty-state.visible { + display: flex; +} + +.empty-emoji { + font-size: 42px; +} + +.empty-title { + font-size: 15px; + font-weight: 700; + color: var(--text-normal); +} + +.empty-desc { + font-size: 13px; + color: var(--text-muted); + max-width: 260px; +} + +/* ── Now Playing (Topbar) ── */ +.now-playing { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 20px; + background: rgba(var(--accent-rgb, 88, 101, 242), .12); + border: 1px solid rgba(var(--accent-rgb, 88, 101, 242), .2); + font-size: 12px; + color: var(--text-muted); + max-width: none; + min-width: 0; + animation: np-fade-in 300ms ease; +} + +@keyframes np-fade-in { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.np-name { + color: var(--accent); + font-weight: 600; + white-space: nowrap; +} + +.np-waves { + display: none; + gap: 1.5px; + align-items: flex-end; + height: 12px; + flex-shrink: 0; +} + +.np-waves.active { + display: flex; +} + +.np-wave-bar { + width: 2px; + background: var(--accent); + border-radius: 1px; + animation: wave 500ms ease-in-out infinite alternate; +} + +.np-wave-bar:nth-child(1) { height: 3px; animation-delay: 0ms; } +.np-wave-bar:nth-child(2) { height: 8px; animation-delay: 120ms; } +.np-wave-bar:nth-child(3) { height: 5px; animation-delay: 240ms; } +.np-wave-bar:nth-child(4) { height: 10px; animation-delay: 80ms; } + +/* ── Volume Control (Toolbar) ── */ +.volume-control { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.vol-icon { + font-size: 16px; + color: var(--text-faint); + cursor: pointer; + transition: color var(--transition); + user-select: none; +} + +.vol-icon:hover { + color: var(--text-normal); +} + +.vol-slider { + -webkit-appearance: none; + appearance: none; + width: 80px; + height: 3px; + border-radius: 2px; + background: linear-gradient(to right, var(--accent) 0%, var(--accent) var(--vol, 80%), var(--bg-modifier-selected) var(--vol, 80%)); + outline: none; + cursor: pointer; +} + +.vol-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + cursor: pointer; + transition: transform var(--transition); +} + +.vol-slider::-webkit-slider-thumb:hover { + transform: scale(1.3); +} + +.vol-slider::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--accent); + border: none; + cursor: pointer; +} + +.vol-pct { + font-size: 11px; + color: var(--text-faint); + min-width: 28px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +/* ──────────────────────────────────────────── + Party Mode Overlay + ──────────────────────────────────────────── */ +.party-overlay { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 50; + opacity: 0; + transition: opacity .3s ease; +} + +.party-overlay.active { + opacity: 1; + animation: party-hue 2s linear infinite; +} + +.party-overlay::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(45deg, + rgba(255, 0, 0, .04), + rgba(0, 255, 0, .04), + rgba(0, 0, 255, .04), + rgba(255, 255, 0, .04) + ); + background-size: 400% 400%; + animation: party-grad 3s ease infinite; +} + +@keyframes party-grad { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@keyframes party-hue { + to { filter: hue-rotate(360deg); } +} + +/* ──────────────────────────────────────────── + Context Menu + ──────────────────────────────────────────── */ +.ctx-menu { + position: fixed; + min-width: 160px; + background: var(--bg-deep); + border: 1px solid rgba(255, 255, 255, .06); + border-radius: var(--radius); + box-shadow: var(--shadow-high); + padding: 4px; + z-index: 1000; + animation: ctx-in 100ms ease-out; +} + +@keyframes ctx-in { + from { opacity: 0; transform: scale(.96) translateY(-4px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.ctx-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 4px; + font-size: 13px; + color: var(--text-normal); + cursor: pointer; + transition: all var(--transition); +} + +.ctx-item:hover { + background: var(--accent); + color: var(--white); +} + +.ctx-item.danger { + color: var(--red); +} + +.ctx-item.danger:hover { + background: var(--red); + color: var(--white); +} + +.ctx-item .ctx-icon { + font-size: 15px; +} + +.ctx-sep { + height: 1px; + background: rgba(255, 255, 255, .06); + margin: 3px 8px; +} + +/* ──────────────────────────────────────────── + Toast Notification + ──────────────────────────────────────────── */ +.toast { + position: fixed; + bottom: 64px; + left: 50%; + transform: translateX(-50%); + padding: 10px 20px; + border-radius: 20px; + font-size: 13px; + font-weight: 600; + z-index: 100; + display: flex; + align-items: center; + gap: 8px; + box-shadow: var(--shadow-high); + animation: toast-in 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); + pointer-events: none; +} + +.toast .toast-icon { + font-size: 16px; +} + +.toast.error { + background: var(--red); + color: white; +} + +.toast.info { + background: var(--green); + color: white; +} + +@keyframes toast-in { + from { transform: translate(-50%, 16px); opacity: 0; } + to { transform: translate(-50%, 0); opacity: 1; } +} + +/* ──────────────────────────────────────────── + Admin Panel Overlay + ──────────────────────────────────────────── */ +.admin-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .6); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 60; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 200ms ease; +} + +@keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.admin-panel { + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: var(--radius-lg); + padding: 28px; + width: 92%; + max-width: 920px; + max-height: min(88vh, 860px); + display: flex; + flex-direction: column; + box-shadow: var(--shadow-high); +} + +.admin-panel h3 { + font-size: 18px; + font-weight: 700; + margin-bottom: 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.admin-close { + width: 28px; + height: 28px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + transition: all var(--transition); +} + +.admin-close:hover { + background: var(--bg-tertiary); + color: var(--text-normal); +} + +.admin-field { + margin-bottom: 16px; +} + +.admin-field label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; + text-transform: uppercase; + letter-spacing: .5px; +} + +.admin-field input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 8px; + padding: 10px 12px; + font-size: 14px; + color: var(--text-normal); + font-family: var(--font); + transition: all var(--transition); +} + +.admin-field input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +.admin-btn-action { + padding: 10px 20px; + border-radius: 8px; + font-size: 13px; + font-weight: 600; + font-family: var(--font); + cursor: pointer; + transition: all var(--transition); + line-height: 1; +} + +.admin-btn-action.primary { + background: var(--accent); + color: white; + border: none; +} + +.admin-btn-action.primary:hover { + background: var(--accent-hover); +} + +.admin-btn-action.outline { + background: transparent; + border: 1px solid rgba(255, 255, 255, .08); + color: var(--text-muted); +} + +.admin-btn-action.outline:hover { + border-color: rgba(255, 255, 255, .12); + color: var(--text-normal); +} + +.admin-btn-action.danger { + background: var(--red); + color: var(--white); + border: 1px solid var(--red); +} + +.admin-btn-action.danger:hover { + filter: brightness(1.06); +} + +.admin-btn-action.danger.ghost { + background: transparent; + color: var(--red); + border: 1px solid rgba(242, 63, 66, .5); +} + +.admin-btn-action.danger.ghost:hover { + background: rgba(242, 63, 66, .14); +} + +.admin-btn-action:disabled { + opacity: .5; + pointer-events: none; +} + +.admin-shell { + display: flex; + flex-direction: column; + gap: 12px; + min-height: 0; +} + +.admin-header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.admin-status { + font-size: 13px; + color: var(--text-muted); +} + +.admin-actions-inline { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-search-field { + margin-bottom: 0; +} + +.admin-bulk-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 10px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + flex-wrap: wrap; +} + +.admin-select-all { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--text-muted); +} + +.admin-select-all input, +.admin-item-check input { + accent-color: var(--accent); +} + +.admin-list-wrap { + min-height: 260px; + max-height: 52vh; + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 10px; + background: var(--bg-primary); +} + +.admin-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px; +} + +.admin-empty { + padding: 24px 12px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.admin-item { + display: grid; + grid-template-columns: 28px minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + padding: 10px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .06); +} + +.admin-item-main { + min-width: 0; +} + +.admin-item-name { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-item-meta { + margin-top: 3px; + font-size: 11px; + color: var(--text-faint); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-item-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.admin-item-actions .admin-btn-action, +.admin-rename-row .admin-btn-action { + padding: 8px 12px; + font-size: 12px; +} + +.admin-rename-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; +} + +.admin-rename-row input { + flex: 1; + min-width: 120px; + background: var(--bg-tertiary); + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 8px; + padding: 8px 10px; + font-size: 13px; + color: var(--text-normal); + font-family: var(--font); + transition: all var(--transition); +} + +.admin-rename-row input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-glow); +} + +/* ──────────────────────────────────────────── + Responsive + ──────────────────────────────────────────── */ +@media (max-width: 700px) { + .toolbar { + gap: 6px; + padding: 8px 12px; + } + + .cat-tabs { + overflow-x: auto; + scrollbar-width: none; + } + + .cat-tabs::-webkit-scrollbar { + display: none; + } + + .search-wrap { + max-width: 100%; + min-width: 100%; + order: -1; + } + + .url-import-wrap { + max-width: 100%; + min-width: 100%; + order: -1; + } + + .size-control, + .theme-selector { + display: none; + } + + .main { + padding: 12px; + } + + .topbar { + padding: 0 12px; + gap: 8px; + } + + .channel-label { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + } + + .clock { + font-size: 16px; + } + + .clock-seconds { + font-size: 11px; + } + + .tb-btn span:not(.tb-icon) { + display: none; + } + + .analytics-strip { + padding: 8px 12px; + flex-direction: column; + gap: 6px; + } + + .analytics-card.analytics-wide { + width: 100%; + } + + .admin-panel { + width: 96%; + padding: 16px; + max-height: 92vh; + } + + .admin-item { + grid-template-columns: 24px minmax(0, 1fr); + } + + .admin-item-actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .admin-rename-row { + flex-wrap: wrap; + } +} + +@media (max-width: 480px) { + .connection { + display: none; + } + + .sb-app-title { + display: none; + } + + .now-playing { + max-width: none; + } + + .toolbar .tb-btn { + padding: 6px 8px; + } + + .url-import-btn { + padding: 0 8px; + } +} + +/* ──────────────────────────────────────────── + Drag & Drop Overlay + ──────────────────────────────────────────── */ +.drop-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, .78); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: fade-in 120ms ease; + pointer-events: none; +} + +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 64px 72px; + border-radius: 24px; + border: 2.5px dashed rgba(var(--accent-rgb), .55); + background: rgba(var(--accent-rgb), .07); + animation: drop-pulse 2.2s ease-in-out infinite; +} + +@keyframes drop-pulse { + 0%, 100% { + border-color: rgba(var(--accent-rgb), .45); + box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); + } + 50% { + border-color: rgba(var(--accent-rgb), .9); + box-shadow: 0 0 60px 12px rgba(var(--accent-rgb), .12); + } +} + +.drop-icon { + font-size: 64px; + color: var(--accent); + animation: drop-bounce 1.8s ease-in-out infinite; +} + +@keyframes drop-bounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-8px); } +} + +.drop-title { + font-size: 22px; + font-weight: 700; + color: var(--text-normal); + letter-spacing: -.3px; +} + +.drop-sub { + font-size: 13px; + color: var(--text-muted); +} + +/* ──────────────────────────────────────────── + Upload Queue (floating card) + ──────────────────────────────────────────── */ +.upload-queue { + position: fixed; + bottom: 24px; + right: 24px; + width: 340px; + background: var(--bg-secondary); + border: 1px solid rgba(255, 255, 255, .09); + border-radius: 14px; + box-shadow: 0 8px 40px rgba(0, 0, 0, .45); + z-index: 200; + animation: slide-up 200ms cubic-bezier(.16,1,.3,1); +} + +@keyframes slide-up { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +.uq-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 14px; + background: rgba(var(--accent-rgb), .12); + border-bottom: 1px solid rgba(255, 255, 255, .06); + font-size: 13px; + font-weight: 600; + color: var(--text-normal); +} + +.uq-header .material-icons { color: var(--accent); } + +.uq-close { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,.06); + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition), color var(--transition); +} +.uq-close:hover { background: rgba(255,255,255,.14); color: var(--text-normal); } + +.uq-list { + display: flex; + flex-direction: column; + max-height: 260px; + overflow-y: auto; + padding: 6px 0; +} + +.uq-item { + display: grid; + grid-template-columns: 20px 1fr auto 18px; + align-items: center; + gap: 8px; + padding: 8px 14px; + position: relative; +} + +.uq-item + .uq-item { + border-top: 1px solid rgba(255, 255, 255, .04); +} + +.uq-file-icon { + font-size: 18px; + color: var(--text-faint); +} + +.uq-info { + min-width: 0; +} + +.uq-name { + font-size: 12px; + font-weight: 500; + color: var(--text-normal); + white-space: nowrap; + text-overflow: ellipsis; +} + +.uq-size { + font-size: 10px; + color: var(--text-faint); + margin-top: 1px; +} + +.uq-progress-wrap { + grid-column: 1 / -1; + height: 3px; + background: rgba(255, 255, 255, .07); + border-radius: 2px; + margin-top: 4px; +} + +/* Vertikaler layout-Trick: progress bar als extra row nach den anderen */ +.uq-item { + flex-wrap: wrap; +} + +.uq-progress-wrap { + width: 100%; + order: 10; +} + +.uq-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 120ms ease; +} + +.uq-status-icon { font-size: 16px; } +.uq-status-waiting .uq-status-icon { color: var(--text-faint); } +.uq-status-uploading .uq-status-icon { + color: var(--accent); + animation: spin 1s linear infinite; +} +.uq-status-done .uq-status-icon { color: var(--green); } +.uq-status-error .uq-status-icon { color: var(--red); } + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.uq-error { + grid-column: 2 / -1; + font-size: 10px; + color: var(--red); + margin-top: 2px; +} + +/* ──────────────────────────────────────────── + Utility + ──────────────────────────────────────────── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +}