diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx index 2384866ea..e96ada1fa 100644 --- a/ui/src/share/SharePlayer.jsx +++ b/ui/src/share/SharePlayer.jsx @@ -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: , } return ( { vi.restoreAllMocks() }) + it('adds a volume control, since the share layout has none of its own', () => { + render() + + expect(playerProps.getAudioInstance).toBeInstanceOf(Function) + expect(playerProps.extendsContent).toBeTruthy() + }) + it('downloads via an anchor so the service worker does not intercept it', () => { render() diff --git a/ui/src/share/VolumeControl.jsx b/ui/src/share/VolumeControl.jsx new file mode 100644 index 000000000..a93a6ea9e --- /dev/null +++ b/ui/src/share/VolumeControl.jsx @@ -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 ( +
  • + + +
  • + ) +} + +export default VolumeControl diff --git a/ui/src/share/VolumeControl.test.jsx b/ui/src/share/VolumeControl.test.jsx new file mode 100644 index 000000000..43ec9fd6b --- /dev/null +++ b/ui/src/share/VolumeControl.test.jsx @@ -0,0 +1,72 @@ +import { render, screen, fireEvent, act } from '@testing-library/react' +import VolumeControl from './VolumeControl' + +const renderControl = (audio) => + render( +
      + +
    , + ) + +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', + ) + }) +})