feat: mini player bar with expandable track list
This commit is contained in:
@@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
78
web/src/player/MiniBar.tsx
Normal file
78
web/src/player/MiniBar.tsx
Normal 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
72
web/test/minibar.test.tsx
Normal 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())
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user