navidrome/ui/src/audioplayer/Player.js

347 lines
9.6 KiB
JavaScript

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&section=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) => (
<AudioTitle
audioInfo={audioInfo}
gainInfo={gainInfo}
isMobile={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: (
<PlayerToolbar id={current.trackId} isRadio={current.isRadio} />
),
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 (
<ThemeProvider theme={createMuiTheme(theme)}>
<ReactJkMusicPlayer
{...options}
className={classes.player}
onAudioListsChange={onAudioListsChange}
onAudioVolumeChange={onAudioVolumeChange}
onAudioProgress={onAudioProgress}
onAudioPlay={onAudioPlay}
onAudioPlayTrackChange={onAudioPlayTrackChange}
onAudioPause={onAudioPause}
onAudioEnded={onAudioEnded}
onCoverClick={onCoverClick}
onBeforeDestroy={onBeforeDestroy}
getAudioInstance={setAudioInstance}
/>
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
</ThemeProvider>
)
}
export { Player }