From b2711b2b79302887f6d65f4ec1eb6f8b83b29aa8 Mon Sep 17 00:00:00 2001 From: Samu Date: Thu, 3 Sep 2026 22:28:20 +0200 Subject: [PATCH] feat: mini player bar with expandable track list --- web/src/App.tsx | 7 +++- web/src/player/MiniBar.tsx | 78 ++++++++++++++++++++++++++++++++++++++ web/test/minibar.test.tsx | 72 +++++++++++++++++++++++++++++++++++ web/test/player.test.tsx | 29 +++++++++++++- 4 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 web/src/player/MiniBar.tsx create mode 100644 web/test/minibar.test.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index d69c3ad..518f327 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,6 +2,8 @@ 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' @@ -30,7 +32,10 @@ export default function App() { - + + + + } > diff --git a/web/src/player/MiniBar.tsx b/web/src/player/MiniBar.tsx new file mode 100644 index 0000000..e0f78ab --- /dev/null +++ b/web/src/player/MiniBar.tsx @@ -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 ( +
+
+
+

{state.album.title}

+

{state.album.artist}

+
+
+ + +
+
+
    + {state.tracks.map((t, i) => ( +
  1. + + {t.track ?? i + 1}. {t.title} + + {t.duration != null && {Math.floor(t.duration / 60)}:{String(t.duration % 60).padStart(2, '0')}} +
  2. + ))} +
+
+ ) + } + + return ( +
+
+ +
+

{current?.title}

+

{state.album.title}

+
+ + + + +
+
+ ) +} diff --git a/web/test/minibar.test.tsx b/web/test/minibar.test.tsx new file mode 100644 index 0000000..62a8241 --- /dev/null +++ b/web/test/minibar.test.tsx @@ -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() + 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()) + }) +}) diff --git a/web/test/player.test.tsx b/web/test/player.test.tsx index 481cf4f..6f77c39 100644 --- a/web/test/player.test.tsx +++ b/web/test/player.test.tsx @@ -1,7 +1,7 @@ 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 } from '../src/player/PlayerContext.js' +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) => { @@ -106,3 +106,30 @@ describe('PlayerProvider', () => { 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]) + }) +})