From 80460f9dee1b2e56b5536e58019082636d9edc34 Mon Sep 17 00:00:00 2001
From: Hellraiser28 <193626466+Hellraiser28@users.noreply.github.com>
Date: Mon, 19 Oct 2026 10:11:03 +0200
Subject: [PATCH 1/2] feat(player): add a volume control to the desktop player
Adds a speaker button next to shuffle in the sidebar player that opens a popover with a volume slider. The volume lives in the player store, is applied to the shared audio element (also when it is created) and is remembered in localStorage. The mobile player is unchanged. Closes #49.
---
.../sidebar-player-audio-player.tsx | 2 +
.../sidebar-player-volume-control.test.tsx | 33 ++++++++++++
.../sidebar-player-volume-control.tsx | 37 +++++++++++++
src/stores/player-store.test.ts | 54 +++++++++++++++++++
src/stores/player-store.ts | 36 +++++++++++++
5 files changed, 162 insertions(+)
create mode 100644 src/components/sidebar-player/components/sidebar-player-volume-control.test.tsx
create mode 100644 src/components/sidebar-player/components/sidebar-player-volume-control.tsx
create mode 100644 src/stores/player-store.test.ts
diff --git a/src/components/sidebar-player/components/sidebar-player-audio-player.tsx b/src/components/sidebar-player/components/sidebar-player-audio-player.tsx
index 47c45c21..9836d261 100644
--- a/src/components/sidebar-player/components/sidebar-player-audio-player.tsx
+++ b/src/components/sidebar-player/components/sidebar-player-audio-player.tsx
@@ -8,6 +8,7 @@ import { Waveform } from '@/components/waveform/waveform'
import { useMediaSession } from '@/features/player/hooks/use-media-session'
import { cn } from '@/lib/utils'
import { usePlayerStore } from '@/stores/player-store'
+import { SidebarPlayerVolumeControl } from './sidebar-player-volume-control'
interface SidebarPlayerAudioPlayerProps {
expanded: boolean
@@ -71,6 +72,7 @@ export function SidebarPlayerAudioPlayer({ expanded }: SidebarPlayerAudioPlayerP
aria-pressed={shuffle}>
+
diff --git a/src/components/sidebar-player/components/sidebar-player-volume-control.test.tsx b/src/components/sidebar-player/components/sidebar-player-volume-control.test.tsx
new file mode 100644
index 00000000..4acdc33b
--- /dev/null
+++ b/src/components/sidebar-player/components/sidebar-player-volume-control.test.tsx
@@ -0,0 +1,33 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+import { usePlayerStore } from '@/stores/player-store'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { SidebarPlayerVolumeControl } from './sidebar-player-volume-control'
+
+describe('SidebarPlayerVolumeControl', () => {
+ beforeEach(() => {
+ usePlayerStore.setState({ volume: 0.5 })
+ })
+
+ it('opens a slider showing the current volume', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.click(screen.getByRole('button', { name: 'playback.volume' }))
+
+ expect(screen.getByRole('slider')).toHaveAttribute('aria-valuenow', '50')
+ expect(screen.getByText('50%')).toBeInTheDocument()
+ })
+
+ it('changes the volume with the keyboard', async () => {
+ const user = userEvent.setup()
+ render(
)
+
+ await user.click(screen.getByRole('button', { name: 'playback.volume' }))
+ screen.getByRole('slider').focus()
+ await user.keyboard('{ArrowRight}')
+
+ expect(usePlayerStore.getState().volume).toBeCloseTo(0.51)
+ expect(screen.getByText('51%')).toBeInTheDocument()
+ })
+})
diff --git a/src/components/sidebar-player/components/sidebar-player-volume-control.tsx b/src/components/sidebar-player/components/sidebar-player-volume-control.tsx
new file mode 100644
index 00000000..0a4e835a
--- /dev/null
+++ b/src/components/sidebar-player/components/sidebar-player-volume-control.tsx
@@ -0,0 +1,37 @@
+'use client'
+
+import { Volume1Icon, Volume2Icon, VolumeXIcon } from 'lucide-react'
+import { useTranslations } from 'next-intl'
+import { Button } from '@/components/ui/button'
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
+import { Slider } from '@/components/ui/slider'
+import { usePlayerStore } from '@/stores/player-store'
+
+export function SidebarPlayerVolumeControl() {
+ const t = useTranslations('playback')
+ const volume = usePlayerStore(s => s.volume)
+ const setVolume = usePlayerStore(s => s.setVolume)
+
+ const percent = Math.round(volume * 100)
+ const VolumeIcon = percent === 0 ? VolumeXIcon : percent < 50 ? Volume1Icon : Volume2Icon
+
+ return (
+
+
+
+
+
+
+
+
+ setVolume(value / 100)} />
+ {percent}%
+
+
+
+ )
+}
diff --git a/src/stores/player-store.test.ts b/src/stores/player-store.test.ts
new file mode 100644
index 00000000..df821a1f
--- /dev/null
+++ b/src/stores/player-store.test.ts
@@ -0,0 +1,54 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const VOLUME_STORAGE_KEY = 'tagr-player-volume'
+
+// The stored volume is read once when the store module is created, so each test
+// imports a fresh copy of the module after preparing localStorage.
+async function loadStore() {
+ vi.resetModules()
+ return import('./player-store')
+}
+
+describe('player store volume', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ it('defaults to full volume when nothing is stored', async () => {
+ const { usePlayerStore } = await loadStore()
+ expect(usePlayerStore.getState().volume).toBe(1)
+ })
+
+ it('restores the stored volume', async () => {
+ localStorage.setItem(VOLUME_STORAGE_KEY, '0.35')
+ const { usePlayerStore } = await loadStore()
+ expect(usePlayerStore.getState().volume).toBe(0.35)
+ })
+
+ it('falls back to full volume for an invalid stored value', async () => {
+ localStorage.setItem(VOLUME_STORAGE_KEY, 'loud')
+ const { usePlayerStore } = await loadStore()
+ expect(usePlayerStore.getState().volume).toBe(1)
+ })
+
+ it('clamps the volume to the 0..1 range', async () => {
+ const { usePlayerStore } = await loadStore()
+ usePlayerStore.getState().setVolume(1.7)
+ expect(usePlayerStore.getState().volume).toBe(1)
+ usePlayerStore.getState().setVolume(-0.2)
+ expect(usePlayerStore.getState().volume).toBe(0)
+ })
+
+ it('persists the volume and applies it to the audio element', async () => {
+ const { usePlayerStore, getAudio } = await loadStore()
+ usePlayerStore.getState().setVolume(0.4)
+ expect(localStorage.getItem(VOLUME_STORAGE_KEY)).toBe('0.4')
+ expect(getAudio()?.volume).toBe(0.4)
+ })
+
+ it('applies the stored volume when the audio element is created', async () => {
+ localStorage.setItem(VOLUME_STORAGE_KEY, '0.2')
+ const { getAudio } = await loadStore()
+ expect(getAudio()?.volume).toBe(0.2)
+ })
+})
diff --git a/src/stores/player-store.ts b/src/stores/player-store.ts
index c05e46cb..497a3af7 100644
--- a/src/stores/player-store.ts
+++ b/src/stores/player-store.ts
@@ -9,6 +9,30 @@ let bufferingTimeout: ReturnType
| null = null
const MAX_HISTORY = 100
+const VOLUME_STORAGE_KEY = 'tagr-player-volume'
+
+function clampVolume(value: number): number {
+ return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 1
+}
+
+function loadVolume(): number {
+ if (typeof window === 'undefined') return 1
+ try {
+ const stored = window.localStorage.getItem(VOLUME_STORAGE_KEY)
+ return stored === null ? 1 : clampVolume(parseFloat(stored))
+ } catch {
+ return 1
+ }
+}
+
+function saveVolume(value: number) {
+ try {
+ window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value))
+ } catch {
+ // Storage may be unavailable (private mode, blocked site data); the volume still applies for this session.
+ }
+}
+
const listeners = {
play: () => usePlayerStore.setState({ isPlaying: true }),
pause: () => usePlayerStore.setState({ isPlaying: false }),
@@ -44,6 +68,7 @@ export function getAudio(): HTMLAudioElement | null {
audio = new Audio()
audio.preload = 'auto'
audio.loop = usePlayerStore.getState().repeat
+ audio.volume = usePlayerStore.getState().volume
for (const [event, handler] of Object.entries(listeners)) {
audio.addEventListener(event, handler)
}
@@ -99,6 +124,7 @@ interface PlayerState {
shuffle: boolean
shuffleTick: number
repeat: boolean
+ volume: number
_shuffleHistory: Song[]
_shuffleHistoryIndex: number
@@ -112,6 +138,7 @@ interface PlayerState {
setAdjacentLoading: (loading: boolean) => void
toggleShuffle: () => void
toggleRepeat: () => void
+ setVolume: (volume: number) => void
}
export const usePlayerStore = create((set, get) => ({
@@ -131,6 +158,7 @@ export const usePlayerStore = create((set, get) => ({
shuffle: false,
shuffleTick: 0,
repeat: false,
+ volume: loadVolume(),
_shuffleHistory: [],
_shuffleHistoryIndex: -1,
@@ -259,5 +287,13 @@ export const usePlayerStore = create((set, get) => ({
set({ repeat: next })
const a = getAudio()
if (a) a.loop = next
+ },
+
+ setVolume: volume => {
+ const next = clampVolume(volume)
+ set({ volume: next })
+ saveVolume(next)
+ const a = getAudio()
+ if (a) a.volume = next
}
}))
From 75a690372548616d9cea6c59cf2a9d53bb37708b Mon Sep 17 00:00:00 2001
From: Hellraiser28 <193626466+Hellraiser28@users.noreply.github.com>
Date: Tue, 20 Oct 2026 09:51:14 +0200
Subject: [PATCH 2/2] Move volume persistence helpers into
src/features/player/volume.ts
---
src/features/player/volume.ts | 23 +++++++++++++++++++++++
src/stores/player-store.test.ts | 3 +--
src/stores/player-store.ts | 25 +------------------------
3 files changed, 25 insertions(+), 26 deletions(-)
create mode 100644 src/features/player/volume.ts
diff --git a/src/features/player/volume.ts b/src/features/player/volume.ts
new file mode 100644
index 00000000..80343206
--- /dev/null
+++ b/src/features/player/volume.ts
@@ -0,0 +1,23 @@
+export const VOLUME_STORAGE_KEY = 'tagr-player-volume'
+
+export function clampVolume(value: number): number {
+ return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 1
+}
+
+export function loadVolume(): number {
+ if (typeof window === 'undefined') return 1
+ try {
+ const stored = window.localStorage.getItem(VOLUME_STORAGE_KEY)
+ return stored === null ? 1 : clampVolume(parseFloat(stored))
+ } catch {
+ return 1
+ }
+}
+
+export function saveVolume(value: number) {
+ try {
+ window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value))
+ } catch {
+ // Storage may be unavailable (private mode, blocked site data); the volume still applies for this session.
+ }
+}
diff --git a/src/stores/player-store.test.ts b/src/stores/player-store.test.ts
index df821a1f..98b70106 100644
--- a/src/stores/player-store.test.ts
+++ b/src/stores/player-store.test.ts
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const VOLUME_STORAGE_KEY = 'tagr-player-volume'
+import { VOLUME_STORAGE_KEY } from '@/features/player/volume'
// The stored volume is read once when the store module is created, so each test
// imports a fresh copy of the module after preparing localStorage.
diff --git a/src/stores/player-store.ts b/src/stores/player-store.ts
index 497a3af7..fcbb00db 100644
--- a/src/stores/player-store.ts
+++ b/src/stores/player-store.ts
@@ -1,4 +1,5 @@
import { create } from 'zustand'
+import { clampVolume, loadVolume, saveVolume } from '@/features/player/volume'
import { RESTART_SONG_THRESHOLD_S, Song, SongColumnFilters } from '@/features/songs/domain'
import type { SongsSortParams } from '@/features/songs/hooks/use-songs-by-folder'
import { getSongAudioUrl } from '@/features/songs/song-file-helpers'
@@ -9,30 +10,6 @@ let bufferingTimeout: ReturnType | null = null
const MAX_HISTORY = 100
-const VOLUME_STORAGE_KEY = 'tagr-player-volume'
-
-function clampVolume(value: number): number {
- return Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 1
-}
-
-function loadVolume(): number {
- if (typeof window === 'undefined') return 1
- try {
- const stored = window.localStorage.getItem(VOLUME_STORAGE_KEY)
- return stored === null ? 1 : clampVolume(parseFloat(stored))
- } catch {
- return 1
- }
-}
-
-function saveVolume(value: number) {
- try {
- window.localStorage.setItem(VOLUME_STORAGE_KEY, String(value))
- } catch {
- // Storage may be unavailable (private mode, blocked site data); the volume still applies for this session.
- }
-}
-
const listeners = {
play: () => usePlayerStore.setState({ isPlaying: true }),
pause: () => usePlayerStore.setState({ isPlaying: false }),