From 6a1382dad5a2edd3f6155ae8f5c3fddee31786ad Mon Sep 17 00:00:00 2001
From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com>
Date: Thu, 29 Oct 2026 22:44:54 +0300
Subject: [PATCH] fix(player): toggle current track without restarting playback
---
apps/web/src/App.test.tsx | 35 +++++++++++++++++++++++++++++++
apps/web/src/entities/catalog.tsx | 12 +++++++----
apps/web/src/test/fakeApi.ts | 2 ++
3 files changed, 45 insertions(+), 4 deletions(-)
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);