diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index ca2bfad..6cce3b9 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -531,6 +531,41 @@ describe('search box', () => { }); describe('player', () => { + it.each(['album', 'artist'] as const)('toggles the current %s row without restarting or replacing the listening attempt', async (kind) => { + const alice = makeUser('alice', 'user'); + const id = kind === 'artist' ? '00000000-0000-4000-8000-0000000000aa' : '00000000-0000-4000-8000-0000000000bb'; + const { user, api } = await renderApp([alice], `/${kind}/${id}`, { signedInAs: alice }); + const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!; + await user.click(row); + const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); + await waitFor(() => expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument()); + await user.keyboard('{ArrowRight}'); + const progress = within(bar).getByRole('slider', { name: 'Позиция' }); + expect(progress).toHaveValue('10000'); + await user.click(row); + expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument(); + expect(progress).toHaveValue('10000'); + await user.click(row); + expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument(); + expect(progress).toHaveValue('10000'); + await user.keyboard('{Enter}'); + expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument(); + await user.keyboard('{Enter}'); + expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument(); + expect(progress).toHaveValue('10000'); + window.dispatchEvent(new Event('pagehide')); + await waitFor(() => { + const events = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events); + expect(events.filter((event) => event.type === 'play_start')).toHaveLength(1); + expect(events.find((event) => event.type === 'play_start')).toMatchObject({ context_type: kind }); + expect(events.some((event) => event.type === 'skip')).toBe(false); + }); + const other = screen.getAllByText('Группа крови').map((node) => node.closest('[role="listitem"]')).find(Boolean)!; + await user.click(other); + await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument()); + expect(progress).toHaveValue('0'); + }); + it('opens a shared track after login and copies its page URL from the player', async () => { const alice = makeUser('alice', 'user'); const path = '/track/00000000-0000-4000-8000-000000000101'; diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index 2c3b1f0..cace051 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -11,7 +11,7 @@ import { useSession } from '../app/session'; import { formatDuration } from '../shared/lib/format'; import { usePlayer, usePlayerState } from '../player/context'; import type { PlayContext } from '../player/player'; -import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons'; +import { albumIcon, likeFillIcon, likeIcon, pauseIcon, personIcon, playIcon, undoIcon} from '../shared/ui/icons'; import { cx, Icon } from '../shared/ui/ui'; import s from './catalog.module.css'; @@ -143,7 +143,7 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) { /** * Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома). - * Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры. + * Клик или Enter запускает список с выбранного трека; у текущего трека переключает паузу без перезапуска. */ export function TrackList({ tracks, @@ -183,7 +183,11 @@ export function TrackList({ const row = (tr: Track, i: number) => { const isCurrent = current?.id === tr.id; - const play = () => tr.available && player.playList(tracks, i, context); + const play = () => { + if (!tr.available) return; + if (isCurrent) player.toggle(); + else player.playList(tracks, i, context); + }; const liked = tr.reaction === 'like'; const meta = rowMeta?.(tr, i); return ( @@ -206,7 +210,7 @@ export function TrackList({ {numbered ? (tr.track_no ?? i + 1) : } {tr.available && ( - + )} diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index b94bca7..97a82d9 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -472,6 +472,8 @@ export function fakeApi(initial: FakeUser[]) { return json({ ...kino, album_count: 1, track_count: 0 }); case `GET /api/v1/artists/${kino.id}/albums`: return json({ items: [albumSummary] }); + case `GET /api/v1/artists/${kino.id}/top-tracks`: + return json({ items: albumTracks }); } if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) { onboarding.set(path.split('/')[5]!, null);