feat: global player context with album queue and audio element
This commit is contained in:
107
web/src/player/PlayerContext.tsx
Normal file
107
web/src/player/PlayerContext.tsx
Normal 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
108
web/test/player.test.tsx
Normal 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())
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user