import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' import { createMuiTheme, useAuthState, useDataProvider, useTranslate, } from 'react-admin' import ReactGA from 'react-ga' import { GlobalHotKeys } from 'react-hotkeys' import ReactJkMusicPlayer from 'navidrome-music-player' import 'navidrome-music-player/assets/index.css' import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import useStyle from './styles' import AudioTitle from './AudioTitle' import { clearQueue, currentPlaying, setVolume, syncQueue } from '../actions' import PlayerToolbar from './PlayerToolbar' import { sendNotification } from '../utils' import subsonic from '../subsonic' import locale from './locale' import { keyMap } from '../hotkeys' import keyHandlers from './keyHandlers' function calculateReplayGain(preAmp, gain, peak) { if (gain === undefined || peak === undefined) { return 1 } // https://wiki.hydrogenaud.io/index.php?title=ReplayGain_1.0_specification§ion=19 // Normalized to max gain return Math.min(10 ** ((gain + preAmp) / 20), 1 / peak) } const Player = () => { const theme = useCurrentTheme() const translate = useTranslate() const playerTheme = theme.player?.theme || 'dark' const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() const [startTime, setStartTime] = useState(null) const [scrobbled, setScrobbled] = useState(false) const [preloaded, setPreload] = useState(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( navigator.userAgent ) const { authenticated } = useAuthState() const visible = authenticated && playerState.queue.length > 0 const isRadio = playerState.current?.isRadio || false const classes = useStyle({ isRadio, visible, enableCoverAnimation: config.enableCoverAnimation, }) const showNotifications = useSelector( (state) => state.settings.notifications || false ) const gainInfo = useSelector((state) => state.replayGain) const [context, setContext] = useState(null) const [gainNode, setGainNode] = useState(null) useEffect(() => { if ( context === null && audioInstance && config.enableReplayGain && 'AudioContext' in window && (gainInfo.gainMode === 'album' || gainInfo.gainMode === 'track') ) { const ctx = new AudioContext() // we need this to support radios in firefox audioInstance.crossOrigin = 'anonymous' const source = ctx.createMediaElementSource(audioInstance) const gain = ctx.createGain() source.connect(gain) gain.connect(ctx.destination) setContext(ctx) setGainNode(gain) } }, [audioInstance, context, gainInfo.gainMode]) useEffect(() => { if (gainNode) { const current = playerState.current || {} const song = current.song || {} let numericGain switch (gainInfo.gainMode) { case 'album': { numericGain = calculateReplayGain( gainInfo.preAmp, song.rgAlbumGain, song.rgAlbumPeak ) break } case 'track': { numericGain = calculateReplayGain( gainInfo.preAmp, song.rgTrackGain, song.rgTrackPeak ) break } default: { numericGain = 1 } } gainNode.gain.setValueAtTime(numericGain, context.currentTime) } }, [ audioInstance, context, gainNode, gainInfo.gainMode, gainInfo.preAmp, playerState, ]) const defaultOptions = useMemo( () => ({ theme: playerTheme, bounds: 'body', mode: 'full', loadAudioErrorPlayNext: false, autoPlayInitLoadPlayList: true, clearPriorAudioLists: false, showDestroy: true, showDownload: false, showLyric: true, showReload: false, toggleMode: !isDesktop, glassBg: false, showThemeSwitch: false, showMediaSession: true, restartCurrentOnPrev: true, quietUpdate: true, defaultPosition: { top: 300, left: 120, }, volumeFade: { fadeIn: 200, fadeOut: 200 }, renderAudioTitle: (audioInfo, isMobile) => ( ), locale: locale(translate), }), [gainInfo, isDesktop, playerTheme, translate] ) const options = useMemo(() => { const current = playerState.current || {} return { ...defaultOptions, audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: playerState.clear || playerState.playIndex === 0, clearPriorAudioLists: playerState.clear, extendsContent: ( ), defaultVolume: isMobilePlayer ? 1 : playerState.volume, showMediaSession: !current.isRadio, } }, [playerState, defaultOptions, isMobilePlayer]) const onAudioListsChange = useCallback( (_, audioLists, audioInfo) => dispatch(syncQueue(audioInfo, audioLists)), [dispatch] ) const nextSong = useCallback(() => { const idx = playerState.queue.findIndex( (item) => item.uuid === playerState.current.uuid ) return idx !== null ? playerState.queue[idx + 1] : null }, [playerState]) const onAudioProgress = useCallback( (info) => { if (info.ended) { document.title = 'Navidrome' } const progress = (info.currentTime / info.duration) * 100 if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { return } if (info.isRadio) { return } if (!preloaded) { const next = nextSong() if (next != null) { const audio = new Audio() audio.src = next.musicSrc } setPreload(true) return } if (!scrobbled) { info.trackId && subsonic.scrobble(info.trackId, startTime) setScrobbled(true) } }, [startTime, scrobbled, nextSong, preloaded] ) const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), [dispatch] ) const onAudioPlay = useCallback( (info) => { // Do this to start the context; on chrome-based browsers, the context // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) if (startTime === null) { setStartTime(Date.now()) } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { subsonic.nowPlaying(info.trackId) } setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', action: 'Play song', label: `${song.title} - ${song.artist}`, }) } if (showNotifications) { sendNotification( song.title, `${song.artist} - ${song.album}`, info.cover ) } } }, [context, dispatch, showNotifications, startTime] ) const onAudioPlayTrackChange = useCallback(() => { if (scrobbled) { setScrobbled(false) } if (startTime !== null) { setStartTime(null) } }, [scrobbled, startTime]) const onAudioPause = useCallback( (info) => dispatch(currentPlaying(info)), [dispatch] ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { setScrobbled(false) setStartTime(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) .catch((e) => console.log('Keepalive error:', e)) }, [dispatch, dataProvider] ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { if (mode === 'full' && audioInfo?.song?.albumId) { window.location.href = `#/album/${audioInfo.song.albumId}/show` } }, []) const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { dispatch(clearQueue()) reject() }) }, [dispatch]) if (!visible) { document.title = 'Navidrome' } const handlers = useMemo( () => keyHandlers(audioInstance, playerState), [audioInstance, playerState] ) useEffect(() => { if (isMobilePlayer && audioInstance) { audioInstance.volume = 1 } }, [isMobilePlayer, audioInstance]) return ( ) } export { Player }