feat(player): add current track to playlists

This commit is contained in:
mrleo1nid committed 2026-10-22 07:54:57 +03:00
1 parent 78c006184b
commit fb20258489
8 files changed
+47 -11

No files matched your search

+6
View File
@@ -91,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
<Pressable onPress={() => 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' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View>
<View style={{ gap: 6 }}>
+3 -1
View File
@@ -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();
+20
View File
@@ -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 });
+10 -9
View File
@@ -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: ()
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
});
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<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>;
}
+2
View File
@@ -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; } }
+1
View File
@@ -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';
+3 -1
View File
@@ -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)); }
}
+2
View File
@@ -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' }) {
</div>
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>