feat: navidrome deep link replaces in-app player
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user