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:
parent
971fd2c3fc
commit
d1ae2db00b
3 changed files with 128 additions and 3 deletions
|
|
@ -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(),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue