diff --git a/web/src/player/PlayerContext.tsx b/web/src/player/PlayerContext.tsx new file mode 100644 index 0000000..c305df9 --- /dev/null +++ b/web/src/player/PlayerContext.tsx @@ -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 + toggle: () => void + next: () => void + prev: () => void + close: () => void +} + +const PlayerContext = createContext(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(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 ( + + {children} + + ) +} + +export function usePlayer(): PlayerContextValue { + const ctx = useContext(PlayerContext) + if (!ctx) throw new Error('usePlayer outside PlayerProvider') + return ctx +} diff --git a/web/test/player.test.tsx b/web/test/player.test.tsx new file mode 100644 index 0000000..481cf4f --- /dev/null +++ b/web/test/player.test.tsx @@ -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() + 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 ( +
+
phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}
+
track:{state.tracks[state.index]?.title ?? 'none'}
+ + + + + +
+ ) +} + +function renderPlayer() { + return render( + + + + ) +} + +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()) + }) +})