From 642ca52c7179ff3af6e2a7f14af7ad9ad786d023 Mon Sep 17 00:00:00 2001 From: Samu Date: Fri, 4 Sep 2026 13:45:12 +0200 Subject: [PATCH] feat: navidrome deep link replaces in-app player --- web/src/App.tsx | 7 +- web/src/api.ts | 5 -- web/src/pages/ItemPage.tsx | 17 ++-- web/src/player/MiniBar.tsx | 92 --------------------- web/src/player/PlayerContext.tsx | 107 ------------------------ web/src/types.ts | 13 +-- web/test/api.test.ts | 12 +-- web/test/item.test.tsx | 46 +++++------ web/test/minibar.test.tsx | 81 ------------------- web/test/player.test.tsx | 135 ------------------------------- 10 files changed, 31 insertions(+), 484 deletions(-) delete mode 100644 web/src/player/MiniBar.tsx delete mode 100644 web/src/player/PlayerContext.tsx delete mode 100644 web/test/minibar.test.tsx delete mode 100644 web/test/player.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index c8029be..28211f7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,8 +2,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' import type { ReactNode } from 'react' import { AuthProvider, useAuth } from './auth' import Shell from './shell' -import { PlayerProvider } from './player/PlayerContext.js' -import MiniBar from './player/MiniBar.js' import SetupPage from './pages/SetupPage' import LoginPage from './pages/LoginPage' import LibraryPage from './pages/LibraryPage' @@ -34,10 +32,7 @@ export default function App() { - - - - + } > diff --git a/web/src/api.ts b/web/src/api.ts index f244f9f..de11eca 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,5 +1,4 @@ import type { - AlbumTracks, BackupFile, Candidate, CollectionResponse, @@ -106,10 +105,6 @@ export const api = { startSync: () => request('/api/library/sync', { method: 'POST' }), searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`), - getAlbumTracks: (subsonicId: string) => request(`/api/album/${encodeURIComponent(subsonicId)}/tracks`), - markPlayed: (subsonicId: string) => post<{ ok: boolean }>(`/api/album/${encodeURIComponent(subsonicId)}/played`), - streamUrl: (songId: string) => `/api/stream/${encodeURIComponent(songId)}`, - getStats: () => request('/api/stats'), exportUrl: () => '/api/export', diff --git a/web/src/pages/ItemPage.tsx b/web/src/pages/ItemPage.tsx index 8bc2a4d..04136eb 100644 --- a/web/src/pages/ItemPage.tsx +++ b/web/src/pages/ItemPage.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { api, ApiError } from '../api.js' -import { usePlayer } from '../player/PlayerContext.js' import type { DigitalAlbum, Item, ItemDetail } from '../types.js' import Cover from '../components/Cover.js' @@ -22,7 +21,6 @@ function timeAgo(iso: string): string { export default function ItemPage() { const { id } = useParams() const navigate = useNavigate() - const { load } = usePlayer() const [item, setItem] = useState(null) const [error, setError] = useState(false) const [matching, setMatching] = useState(false) @@ -110,14 +108,15 @@ export default function ItemPage() {

)} - {item.ripStatus === 'ripped' && item.matchedAlbum && ( - + Listen in Navidrome ↗ + )} {item.matchedAlbum?.lastPlayedAt && (

Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}

diff --git a/web/src/player/MiniBar.tsx b/web/src/player/MiniBar.tsx deleted file mode 100644 index e33061d..0000000 --- a/web/src/player/MiniBar.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useState } from 'react' -import { usePlayer } from './PlayerContext.js' -import Cover from '../components/Cover.js' - -export default function MiniBar() { - const { state, toggle, next, prev, close } = usePlayer() - const [expanded, setExpanded] = useState(false) - if (!state.album) return null - - const current = state.tracks[state.index] - - if (expanded) { - return ( -
-
-
-

{state.album.title}

-

{state.album.artist}

-
-
- - - - - -
-
-
    - {state.tracks.map((t, i) => ( -
  1. - - {t.track ?? i + 1}. {t.title} - - {t.duration != null && {Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}} -
  2. - ))} -
-
- ) - } - - return ( -
-
- -
-

{current?.title}

-

{state.album.title}

-
- - - - -
-
- ) -} diff --git a/web/src/player/PlayerContext.tsx b/web/src/player/PlayerContext.tsx deleted file mode 100644 index c305df9..0000000 --- a/web/src/player/PlayerContext.tsx +++ /dev/null @@ -1,107 +0,0 @@ -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/src/types.ts b/web/src/types.ts index cd90dba..e51f9ef 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -68,22 +68,11 @@ export interface DigitalAlbum { title: string artist: string } -export interface Track { - id: string - title: string - duration: number | null - track: number | null -} -export interface AlbumTracks { - id: string - title: string - artist: string - tracks: Track[] -} export interface MatchedAlbum { id: number subsonicId: string lastPlayedAt: string | null + webUrl: string } export interface Stats { totals: { items: number; ripped: number; notRipped: number; onLoan: number } diff --git a/web/test/api.test.ts b/web/test/api.test.ts index 98f9110..cb5e2de 100644 --- a/web/test/api.test.ts +++ b/web/test/api.test.ts @@ -10,15 +10,6 @@ function jsonOk(body: unknown) { } describe('api additions', () => { - it('album tracks + played + stream url', async () => { - fetchMock.mockImplementation(() => jsonOk({ id: 'a1', title: 'Motion', artist: 'TCO', tracks: [] })) - await api.getAlbumTracks('a1') - expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/tracks', expect.anything()) - await api.markPlayed('a1') - expect(fetchMock).toHaveBeenCalledWith('/api/album/a1/played', expect.objectContaining({ method: 'POST' })) - expect(api.streamUrl('s 1')).toBe('/api/stream/s%201') - }) - it('stats, loans, backups urls', async () => { fetchMock.mockImplementation(() => jsonOk({})) await api.getStats() @@ -36,7 +27,7 @@ describe('api additions', () => { fetchMock.mockReturnValue( Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 })) ) - const err = await api.getAlbumTracks('a1').catch((e) => e) + const err = await api.startSync().catch((e) => e) expect(err).toBeInstanceOf(ApiError) expect((err as ApiError).code).toBe('no_subsonic_config') }) @@ -45,7 +36,6 @@ describe('api additions', () => { fetchMock.mockClear() fetchMock.mockImplementation(() => jsonOk({ ok: true })) await api.returnLoan(7) - await api.markPlayed('a1') await api.logout() await api.triggerBackup() for (const call of fetchMock.mock.calls) { diff --git a/web/test/item.test.tsx b/web/test/item.test.tsx index 518366c..71e9b28 100644 --- a/web/test/item.test.tsx +++ b/web/test/item.test.tsx @@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import ItemPage from '../src/pages/ItemPage.js' -import { PlayerProvider } from '../src/player/PlayerContext.js' import type { ItemDetail, MatchedAlbum } from '../src/types.js' vi.mock('../src/api.js', async (importOriginal) => { @@ -17,8 +16,6 @@ vi.mock('../src/api.js', async (importOriginal) => { setMatch: vi.fn(), deleteItem: vi.fn(), searchAlbums: vi.fn(), - getAlbumTracks: vi.fn(), - markPlayed: vi.fn(), lendItem: vi.fn(), returnLoan: vi.fn(), }, @@ -48,20 +45,21 @@ const item: ItemDetail = { loan: null, } -const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' } +const matched: MatchedAlbum = { + id: 77, + subsonicId: 'alb-1', + lastPlayedAt: '2026-09-01T10:00:00Z', + webUrl: 'http://navidrome.local/app/#/album/alb-1', +} const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched } beforeEach(() => { - vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined) vi.mocked(api.getItem).mockReset() vi.mocked(api.getItem).mockResolvedValue(item as never) vi.mocked(api.setRip).mockReset() vi.mocked(api.setMatch).mockReset() vi.mocked(api.deleteItem).mockReset() vi.mocked(api.searchAlbums).mockReset() - vi.mocked(api.getAlbumTracks).mockReset() - vi.mocked(api.markPlayed).mockReset() vi.mocked(api.lendItem).mockReset() vi.mocked(api.returnLoan).mockReset() }) @@ -69,12 +67,10 @@ beforeEach(() => { function renderItem() { return render( - - - } /> - library

} /> -
-
+ + } /> + library

} /> +
) } @@ -119,7 +115,7 @@ describe('ItemPage', () => { await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77)) }) - it('re-match refreshes the play button state', async () => { + it('re-match refreshes the link state', async () => { vi.mocked(api.getItem) .mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never) .mockResolvedValue({ ...rippedItem } as never) @@ -129,7 +125,7 @@ describe('ItemPage', () => { vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never) renderItem() await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy()) - expect(screen.queryByRole('button', { name: /play album/i })).toBeNull() + expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull() await userEvent.click(screen.getByRole('button', { name: /re-match/i })) await userEvent.click(screen.getByRole('button', { name: /^search$/i })) @@ -137,7 +133,7 @@ describe('ItemPage', () => { await userEvent.click(albumRadio) await userEvent.click(screen.getByRole('button', { name: /^link$/i })) - await waitFor(() => expect(screen.getByRole('button', { name: /play album/i })).toBeTruthy()) + await waitFor(() => expect(screen.getByRole('link', { name: /listen in navidrome/i })).toBeTruthy()) }) it('unlink clears the match', async () => { @@ -189,21 +185,19 @@ describe('ItemPage', () => { await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy()) }) - it('shows Play for a ripped item with a matched album and loads the player', async () => { + it('shows Listen in Navidrome link for a ripped item with a matched album', async () => { vi.mocked(api.getItem).mockResolvedValue(rippedItem as never) - vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never) - vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderItem() - const play = await screen.findByRole('button', { name: /play album/i }) - await userEvent.click(play) - await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1')) - await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1')) + const link = await screen.findByRole('link', { name: /listen in navidrome/i }) + expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1') + expect(link.getAttribute('target')).toBe('_blank') }) - it('hides Play when unmatched or not ripped', async () => { + it('hides the link when matchedAlbum is null even if ripped', async () => { + vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem, matchedAlbum: null } as never) renderItem() await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy()) - expect(screen.queryByRole('button', { name: /play album/i })).toBeNull() + expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull() }) it('shows last played under the rip banner', async () => { diff --git a/web/test/minibar.test.tsx b/web/test/minibar.test.tsx deleted file mode 100644 index d0c71c4..0000000 --- a/web/test/minibar.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' -import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js' -import MiniBar from '../src/player/MiniBar.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' - -const tracks: Track[] = [ - { id: 's1', title: 'Overture', duration: 200, track: 1 }, - { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, -] - -function Loader() { - const { load } = usePlayer() - return ( - - ) -} - -beforeEach(() => { - vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() - vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined) - vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) - vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) -}) - -function renderBar() { - return render( - - - - - ) -} - -describe('MiniBar', () => { - it('hidden when nothing is loaded, shows controls when playing', async () => { - renderBar() - expect(screen.queryByRole('button', { name: /play or pause/i })).toBeNull() - await userEvent.click(screen.getByRole('button', { name: 'load' })) - await waitFor(() => expect(screen.getByText('Motion')).toBeTruthy()) - expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy() - }) - - it('pause/resume works from the bar', async () => { - renderBar() - await userEvent.click(screen.getByRole('button', { name: 'load' })) - const pauseBtn = await screen.findByRole('button', { name: /pause/i }) - await userEvent.click(pauseBtn) - expect(screen.getByRole('button', { name: 'play' })).toBeTruthy() - }) - - it('expands to the track list and closes', async () => { - renderBar() - await userEvent.click(screen.getByRole('button', { name: 'load' })) - await userEvent.click(await screen.findByRole('button', { name: /expand/i })) - expect(screen.getByText(/Theme de Yoyo/)).toBeTruthy() - expect(screen.getByText(/Overture/)).toBeTruthy() - await userEvent.click(screen.getByRole('button', { name: /collapse/i })) - expect(screen.queryByText(/Theme de Yoyo/)).toBeNull() - await userEvent.click(screen.getByRole('button', { name: /close player/i })) - await waitFor(() => expect(screen.queryByText('Motion')).toBeNull()) - }) - - it('expanded view has prev/next/pause controls', async () => { - renderBar() - await userEvent.click(screen.getByRole('button', { name: 'load' })) - await userEvent.click(await screen.findByRole('button', { name: /expand/i })) - expect(screen.getByRole('button', { name: /previous track/i })).toBeTruthy() - expect(screen.getByRole('button', { name: /next track/i })).toBeTruthy() - expect(screen.getByRole('button', { name: /pause/i })).toBeTruthy() - }) -}) diff --git a/web/test/player.test.tsx b/web/test/player.test.tsx deleted file mode 100644 index 6f77c39..0000000 --- a/web/test/player.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -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, playerReducer, type PlayerState } 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()) - }) -}) - -const twoTracks: Track[] = [ - { id: 's1', title: 'Overture', duration: 200, track: 1 }, - { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, -] - -describe('playerReducer edges', () => { - const loaded: PlayerState = playerReducer( - { album: null, tracks: [], index: 0, playing: false, failed: [] }, - { type: 'LOAD', album: { id: 'a1', title: 'Motion', artist: 'TCO' }, tracks: twoTracks } - ) - - it('LOAD with empty tracks does not play', () => { - const s = playerReducer(loaded, { type: 'LOAD', album: loaded.album!, tracks: [] }) - expect(s.playing).toBe(false) - }) - - it('TRACK_ERROR at last track pauses without advancing, records failure once', () => { - const atLast = playerReducer(loaded, { type: 'NEXT' }) - const err = playerReducer(atLast, { type: 'TRACK_ERROR' }) - expect(err.index).toBe(1) - expect(err.playing).toBe(false) - expect(err.failed).toEqual([1]) - const again = playerReducer(err, { type: 'TRACK_ERROR' }) - expect(again.failed).toEqual([1]) - }) -})