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; startedAt: string;
ffmpeg: ChildProcess; ffmpeg: ChildProcess;
player: ReturnType<typeof createAudioPlayer>; player: ReturnType<typeof createAudioPlayer>;
resource: ReturnType<typeof createAudioResource>;
channelId: string; channelId: string;
channelName: string; channelName: string;
volume: number;
} }
interface Favorite { interface Favorite {
@ -39,6 +41,17 @@ interface Favorite {
// ── State ── // ── State ──
const guildRadioState = new Map<string, GuildRadioState>(); 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[] { function getFavorites(): Favorite[] {
return getState<Favorite[]>('radio_favorites', []); 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!, { const resource = createAudioResource(ffmpeg.stdout!, {
inputType: StreamType.Raw, inputType: StreamType.Raw,
inlineVolume: true,
}); });
resource.volume?.setVolume(vol);
const player = createAudioPlayer(); const player = createAudioPlayer();
player.play(resource); player.play(resource);
@ -137,7 +153,8 @@ async function startStream(
guildRadioState.set(guildId, { guildRadioState.set(guildId, {
stationId, stationName, placeName, country, stationId, stationName, placeName, country,
streamUrl, startedAt: new Date().toISOString(), streamUrl, startedAt: new Date().toISOString(),
ffmpeg, player, channelId: voiceChannelId, channelName, ffmpeg, player, resource, channelId: voiceChannelId, channelName,
volume: vol,
}); });
broadcastState(guildId); broadcastState(guildId);
@ -151,6 +168,7 @@ function broadcastState(guildId: string): void {
type: 'radio', type: 'radio',
plugin: 'radio', plugin: 'radio',
guildId, guildId,
volume: state?.volume ?? getVolume(guildId),
playing: state ? { playing: state ? {
stationId: state.stationId, stationId: state.stationId,
stationName: state.stationName, stationName: state.stationName,
@ -252,6 +270,21 @@ const radioPlugin: Plugin = {
res.json({ ok: true }); 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 ── // ── Favoriten lesen ──
app.get('/api/radio/favorites', (_req, res) => { app.get('/api/radio/favorites', (_req, res) => {
res.json(getFavorites()); res.json(getFavorites());
@ -299,6 +332,7 @@ const radioPlugin: Plugin = {
getSnapshot() { getSnapshot() {
const playing: Record<string, any> = {}; const playing: Record<string, any> = {};
const volumes: Record<string, number> = {};
for (const [gId, st] of guildRadioState) { for (const [gId, st] of guildRadioState) {
playing[gId] = { playing[gId] = {
stationId: st.stationId, stationId: st.stationId,
@ -308,10 +342,12 @@ const radioPlugin: Plugin = {
startedAt: st.startedAt, startedAt: st.startedAt,
channelName: st.channelName, channelName: st.channelName,
}; };
volumes[gId] = st.volume;
} }
return { return {
radio: { radio: {
playing, playing,
volumes,
favorites: getFavorites(), favorites: getFavorites(),
placesCount: getPlacesCount(), placesCount: getPlacesCount(),
}, },

View file

@ -65,7 +65,9 @@ export default function RadioTab({ data }: { data: any }) {
const [favorites, setFavorites] = useState<Favorite[]>([]); const [favorites, setFavorites] = useState<Favorite[]>([]);
const [showFavorites, setShowFavorites] = useState(false); const [showFavorites, setShowFavorites] = useState(false);
const [playingLoading, setPlayingLoading] = useState(false); const [playingLoading, setPlayingLoading] = useState(false);
const [volume, setVolume] = useState(0.5);
const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined); const searchTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
const volumeTimeout = useRef<ReturnType<typeof setTimeout>>(undefined);
// ── Fetch initial data ── // ── Fetch initial data ──
useEffect(() => { useEffect(() => {
@ -90,7 +92,14 @@ export default function RadioTab({ data }: { data: any }) {
useEffect(() => { useEffect(() => {
if (data?.playing) setNowPlaying(data.playing); if (data?.playing) setNowPlaying(data.playing);
if (data?.favorites) setFavorites(data.favorites); 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) ── // ── Point click handler (stable ref) ──
const handlePointClickRef = useRef<(point: any) => void>(undefined); const handlePointClickRef = useRef<(point: any) => void>(undefined);
@ -274,6 +283,20 @@ export default function RadioTab({ data }: { data: any }) {
} catch {} } catch {}
}, [selectedPlace]); }, [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 isFavorite = (stationId: string) => favorites.some(f => f.stationId === stationId);
const currentPlaying = selectedGuild ? nowPlaying[selectedGuild] : null; const currentPlaying = selectedGuild ? nowPlaying[selectedGuild] : null;
const currentGuild = guilds.find(g => g.id === selectedGuild); const currentGuild = guilds.find(g => g.id === selectedGuild);
@ -438,6 +461,17 @@ export default function RadioTab({ data }: { data: any }) {
<span className="radio-np-name">{currentPlaying.stationName}</span> <span className="radio-np-name">{currentPlaying.stationName}</span>
<span className="radio-np-loc">{currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''}</span> <span className="radio-np-loc">{currentPlaying.placeName}{currentPlaying.country ? `, ${currentPlaying.country}` : ''}</span>
</div> </div>
<div className="radio-volume">
<span className="radio-volume-icon">{volume === 0 ? '\u{1F507}' : volume < 0.4 ? '\u{1F509}' : '\u{1F50A}'}</span>
<input
type="range"
className="radio-volume-slider"
min={0} max={1} step={0.01}
value={volume}
onChange={e => handleVolume(Number(e.target.value))}
/>
<span className="radio-volume-val">{Math.round(volume * 100)}%</span>
</div>
<span className="radio-np-ch">{'\u{1F50A}'} {currentPlaying.channelName}</span> <span className="radio-np-ch">{'\u{1F50A}'} {currentPlaying.channelName}</span>
<button className="radio-btn-stop" onClick={handleStop}>{'\u23F9'} Stop</button> <button className="radio-btn-stop" onClick={handleStop}>{'\u23F9'} Stop</button>
</div> </div>

View file

@ -859,6 +859,61 @@ html, body {
flex-shrink: 0; 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 ── */ /* ── Station count ── */
.radio-counter { .radio-counter {
position: absolute; position: absolute;