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) => (
- -
-
- {t.track ?? i + 1}. {t.title}
-
- {t.duration != null && {Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}}
-
- ))}
-
-
- )
- }
-
- 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])
- })
-})