use existing web player engine for radio playback (#1543)

This commit is contained in:
jeffvli
2026-01-14 20:01:30 -08:00
parent 41054ed819
commit c676f5b91f
3 changed files with 159 additions and 112 deletions
@@ -15,6 +15,7 @@ import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power
import { useQueueRestoreTimestamp } from '/@/renderer/features/player/hooks/use-queue-restore';
import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { RadioWebPlayer } from '/@/renderer/features/radio/components/radio-web-player';
import {
useIsRadioActive,
useRadioAudioInstance,
@@ -147,7 +148,7 @@ export const AudioPlayers = () => {
}
if (isRadioActive && playbackType === PlayerType.WEB) {
return null;
return <RadioWebPlayer />;
}
return (
@@ -0,0 +1,155 @@
import type ReactPlayer from 'react-player';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
WebPlayerEngine,
WebPlayerEngineHandle,
} from '/@/renderer/features/player/audio-player/engine/web-player-engine';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import {
useIsRadioActive,
useRadioPlayer,
useRadioStore,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { usePlaybackSettings, usePlayerMuted, usePlayerVolume } from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { PlayerStatus } from '/@/shared/types/types';
export function RadioWebPlayer() {
const playerRef = useRef<null | WebPlayerEngineHandle>(null);
const { currentStreamUrl, isPlaying } = useRadioPlayer();
const { actions } = useRadioStore();
const { setCurrentStreamUrl, setIsPlaying, setStationName } = actions;
const isRadioActive = useIsRadioActive();
const isMuted = usePlayerMuted();
const volume = usePlayerVolume();
const { preservePitch } = usePlaybackSettings();
const { webAudio } = useWebAudio();
const [playerStatus, setPlayerStatus] = useState<PlayerStatus>(
isPlaying ? PlayerStatus.PLAYING : PlayerStatus.PAUSED,
);
const [player1Source, setPlayer1Source] = useState<MediaElementAudioSourceNode | null>(null);
const processedMediaElementRef = useRef<HTMLMediaElement | null>(null);
const player1SourceRef = useRef<MediaElementAudioSourceNode | null>(null);
useEffect(() => {
player1SourceRef.current = player1Source;
}, [player1Source]);
useEffect(() => {
setPlayerStatus(isPlaying ? PlayerStatus.PLAYING : PlayerStatus.PAUSED);
}, [isPlaying]);
// Cleanup source only on unmount
useEffect(() => {
return () => {
if (player1SourceRef.current) {
try {
player1SourceRef.current.disconnect();
} catch {
// Ignore disconnect errors
}
setPlayer1Source(null);
processedMediaElementRef.current = null;
}
};
}, []);
useEffect(() => {
if (!webAudio || !player1Source) return;
const linearVolume = volume / 100;
const gainValue = isMuted ? 0 : linearVolume;
try {
webAudio.gains[0].gain.setValueAtTime(gainValue, 0);
} catch (error) {
console.error('Error setting radio volume gain', error);
}
}, [volume, isMuted, webAudio, player1Source]);
const handlePlayer1Start = useCallback(
async (player: ReactPlayer) => {
if (!webAudio) return;
const internal = player.getInternalPlayer() as HTMLMediaElement | undefined;
if (!internal) return;
// If we've already processed this exact media element, reuse the existing source
if (processedMediaElementRef.current === internal && player1Source) {
// Ensure it's still connected to the gain node
try {
if (!player1Source.context) {
const { gains } = webAudio;
player1Source.connect(gains[0]);
}
} catch {
// Already connected, which is what we want
}
return;
}
if (currentStreamUrl) {
if (webAudio.context.state !== 'running') {
await webAudio.context.resume();
}
}
try {
const { context, gains } = webAudio;
const source = context.createMediaElementSource(internal);
source.connect(gains[0]);
setPlayer1Source(source);
processedMediaElementRef.current = internal;
} catch {
processedMediaElementRef.current = internal;
if (webAudio && webAudio.gains[0]) {
const linearVolume = volume / 100;
const gainValue = isMuted ? 0 : linearVolume;
webAudio.gains[0].gain.setValueAtTime(gainValue, 0);
}
}
},
[player1Source, currentStreamUrl, webAudio, volume, isMuted],
);
const onProgressPlayer1 = useCallback(() => {
// We don't need to handle progress for radio streams
}, []);
const onEndedPlayer1 = useCallback(() => {
console.error('Radio stream ended unexpectedly');
setIsPlaying(false);
setCurrentStreamUrl(null);
setStationName(null);
toast.error({ message: 'Radio stream ended unexpectedly' });
}, [setIsPlaying, setCurrentStreamUrl, setStationName]);
if (!isRadioActive) {
return null;
}
return (
<WebPlayerEngine
isMuted={isMuted}
isTransitioning={false}
onEndedPlayer1={onEndedPlayer1}
onEndedPlayer2={() => {}}
onProgressPlayer1={onProgressPlayer1}
onProgressPlayer2={() => {}}
onStartedPlayer1={handlePlayer1Start}
onStartedPlayer2={() => {}}
playerNum={1}
playerRef={playerRef}
playerStatus={playerStatus}
preservesPitch={preservePitch}
speed={1}
src1={currentStreamUrl || undefined}
src2={undefined}
volume={volume}
/>
);
}
@@ -1,19 +1,10 @@
import console from 'console';
import IcecastMetadataStats from 'icecast-metadata-stats';
import isElectron from 'is-electron';
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { createWithEqualityFn } from 'zustand/traditional';
import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events';
import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils';
import {
usePlaybackType,
usePlayerMuted,
usePlayerStoreBase,
usePlayerVolume,
useSettingsStore,
} from '/@/renderer/store';
import { toast } from '/@/shared/components/toast/toast';
import { usePlaybackType, usePlayerStoreBase, useSettingsStore } from '/@/renderer/store';
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
export interface RadioMetadata {
@@ -132,10 +123,6 @@ export const useRadioAudioInstance = () => {
const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);
const isPlaying = useRadioStore((state) => state.isPlaying);
const playbackType = usePlaybackType();
const volume = usePlayerVolume();
const isMuted = usePlayerMuted();
const audioRef = useRef<HTMLAudioElement | null>(null);
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer;
// Handle mpv playback
@@ -188,102 +175,6 @@ export const useRadioAudioInstance = () => {
};
}, [isUsingMpv, setIsPlaying, setCurrentStreamUrl, setStationName]);
// Handle web playback
useEffect(() => {
if (isUsingMpv) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
return;
}
if (currentStreamUrl && isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
}
const audio = new Audio(currentStreamUrl);
audioRef.current = audio;
activeAudioRef.current = audio;
const linearVolume = volume / 100;
const logVolume = convertToLogVolume(linearVolume);
audio.volume = logVolume;
audio.muted = isMuted;
audio.addEventListener('play', () => {
setIsPlaying(true);
});
audio.addEventListener('pause', () => {
setIsPlaying(false);
});
audio.addEventListener('ended', () => {
setIsPlaying(false);
setCurrentStreamUrl(null);
setStationName(null);
});
audio.addEventListener('error', (error) => {
console.error('Radio stream error:', error);
});
// Attempt to play
audio.play().catch((error) => {
if (activeAudioRef.current !== audio) {
return;
}
console.error('Failed to play audio:', error);
setIsPlaying(false);
setCurrentStreamUrl(null);
setStationName(null);
toast.error({ message: 'Failed to play radio stream' });
});
} else if (!currentStreamUrl || !isPlaying) {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
activeAudioRef.current = null;
}
return () => {
if (audioRef.current) {
if (activeAudioRef.current === audioRef.current) {
activeAudioRef.current = null;
}
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentStreamUrl,
isPlaying,
isUsingMpv,
setIsPlaying,
setCurrentStreamUrl,
setStationName,
]);
useEffect(() => {
if (isUsingMpv || !audioRef.current) {
return;
}
const linearVolume = volume / 100;
const logVolume = convertToLogVolume(linearVolume);
audioRef.current.volume = logVolume;
audioRef.current.muted = isMuted;
}, [volume, isMuted, isUsingMpv]);
usePlayerEvents(
{
onPlayerStatus: (properties, prev) => {