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' }) {
+