From c676f5b91f47645dfbbbe8397c9287b9c01f4667 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 14 Jan 2026 20:01:30 -0800 Subject: [PATCH] use existing web player engine for radio playback (#1543) --- .../player/components/audio-players.tsx | 3 +- .../radio/components/radio-web-player.tsx | 155 ++++++++++++++++++ .../features/radio/hooks/use-radio-player.ts | 113 +------------ 3 files changed, 159 insertions(+), 112 deletions(-) create mode 100644 src/renderer/features/radio/components/radio-web-player.tsx diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 54991d81f..3b0f4eb41 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -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 ; } return ( diff --git a/src/renderer/features/radio/components/radio-web-player.tsx b/src/renderer/features/radio/components/radio-web-player.tsx new file mode 100644 index 000000000..1332532ce --- /dev/null +++ b/src/renderer/features/radio/components/radio-web-player.tsx @@ -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); + 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( + isPlaying ? PlayerStatus.PLAYING : PlayerStatus.PAUSED, + ); + const [player1Source, setPlayer1Source] = useState(null); + const processedMediaElementRef = useRef(null); + const player1SourceRef = useRef(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 ( + {}} + onProgressPlayer1={onProgressPlayer1} + onProgressPlayer2={() => {}} + onStartedPlayer1={handlePlayer1Start} + onStartedPlayer2={() => {}} + playerNum={1} + playerRef={playerRef} + playerStatus={playerStatus} + preservesPitch={preservePitch} + speed={1} + src1={currentStreamUrl || undefined} + src2={undefined} + volume={volume} + /> + ); +} diff --git a/src/renderer/features/radio/hooks/use-radio-player.ts b/src/renderer/features/radio/hooks/use-radio-player.ts index 8a7156de8..3c2779b34 100644 --- a/src/renderer/features/radio/hooks/use-radio-player.ts +++ b/src/renderer/features/radio/hooks/use-radio-player.ts @@ -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(null); - const activeAudioRef = useRef(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) => {