feat(player): add current track to playlists
This commit is contained in:
1 parent
78c006184b
commit
fb20258489
8 files changed
+47
-11
No files matched your search
@@ -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 }}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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} />}
|
||||
</>;
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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';
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user