1
0

feat: navidrome deep link replaces in-app player

This commit is contained in:
2026-09-04 13:45:12 +02:00
parent 165ac96830
commit 642ca52c71
10 changed files with 31 additions and 484 deletions

View File

@@ -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() {
<Route
element={
<Gate>
<PlayerProvider>
<Shell />
<MiniBar />
</PlayerProvider>
<Shell />
</Gate>
}
>

View File

@@ -1,5 +1,4 @@
import type {
AlbumTracks,
BackupFile,
Candidate,
CollectionResponse,
@@ -106,10 +105,6 @@ export const api = {
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
getAlbumTracks: (subsonicId: string) => request<AlbumTracks>(`/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<Stats>('/api/stats'),
exportUrl: () => '/api/export',

View File

@@ -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<ItemDetail | null>(null)
const [error, setError] = useState(false)
const [matching, setMatching] = useState(false)
@@ -110,14 +108,15 @@ export default function ItemPage() {
</p>
)}
{item.ripStatus === 'ripped' && item.matchedAlbum && (
<button
type="button"
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
{item.matchedAlbum && (
<a
href={item.matchedAlbum.webUrl}
target="_blank"
rel="noreferrer"
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
>
Play album
</button>
Listen in Navidrome
</a>
)}
{item.matchedAlbum?.lastPlayedAt && (
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>

View File

@@ -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 (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl rounded-t-2xl border border-neutral-700 bg-neutral-900 p-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="truncate font-medium">{state.album.title}</p>
<p className="truncate text-sm text-neutral-400">{state.album.artist}</p>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={() => setExpanded(false)} aria-label="collapse player" className="text-neutral-400">
</button>
<button type="button" onClick={close} aria-label="close player" className="text-neutral-400">
</button>
</div>
</div>
<ol className="mt-3 max-h-64 space-y-1 overflow-y-auto text-sm">
{state.tracks.map((t, i) => (
<li
key={t.id}
className={`flex justify-between rounded-lg px-2 py-1 ${
i === state.index ? 'bg-neutral-800 text-emerald-400' : 'text-neutral-300'
} ${state.failed.includes(i) ? 'line-through opacity-50' : ''}`}
>
<span className="truncate">
{t.track ?? i + 1}. {t.title}
</span>
{t.duration != null && <span className="ml-2 shrink-0 text-neutral-500">{Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}</span>}
</li>
))}
</ol>
</div>
)
}
return (
<div className="fixed inset-x-0 bottom-16 z-20 mx-auto max-w-3xl px-4">
<div className="flex items-center gap-3 rounded-2xl border border-neutral-700 bg-neutral-900/95 p-2 shadow-lg backdrop-blur">
<button type="button" onClick={() => setExpanded(true)} aria-label="expand player" className="shrink-0">
<Cover src={null} alt="" className="size-10" />
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{current?.title}</p>
<p className="truncate text-xs text-neutral-400">{state.album.title}</p>
</div>
<button type="button" onClick={prev} aria-label="previous track" className="px-1 text-neutral-300">
</button>
<button
type="button"
onClick={toggle}
aria-label={state.playing ? 'pause' : 'play'}
className="rounded-full bg-emerald-500 px-3 py-1.5 text-neutral-950"
>
{state.playing ? '⏸' : '▶'}
</button>
<button type="button" onClick={next} aria-label="next track" className="px-1 text-neutral-300">
</button>
<button type="button" onClick={close} aria-label="close player" className="px-1 text-neutral-500">
</button>
</div>
</div>
)
}

View File

@@ -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<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
}

View File

@@ -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 }

View File

@@ -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) {

View File

@@ -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(
<MemoryRouter initialEntries={['/item/1']}>
<PlayerProvider>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</PlayerProvider>
<Routes>
<Route path="/item/:id" element={<ItemPage />} />
<Route path="/library" element={<p>library</p>} />
</Routes>
</MemoryRouter>
)
}
@@ -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 () => {

View File

@@ -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<typeof import('../src/api.js')>()
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 (
<button type="button" onClick={() => void load({ id: 'a1', title: 'Motion', artist: 'TCO' })}>
load
</button>
)
}
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(
<PlayerProvider>
<Loader />
<MiniBar />
</PlayerProvider>
)
}
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()
})
})

View File

@@ -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<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())
})
})
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])
})
})