From ed082226a0f94f4295bb47ed9a442af732224e47 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 24 Apr 2026 21:41:24 -0400 Subject: [PATCH] feat(web): add PlayerBar component Bottom-bar UI with left: cover+title+linked artist, center: stacked seek row (elapsed / slider / duration) + transport (prev/play/next), right: shuffle/repeat/volume. Loading shows spinner; error replaces transport with retry card. --- web/src/lib/components/PlayerBar.svelte | 157 ++++++++++++++++++++++ web/src/lib/components/PlayerBar.test.ts | 161 +++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 web/src/lib/components/PlayerBar.svelte create mode 100644 web/src/lib/components/PlayerBar.test.ts diff --git a/web/src/lib/components/PlayerBar.svelte b/web/src/lib/components/PlayerBar.svelte new file mode 100644 index 00000000..a35c8932 --- /dev/null +++ b/web/src/lib/components/PlayerBar.svelte @@ -0,0 +1,157 @@ + + +{#if current} +
+ +
+ + + +
+
{current.title}
+ + {current.artist_name} + +
+
+ + + {#if player.state === 'error'} + + {:else} +
+
+ + {formatDuration(player.position)} + + + + {formatDuration(player.duration)} + +
+
+ + + +
+
+ {/if} + + +
+ + + +
+
+{/if} diff --git a/web/src/lib/components/PlayerBar.test.ts b/web/src/lib/components/PlayerBar.test.ts new file mode 100644 index 00000000..e817aa64 --- /dev/null +++ b/web/src/lib/components/PlayerBar.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import type { TrackRef } from '$lib/api/types'; + +// Mutable state the mocked store reads from. +const state = vi.hoisted(() => ({ + current: undefined as TrackRef | undefined, + queue: [] as TrackRef[], + index: 0, + state: 'idle' as 'idle' | 'loading' | 'playing' | 'paused' | 'error', + position: 0, + duration: 0, + volume: 1, + shuffle: false, + repeat: 'off' as 'off' | 'all' | 'one', + error: null as string | null +})); + +vi.mock('$lib/player/store.svelte', () => ({ + player: { + get queue() { return state.queue; }, + get index() { return state.index; }, + get current() { return state.current; }, + get state() { return state.state; }, + get isPlaying() { return state.state === 'playing'; }, + get position() { return state.position; }, + get duration() { return state.duration; }, + get volume() { return state.volume; }, + get shuffle() { return state.shuffle; }, + get repeat() { return state.repeat; }, + get error() { return state.error; } + }, + togglePlay: vi.fn(), + skipNext: vi.fn(), + skipPrev: vi.fn(), + seekTo: vi.fn(), + setVolume: vi.fn(), + toggleShuffle: vi.fn(), + cycleRepeat: vi.fn(), + playQueue: vi.fn() +})); + +import PlayerBar from './PlayerBar.svelte'; +import { + togglePlay, skipNext, skipPrev, seekTo, setVolume, + toggleShuffle, cycleRepeat, playQueue +} from '$lib/player/store.svelte'; + +function track(): TrackRef { + return { + id: 't1', title: 'So What', + album_id: 'xyz', album_title: 'Kind of Blue', + artist_id: 'md', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, + duration_sec: 545, stream_url: '/api/tracks/t1/stream' + }; +} + +beforeEach(() => { + state.queue = [track()]; + state.current = state.queue[0]; + state.index = 0; + state.state = 'paused'; + state.position = 42; + state.duration = 545; + state.volume = 0.8; + state.shuffle = false; + state.repeat = 'off'; + state.error = null; +}); + +afterEach(() => vi.clearAllMocks()); + +describe('PlayerBar', () => { + test('renders title, linked artist, linked cover', () => { + render(PlayerBar); + expect(screen.getByText('So What')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); + const cover = screen.getByRole('link', { name: /open album/i }); + expect(cover).toHaveAttribute('href', '/albums/xyz'); + }); + + test('play button click calls togglePlay', async () => { + render(PlayerBar); + await fireEvent.click(screen.getByRole('button', { name: /play|pause/i })); + expect(togglePlay).toHaveBeenCalledTimes(1); + }); + + test('skip-next click calls skipNext; skip-prev click calls skipPrev', async () => { + render(PlayerBar); + await fireEvent.click(screen.getByRole('button', { name: /next/i })); + await fireEvent.click(screen.getByRole('button', { name: /previous/i })); + expect(skipNext).toHaveBeenCalledTimes(1); + expect(skipPrev).toHaveBeenCalledTimes(1); + }); + + test('skip-prev disabled when index=0 AND position<3', () => { + state.index = 0; + state.position = 1; + render(PlayerBar); + expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); + }); + + test('skip-next disabled at end with repeat=off', () => { + state.queue = [track()]; + state.index = 0; + state.repeat = 'off'; + render(PlayerBar); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + test('seek slider input calls seekTo(value)', async () => { + render(PlayerBar); + const slider = screen.getByLabelText(/seek/i) as HTMLInputElement; + await fireEvent.input(slider, { target: { value: '120' } }); + expect(seekTo).toHaveBeenCalledWith(120); + }); + + test('volume slider input calls setVolume(value)', async () => { + render(PlayerBar); + const slider = screen.getByLabelText(/volume/i) as HTMLInputElement; + await fireEvent.input(slider, { target: { value: '0.3' } }); + expect(setVolume).toHaveBeenCalledWith(0.3); + }); + + test('shuffle button click calls toggleShuffle; active class reflects shuffle', () => { + state.shuffle = true; + render(PlayerBar); + const btn = screen.getByRole('button', { name: /shuffle/i }); + expect(btn.getAttribute('aria-pressed')).toBe('true'); + }); + + test('repeat button aria-label reflects current mode', () => { + state.repeat = 'one'; + render(PlayerBar); + expect(screen.getByRole('button', { name: /repeat one/i })).toBeInTheDocument(); + }); + + test('loading state shows spinner in place of play icon', () => { + state.state = 'loading'; + render(PlayerBar); + expect(screen.getByTestId('play-spinner')).toBeInTheDocument(); + }); + + test('error state renders retry card; retry click calls playQueue(queue, index)', async () => { + state.state = 'error'; + state.error = 'Playback failed.'; + render(PlayerBar); + expect(screen.getByText(/playback failed/i)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(playQueue).toHaveBeenCalledWith(state.queue, state.index); + }); + + test('elapsed and total render formatted duration', () => { + state.position = 65; + state.duration = 245; + render(PlayerBar); + expect(screen.getByText('1:05')).toBeInTheDocument(); + expect(screen.getByText('4:05')).toBeInTheDocument(); + }); +});