feat: navidrome deep link replaces in-app player
This commit is contained in:
@@ -2,8 +2,6 @@ import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { AuthProvider, useAuth } from './auth'
|
import { AuthProvider, useAuth } from './auth'
|
||||||
import Shell from './shell'
|
import Shell from './shell'
|
||||||
import { PlayerProvider } from './player/PlayerContext.js'
|
|
||||||
import MiniBar from './player/MiniBar.js'
|
|
||||||
import SetupPage from './pages/SetupPage'
|
import SetupPage from './pages/SetupPage'
|
||||||
import LoginPage from './pages/LoginPage'
|
import LoginPage from './pages/LoginPage'
|
||||||
import LibraryPage from './pages/LibraryPage'
|
import LibraryPage from './pages/LibraryPage'
|
||||||
@@ -34,10 +32,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<Gate>
|
<Gate>
|
||||||
<PlayerProvider>
|
<Shell />
|
||||||
<Shell />
|
|
||||||
<MiniBar />
|
|
||||||
</PlayerProvider>
|
|
||||||
</Gate>
|
</Gate>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type {
|
import type {
|
||||||
AlbumTracks,
|
|
||||||
BackupFile,
|
BackupFile,
|
||||||
Candidate,
|
Candidate,
|
||||||
CollectionResponse,
|
CollectionResponse,
|
||||||
@@ -106,10 +105,6 @@ export const api = {
|
|||||||
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
startSync: () => request<SyncState>('/api/library/sync', { method: 'POST' }),
|
||||||
searchAlbums: (q: string) => request<{ albums: DigitalAlbum[] }>(`/api/library/albums?q=${encodeURIComponent(q)}`),
|
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'),
|
getStats: () => request<Stats>('/api/stats'),
|
||||||
exportUrl: () => '/api/export',
|
exportUrl: () => '/api/export',
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { api, ApiError } from '../api.js'
|
import { api, ApiError } from '../api.js'
|
||||||
import { usePlayer } from '../player/PlayerContext.js'
|
|
||||||
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
import type { DigitalAlbum, Item, ItemDetail } from '../types.js'
|
||||||
import Cover from '../components/Cover.js'
|
import Cover from '../components/Cover.js'
|
||||||
|
|
||||||
@@ -22,7 +21,6 @@ function timeAgo(iso: string): string {
|
|||||||
export default function ItemPage() {
|
export default function ItemPage() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { load } = usePlayer()
|
|
||||||
const [item, setItem] = useState<ItemDetail | null>(null)
|
const [item, setItem] = useState<ItemDetail | null>(null)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
const [matching, setMatching] = useState(false)
|
const [matching, setMatching] = useState(false)
|
||||||
@@ -110,14 +108,15 @@ export default function ItemPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.ripStatus === 'ripped' && item.matchedAlbum && (
|
{item.matchedAlbum && (
|
||||||
<button
|
<a
|
||||||
type="button"
|
href={item.matchedAlbum.webUrl}
|
||||||
onClick={() => void load({ id: item.matchedAlbum!.subsonicId, title: item.title, artist: item.artist })}
|
target="_blank"
|
||||||
className="w-full rounded-xl bg-emerald-500 py-2.5 font-medium text-neutral-950"
|
rel="noreferrer"
|
||||||
|
className="block w-full rounded-xl bg-emerald-500 py-2.5 text-center font-medium text-neutral-950"
|
||||||
>
|
>
|
||||||
▶ Play album
|
Listen in Navidrome ↗
|
||||||
</button>
|
</a>
|
||||||
)}
|
)}
|
||||||
{item.matchedAlbum?.lastPlayedAt && (
|
{item.matchedAlbum?.lastPlayedAt && (
|
||||||
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
<p className="text-xs text-neutral-500">Last played {timeAgo(item.matchedAlbum.lastPlayedAt)}</p>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -68,22 +68,11 @@ export interface DigitalAlbum {
|
|||||||
title: string
|
title: string
|
||||||
artist: 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 {
|
export interface MatchedAlbum {
|
||||||
id: number
|
id: number
|
||||||
subsonicId: string
|
subsonicId: string
|
||||||
lastPlayedAt: string | null
|
lastPlayedAt: string | null
|
||||||
|
webUrl: string
|
||||||
}
|
}
|
||||||
export interface Stats {
|
export interface Stats {
|
||||||
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
totals: { items: number; ripped: number; notRipped: number; onLoan: number }
|
||||||
|
|||||||
@@ -10,15 +10,6 @@ function jsonOk(body: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('api additions', () => {
|
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 () => {
|
it('stats, loans, backups urls', async () => {
|
||||||
fetchMock.mockImplementation(() => jsonOk({}))
|
fetchMock.mockImplementation(() => jsonOk({}))
|
||||||
await api.getStats()
|
await api.getStats()
|
||||||
@@ -36,7 +27,7 @@ describe('api additions', () => {
|
|||||||
fetchMock.mockReturnValue(
|
fetchMock.mockReturnValue(
|
||||||
Promise.resolve(new Response(JSON.stringify({ error: 'no_subsonic_config' }), { status: 409 }))
|
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).toBeInstanceOf(ApiError)
|
||||||
expect((err as ApiError).code).toBe('no_subsonic_config')
|
expect((err as ApiError).code).toBe('no_subsonic_config')
|
||||||
})
|
})
|
||||||
@@ -45,7 +36,6 @@ describe('api additions', () => {
|
|||||||
fetchMock.mockClear()
|
fetchMock.mockClear()
|
||||||
fetchMock.mockImplementation(() => jsonOk({ ok: true }))
|
fetchMock.mockImplementation(() => jsonOk({ ok: true }))
|
||||||
await api.returnLoan(7)
|
await api.returnLoan(7)
|
||||||
await api.markPlayed('a1')
|
|
||||||
await api.logout()
|
await api.logout()
|
||||||
await api.triggerBackup()
|
await api.triggerBackup()
|
||||||
for (const call of fetchMock.mock.calls) {
|
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 userEvent from '@testing-library/user-event'
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
import ItemPage from '../src/pages/ItemPage.js'
|
import ItemPage from '../src/pages/ItemPage.js'
|
||||||
import { PlayerProvider } from '../src/player/PlayerContext.js'
|
|
||||||
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
||||||
|
|
||||||
vi.mock('../src/api.js', async (importOriginal) => {
|
vi.mock('../src/api.js', async (importOriginal) => {
|
||||||
@@ -17,8 +16,6 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
|||||||
setMatch: vi.fn(),
|
setMatch: vi.fn(),
|
||||||
deleteItem: vi.fn(),
|
deleteItem: vi.fn(),
|
||||||
searchAlbums: vi.fn(),
|
searchAlbums: vi.fn(),
|
||||||
getAlbumTracks: vi.fn(),
|
|
||||||
markPlayed: vi.fn(),
|
|
||||||
lendItem: vi.fn(),
|
lendItem: vi.fn(),
|
||||||
returnLoan: vi.fn(),
|
returnLoan: vi.fn(),
|
||||||
},
|
},
|
||||||
@@ -48,20 +45,21 @@ const item: ItemDetail = {
|
|||||||
loan: null,
|
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 }
|
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
|
||||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
|
||||||
vi.mocked(api.getItem).mockReset()
|
vi.mocked(api.getItem).mockReset()
|
||||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||||
vi.mocked(api.setRip).mockReset()
|
vi.mocked(api.setRip).mockReset()
|
||||||
vi.mocked(api.setMatch).mockReset()
|
vi.mocked(api.setMatch).mockReset()
|
||||||
vi.mocked(api.deleteItem).mockReset()
|
vi.mocked(api.deleteItem).mockReset()
|
||||||
vi.mocked(api.searchAlbums).mockReset()
|
vi.mocked(api.searchAlbums).mockReset()
|
||||||
vi.mocked(api.getAlbumTracks).mockReset()
|
|
||||||
vi.mocked(api.markPlayed).mockReset()
|
|
||||||
vi.mocked(api.lendItem).mockReset()
|
vi.mocked(api.lendItem).mockReset()
|
||||||
vi.mocked(api.returnLoan).mockReset()
|
vi.mocked(api.returnLoan).mockReset()
|
||||||
})
|
})
|
||||||
@@ -69,12 +67,10 @@ beforeEach(() => {
|
|||||||
function renderItem() {
|
function renderItem() {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={['/item/1']}>
|
<MemoryRouter initialEntries={['/item/1']}>
|
||||||
<PlayerProvider>
|
<Routes>
|
||||||
<Routes>
|
<Route path="/item/:id" element={<ItemPage />} />
|
||||||
<Route path="/item/:id" element={<ItemPage />} />
|
<Route path="/library" element={<p>library</p>} />
|
||||||
<Route path="/library" element={<p>library</p>} />
|
</Routes>
|
||||||
</Routes>
|
|
||||||
</PlayerProvider>
|
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -119,7 +115,7 @@ describe('ItemPage', () => {
|
|||||||
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
|
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)
|
vi.mocked(api.getItem)
|
||||||
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
||||||
.mockResolvedValue({ ...rippedItem } as never)
|
.mockResolvedValue({ ...rippedItem } as never)
|
||||||
@@ -129,7 +125,7 @@ describe('ItemPage', () => {
|
|||||||
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
||||||
renderItem()
|
renderItem()
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
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: /re-match/i }))
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
||||||
@@ -137,7 +133,7 @@ describe('ItemPage', () => {
|
|||||||
await userEvent.click(albumRadio)
|
await userEvent.click(albumRadio)
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
|
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 () => {
|
it('unlink clears the match', async () => {
|
||||||
@@ -189,21 +185,19 @@ describe('ItemPage', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
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.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()
|
renderItem()
|
||||||
const play = await screen.findByRole('button', { name: /play album/i })
|
const link = await screen.findByRole('link', { name: /listen in navidrome/i })
|
||||||
await userEvent.click(play)
|
expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1')
|
||||||
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
|
expect(link.getAttribute('target')).toBe('_blank')
|
||||||
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
renderItem()
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
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 () => {
|
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