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',
+ )
+ })
+})