347 lines
9.6 KiB
JavaScript
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§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) => (
|
|
<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 }
|