Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -71,6 +72,7 @@ export function SidebarPlayerAudioPlayer({ expanded }: SidebarPlayerAudioPlayerP
aria-pressed={shuffle}>
<ShuffleIcon className='h-3 w-3' />
</Button>
<SidebarPlayerVolumeControl />
</div>

<div className={cn('flex items-center gap-2', expanded ? 'w-full' : 'flex-1')}>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<SidebarPlayerVolumeControl />)

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(<SidebarPlayerVolumeControl />)

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()
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant='ghost'
size='icon'
className='h-6 w-6 text-muted-foreground/70 hover:text-foreground'
aria-label={t('volume')}>
<VolumeIcon className='h-3 w-3' />
</Button>
</PopoverTrigger>
<PopoverContent side='top' className='w-48' aria-label={t('volume')}>
<div className='flex items-center gap-3'>
<Slider value={[percent]} max={100} step={1} onValueChange={([value]) => setVolume(value / 100)} />
<span className='w-9 shrink-0 text-right text-xs tabular-nums text-muted-foreground'>{percent}%</span>
</div>
</PopoverContent>
</Popover>
)
}
23 changes: 23 additions & 0 deletions src/features/player/volume.ts
Original file line number Diff line number Diff line change
@@ -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.
}
}
53 changes: 53 additions & 0 deletions src/stores/player-store.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
13 changes: 13 additions & 0 deletions src/stores/player-store.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -99,6 +101,7 @@ interface PlayerState {
shuffle: boolean
shuffleTick: number
repeat: boolean
volume: number
_shuffleHistory: Song[]
_shuffleHistoryIndex: number

Expand All @@ -112,6 +115,7 @@ interface PlayerState {
setAdjacentLoading: (loading: boolean) => void
toggleShuffle: () => void
toggleRepeat: () => void
setVolume: (volume: number) => void
}

export const usePlayerStore = create<PlayerState>((set, get) => ({
Expand All @@ -131,6 +135,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
shuffle: false,
shuffleTick: 0,
repeat: false,
volume: loadVolume(),
_shuffleHistory: [],
_shuffleHistoryIndex: -1,

Expand Down Expand Up @@ -259,5 +264,13 @@ export const usePlayerStore = create<PlayerState>((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
}
}))
Loading