From 424627b404aaf3980b50b25cc5c99dea50900395 Mon Sep 17 00:00:00 2001 From: Sloth Date: Sun, 20 Sep 2026 11:16:16 -0700 Subject: [PATCH 1/8] feat(app): add volume controls to global player --- src/ui/components/MiniPlayerBar.test.tsx | 40 +++++++++++++ src/ui/components/MiniPlayerBar.tsx | 35 +++++++++++- src/ui/components/QueueDrawer.test.tsx | 70 +++++++++++++++++++++++ src/ui/components/QueueDrawer.tsx | 28 ++++++++- src/ui/components/VolumePopover.tsx | 48 ++++++++++++++++ src/ui/hooks/useBackgroundPlayer.test.tsx | 42 ++++++++++++++ src/ui/hooks/useBackgroundPlayer.tsx | 21 +++++++ 7 files changed, 280 insertions(+), 4 deletions(-) create mode 100644 src/ui/components/VolumePopover.tsx diff --git a/src/ui/components/MiniPlayerBar.test.tsx b/src/ui/components/MiniPlayerBar.test.tsx index ccc4cb0..ca4181a 100644 --- a/src/ui/components/MiniPlayerBar.test.tsx +++ b/src/ui/components/MiniPlayerBar.test.tsx @@ -106,6 +106,46 @@ describe('MiniPlayerBar', () => { expect(await screen.findByText('Video One')).toBeInTheDocument(); }); + it('the volume popover opens on click, closes on a second click, and its mute button toggles muted', async () => { + const user = userEvent.setup(); + const { container } = renderBar(); + await user.click(screen.getByRole('button', { name: 'start playing (test)' })); + await screen.findByText('Video One'); + const video = container.querySelector('video')!; + + expect(screen.queryByRole('button', { name: 'Mute' })).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Show volume' })); + expect(await screen.findByRole('button', { name: 'Mute' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Mute' })); + expect(video.muted).toBe(true); + expect(await screen.findByRole('button', { name: 'Unmute' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Unmute' })); + expect(video.muted).toBe(false); + + // hidden: true -- MUI's Popover marks everything outside itself + // aria-hidden while open, including this same anchor button, which is + // otherwise perfectly real and clickable. + await user.click(screen.getByRole('button', { name: 'Hide volume', hidden: true })); + expect(screen.queryByRole('button', { name: 'Unmute' })).not.toBeInTheDocument(); + }); + + it('the volume popover\'s slider drives the underlying element\'s volume', async () => { + const user = userEvent.setup(); + const { container } = renderBar(); + await user.click(screen.getByRole('button', { name: 'start playing (test)' })); + await screen.findByText('Video One'); + const video = container.querySelector('video')!; + + await user.click(screen.getByRole('button', { name: 'Show volume' })); + const slider = await screen.findByRole('slider', { name: 'Volume' }); + slider.focus(); + await user.keyboard('{ArrowDown}'); + + expect(video.volume).toBeLessThan(1); + }); + it('the close button stops playback, hiding the bar', async () => { const user = userEvent.setup(); renderBar(); diff --git a/src/ui/components/MiniPlayerBar.tsx b/src/ui/components/MiniPlayerBar.tsx index 6a2723f..0ab43d3 100644 --- a/src/ui/components/MiniPlayerBar.tsx +++ b/src/ui/components/MiniPlayerBar.tsx @@ -5,12 +5,15 @@ import PlayArrowIcon from '@mui/icons-material/PlayArrow'; import PauseIcon from '@mui/icons-material/Pause'; import SkipPreviousIcon from '@mui/icons-material/SkipPrevious'; import SkipNextIcon from '@mui/icons-material/SkipNext'; +import VolumeUpIcon from '@mui/icons-material/VolumeUp'; +import VolumeOffIcon from '@mui/icons-material/VolumeOff'; import CloseIcon from '@mui/icons-material/Close'; import MusicNoteIcon from '@mui/icons-material/MusicNote'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import { buildAppVideoUrl } from '../../utils/utils.ts'; import { useBackgroundPlayer, videoDetailPathFor, formatPlaybackTime } from '../hooks/useBackgroundPlayer.tsx'; import QueueDrawer from './QueueDrawer'; +import VolumePopover from './VolumePopover'; // Persistent across every tab -- rendered in MainPage.tsx as a sibling to // its CustomTabPanels, the same placement BulkAddSidePanel already uses to @@ -18,8 +21,12 @@ import QueueDrawer from './QueueDrawer'; // -- the underlying