From d1ae2db00b26e3d4bf824edb05a2bd1a14af17d1 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 02:10:02 +0100 Subject: [PATCH] feat: Radio volume control - server-wide slider synced via SSE - Server: inlineVolume on AudioResource, POST /api/radio/volume endpoint - Volume persisted per guild, broadcast via SSE to all clients - Frontend: volume slider in bottom bar with debounced API calls - Volume icon changes based on level (muted/low/normal) Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/radio/index.ts | 40 ++++++++++++++++++++-- web/src/plugins/radio/RadioTab.tsx | 36 ++++++++++++++++++- web/src/styles.css | 55 ++++++++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/server/src/plugins/radio/index.ts b/server/src/plugins/radio/index.ts index 2739295..5a64edd 100644 --- a/server/src/plugins/radio/index.ts +++ b/server/src/plugins/radio/index.ts @@ -24,8 +24,10 @@ interface GuildRadioState { startedAt: string; ffmpeg: ChildProcess; player: ReturnType; + resource: ReturnType; channelId: string; channelName: string; + volume: number; } interface Favorite { @@ -39,6 +41,17 @@ interface Favorite { // ── State ── const guildRadioState = new Map(); +function getVolume(guildId: string): number { + const vols = getState>('radio_volumes', {}); + return vols[guildId] ?? 0.5; +} + +function setVolume(guildId: string, vol: number): void { + const vols = getState>('radio_volumes', {}); + vols[guildId] = vol; + setState('radio_volumes', vols); +} + function getFavorites(): Favorite[] { return getState('radio_favorites', []); } @@ -118,10 +131,13 @@ async function startStream( } }); - // AudioResource + Player + // AudioResource + Player (inlineVolume für Lautstärkeregelung) + const vol = getVolume(guildId); const resource = createAudioResource(ffmpeg.stdout!, { inputType: StreamType.Raw, + inlineVolume: true, }); + resource.volume?.setVolume(vol); const player = createAudioPlayer(); player.play(resource); @@ -137,7 +153,8 @@ async function startStream( guildRadioState.set(guildId, { stationId, stationName, placeName, country, streamUrl, startedAt: new Date().toISOString(), - ffmpeg, player, channelId: voiceChannelId, channelName, + ffmpeg, player, resource, channelId: voiceChannelId, channelName, + volume: vol, }); broadcastState(guildId); @@ -151,6 +168,7 @@ function broadcastState(guildId: string): void { type: 'radio', plugin: 'radio', guildId, + volume: state?.volume ?? getVolume(guildId), playing: state ? { stationId: state.stationId, stationName: state.stationName, @@ -252,6 +270,21 @@ const radioPlugin: Plugin = { res.json({ ok: true }); }); + // ── Volume ── + app.post('/api/radio/volume', (req, res) => { + const { guildId, volume } = req.body ?? {}; + if (!guildId || volume == null) return res.status(400).json({ error: 'guildId, volume required' }); + const vol = Math.max(0, Math.min(1, Number(volume))); + setVolume(guildId, vol); + const state = guildRadioState.get(guildId); + if (state) { + state.volume = vol; + state.resource.volume?.setVolume(vol); + } + sseBroadcast({ type: 'radio_volume', plugin: 'radio', guildId, volume: vol }); + res.json({ ok: true, volume: vol }); + }); + // ── Favoriten lesen ── app.get('/api/radio/favorites', (_req, res) => { res.json(getFavorites()); @@ -299,6 +332,7 @@ const radioPlugin: Plugin = { getSnapshot() { const playing: Record = {}; + const volumes: Record = {}; for (const [gId, st] of guildRadioState) { playing[gId] = { stationId: st.stationId, @@ -308,10 +342,12 @@ const radioPlugin: Plugin = { startedAt: st.startedAt, channelName: st.channelName, }; + volumes[gId] = st.volume; } return { radio: { playing, + volumes, favorites: getFavorites(), placesCount: getPlacesCount(), }, diff --git a/web/src/plugins/radio/RadioTab.tsx b/web/src/plugins/radio/RadioTab.tsx index 43f5728..067e0c4 100644 --- a/web/src/plugins/radio/RadioTab.tsx +++ b/web/src/plugins/radio/RadioTab.tsx @@ -65,7 +65,9 @@ export default function RadioTab({ data }: { data: any }) { const [favorites, setFavorites] = useState([]); const [showFavorites, setShowFavorites] = useState(false); const [playingLoading, setPlayingLoading] = useState(false); + const [volume, setVolume] = useState(0.5); const searchTimeout = useRef>(undefined); + const volumeTimeout = useRef>(undefined); // ── Fetch initial data ── useEffect(() => { @@ -90,7 +92,14 @@ export default function RadioTab({ data }: { data: any }) { useEffect(() => { if (data?.playing) setNowPlaying(data.playing); if (data?.favorites) setFavorites(data.favorites); - }, [data]); + // Volume from snapshot or radio_volume event + if (data?.volumes && selectedGuild && data.volumes[selectedGuild] != null) { + setVolume(data.volumes[selectedGuild]); + } + if (data?.volume != null && data?.guildId === selectedGuild) { + setVolume(data.volume); + } + }, [data, selectedGuild]); // ── Point click handler (stable ref) ── const handlePointClickRef = useRef<(point: any) => void>(undefined); @@ -274,6 +283,20 @@ export default function RadioTab({ data }: { data: any }) { } catch {} }, [selectedPlace]); + // ── Volume handler (debounced) ── + const handleVolume = useCallback((val: number) => { + setVolume(val); + if (!selectedGuild) return; + if (volumeTimeout.current) clearTimeout(volumeTimeout.current); + volumeTimeout.current = setTimeout(() => { + fetch('/api/radio/volume', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ guildId: selectedGuild, volume: val }), + }).catch(console.error); + }, 100); + }, [selectedGuild]); + const isFavorite = (stationId: string) => favorites.some(f => f.stationId === stationId); const currentPlaying = selectedGuild ? nowPlaying[selectedGuild] : null; const currentGuild = guilds.find(g => g.id === selectedGuild); @@ -438,6 +461,17 @@ export default function RadioTab({ data }: { data: any }) { {currentPlaying.stationName} {currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''} +
+ {volume === 0 ? '\u{1F507}' : volume < 0.4 ? '\u{1F509}' : '\u{1F50A}'} + handleVolume(Number(e.target.value))} + /> + {Math.round(volume * 100)}% +
{'\u{1F50A}'} {currentPlaying.channelName} diff --git a/web/src/styles.css b/web/src/styles.css index f054217..ea3a539 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -859,6 +859,61 @@ html, body { flex-shrink: 0; } +/* ── Volume Slider ── */ +.radio-volume { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.radio-volume-icon { + font-size: 16px; + width: 20px; + text-align: center; + cursor: pointer; +} + +.radio-volume-slider { + -webkit-appearance: none; + appearance: none; + width: 100px; + height: 4px; + border-radius: 2px; + background: var(--bg-tertiary, #383a40); + outline: none; + cursor: pointer; +} + +.radio-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent, #e67e22); + cursor: pointer; + border: none; + box-shadow: 0 0 4px rgba(0,0,0,0.3); +} + +.radio-volume-slider::-moz-range-thumb { + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--accent, #e67e22); + cursor: pointer; + border: none; + box-shadow: 0 0 4px rgba(0,0,0,0.3); +} + +.radio-volume-val { + font-size: 11px; + color: var(--text-muted); + min-width: 32px; + text-align: right; +} + /* ── Station count ── */ .radio-counter { position: absolute;