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 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-06 02:10:02 +01:00
parent 971fd2c3fc
commit d1ae2db00b
3 changed files with 128 additions and 3 deletions

View file

@ -24,8 +24,10 @@ interface GuildRadioState {
startedAt: string;
ffmpeg: ChildProcess;
player: ReturnType<typeof createAudioPlayer>;
resource: ReturnType<typeof createAudioResource>;
channelId: string;
channelName: string;
volume: number;
}
interface Favorite {
@ -39,6 +41,17 @@ interface Favorite {
// ── State ──
const guildRadioState = new Map<string, GuildRadioState>();
function getVolume(guildId: string): number {
const vols = getState<Record<string, number>>('radio_volumes', {});
return vols[guildId] ?? 0.5;
}
function setVolume(guildId: string, vol: number): void {
const vols = getState<Record<string, number>>('radio_volumes', {});
vols[guildId] = vol;
setState('radio_volumes', vols);
}
function getFavorites(): Favorite[] {
return getState<Favorite[]>('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<string, any> = {};
const volumes: Record<string, number> = {};
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(),
},