diff --git a/apps/mobile/src/app/player.tsx b/apps/mobile/src/app/player.tsx
index 1e266ab..5cd4356 100644
--- a/apps/mobile/src/app/player.tsx
+++ b/apps/mobile/src/app/player.tsx
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
{track.artists.map((a) => a.name).join(', ')}
+ router.push({ pathname: '/playlists', params: { track: track.id } })}
+ accessibilityRole="button" accessibilityLabel={t('playlists.add')}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
+
+ {t('playlists.add')}
+
diff --git a/apps/web/e2e/playlists.spec.ts b/apps/web/e2e/playlists.spec.ts
index 18fcc40..f18595a 100644
--- a/apps/web/e2e/playlists.spec.ts
+++ b/apps/web/e2e/playlists.spec.ts
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
- await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
+ await page.getByRole('listitem').first().click();
+ const player = page.getByRole('contentinfo', { name: 'Плеер' });
+ await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index 7cfe8c8..57b1495 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
});
describe('playlists', () => {
+ it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
+ const alice = makeUser('alice', 'user');
+ const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
+ await screen.findByRole('heading', { name: 'Группа крови' });
+ await user.click(screen.getAllByRole('listitem')[0]!);
+ const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
+ await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
+ await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
+ await user.type(screen.getByLabelText('Название'), 'Из плеера');
+ await user.click(screen.getByRole('button', { name: 'Сохранить' }));
+ await waitFor(() => expect(api.playlists.size).toBe(1));
+ expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
+ // Simulate automatic advancement while the picker is open.
+ await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
+ await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
+ await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
+ expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
+ });
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
diff --git a/apps/web/src/entities/playlists.tsx b/apps/web/src/entities/playlists.tsx
index 9f999fe..6ac106c 100644
--- a/apps/web/src/entities/playlists.tsx
+++ b/apps/web/src/entities/playlists.tsx
@@ -1,12 +1,12 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { useCallback, useState } from 'react';
+import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
-import { queueIcon } from '../shared/ui/icons';
+import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
@@ -61,7 +61,7 @@ export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
-function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
+const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -84,14 +84,15 @@ function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: ()
;
-}
+});
-export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
+export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
- const [open, setOpen] = useState(false);
+ const [selectedIds, setSelectedIds] = useState(null);
+ const close = useCallback(() => setSelectedIds(null), []);
return <>
- {compact ?
- : }
- {open && setOpen(false)} />}
+ {compact ?
+ : }
+ {selectedIds && }
>;
}
diff --git a/apps/web/src/pages/playlists.module.css b/apps/web/src/pages/playlists.module.css
index 70ca1f2..ec39db9 100644
--- a/apps/web/src/pages/playlists.module.css
+++ b/apps/web/src/pages/playlists.module.css
@@ -9,4 +9,6 @@
.actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; }
+.hero { overflow-wrap: anywhere; }
+@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts
index 43f1703..dd7df25 100644
--- a/apps/web/src/shared/ui/icons.ts
+++ b/apps/web/src/shared/ui/icons.ts
@@ -70,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
+export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
diff --git a/apps/web/src/widgets/PlayerBar.module.css b/apps/web/src/widgets/PlayerBar.module.css
index 86ba601..0e2147c 100644
--- a/apps/web/src/widgets/PlayerBar.module.css
+++ b/apps/web/src/widgets/PlayerBar.module.css
@@ -311,7 +311,9 @@ a.source:hover {
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
- .right > :not(.queueButton):not(.shareButton) { display: none; }
+ .right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
+ .error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
+ padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
diff --git a/apps/web/src/widgets/PlayerBar.tsx b/apps/web/src/widgets/PlayerBar.tsx
index d87d715..2ece1d8 100644
--- a/apps/web/src/widgets/PlayerBar.tsx
+++ b/apps/web/src/widgets/PlayerBar.tsx
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
+import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
@@ -178,6 +179,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {