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/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
new file mode 100644
index 00000000..98b70106
--- /dev/null
+++ b/src/stores/player-store.test.ts
@@ -0,0 +1,53 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+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.
+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..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'
@@ -44,6 +45,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 +101,7 @@ interface PlayerState {
shuffle: boolean
shuffleTick: number
repeat: boolean
+ volume: number
_shuffleHistory: Song[]
_shuffleHistoryIndex: number
@@ -112,6 +115,7 @@ interface PlayerState {
setAdjacentLoading: (loading: boolean) => void
toggleShuffle: () => void
toggleRepeat: () => void
+ setVolume: (volume: number) => void
}
export const usePlayerStore = create
((set, get) => ({
@@ -131,6 +135,7 @@ export const usePlayerStore = create((set, get) => ({
shuffle: false,
shuffleTick: 0,
repeat: false,
+ volume: loadVolume(),
_shuffleHistory: [],
_shuffleHistoryIndex: -1,
@@ -259,5 +264,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
}
}))