feat(ui): add volume control to the share player

Signed-off-by: jungle-010 <jungle-010@tuta.com>
This commit is contained in:
jungle-010 2026-10-07 02:07:25 +01:00
commit 45408bb8b5
4 changed files with 179 additions and 0 deletions

View file

@ -2,6 +2,7 @@ import ReactJkMusicPlayer from 'navidrome-music-player'
import { useCallback, useEffect, useRef, useState } from 'react'
import config, { shareInfo } from '../config'
import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils'
import VolumeControl from './VolumeControl'
import { makeStyles } from '@material-ui/core/styles'
@ -33,6 +34,7 @@ const useStyle = makeStyles({
const SharePlayer = () => {
const [downloading, setDownloading] = useState(false)
const [audio, setAudio] = useState(null)
const timer = useRef(null)
const classes = useStyle({
single: shareInfo?.tracks.length === 1,
@ -82,6 +84,8 @@ const SharePlayer = () => {
spaceBar: true,
volumeFade: { fadeIn: 200, fadeOut: 200 },
sortableOptions: { delay: 200, delayOnTouchOnly: true },
getAudioInstance: setAudio,
extendsContent: <VolumeControl audio={audio} />,
}
return (
<ReactJkMusicPlayer

View file

@ -46,6 +46,13 @@ describe('SharePlayer', () => {
vi.restoreAllMocks()
})
it('adds a volume control, since the share layout has none of its own', () => {
render(<SharePlayer />)
expect(playerProps.getAudioInstance).toBeInstanceOf(Function)
expect(playerProps.extendsContent).toBeTruthy()
})
it('downloads via an anchor so the service worker does not intercept it', () => {
render(<SharePlayer />)

View file

@ -0,0 +1,96 @@
import { useCallback, useEffect, useState } from 'react'
import { makeStyles } from '@material-ui/core/styles'
import Slider from '@material-ui/core/Slider'
import VolumeUpIcon from '@material-ui/icons/VolumeUp'
import VolumeOffIcon from '@material-ui/icons/VolumeOff'
const useStyle = makeStyles({
root: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
// Touch devices have hardware volume buttons (and iOS ignores
// `audio.volume` anyway), so only show the control where a pointer exists.
'@media (hover: none)': {
display: 'none',
},
},
button: {
display: 'inline-flex',
cursor: 'pointer',
background: 'none',
border: 0,
padding: 0,
color: 'inherit',
},
slider: {
width: 100,
marginLeft: 12,
color: '#31c27c',
},
})
// The player maps its volume bar to the real volume with a square, so that the
// bar feels linear. Use the same mapping, as the main player does.
const toBarValue = (volume) => Math.sqrt(volume)
const toVolume = (barValue) => barValue * barValue
// Volume slider for the share page. The share page always uses the player's
// "mobile" layout, which does not include a volume control.
const VolumeControl = ({ audio }) => {
const classes = useStyle()
const [value, setValue] = useState(1)
const [muted, setMuted] = useState(false)
useEffect(() => {
if (!audio) return
const sync = () => {
setValue(toBarValue(audio.volume))
setMuted(audio.muted)
}
sync()
audio.addEventListener('volumechange', sync)
return () => audio.removeEventListener('volumechange', sync)
}, [audio])
const handleChange = useCallback(
(_, newValue) => {
if (!audio) return
audio.muted = false
audio.volume = toVolume(newValue)
},
[audio],
)
const toggleMute = useCallback(() => {
if (audio) audio.muted = !audio.muted
}, [audio])
const silent = muted || value === 0
return (
<li className={`item ${classes.root}`}>
<button
type="button"
className={classes.button}
onClick={toggleMute}
title={muted ? 'Unmute' : 'Mute'}
aria-label={muted ? 'Unmute' : 'Mute'}
>
{silent ? <VolumeOffIcon /> : <VolumeUpIcon />}
</button>
<Slider
className={classes.slider}
value={muted ? 0 : value}
onChange={handleChange}
min={0}
max={1}
step={0.01}
disabled={!audio}
aria-label="Volume"
/>
</li>
)
}
export default VolumeControl

View file

@ -0,0 +1,72 @@
import { render, screen, fireEvent, act } from '@testing-library/react'
import VolumeControl from './VolumeControl'
const renderControl = (audio) =>
render(
<ul>
<VolumeControl audio={audio} />
</ul>,
)
describe('VolumeControl', () => {
let audio
beforeEach(() => {
audio = document.createElement('audio')
})
it('reflects the current audio volume on the slider scale', () => {
audio.volume = 0.25
renderControl(audio)
// The slider uses a square-root scale, like the player's own volume bar
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0.5')
})
it('changes the audio volume using the square of the slider value', () => {
renderControl(audio)
const slider = screen.getByRole('slider')
fireEvent.keyDown(slider, { key: 'PageDown' })
// 1 -> 0.9 on the slider, 0.81 real volume
expect(audio.volume).toBeCloseTo(0.81)
fireEvent.keyDown(slider, { key: 'Home' })
expect(audio.volume).toBe(0)
})
it('toggles mute and unmutes when the slider is moved', () => {
renderControl(audio)
fireEvent.click(screen.getByRole('button', { name: 'Mute' }))
expect(audio.muted).toBe(true)
// jsdom does not dispatch volumechange by itself
act(() => {
audio.dispatchEvent(new Event('volumechange'))
})
expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument()
fireEvent.keyDown(screen.getByRole('slider'), { key: 'End' })
expect(audio.muted).toBe(false)
})
it('follows volume changes made elsewhere', () => {
renderControl(audio)
act(() => {
audio.volume = 0.04
audio.dispatchEvent(new Event('volumechange'))
})
expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '0.2')
})
it('is disabled until the audio element is available', () => {
renderControl(null)
expect(screen.getByRole('slider').closest('.MuiSlider-root')).toHaveClass(
'Mui-disabled',
)
})
})