import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useInterval } from '../common' 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, refreshQueue, setPlayMode, setTranscodingProfile, 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' import { calculateGain } from '../utils/calculateReplayGain' import { detectBrowserProfile, decisionService } from '../transcode' 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 [currentTrackId, setCurrentTrackId] = useState(null) const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) const lastPositionMsRef = useRef(0) const currentTrackIdRef = useRef(null) const stoppedRef = useRef(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() // Keep a ref to playerState so the mount effect can read the latest value // without re-triggering on every queue/position change const playerStateRef = useRef(playerState) playerStateRef.current = playerState currentTrackIdRef.current = currentTrackId useInterval( () => { if (heartbeatTrackId && !stoppedRef.current) { subsonic.reportPlayback( heartbeatTrackId, lastPositionMsRef.current, 'playing', ) } }, heartbeatTrackId ? config.playbackReportIntervalMs : null, ) // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { const profile = detectBrowserProfile() decisionService.setProfile(profile) dispatch(setTranscodingProfile(profile)) const state = playerStateRef.current const currentIdx = state.savedPlayIndex || 0 const trackIds = state.queue .slice(currentIdx, currentIdx + 4) .filter((item) => !item.isRadio && item.trackId) .map((item) => item.trackId) if (trackIds.length === 0) { dispatch(refreshQueue()) return } Promise.allSettled( trackIds.map((id) => decisionService.resolveStreamUrl(id).then((url) => [id, url]), ), ).then((results) => { const resolvedUrls = {} results.forEach((r) => { if (r.status === 'fulfilled') { resolvedUrls[r.value[0]] = r.value[1] } }) dispatch(refreshQueue(resolvedUrls)) }) }, [dispatch]) // Pre-fetch transcode decisions for next 2-3 songs when queue or position changes useEffect(() => { if (!playerState.queue.length) return const currentIdx = playerState.savedPlayIndex || 0 const nextSongIds = playerState.queue .slice(currentIdx + 1, currentIdx + 4) .filter((item) => !item.isRadio) .map((item) => item.trackId) if (nextSongIds.length > 0) { decisionService.prefetchDecisions(nextSongIds) } }, [playerState.queue, playerState.savedPlayIndex]) 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 || {} const numericGain = calculateGain(gainInfo, song) gainNode.gain.setValueAtTime(numericGain, context.currentTime) } }, [audioInstance, context, gainNode, playerState, gainInfo]) useEffect(() => { const handleBeforeUnload = (e) => { if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() e.returnValue = '' } } const handlePageHide = () => { if (currentTrackIdRef.current && !playerState.current?.isRadio) { stoppedRef.current = true try { subsonic.reportPlaybackKeepalive( currentTrackIdRef.current, lastPositionMsRef.current, 'stopped', ) } catch { // fetch/sendBeacon may throw; ignore } } } window.addEventListener('beforeunload', handleBeforeUnload) window.addEventListener('pagehide', handlePageHide) return () => { window.removeEventListener('beforeunload', handleBeforeUnload) window.removeEventListener('pagehide', handlePageHide) } }, [playerState, audioInstance]) const defaultOptions = useMemo( () => ({ theme: playerTheme, bounds: 'body', playMode: playerState.mode, 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), sortableOptions: { delay: 200, delayOnTouchOnly: true }, }), [gainInfo, isDesktop, playerTheme, translate, playerState.mode], ) const options = useMemo(() => { const current = playerState.current || {} return { ...defaultOptions, audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: playerState.queue.length > 0 && playerState.autoPlay !== false && (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 onAudioProgress = useCallback((info) => { if (info.ended) { document.title = 'Navidrome' } if (!info.isRadio && info.currentTime != null) { lastPositionMsRef.current = Math.floor(info.currentTime * 1000) } }, []) const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), [dispatch], ) const onAudioPlay = useCallback( (info) => { if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { const posMs = Math.floor(info.currentTime * 1000) lastPositionMsRef.current = posMs const isNewTrack = info.trackId !== currentTrackId if (isNewTrack) { subsonic .reportPlayback(info.trackId, posMs, 'starting') .then(() => subsonic.reportPlayback(info.trackId, posMs, 'playing'), ) setCurrentTrackId(info.trackId) } else { subsonic.reportPlayback(info.trackId, posMs, 'playing') } setHeartbeatTrackId(info.trackId) } 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, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { if (currentTrackId) { subsonic.reportPlayback( currentTrackId, lastPositionMsRef.current, 'stopped', ) } setHeartbeatTrackId(null) setCurrentTrackId(null) }, [currentTrackId]) const onAudioPause = useCallback( (info) => { dispatch(currentPlaying(info)) if (!info.isRadio && currentTrackId) { const posMs = Math.floor(info.currentTime * 1000) lastPositionMsRef.current = posMs subsonic.reportPlayback(currentTrackId, posMs, 'paused') } setHeartbeatTrackId(null) }, [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { if (currentTrackId && !info.isRadio) { const posMs = Math.floor((info.duration || 0) * 1000) subsonic.reportPlayback(currentTrackId, posMs, 'stopped') } setHeartbeatTrackId(null) setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { if (mode === 'full' && audioInfo?.song?.albumId) { window.location.href = `#/album/${audioInfo.song.albumId}/show` } }, []) const onAudioError = useCallback( (error, currentPlayId, audioLists, audioInfo) => { // Invalidate all cached decisions — token may be stale decisionService.invalidateAll() // Pre-fetch decisions for upcoming songs with fresh tokens const currentIdx = playerState.queue.findIndex( (item) => item.uuid === currentPlayId, ) if (currentIdx >= 0) { const nextSongIds = playerState.queue .slice(currentIdx + 1, currentIdx + 4) .filter((item) => !item.isRadio) .map((item) => item.trackId) if (nextSongIds.length > 0) { decisionService.prefetchDecisions(nextSongIds) } } }, [playerState.queue], ) const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { if (currentTrackId && !playerStateRef.current?.current?.isRadio) { subsonic.reportPlayback( currentTrackId, lastPositionMsRef.current, 'stopped', ) } setHeartbeatTrackId(null) setCurrentTrackId(null) dispatch(clearQueue()) reject() }) }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' } const handlers = useMemo( () => keyHandlers(audioInstance, playerState), [audioInstance, playerState], ) useEffect(() => { if (isMobilePlayer && audioInstance) { audioInstance.volume = 1 } }, [isMobilePlayer, audioInstance]) // Report every seek (including programmatic ones the library does not surface // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag // bursts into one report at the final position. useEffect(() => { if (!audioInstance) return let timer = null const flush = () => { timer = null if ( !currentTrackIdRef.current || playerStateRef.current?.current?.isRadio ) { return } const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) const state = audioInstance.paused ? 'paused' : 'playing' subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) } const handleSeeked = () => { if (timer) clearTimeout(timer) timer = setTimeout(flush, 250) } audioInstance.addEventListener('seeked', handleSeeked) return () => { if (timer) clearTimeout(timer) audioInstance.removeEventListener('seeked', handleSeeked) } }, [audioInstance]) return ( dispatch(setPlayMode(mode))} onAudioEnded={onAudioEnded} onCoverClick={onCoverClick} onAudioError={onAudioError} onBeforeDestroy={onBeforeDestroy} getAudioInstance={setAudioInstance} /> ) } export { Player }