Files
feishin/src/renderer/features/player/components/audio-players.tsx
T
2026-04-06 10:58:37 -07:00

275 lines
9.7 KiB
TypeScript

import isElectron from 'is-electron';
import { useEffect } from 'react';
import { eventEmitter } from '/@/renderer/events/event-emitter';
import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events';
import { DiscordRpcHook } from '/@/renderer/features/discord-rpc/use-discord-rpc';
import { MainPlayerListenerHook } from '/@/renderer/features/player/audio-player/hooks/use-main-player-listener';
import { MpvPlayer } from '/@/renderer/features/player/audio-player/mpv-player';
import { WebPlayer } from '/@/renderer/features/player/audio-player/web-player';
import { SleepTimerHook } from '/@/renderer/features/player/components/sleep-timer-button';
import { AutoDJHook } from '/@/renderer/features/player/hooks/use-auto-dj';
import { AutosaveHook } from '/@/renderer/features/player/hooks/use-autosave';
import { MediaSessionHook } from '/@/renderer/features/player/hooks/use-media-session';
import { MPRISHook } from '/@/renderer/features/player/hooks/use-mpris';
import { PlaybackHotkeysHook } from '/@/renderer/features/player/hooks/use-playback-hotkeys';
import { PowerSaveBlockerHook } from '/@/renderer/features/player/hooks/use-power-save-blocker';
import { QueueRestoreTimestampHook } from '/@/renderer/features/player/hooks/use-queue-restore';
import { ScrobbleHook } from '/@/renderer/features/player/hooks/use-scrobble';
import { UpdateCurrentSongHook } from '/@/renderer/features/player/hooks/use-update-current-song';
import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio';
import { RadioWebPlayer } from '/@/renderer/features/radio/components/radio-web-player';
import {
RadioAudioInstanceHook,
RadioMetadataHook,
useIsRadioActive,
} from '/@/renderer/features/radio/hooks/use-radio-player';
import { RemoteHook } from '/@/renderer/features/remote/hooks/use-remote';
import { VisualizerSystemAudioBridgeHook } from '/@/renderer/features/visualizer/components/visualizer-system-audio-bridge';
import {
updateQueueFavorites,
updateQueueRatings,
useCurrentServerId,
usePlaybackSettings,
usePlaybackType,
useSettingsStoreActions,
} from '/@/renderer/store';
import { logFn } from '/@/renderer/utils/logger';
import { toast } from '/@/shared/components/toast/toast';
import { LibraryItem } from '/@/shared/types/domain-types';
import { PlayerType } from '/@/shared/types/types';
const CODEC_PROBES = [
{ codec: 'mp3', container: 'mp3', mime: 'audio/mpeg' },
{ codec: 'aac', container: 'mp4', mime: 'audio/mp4; codecs="mp4a.40.2"' },
{ codec: 'aac', container: 'aac', mime: 'audio/aac' },
{ codec: 'aac', container: 'mp4', mime: 'audio/x-m4a' },
{ codec: 'opus', container: 'ogg', mime: 'audio/ogg; codecs="opus"' },
{ codec: 'opus', container: 'webm', mime: 'audio/webm; codecs="opus"' },
{ codec: 'vorbis', container: 'ogg', mime: 'audio/ogg; codecs="vorbis"' },
{ codec: 'vorbis', container: 'webm', mime: 'audio/webm; codecs="vorbis"' },
{ codec: 'flac', container: 'flac', mime: 'audio/flac' },
{ codec: ['pcm', 'wav'], container: 'wav', mime: 'audio/wav' },
{ codec: 'alac', container: 'mp4', mime: 'audio/mp4; codecs="alac"' },
];
const DEFAULT_TRANSCODING_PROFILES = [
{ audioCodec: 'flac', container: 'flac', protocol: 'http' },
{ audioCodec: 'opus', container: 'ogg', protocol: 'http' },
{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' },
];
const SAFARI_TRANSCODING_PROFILES = [{ audioCodec: 'mp3', container: 'mp3', protocol: 'http' }];
const DIRECT_PLAY_PROFILES: {
audioCodecs: string[];
containers: string[];
protocols: string[];
}[] = [];
export function getDefaultTranscodingProfiles() {
return isSafari() ? SAFARI_TRANSCODING_PROFILES : DEFAULT_TRANSCODING_PROFILES;
}
export function getDirectPlayProfiles() {
return DIRECT_PLAY_PROFILES;
}
// Shamelessly taken from NavidromeUI
function detectBrowserProfile() {
const audio = new Audio();
for (const { codec, container, mime } of CODEC_PROBES) {
if (audio.canPlayType(mime) === 'maybe' || audio.canPlayType(mime) === 'probably') {
DIRECT_PLAY_PROFILES.push({
audioCodecs: Array.isArray(codec) ? codec : [codec],
containers: [container],
protocols: ['http'],
});
}
}
logFn.info('DIRECT_PLAY_PROFILES', { meta: DIRECT_PLAY_PROFILES });
return DIRECT_PLAY_PROFILES;
}
function isSafari() {
const ua = navigator.userAgent;
return ua.includes('Safari') && !ua.includes('Chrome') && !ua.includes('Chromium');
}
export const AudioPlayers = () => {
const playbackType = usePlaybackType();
const serverId = useCurrentServerId();
const { resetSampleRate } = useSettingsStoreActions();
const {
audioDeviceId,
mpvProperties: { audioSampleRateHz },
webAudio,
} = usePlaybackSettings();
const { setWebAudio, webAudio: audioContext } = useWebAudio();
useEffect(() => {
detectBrowserProfile();
}, []);
return (
<>
<SleepTimerHook />
<ScrobbleHook />
<PowerSaveBlockerHook />
<DiscordRpcHook />
<MPRISHook />
<MainPlayerListenerHook />
<MediaSessionHook />
<PlaybackHotkeysHook />
<RemoteHook />
<AutoDJHook />
<QueueRestoreTimestampHook />
<UpdateCurrentSongHook />
<RadioAudioInstanceHook />
<RadioMetadataHook />
<VisualizerSystemAudioBridgeHook />
<AutosaveHook />
<AudioPlayersContent
audioContext={audioContext}
audioDeviceId={audioDeviceId}
audioSampleRateHz={audioSampleRateHz}
playbackType={playbackType}
resetSampleRate={resetSampleRate}
serverId={serverId}
setWebAudio={setWebAudio}
webAudio={webAudio}
/>
</>
);
};
const AudioPlayersContent = ({
audioContext,
audioDeviceId,
audioSampleRateHz,
playbackType,
resetSampleRate,
serverId,
setWebAudio,
webAudio,
}: {
audioContext: ReturnType<typeof useWebAudio>['webAudio'];
audioDeviceId: null | string | undefined;
audioSampleRateHz: number | undefined;
playbackType: PlayerType;
resetSampleRate: ReturnType<typeof useSettingsStoreActions>['resetSampleRate'];
serverId: null | string;
setWebAudio: ReturnType<typeof useWebAudio>['setWebAudio'];
webAudio: boolean;
}) => {
const isRadioActive = useIsRadioActive();
useEffect(() => {
if (webAudio && 'AudioContext' in window) {
let context: AudioContext;
try {
context = new AudioContext({
latencyHint: 'playback',
sampleRate: audioSampleRateHz || undefined,
});
} catch (error) {
// In practice, this should never be hit because the UI should validate
// the range. However, the actual supported range is not guaranteed
toast.error({ message: (error as Error).message });
context = new AudioContext({ latencyHint: 'playback' });
resetSampleRate();
}
const gains = [context.createGain(), context.createGain()];
for (const gain of gains) {
gain.connect(context.destination);
}
setWebAudio!({ context, gains });
}
// Intentionally ignore the sample rate dependency, as it makes things really messy
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
// Not standard, just used in chromium-based browsers. See
// https://developer.chrome.com/blog/audiocontext-setsinkid/.
if (!isElectron()) {
return;
}
if (playbackType !== PlayerType.WEB) {
return;
}
if (audioContext && 'setSinkId' in audioContext.context && audioDeviceId) {
const setSink = async () => {
try {
if (audioContext.context.state !== 'closed') {
await (audioContext.context as any).setSinkId(audioDeviceId);
}
} catch (error) {
toast.error({ message: `Error setting sink: ${(error as Error).message}` });
}
};
setSink();
}
}, [audioContext, audioDeviceId, playbackType]);
// Listen to favorite and rating events to update queue songs
useEffect(() => {
const handleFavorite = (payload: UserFavoriteEventPayload) => {
if (payload.itemType !== LibraryItem.SONG || payload.serverId !== serverId) {
return;
}
updateQueueFavorites(payload.id, payload.favorite);
};
const handleRating = (payload: UserRatingEventPayload) => {
if (payload.itemType !== LibraryItem.SONG || payload.serverId !== serverId) {
return;
}
updateQueueRatings(payload.id, payload.rating);
};
eventEmitter.on('USER_FAVORITE', handleFavorite);
eventEmitter.on('USER_RATING', handleRating);
return () => {
eventEmitter.off('USER_FAVORITE', handleFavorite);
eventEmitter.off('USER_RATING', handleRating);
};
}, [serverId]);
if (isRadioActive && playbackType === PlayerType.LOCAL) {
return <MpvPlayer />;
}
if (isRadioActive && playbackType === PlayerType.WEB) {
return <RadioWebPlayer />;
}
return (
<>
{playbackType === PlayerType.WEB && <WebPlayer />}
{playbackType === PlayerType.LOCAL && <MpvPlayer />}
</>
);
};