Feat: Now-Playing serverseitig syncen + in Topbar verschieben

Backend:
- nowPlaying Map trackt aktuell gespielten Sound pro Guild
- SSE broadcast { type: 'nowplaying' } bei play und stop
- nowplaying im SSE-Snapshot für neue Clients
- playFilePath Helper broadcastet ebenfalls (Party Mode)

Frontend:
- SSE-Handler für nowplaying Events (sync über alle Clients)
- Now-Playing als Pill-Badge in der Topbar (rechts, neben Channel)
- Bottombar komplett entfernt
- Fade-in Animation und accent-farbige Pill
- --accent-rgb CSS Variable für alle Themes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bot 2026-03-01 16:00:22 +01:00
parent 4661c366fb
commit f90401a009
3 changed files with 58 additions and 46 deletions

View file

@ -164,6 +164,8 @@ const guildAudioState = new Map<string, GuildAudioState>();
// Partymode: serverseitige Steuerung (global pro Guild) // Partymode: serverseitige Steuerung (global pro Guild)
const partyTimers = new Map<string, NodeJS.Timeout>(); const partyTimers = new Map<string, NodeJS.Timeout>();
const partyActive = new Set<string>(); const partyActive = new Set<string>();
// Now-Playing: aktuell gespielter Sound pro Guild
const nowPlaying = new Map<string, string>();
// SSE-Klienten für Broadcasts (z.B. Partymode Status) // SSE-Klienten für Broadcasts (z.B. Partymode Status)
const sseClients = new Set<Response>(); const sseClients = new Set<Response>();
function sseBroadcast(payload: any) { function sseBroadcast(payload: any) {
@ -265,6 +267,10 @@ async function playFilePath(guildId: string, channelId: string, filePath: string
state.player.play(resource); state.player.play(resource);
state.currentResource = resource; state.currentResource = resource;
state.currentVolume = useVolume; state.currentVolume = useVolume;
// Now-Playing broadcast
const soundLabel = relativeKey ? path.parse(relativeKey).name : path.parse(filePath).name;
nowPlaying.set(guildId, soundLabel);
sseBroadcast({ type: 'nowplaying', guildId, name: soundLabel });
if (relativeKey) incrementPlaysFor(relativeKey); if (relativeKey) incrementPlaysFor(relativeKey);
} }
@ -1080,6 +1086,9 @@ app.post('/api/play', async (req: Request, res: Response) => {
persistedState.volumes[guildId] = volumeToUse; persistedState.volumes[guildId] = volumeToUse;
writePersistedState(persistedState); writePersistedState(persistedState);
console.log(`${new Date().toISOString()} | player.play() called for ${soundName}`); console.log(`${new Date().toISOString()} | player.play() called for ${soundName}`);
// Now-Playing broadcast
nowPlaying.set(guildId!, soundName!);
sseBroadcast({ type: 'nowplaying', guildId, name: soundName });
// Plays zählen (relativer Key verfügbar?) // Plays zählen (relativer Key verfügbar?)
if (relativePath) incrementPlaysFor(relativePath); if (relativePath) incrementPlaysFor(relativePath);
return res.json({ ok: true }); return res.json({ ok: true });
@ -1139,6 +1148,9 @@ app.post('/api/stop', (req: Request, res: Response) => {
const state = guildAudioState.get(guildId); const state = guildAudioState.get(guildId);
if (!state) return res.status(404).json({ error: 'Kein aktiver Player' }); if (!state) return res.status(404).json({ error: 'Kein aktiver Player' });
state.player.stop(true); state.player.stop(true);
// Now-Playing löschen
nowPlaying.delete(guildId);
sseBroadcast({ type: 'nowplaying', guildId, name: '' });
// Partymode für diese Guild ebenfalls stoppen // Partymode für diese Guild ebenfalls stoppen
try { try {
const t = partyTimers.get(guildId); const t = partyTimers.get(guildId);
@ -1240,7 +1252,7 @@ app.get('/api/events', (req: Request, res: Response) => {
// Snapshot senden // Snapshot senden
try { try {
res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {} })}\n\n`); res.write(`data: ${JSON.stringify({ type: 'snapshot', party: Array.from(partyActive), selected: persistedState.selectedChannels ?? {}, volumes: persistedState.volumes ?? {}, nowplaying: Object.fromEntries(nowPlaying) })}\n\n`);
} catch {} } catch {}
// Ping, damit Proxies die Verbindung offen halten // Ping, damit Proxies die Verbindung offen halten

View file

@ -154,12 +154,20 @@ export default function App() {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (g && typeof vols[g] === 'number') setVolume(vols[g]); if (g && typeof vols[g] === 'number') setVolume(vols[g]);
} catch { } } catch { }
try {
const np = msg?.nowplaying || {};
const g = selectedRef.current?.split(':')[0];
if (g && typeof np[g] === 'string') setLastPlayed(np[g]);
} catch { }
} else if (msg?.type === 'channel') { } else if (msg?.type === 'channel') {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`); if (msg.guildId === g) setSelected(`${msg.guildId}:${msg.channelId}`);
} else if (msg?.type === 'volume') { } else if (msg?.type === 'volume') {
const g = selectedRef.current?.split(':')[0]; const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume); if (msg.guildId === g && typeof msg.volume === 'number') setVolume(msg.volume);
} else if (msg?.type === 'nowplaying') {
const g = selectedRef.current?.split(':')[0];
if (msg.guildId === g) setLastPlayed(msg.name || '');
} }
}); });
return () => { try { unsub(); } catch { } }; return () => { try { unsub(); } catch { } };
@ -362,6 +370,15 @@ export default function App() {
</div> </div>
<div className="topbar-right"> <div className="topbar-right">
{lastPlayed && (
<div className="now-playing">
<div className="np-waves active">
<div className="np-wave-bar" /><div className="np-wave-bar" />
<div className="np-wave-bar" /><div className="np-wave-bar" />
</div>
<span className="np-name">{lastPlayed}</span>
</div>
)}
{selected && ( {selected && (
<div className="connection"> <div className="connection">
<span className="conn-dot" /> <span className="conn-dot" />
@ -594,18 +611,6 @@ export default function App() {
)} )}
</main> </main>
{/* ═══ BOTTOM BAR ═══ */}
<div className="bottombar">
<div className="now-playing">
<div className={`np-waves ${lastPlayed ? 'active' : ''}`}>
<div className="np-wave-bar" /><div className="np-wave-bar" />
<div className="np-wave-bar" /><div className="np-wave-bar" />
</div>
<span className="np-label">Spielt:</span>
<span className="np-name">{lastPlayed || '—'}</span>
</div>
</div>
{/* ═══ CONTEXT MENU ═══ */} {/* ═══ CONTEXT MENU ═══ */}
{ctxMenu && ( {ctxMenu && (
<div <div

View file

@ -23,6 +23,7 @@
--text-faint: #6d6f78; --text-faint: #6d6f78;
--accent: #5865f2; --accent: #5865f2;
--accent-rgb: 88, 101, 242;
--accent-hover: #4752c4; --accent-hover: #4752c4;
--accent-glow: rgba(88, 101, 242, .45); --accent-glow: rgba(88, 101, 242, .45);
@ -53,6 +54,7 @@
--bg-secondary: #241f35; --bg-secondary: #241f35;
--bg-tertiary: #2e2845; --bg-tertiary: #2e2845;
--accent: #9b59b6; --accent: #9b59b6;
--accent-rgb: 155, 89, 182;
--accent-hover: #8e44ad; --accent-hover: #8e44ad;
--accent-glow: rgba(155, 89, 182, .45); --accent-glow: rgba(155, 89, 182, .45);
} }
@ -64,6 +66,7 @@
--bg-secondary: #1c2e22; --bg-secondary: #1c2e22;
--bg-tertiary: #253a2c; --bg-tertiary: #253a2c;
--accent: #2ecc71; --accent: #2ecc71;
--accent-rgb: 46, 204, 113;
--accent-hover: #27ae60; --accent-hover: #27ae60;
--accent-glow: rgba(46, 204, 113, .4); --accent-glow: rgba(46, 204, 113, .4);
} }
@ -75,6 +78,7 @@
--bg-secondary: #2f201c; --bg-secondary: #2f201c;
--bg-tertiary: #3d2a24; --bg-tertiary: #3d2a24;
--accent: #e67e22; --accent: #e67e22;
--accent-rgb: 230, 126, 34;
--accent-hover: #d35400; --accent-hover: #d35400;
--accent-glow: rgba(230, 126, 34, .4); --accent-glow: rgba(230, 126, 34, .4);
} }
@ -86,6 +90,7 @@
--bg-secondary: #162a42; --bg-secondary: #162a42;
--bg-tertiary: #1e3652; --bg-tertiary: #1e3652;
--accent: #3498db; --accent: #3498db;
--accent-rgb: 52, 152, 219;
--accent-hover: #2980b9; --accent-hover: #2980b9;
--accent-glow: rgba(52, 152, 219, .4); --accent-glow: rgba(52, 152, 219, .4);
} }
@ -962,36 +967,25 @@ input, select {
max-width: 260px; max-width: 260px;
} }
/* /* ── Now Playing (Topbar) ── */
Bottom Bar
*/
.bottombar {
display: flex;
align-items: center;
gap: 14px;
padding: 0 20px;
height: 48px;
background: var(--bg-secondary);
border-top: 1px solid rgba(0, 0, 0, .24);
z-index: 10;
flex-shrink: 0;
transition: background .4s ease;
}
.now-playing { .now-playing {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 6px;
font-size: 13px; 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); color: var(--text-muted);
max-width: 200px;
min-width: 0; min-width: 0;
flex: 1; animation: np-fade-in 300ms ease;
} }
.np-label { @keyframes np-fade-in {
color: var(--text-faint); from { opacity: 0; transform: translateX(10px); }
font-size: 12px; to { opacity: 1; transform: translateX(0); }
white-space: nowrap;
} }
.np-name { .np-name {
@ -1006,7 +1000,8 @@ input, select {
display: none; display: none;
gap: 1.5px; gap: 1.5px;
align-items: flex-end; align-items: flex-end;
height: 14px; height: 12px;
flex-shrink: 0;
} }
.np-waves.active { .np-waves.active {
@ -1014,16 +1009,16 @@ input, select {
} }
.np-wave-bar { .np-wave-bar {
width: 2.5px; width: 2px;
background: var(--accent); background: var(--accent);
border-radius: 1px; border-radius: 1px;
animation: wave 500ms ease-in-out infinite alternate; animation: wave 500ms ease-in-out infinite alternate;
} }
.np-wave-bar:nth-child(1) { height: 4px; animation-delay: 0ms; } .np-wave-bar:nth-child(1) { height: 3px; animation-delay: 0ms; }
.np-wave-bar:nth-child(2) { height: 9px; animation-delay: 120ms; } .np-wave-bar:nth-child(2) { height: 8px; animation-delay: 120ms; }
.np-wave-bar:nth-child(3) { height: 6px; animation-delay: 240ms; } .np-wave-bar:nth-child(3) { height: 5px; animation-delay: 240ms; }
.np-wave-bar:nth-child(4) { height: 11px; animation-delay: 80ms; } .np-wave-bar:nth-child(4) { height: 10px; animation-delay: 80ms; }
/* ── Volume Control (Toolbar) ── */ /* ── Volume Control (Toolbar) ── */
.volume-control { .volume-control {
@ -1398,10 +1393,6 @@ input, select {
font-size: 11px; font-size: 11px;
} }
.bottombar {
padding: 0 12px;
}
.tb-btn span:not(.tb-icon) { .tb-btn span:not(.tb-icon) {
display: none; display: none;
} }
@ -1416,6 +1407,10 @@ input, select {
display: none; display: none;
} }
.now-playing {
max-width: 120px;
}
.toolbar .tb-btn { .toolbar .tb-btn {
padding: 6px 8px; padding: 6px 8px;
} }