update
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to GHCR (push) Has been cancelled
Pipeline: Test, Lint, Build / Push to Docker Hub (push) Has been cancelled
Pipeline: Test, Lint, Build / Cleanup digest artifacts (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
Close stale issues and PRs / stale (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
This commit is contained in:
82
ui/src/audioplayer/AudioTitle.jsx
Normal file
82
ui/src/audioplayer/AudioTitle.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { Link } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { QualityInfo } from '../common'
|
||||
import useStyle from './styles'
|
||||
import { useDrag } from 'react-dnd'
|
||||
import { DraggableTypes } from '../consts'
|
||||
|
||||
const AudioTitle = React.memo(({ audioInfo, gainInfo, isMobile }) => {
|
||||
const classes = useStyle()
|
||||
const className = classes.audioTitle
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
|
||||
const song = audioInfo.song
|
||||
const [, dragSongRef] = useDrag(
|
||||
() => ({
|
||||
type: DraggableTypes.SONG,
|
||||
item: { ids: [song?.id] },
|
||||
options: { dropEffect: 'copy' },
|
||||
}),
|
||||
[song],
|
||||
)
|
||||
|
||||
if (!song) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const qi = {
|
||||
suffix: song.suffix,
|
||||
bitRate: song.bitRate,
|
||||
rgAlbumGain: song.rgAlbumGain,
|
||||
rgAlbumPeak: song.rgAlbumPeak,
|
||||
rgTrackGain: song.rgTrackGain,
|
||||
rgTrackPeak: song.rgTrackPeak,
|
||||
}
|
||||
|
||||
const subtitle = song.tags?.['subtitle']
|
||||
const title = song.title + (subtitle ? ` (${subtitle})` : '')
|
||||
|
||||
const linkTo = audioInfo.isRadio
|
||||
? `/radio/${audioInfo.trackId}/show`
|
||||
: song.playlistId
|
||||
? `/playlist/${song.playlistId}/show`
|
||||
: `/album/${song.albumId}/show`
|
||||
|
||||
return (
|
||||
<Link to={linkTo} className={className} ref={dragSongRef}>
|
||||
<span>
|
||||
<span className={clsx(classes.songTitle, 'songTitle')}>{title}</span>
|
||||
{isDesktop && (
|
||||
<QualityInfo
|
||||
record={qi}
|
||||
className={classes.qualityInfo}
|
||||
{...gainInfo}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{isMobile ? (
|
||||
<>
|
||||
<span className={classes.songInfo}>
|
||||
<span className={'songArtist'}>{song.artist}</span>
|
||||
</span>
|
||||
<span className={clsx(classes.songInfo, classes.songAlbum)}>
|
||||
<span className={'songAlbum'}>{song.album}</span>
|
||||
{song.year ? ` - ${song.year}` : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className={classes.songInfo}>
|
||||
<span className={'songArtist'}>{song.artist}</span> -{' '}
|
||||
<span className={'songAlbum'}>{song.album}</span>
|
||||
{song.year ? ` - ${song.year}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
|
||||
AudioTitle.displayName = 'AudioTitle'
|
||||
|
||||
export default AudioTitle
|
||||
58
ui/src/audioplayer/AudioTitle.test.jsx
Normal file
58
ui/src/audioplayer/AudioTitle.test.jsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import AudioTitle from './AudioTitle'
|
||||
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
Link: React.forwardRef(({ to, children, ...props }, ref) => (
|
||||
<a href={to} ref={ref} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)),
|
||||
}))
|
||||
|
||||
vi.mock('react-dnd', () => ({
|
||||
useDrag: vi.fn(() => [null, () => {}]),
|
||||
}))
|
||||
|
||||
describe('<AudioTitle />', () => {
|
||||
const baseSong = {
|
||||
id: 'song-1',
|
||||
albumId: 'album-1',
|
||||
playlistId: 'playlist-1',
|
||||
title: 'Test Song',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
year: '2020',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('links to playlist when playlistId is provided', () => {
|
||||
const audioInfo = { trackId: 'track-1', song: baseSong }
|
||||
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('/playlist/playlist-1/show')
|
||||
})
|
||||
|
||||
it('falls back to album link when no playlistId', () => {
|
||||
const audioInfo = {
|
||||
trackId: 'track-1',
|
||||
song: { ...baseSong, playlistId: undefined },
|
||||
}
|
||||
render(<AudioTitle audioInfo={audioInfo} gainInfo={{}} isMobile={false} />)
|
||||
const link = screen.getByRole('link')
|
||||
expect(link.getAttribute('href')).toBe('/album/album-1/show')
|
||||
})
|
||||
})
|
||||
318
ui/src/audioplayer/Player.jsx
Normal file
318
ui/src/audioplayer/Player.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
setPlayMode,
|
||||
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'
|
||||
|
||||
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 || {}
|
||||
|
||||
const numericGain = calculateGain(gainInfo, song)
|
||||
gainNode.gain.setValueAtTime(numericGain, context.currentTime)
|
||||
}
|
||||
}, [audioInstance, context, gainNode, playerState, gainInfo])
|
||||
|
||||
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) => (
|
||||
<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.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) {
|
||||
const pos = startTime === null ? null : Math.floor(info.currentTime)
|
||||
subsonic.nowPlaying(info.trackId, pos)
|
||||
}
|
||||
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 })
|
||||
// eslint-disable-next-line no-console
|
||||
.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}
|
||||
onPlayModeChange={(mode) => dispatch(setPlayMode(mode))}
|
||||
onAudioEnded={onAudioEnded}
|
||||
onCoverClick={onCoverClick}
|
||||
onBeforeDestroy={onBeforeDestroy}
|
||||
getAudioInstance={setAudioInstance}
|
||||
/>
|
||||
<GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Player }
|
||||
120
ui/src/audioplayer/PlayerToolbar.jsx
Normal file
120
ui/src/audioplayer/PlayerToolbar.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { GlobalHotKeys } from 'react-hotkeys'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { RiSaveLine } from 'react-icons/ri'
|
||||
import { LoveButton, useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import { keyMap } from '../hotkeys'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
justifyContent: 'flex-end',
|
||||
gap: '0.5rem',
|
||||
listStyle: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
},
|
||||
mobileListItem: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
listStyle: 'none',
|
||||
padding: theme.spacing(0.5),
|
||||
margin: 0,
|
||||
height: 24,
|
||||
},
|
||||
button: {
|
||||
width: '2.5rem',
|
||||
height: '2.5rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 0,
|
||||
},
|
||||
mobileButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px',
|
||||
},
|
||||
mobileIcon: {
|
||||
fontSize: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
}))
|
||||
|
||||
const PlayerToolbar = ({ id, isRadio }) => {
|
||||
const dispatch = useDispatch()
|
||||
const { data, loading } = useGetOne('song', id, { enabled: !!id && !isRadio })
|
||||
const [toggleLove, toggling] = useToggleLove('song', data)
|
||||
const isDesktop = useMediaQuery('(min-width:810px)')
|
||||
const classes = useStyles()
|
||||
|
||||
const handlers = {
|
||||
TOGGLE_LOVE: useCallback(() => toggleLove(), [toggleLove]),
|
||||
}
|
||||
|
||||
const handleSaveQueue = useCallback(
|
||||
(e) => {
|
||||
dispatch(openSaveQueueDialog())
|
||||
e.stopPropagation()
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
const buttonClass = isDesktop ? classes.button : classes.mobileButton
|
||||
const listItemClass = isDesktop ? classes.toolbar : classes.mobileListItem
|
||||
|
||||
const saveQueueButton = (
|
||||
<IconButton
|
||||
size={isDesktop ? 'small' : undefined}
|
||||
onClick={handleSaveQueue}
|
||||
disabled={isRadio}
|
||||
data-testid="save-queue-button"
|
||||
className={buttonClass}
|
||||
>
|
||||
<RiSaveLine className={!isDesktop ? classes.mobileIcon : undefined} />
|
||||
</IconButton>
|
||||
)
|
||||
|
||||
const loveButton = (
|
||||
<LoveButton
|
||||
record={data}
|
||||
resource={'song'}
|
||||
size={isDesktop ? undefined : 'inherit'}
|
||||
disabled={loading || toggling || !id || isRadio}
|
||||
className={buttonClass}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges />
|
||||
{isDesktop ? (
|
||||
<li className={`${listItemClass} item`}>
|
||||
{saveQueueButton}
|
||||
{loveButton}
|
||||
</li>
|
||||
) : (
|
||||
<>
|
||||
<li className={`${listItemClass} item`}>{saveQueueButton}</li>
|
||||
<li className={`${listItemClass} item`}>{loveButton}</li>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlayerToolbar
|
||||
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
166
ui/src/audioplayer/PlayerToolbar.test.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
|
||||
import { useMediaQuery } from '@material-ui/core'
|
||||
import { useGetOne } from 'react-admin'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { useToggleLove } from '../common'
|
||||
import { openSaveQueueDialog } from '../actions'
|
||||
import PlayerToolbar from './PlayerToolbar'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@material-ui/core', async () => {
|
||||
const actual = await import('@material-ui/core')
|
||||
return {
|
||||
...actual,
|
||||
useMediaQuery: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('react-admin', () => ({
|
||||
useGetOne: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-redux', () => ({
|
||||
useDispatch: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../common', () => ({
|
||||
LoveButton: ({ className, disabled }) => (
|
||||
<button data-testid="love-button" className={className} disabled={disabled}>
|
||||
Love
|
||||
</button>
|
||||
),
|
||||
useToggleLove: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../actions', () => ({
|
||||
openSaveQueueDialog: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('react-hotkeys', () => ({
|
||||
GlobalHotKeys: () => <div data-testid="global-hotkeys" />,
|
||||
}))
|
||||
|
||||
describe('<PlayerToolbar />', () => {
|
||||
const mockToggleLove = vi.fn()
|
||||
const mockDispatch = vi.fn()
|
||||
const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: false })
|
||||
useToggleLove.mockReturnValue([mockToggleLove, false])
|
||||
useDispatch.mockReturnValue(mockDispatch)
|
||||
openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
|
||||
})
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Desktop layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(true) // isDesktop = true
|
||||
})
|
||||
|
||||
it('renders desktop toolbar with both buttons', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Both buttons should be in a single list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(1)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify desktop classes are applied
|
||||
expect(listItems[0].className).toContain('toolbar')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('opens save queue dialog when save button is clicked', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
fireEvent.click(saveQueueButton)
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
type: 'OPEN_SAVE_QUEUE_DIALOG',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile layout', () => {
|
||||
beforeEach(() => {
|
||||
useMediaQuery.mockReturnValue(false) // isDesktop = false
|
||||
})
|
||||
|
||||
it('renders mobile toolbar with buttons in separate list items', () => {
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
// Each button should be in its own list item
|
||||
const listItems = screen.getAllByRole('listitem')
|
||||
expect(listItems).toHaveLength(2)
|
||||
|
||||
// Verify both buttons are rendered
|
||||
expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('love-button')).toBeInTheDocument()
|
||||
|
||||
// Verify mobile classes are applied
|
||||
expect(listItems[0].className).toContain('mobileListItem')
|
||||
expect(listItems[1].className).toContain('mobileListItem')
|
||||
})
|
||||
|
||||
it('disables save queue button when isRadio is true', () => {
|
||||
render(<PlayerToolbar id="song-1" isRadio={true} />)
|
||||
|
||||
const saveQueueButton = screen.getByTestId('save-queue-button')
|
||||
expect(saveQueueButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('disables love button when conditions are met', () => {
|
||||
useGetOne.mockReturnValue({ data: mockSongData, loading: true })
|
||||
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common behavior', () => {
|
||||
it('renders global hotkeys in both layouts', () => {
|
||||
// Test desktop layout
|
||||
useMediaQuery.mockReturnValue(true)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
|
||||
// Cleanup and test mobile layout
|
||||
cleanup()
|
||||
useMediaQuery.mockReturnValue(false)
|
||||
render(<PlayerToolbar id="song-1" />)
|
||||
expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables buttons when id is not provided', () => {
|
||||
render(<PlayerToolbar />)
|
||||
|
||||
const loveButton = screen.getByTestId('love-button')
|
||||
expect(loveButton).toBeDisabled()
|
||||
})
|
||||
})
|
||||
})
|
||||
1
ui/src/audioplayer/index.js
Normal file
1
ui/src/audioplayer/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from './Player'
|
||||
37
ui/src/audioplayer/keyHandlers.jsx
Normal file
37
ui/src/audioplayer/keyHandlers.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
const keyHandlers = (audioInstance, playerState) => {
|
||||
const nextSong = () => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx + 1] : null
|
||||
}
|
||||
|
||||
const prevSong = () => {
|
||||
const idx = playerState.queue.findIndex(
|
||||
(item) => item.uuid === playerState.current.uuid,
|
||||
)
|
||||
return idx !== null ? playerState.queue[idx - 1] : null
|
||||
}
|
||||
|
||||
return {
|
||||
TOGGLE_PLAY: (e) => {
|
||||
e.preventDefault()
|
||||
audioInstance && audioInstance.togglePlay()
|
||||
},
|
||||
VOL_UP: () =>
|
||||
(audioInstance.volume = Math.min(1, audioInstance.volume + 0.1)),
|
||||
VOL_DOWN: () =>
|
||||
(audioInstance.volume = Math.max(0, audioInstance.volume - 0.1)),
|
||||
PREV_SONG: (e) => {
|
||||
if (!e.metaKey && prevSong()) audioInstance && audioInstance.playPrev()
|
||||
},
|
||||
CURRENT_SONG: () => {
|
||||
window.location.href = `#/album/${playerState.current?.song.albumId}/show`
|
||||
},
|
||||
NEXT_SONG: (e) => {
|
||||
if (!e.metaKey && nextSong()) audioInstance && audioInstance.playNext()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default keyHandlers
|
||||
27
ui/src/audioplayer/locale.js
Normal file
27
ui/src/audioplayer/locale.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const locale = (translate) => ({
|
||||
playListsText: translate('player.playListsText'),
|
||||
openText: translate('player.openText'),
|
||||
closeText: translate('player.closeText'),
|
||||
notContentText: translate('player.notContentText'),
|
||||
clickToPlayText: translate('player.clickToPlayText'),
|
||||
clickToPauseText: translate('player.clickToPauseText'),
|
||||
nextTrackText: translate('player.nextTrackText'),
|
||||
previousTrackText: translate('player.previousTrackText'),
|
||||
reloadText: translate('player.reloadText'),
|
||||
volumeText: translate('player.volumeText'),
|
||||
toggleLyricText: translate('player.toggleLyricText'),
|
||||
toggleMiniModeText: translate('player.toggleMiniModeText'),
|
||||
destroyText: translate('player.destroyText'),
|
||||
downloadText: translate('player.downloadText'),
|
||||
removeAudioListsText: translate('player.removeAudioListsText'),
|
||||
clickToDeleteText: (name) => translate('player.clickToDeleteText', { name }),
|
||||
emptyLyricText: translate('player.emptyLyricText'),
|
||||
playModeText: {
|
||||
order: translate('player.playModeText.order'),
|
||||
orderLoop: translate('player.playModeText.orderLoop'),
|
||||
singleLoop: translate('player.playModeText.singleLoop'),
|
||||
shufflePlay: translate('player.playModeText.shufflePlay'),
|
||||
},
|
||||
})
|
||||
|
||||
export default locale
|
||||
93
ui/src/audioplayer/styles.js
Normal file
93
ui/src/audioplayer/styles.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyle = makeStyles(
|
||||
(theme) => ({
|
||||
audioTitle: {
|
||||
textDecoration: 'none',
|
||||
color: theme.palette.primary.dark,
|
||||
},
|
||||
songTitle: {
|
||||
fontWeight: 'bold',
|
||||
'&:hover + $qualityInfo': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
songInfo: {
|
||||
display: 'block',
|
||||
marginTop: '2px',
|
||||
},
|
||||
songAlbum: {
|
||||
fontStyle: 'italic',
|
||||
fontSize: 'smaller',
|
||||
},
|
||||
qualityInfo: {
|
||||
marginTop: '-4px',
|
||||
opacity: 0,
|
||||
transition: 'all 500ms ease-out',
|
||||
},
|
||||
player: {
|
||||
display: (props) => (props.visible ? 'block' : 'none'),
|
||||
'@media screen and (max-width:810px)': {
|
||||
'& .sound-operation': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
'@media (prefers-reduced-motion)': {
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
animation: 'none',
|
||||
},
|
||||
},
|
||||
'& .progress-bar-content': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .play-mode-title': {
|
||||
'pointer-events': 'none',
|
||||
},
|
||||
'& .music-player-panel .panel-content div.img-rotate': {
|
||||
// Customize desktop player when cover animation is disabled
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
borderRadius: (props) => !props.enableCoverAnimation && '0',
|
||||
// Fix cover display when image is not square
|
||||
backgroundSize: 'contain',
|
||||
backgroundPosition: 'center',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover':
|
||||
{
|
||||
// Customize mobile player when cover animation is disabled
|
||||
borderRadius: (props) => !props.enableCoverAnimation && '0',
|
||||
width: (props) => !props.enableCoverAnimation && '85%',
|
||||
maxWidth: (props) => !props.enableCoverAnimation && '600px',
|
||||
height: (props) => !props.enableCoverAnimation && 'auto',
|
||||
// Fix cover display when image is not square
|
||||
aspectRatio: '1/1',
|
||||
display: 'flex',
|
||||
},
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover':
|
||||
{
|
||||
animationDuration: (props) => !props.enableCoverAnimation && '0s',
|
||||
objectFit: 'contain', // Fix cover display when image is not square
|
||||
},
|
||||
// Hide old singer display
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer':
|
||||
{
|
||||
display: 'none',
|
||||
},
|
||||
// Hide extra whitespace from switch div
|
||||
'& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-switch':
|
||||
{
|
||||
display: 'none',
|
||||
},
|
||||
'& .music-player-panel .panel-content .progress-bar-content section.audio-main':
|
||||
{
|
||||
display: (props) => (props.isRadio ? 'none' : 'inline-flex'),
|
||||
},
|
||||
'& .react-jinke-music-player-mobile-progress': {
|
||||
display: (props) => (props.isRadio ? 'none' : 'flex'),
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ name: 'NDAudioPlayer' },
|
||||
)
|
||||
|
||||
export default useStyle
|
||||
Reference in New Issue
Block a user