navidrome/ui/src/audioplayer/Player.jsx
Yuuta e06fcbbaeb
fix(ui): harden responsive lyrics layout and accessibility
Signed-off-by: ranokay <github@ranokay.com>
2026-07-29 13:58:30 +03:00

522 lines
16 KiB
JavaScript

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'
import usePlayerLyrics from './usePlayerLyrics'
import { useLyricsLayout } from './LyricsLayoutContext'
import {
PLAYER_DESKTOP_MEDIA_QUERY,
PLAYER_MOBILE_MATCH_MEDIA_QUERY,
} from './playerBreakpoints'
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(PLAYER_DESKTOP_MEDIA_QUERY)
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)
const { setDesktopLyricsProps } = useLyricsLayout()
const {
toolbarLyricsProps,
desktopLyricsProps,
mobileLyricsSurface,
useInlineMobileLyrics,
closeLyrics,
} = usePlayerLyrics({
trackId: playerState.current?.trackId || currentTrackId,
trackUpdatedAt:
playerState.current?.song?.updatedAt || playerState.current?.updatedAt,
isRadio: playerState.current?.isRadio || false,
audioInstance,
isDesktop,
translate,
})
useEffect(() => {
if (!isDesktop) {
setDesktopLyricsProps(null)
return undefined
}
setDesktopLyricsProps(desktopLyricsProps)
return () => setDesktopLyricsProps(null)
}, [desktopLyricsProps, isDesktop, setDesktopLyricsProps])
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: false,
showReload: false,
toggleMode: !isDesktop,
glassBg: false,
showThemeSwitch: false,
showMediaSession: true,
mobileMediaQuery: PLAYER_MOBILE_MATCH_MEDIA_QUERY,
restartCurrentOnPrev: true,
quietUpdate: true,
defaultPosition: {
top: 300,
left: 120,
},
volumeFade: { fadeIn: 200, fadeOut: 200 },
renderAudioTitle: (audioInfo, isMobile) => (
<AudioTitle
audioInfo={audioInfo}
gainInfo={gainInfo}
isMobile={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: (
<PlayerToolbar
id={current.trackId}
isRadio={current.isRadio}
{...toolbarLyricsProps}
/>
),
defaultVolume: isMobilePlayer ? 1 : playerState.volume,
showMediaSession: !current.isRadio,
}
}, [playerState, defaultOptions, isMobilePlayer, toolbarLyricsProps])
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 (useInlineMobileLyrics) return
if (mode === 'full' && audioInfo?.song?.albumId) {
window.location.href = `#/album/${audioInfo.song.albumId}/show`
}
},
[useInlineMobileLyrics],
)
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)
closeLyrics()
dispatch(clearQueue())
reject()
})
}, [closeLyrics, 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 (
<ThemeProvider theme={createMuiTheme(theme)}>
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onAudioError={onAudioError}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
{mobileLyricsSurface}
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
</ThemeProvider>
)
}
export { Player }