mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-10 04:30:25 +02:00
Add internet radio (#1384)
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
import IcecastMetadataStats from 'icecast-metadata-stats';
|
||||
import isElectron from 'is-electron';
|
||||
import { useEffect, useRef } 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,
|
||||
} from '/@/renderer/store';
|
||||
import { toast } from '/@/shared/components/toast/toast';
|
||||
import { PlayerStatus, PlayerType } from '/@/shared/types/types';
|
||||
|
||||
export interface RadioMetadata {
|
||||
artist: null | string;
|
||||
title: null | string;
|
||||
}
|
||||
|
||||
interface RadioStore {
|
||||
actions: {
|
||||
pause: () => void;
|
||||
play: (streamUrl?: string, stationName?: string) => void;
|
||||
setCurrentStreamUrl: (currentStreamUrl: null | string) => void;
|
||||
setIsPlaying: (isPlaying: boolean) => void;
|
||||
setMetadata: (metadata: null | RadioMetadata) => void;
|
||||
setStationName: (stationName: null | string) => void;
|
||||
stop: () => void;
|
||||
};
|
||||
currentStreamUrl: null | string;
|
||||
isPlaying: boolean;
|
||||
metadata: null | RadioMetadata;
|
||||
stationName: null | string;
|
||||
}
|
||||
|
||||
export const useRadioStore = createWithEqualityFn<RadioStore>((set) => ({
|
||||
actions: {
|
||||
pause: () => {
|
||||
set({ isPlaying: false });
|
||||
usePlayerStoreBase.getState().mediaPause();
|
||||
},
|
||||
play: (streamUrl?: string, stationName?: string) => {
|
||||
set((state) => {
|
||||
const newStreamUrl = streamUrl ?? state.currentStreamUrl;
|
||||
const newStationName = stationName ?? state.stationName;
|
||||
|
||||
if (!newStreamUrl) {
|
||||
return state;
|
||||
}
|
||||
|
||||
// Reset metadata when switching stations (streamUrl changes)
|
||||
const isSwitchingStation = newStreamUrl !== state.currentStreamUrl;
|
||||
|
||||
usePlayerStoreBase.getState().mediaPlay();
|
||||
|
||||
return {
|
||||
currentStreamUrl: newStreamUrl,
|
||||
isPlaying: true,
|
||||
metadata: isSwitchingStation ? null : state.metadata,
|
||||
stationName: newStationName,
|
||||
};
|
||||
});
|
||||
},
|
||||
setCurrentStreamUrl: (currentStreamUrl) => set({ currentStreamUrl }),
|
||||
setIsPlaying: (isPlaying) => set({ isPlaying }),
|
||||
setMetadata: (metadata) => set({ metadata }),
|
||||
setStationName: (stationName) => set({ stationName }),
|
||||
stop: () => {
|
||||
set({
|
||||
currentStreamUrl: null,
|
||||
isPlaying: false,
|
||||
metadata: null,
|
||||
stationName: null,
|
||||
});
|
||||
usePlayerStoreBase.getState().mediaStop();
|
||||
},
|
||||
},
|
||||
currentStreamUrl: null,
|
||||
isPlaying: false,
|
||||
metadata: null,
|
||||
stationName: null,
|
||||
}));
|
||||
|
||||
export const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying);
|
||||
|
||||
export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl));
|
||||
|
||||
export const useRadioPlayer = () => {
|
||||
const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl);
|
||||
const isPlaying = useRadioStore((state) => state.isPlaying);
|
||||
const metadata = useRadioStore((state) => state.metadata);
|
||||
const stationName = useRadioStore((state) => state.stationName);
|
||||
|
||||
return {
|
||||
currentStreamUrl,
|
||||
isPlaying,
|
||||
metadata,
|
||||
stationName,
|
||||
};
|
||||
};
|
||||
|
||||
export const useRadioControls = () => {
|
||||
const { pause, play, stop } = useRadioStore((state) => state.actions);
|
||||
|
||||
return {
|
||||
pause,
|
||||
play,
|
||||
stop,
|
||||
};
|
||||
};
|
||||
|
||||
const mpvPlayer = isElectron() ? window.api.mpvPlayer : null;
|
||||
const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null;
|
||||
const ipc = isElectron() ? window.api.ipc : null;
|
||||
|
||||
export const useRadioAudioInstance = () => {
|
||||
const { actions } = useRadioStore();
|
||||
const { setCurrentStreamUrl, setIsPlaying, setStationName } = actions;
|
||||
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 isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer;
|
||||
|
||||
// Handle mpv playback
|
||||
useEffect(() => {
|
||||
if (!isUsingMpv || !mpvPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStreamUrl) {
|
||||
mpvPlayer.setQueue(currentStreamUrl, undefined, !isPlaying);
|
||||
} else {
|
||||
mpvPlayer.setQueue(undefined, undefined, true);
|
||||
}
|
||||
}, [
|
||||
currentStreamUrl,
|
||||
isPlaying,
|
||||
isUsingMpv,
|
||||
setIsPlaying,
|
||||
setCurrentStreamUrl,
|
||||
setStationName,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUsingMpv || !mpvPlayerListener || !ipc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMpvPlay = () => {
|
||||
setIsPlaying(true);
|
||||
};
|
||||
|
||||
const handleMpvPause = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handleMpvStop = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentStreamUrl(null);
|
||||
setStationName(null);
|
||||
};
|
||||
|
||||
mpvPlayerListener.rendererPlay(handleMpvPlay);
|
||||
mpvPlayerListener.rendererPause(handleMpvPause);
|
||||
mpvPlayerListener.rendererStop(handleMpvStop);
|
||||
|
||||
return () => {
|
||||
ipc.removeAllListeners('renderer-player-play');
|
||||
ipc.removeAllListeners('renderer-player-pause');
|
||||
ipc.removeAllListeners('renderer-player-stop');
|
||||
};
|
||||
}, [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;
|
||||
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
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) => {
|
||||
const radioState = useRadioStore.getState();
|
||||
if (!radioState.currentStreamUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { status } = properties;
|
||||
const { status: prevStatus } = prev;
|
||||
|
||||
if (status === prevStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === PlayerStatus.PLAYING && prevStatus === PlayerStatus.PAUSED) {
|
||||
actions.play();
|
||||
} else if (status === PlayerStatus.PAUSED && prevStatus === PlayerStatus.PLAYING) {
|
||||
actions.pause();
|
||||
}
|
||||
},
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
};
|
||||
|
||||
export const useRadioMetadata = () => {
|
||||
const { actions, currentStreamUrl } = useRadioStore();
|
||||
const { setMetadata } = actions;
|
||||
const playbackType = usePlaybackType();
|
||||
const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentStreamUrl) {
|
||||
setMetadata(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// If using mpv, fetch metadata from mpv periodically
|
||||
if (isUsingMpv && mpvPlayer) {
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
const fetchMpvMetadata = async () => {
|
||||
try {
|
||||
const metadata = await mpvPlayer.getStreamMetadata();
|
||||
setMetadata(metadata);
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
};
|
||||
|
||||
intervalId = setInterval(fetchMpvMetadata, 5000);
|
||||
|
||||
return () => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
setMetadata(null);
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, use IcecastMetadataStats for web player
|
||||
let statsListener: IcecastMetadataStats | null = null;
|
||||
|
||||
try {
|
||||
statsListener = new IcecastMetadataStats(currentStreamUrl, {
|
||||
interval: 12,
|
||||
onStats: (stats) => {
|
||||
// Parse ICY metadata - typically in format "Artist - Title" or just "Title"
|
||||
let streamTitle: null | string = null;
|
||||
|
||||
if (stats.StreamTitle) {
|
||||
streamTitle = stats.StreamTitle;
|
||||
} else if (stats.icy?.StreamTitle) {
|
||||
streamTitle = stats.icy.StreamTitle;
|
||||
}
|
||||
|
||||
// Parse the combined format into title and artist
|
||||
let artist: null | string = null;
|
||||
let title: null | string = null;
|
||||
|
||||
if (streamTitle) {
|
||||
// Try to parse "Artist - Title" format
|
||||
const match = streamTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/);
|
||||
if (match) {
|
||||
artist = match[1].trim() || null;
|
||||
title = match[2].trim() || null;
|
||||
} else {
|
||||
// If no separator found, treat the whole thing as title
|
||||
title = streamTitle;
|
||||
}
|
||||
}
|
||||
|
||||
setMetadata(title || artist ? { artist, title } : null);
|
||||
},
|
||||
sources: ['icy'],
|
||||
});
|
||||
|
||||
statsListener.start();
|
||||
} catch {
|
||||
setMetadata(null);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (statsListener) {
|
||||
statsListener.stop();
|
||||
}
|
||||
setMetadata(null);
|
||||
};
|
||||
}, [currentStreamUrl, setMetadata, isUsingMpv]);
|
||||
};
|
||||
Reference in New Issue
Block a user