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 } }))