1
0

feat: mini player bar with expandable track list

This commit is contained in:
2026-09-03 22:28:20 +02:00
parent c8b596bb02
commit b2711b2b79
4 changed files with 184 additions and 2 deletions

View File

@@ -2,6 +2,8 @@ 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'
@@ -30,7 +32,10 @@ export default function App() {
<Route <Route
element={ element={
<Gate> <Gate>
<Shell /> <PlayerProvider>
<Shell />
<MiniBar />
</PlayerProvider>
</Gate> </Gate>
} }
> >

View File

@@ -0,0 +1,78 @@
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 gap-2">
<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>
)
}

72
web/test/minibar.test.tsx Normal file
View File

@@ -0,0 +1,72 @@
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())
})
})

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor, act } from '@testing-library/react' import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { PlayerProvider, usePlayer } from '../src/player/PlayerContext.js' import { PlayerProvider, usePlayer, playerReducer, type PlayerState } from '../src/player/PlayerContext.js'
import type { Track } from '../src/types.js' import type { Track } from '../src/types.js'
vi.mock('../src/api.js', async (importOriginal) => { vi.mock('../src/api.js', async (importOriginal) => {
@@ -106,3 +106,30 @@ describe('PlayerProvider', () => {
await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()) 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])
})
})