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 }}>
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
{track.artists.map((a) => a.name).join(', ')}
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
</Text>
|
</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>
|
||||||
|
|
||||||
<View style={{ gap: 6 }}>
|
<View style={{ gap: 6 }}>
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ for (const width of [320, 1280]) {
|
|||||||
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
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 page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
|||||||
@@ -409,6 +409,26 @@ describe('safeRedirect', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('playlists', () => {
|
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 () => {
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
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';
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
export type Playlist = components['schemas']['Playlist'];
|
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. */
|
/** 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 { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const cache = useQueryClient();
|
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>
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>;
|
</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 { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
||||||
|
const close = useCallback(() => setSelectedIds(null), []);
|
||||||
return <>
|
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>
|
{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={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
||||||
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
@@ -9,4 +9,6 @@
|
|||||||
.actions { display: flex; align-items: center; }
|
.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 { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||||
.actions button:disabled { opacity: .3; cursor: default; }
|
.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; } }
|
@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 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 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 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';
|
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
|
||||||
@@ -311,7 +311,9 @@ a.source:hover {
|
|||||||
.controls { gap: 2px; }
|
.controls { gap: 2px; }
|
||||||
.play, .icon { width: 44px; height: 44px; }
|
.play, .icon { width: 44px; height: 44px; }
|
||||||
.right { display: flex; gap: 0; }
|
.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));
|
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||||
max-height: calc(100dvh - 180px - 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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { shareTrack } from '../shared/lib/share';
|
import { shareTrack } from '../shared/lib/share';
|
||||||
@@ -178,6 +179,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.right}>
|
<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()}>
|
<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} />
|
<Icon src={shareIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in new issue
Block a user