1
0

feat: global player context with album queue and audio element

This commit is contained in:
2026-09-03 22:15:19 +02:00
parent ee19bc773c
commit c8b596bb02
2 changed files with 215 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
import { createContext, useContext, useEffect, useReducer, useCallback, useRef, type ReactNode } from 'react'
import { api } from '../api.js'
import type { Track } from '../types.js'
export interface PlayerAlbum {
id: string
title: string
artist: string
}
export interface PlayerState {
album: PlayerAlbum | null
tracks: Track[]
index: number
playing: boolean
failed: number[]
}
type PlayerAction =
| { type: 'LOAD'; album: PlayerAlbum; tracks: Track[] }
| { type: 'TOGGLE' }
| { type: 'NEXT' }
| { type: 'PREV' }
| { type: 'TRACK_ERROR' }
| { type: 'CLOSE' }
const INITIAL: PlayerState = { album: null, tracks: [], index: 0, playing: false, failed: [] }
export function playerReducer(state: PlayerState, action: PlayerAction): PlayerState {
switch (action.type) {
case 'LOAD':
return { album: action.album, tracks: action.tracks, index: 0, playing: action.tracks.length > 0, failed: [] }
case 'TOGGLE':
return { ...state, playing: !state.playing }
case 'NEXT':
return state.index < state.tracks.length - 1 ? { ...state, index: state.index + 1, playing: true } : { ...state, playing: false }
case 'PREV':
return state.index > 0 ? { ...state, index: state.index - 1, playing: true } : state
case 'TRACK_ERROR':
return {
...state,
failed: state.failed.includes(state.index) ? state.failed : [...state.failed, state.index],
...(state.index < state.tracks.length - 1 ? { index: state.index + 1, playing: true } : { playing: false }),
}
case 'CLOSE':
return INITIAL
}
}
interface PlayerContextValue {
state: PlayerState
load: (album: PlayerAlbum) => Promise<void>
toggle: () => void
next: () => void
prev: () => void
close: () => void
}
const PlayerContext = createContext<PlayerContextValue | null>(null)
export function PlayerProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(playerReducer, INITIAL)
const load = useCallback(async (album: PlayerAlbum) => {
const data = await api.getAlbumTracks(album.id)
dispatch({ type: 'LOAD', album: { id: data.id, title: data.title, artist: data.artist }, tracks: data.tracks })
void api.markPlayed(album.id).catch(() => {})
}, [])
const audioRef = useRef<HTMLAudioElement | null>(null)
const current = state.tracks[state.index]
// keep the single audio element in sync with the reducer
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (!current) {
audio.pause()
audio.removeAttribute('src')
return
}
const wanted = api.streamUrl(current.id)
if (!audio.src.endsWith(wanted)) audio.src = wanted
if (state.playing) void audio.play().catch(() => {})
else audio.pause()
}, [current, state.playing])
const onEnded = useCallback(() => dispatch({ type: 'NEXT' }), [])
const onError = useCallback(() => dispatch({ type: 'TRACK_ERROR' }), [])
const toggle = useCallback(() => dispatch({ type: 'TOGGLE' }), [])
const next = useCallback(() => dispatch({ type: 'NEXT' }), [])
const prev = useCallback(() => dispatch({ type: 'PREV' }), [])
const close = useCallback(() => dispatch({ type: 'CLOSE' }), [])
return (
<PlayerContext.Provider value={{ state, load, toggle, next, prev, close }}>
{children}
<audio ref={audioRef} onEnded={onEnded} onError={onError} preload="none" />
</PlayerContext.Provider>
)
}
export function usePlayer(): PlayerContextValue {
const ctx = useContext(PlayerContext)
if (!ctx) throw new Error('usePlayer outside PlayerProvider')
return ctx
}

108
web/test/player.test.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js'
import type { Track } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/api.js')>()
return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } }
})
import { api } from '../src/api.js'
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
})
const tracks: Track[] = [
{ id: 's1', title: 'Overture', duration: 200, track: 1 },
{ id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 },
]
function Probe() {
const { state, load, toggle, next, prev, close } = usePlayer()
return (
<div>
<div>phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}</div>
<div>track:{state.tracks[state.index]?.title ?? 'none'}</div>
<button type="button" onClick={() => load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>load</button>
<button type="button" onClick={() => toggle()}>toggle</button>
<button type="button" onClick={() => next()}>next</button>
<button type="button" onClick={() => prev()}>prev</button>
<button type="button" onClick={() => close()}>close</button>
</div>
)
}
function renderPlayer() {
return render(
<PlayerProvider>
<Probe />
</PlayerProvider>
)
}
describe('PlayerProvider', () => {
it('loads a queue, stamps played, starts at track 1', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
expect(screen.getByText('track:Overture')).toBeTruthy()
expect(api.getAlbumTracks).toHaveBeenCalledWith('a1')
expect(api.markPlayed).toHaveBeenCalledWith('a1')
})
it('toggle pauses and resumes', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:paused')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'toggle' }))
expect(screen.getByText('phase:playing')).toBeTruthy()
})
it('next/prev move through the queue and stop at the edges', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'next' }))
expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() // last track: no advance
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy()
await userEvent.click(screen.getByRole('button', { name: 'prev' }))
expect(screen.getByText('track:Overture')).toBeTruthy() // first track: no rewind
})
it('close empties the player', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy())
await userEvent.click(screen.getByRole('button', { name: 'close' }))
expect(screen.getByText('phase:empty')).toBeTruthy()
})
it('audio error marks the track failed and skips to the next', async () => {
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never)
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
renderPlayer()
await userEvent.click(screen.getByRole('button', { name: 'load' }))
await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy())
act(() => {
document.querySelector('audio')!.dispatchEvent(new Event('error'))
})
await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy())
})
})