feat(web): player bar, playable track lists and Collection (M3)
- player bar with cover, like and dislike, previous / play / next, shuffle, repeat, seek bar and volume; PlayerProvider lives with the signed-in shell and restores the last track and position paused - clicking a row (or Enter) plays the list from that track with its context (album, search, history, collection); the current track is highlighted and rows have a like button; a reaction refreshes lists - Collection: history grouped by day with clearing, likes with "play all" and dislikes, all with paging; sidebar section - events and playback state are flushed when the page is hidden; player timers survive StrictMode remounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ec3c2810c8
commit
976ffb574b
15 files changed
+803
-49
No files matched your search
@@ -245,6 +245,46 @@ describe('search box', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('player', () => {
|
||||||
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
|
||||||
|
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
|
||||||
|
await user.click(row);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||||
|
expect(row).toHaveAttribute('aria-current', 'true');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Нравится' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.calls).toContainEqual({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101',
|
||||||
|
body: { reaction: 'like' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления.
|
||||||
|
(document.activeElement as HTMLElement).blur();
|
||||||
|
await user.keyboard(' ');
|
||||||
|
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Уход со страницы досылает события и сохраняет состояние плеера.
|
||||||
|
window.dispatchEvent(new Event('pagehide'));
|
||||||
|
await waitFor(() => {
|
||||||
|
const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
|
||||||
|
expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' });
|
||||||
|
});
|
||||||
|
expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({
|
||||||
|
track_id: '00000000-0000-4000-8000-000000000101',
|
||||||
|
queue_index: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('safeRedirect', () => {
|
describe('safeRedirect', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['/settings', '/settings'],
|
['/settings', '/settings'],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { SourcesPage } from '../pages/admin/SourcesPage';
|
|||||||
import { UsersPage } from '../pages/admin/UsersPage';
|
import { UsersPage } from '../pages/admin/UsersPage';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||||
@@ -126,6 +127,24 @@ const auditRoute = createRoute({
|
|||||||
component: AuditPage,
|
component: AuditPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const historyRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/history',
|
||||||
|
component: () => <CollectionPage tab="history" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const likesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/likes',
|
||||||
|
component: () => <CollectionPage tab="likes" />,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dislikesRoute = createRoute({
|
||||||
|
getParentRoute: () => appRoute,
|
||||||
|
path: '/collection/dislikes',
|
||||||
|
component: () => <CollectionPage tab="dislikes" />,
|
||||||
|
});
|
||||||
|
|
||||||
const searchRoute = createRoute({
|
const searchRoute = createRoute({
|
||||||
getParentRoute: () => appRoute,
|
getParentRoute: () => appRoute,
|
||||||
path: '/search',
|
path: '/search',
|
||||||
@@ -194,6 +213,9 @@ const routeTree = rootRoute.addChildren([
|
|||||||
homeRoute,
|
homeRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
|
historyRoute,
|
||||||
|
likesRoute,
|
||||||
|
dislikesRoute,
|
||||||
artistRoute,
|
artistRoute,
|
||||||
albumRoute,
|
albumRoute,
|
||||||
usersRoute,
|
usersRoute,
|
||||||
|
|||||||
@@ -119,12 +119,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.num {
|
.num {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playHint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(5, 7, 13, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:not(.unavailable) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .playHint,
|
||||||
|
.track:focus-visible .playHint {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current .trackTitle {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text3);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .like,
|
||||||
|
.track:focus-within .like,
|
||||||
|
.liked {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liked {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowMeta {
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
.trackMain {
|
.trackMain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Fragment } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { albumIcon, personIcon } from '../shared/ui/icons';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import type { PlayContext } from '../player/player';
|
||||||
|
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
|
||||||
import { cx, Icon } from '../shared/ui/ui';
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
import s from './catalog.module.css';
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
@@ -101,11 +103,26 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Список треков. showAlbum — колонка альбома (поиск); иначе номер трека (страница альбома).
|
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
|
||||||
* Воспроизведение появится в M3: пока строка только показывает трек.
|
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
|
||||||
*/
|
*/
|
||||||
export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; showAlbum?: boolean; numbered?: boolean }) {
|
export function TrackList({
|
||||||
|
tracks,
|
||||||
|
showAlbum,
|
||||||
|
numbered,
|
||||||
|
context,
|
||||||
|
rowMeta,
|
||||||
|
}: {
|
||||||
|
tracks: Track[];
|
||||||
|
showAlbum?: boolean;
|
||||||
|
numbered?: boolean;
|
||||||
|
context: PlayContext;
|
||||||
|
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
|
||||||
|
rowMeta?: (t: Track, i: number) => string | undefined;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const current = usePlayerState().track;
|
||||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
let lastDisc: number | null = null;
|
let lastDisc: number | null = null;
|
||||||
return (
|
return (
|
||||||
@@ -114,19 +131,39 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh
|
|||||||
const disc = tr.disc_no ?? 1;
|
const disc = tr.disc_no ?? 1;
|
||||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||||
lastDisc = disc;
|
lastDisc = disc;
|
||||||
|
const isCurrent = current?.id === tr.id;
|
||||||
|
const play = () => tr.available && player.playList(tracks, i, context);
|
||||||
|
const liked = tr.reaction === 'like';
|
||||||
|
const meta = rowMeta?.(tr, i);
|
||||||
return (
|
return (
|
||||||
<Fragment key={tr.id}>
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
<div
|
<div
|
||||||
role="row"
|
role="row"
|
||||||
className={cx(s.track, !tr.available && s.unavailable)}
|
tabIndex={tr.available ? 0 : -1}
|
||||||
|
aria-current={isCurrent || undefined}
|
||||||
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className={s.num}>{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}</span>
|
<span className={s.num}>
|
||||||
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
|
||||||
|
{tr.available && (
|
||||||
|
<span className={s.playHint} aria-hidden>
|
||||||
|
<Icon src={playIcon} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className={s.trackMain}>
|
<span className={s.trackMain}>
|
||||||
<span className={s.trackTitle}>{tr.title}</span>
|
<span className={s.trackTitle}>{tr.title}</span>
|
||||||
<span className={s.trackArtists}>
|
<span className={s.trackArtists}>
|
||||||
<ArtistLinks artists={tr.artists} />
|
<ArtistLinks artists={tr.artists} />
|
||||||
|
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{showAlbum && (
|
{showAlbum && (
|
||||||
@@ -134,7 +171,18 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh
|
|||||||
{tr.album.title}
|
{tr.album.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
<span className={s.end}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, liked && s.liked)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{a && <TrackList tracks={a.tracks} numbered />}
|
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { TrackList, type Track } from '../entities/catalog';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { playIcon } from '../shared/ui/icons';
|
||||||
|
import { Button, Dialog, FormError } from '../shared/ui/ui';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
import s from './page.module.css';
|
||||||
|
|
||||||
|
export type CollectionTab = 'history' | 'likes' | 'dislikes';
|
||||||
|
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
|
||||||
|
|
||||||
|
export function CollectionPage({ tab }: { tab: CollectionTab }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<h1>{t('collection.title')}</h1>
|
||||||
|
</div>
|
||||||
|
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
|
||||||
|
{TABS.map((k) => (
|
||||||
|
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
|
||||||
|
{t(`collection.${k}`)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ключ дня в локальном времени: история группируется на клиенте. */
|
||||||
|
function dayKey(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
|
||||||
|
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
|
||||||
|
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
|
||||||
|
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function History() {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const history = useInfiniteQuery({
|
||||||
|
queryKey: ['history'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const clear = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
|
||||||
|
});
|
||||||
|
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
|
||||||
|
const groups: { key: string; title: string; items: typeof items }[] = [];
|
||||||
|
for (const it of items) {
|
||||||
|
const key = dayKey(it.played_at);
|
||||||
|
const last = groups.at(-1);
|
||||||
|
if (last?.key === key) last.items.push(it);
|
||||||
|
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
|
||||||
|
}
|
||||||
|
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(true)}>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||||
|
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
||||||
|
{groups.map((g) => (
|
||||||
|
<section key={g.key} className={c.section}>
|
||||||
|
<h2>{g.title}</h2>
|
||||||
|
<TrackList
|
||||||
|
tracks={g.items.map((i) => i.track)}
|
||||||
|
context={{ type: 'history' }}
|
||||||
|
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
{history.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{confirm && (
|
||||||
|
<Dialog
|
||||||
|
title={t('collection.clear_history')}
|
||||||
|
onClose={() => setConfirm(false)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={() => setConfirm(false)}>
|
||||||
|
{t('form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirm(false);
|
||||||
|
clear.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('collection.clear_history')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={s.note}>{t('collection.clear_confirm')}</p>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
|
||||||
|
const list = useInfiniteQuery({
|
||||||
|
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
});
|
||||||
|
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
|
||||||
|
const total = list.data?.pages[0]?.total ?? 0;
|
||||||
|
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{reaction === 'like' && tracks.length > 0 && (
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
|
||||||
|
{t('collection.play_all')}
|
||||||
|
</Button>
|
||||||
|
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
|
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
||||||
|
)}
|
||||||
|
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('collection.more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -89,7 +89,7 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
|
|||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<section className={c.section}>
|
<section className={c.section}>
|
||||||
{type === 'all' && <h2>{t('search.tracks')}</h2>}
|
{type === 'all' && <h2>{t('search.tracks')}</h2>}
|
||||||
<TrackList tracks={tracks} showAlbum />
|
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{results.hasNextPage && (
|
{results.hasNextPage && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
@@ -8,9 +9,12 @@ const PlayerContext = createContext<Player | null>(null);
|
|||||||
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
|
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
|
||||||
export function PlayerProvider({ children }: { children: ReactNode }) {
|
export function PlayerProvider({ children }: { children: ReactNode }) {
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [player] = useState(() => new Player(session));
|
const [player] = useState(() => new Player(session));
|
||||||
|
player.onReaction = () => void queryClient.invalidateQueries();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
player.start();
|
||||||
void player.restore().catch(() => undefined);
|
void player.restore().catch(() => undefined);
|
||||||
const onHide = () => player.onHide();
|
const onHide = () => player.onHide();
|
||||||
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
|
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export class Player {
|
|||||||
private preloaded: string | null = null;
|
private preloaded: string | null = null;
|
||||||
private refreshedFor: string | null = null;
|
private refreshedFor: string | null = null;
|
||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly session: AuthSession,
|
private readonly session: AuthSession,
|
||||||
@@ -93,6 +95,10 @@ export class Player {
|
|||||||
};
|
};
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.bindMediaSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
|
start() {
|
||||||
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +152,8 @@ export class Player {
|
|||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.audio.play().catch(() => this.update({ playing: false }));
|
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
|
||||||
|
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
pause() {
|
pause() {
|
||||||
@@ -238,6 +245,7 @@ export class Player {
|
|||||||
apply(prev);
|
apply(prev);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
this.onReaction?.();
|
||||||
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
|
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,6 +529,7 @@ export class Player {
|
|||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.audio.removeAttribute('src');
|
this.audio.removeAttribute('src');
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
|
this.timers = [];
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,20 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta
|
|||||||
let seq = 0;
|
let seq = 0;
|
||||||
|
|
||||||
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
|
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
|
||||||
|
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
|
||||||
|
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
|
||||||
|
id: `00000000-0000-4000-8000-00000000010${i}`,
|
||||||
|
title,
|
||||||
|
duration_ms: 200000,
|
||||||
|
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
|
||||||
|
album: albumRef,
|
||||||
|
genres: [],
|
||||||
|
disc_no: 1,
|
||||||
|
track_no: i + 1,
|
||||||
|
available: true,
|
||||||
|
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
|
||||||
|
reaction: null as 'like' | 'dislike' | null,
|
||||||
|
}));
|
||||||
const albumSummary = {
|
const albumSummary = {
|
||||||
id: '00000000-0000-4000-8000-0000000000bb',
|
id: '00000000-0000-4000-8000-0000000000bb',
|
||||||
title: 'Группа крови',
|
title: 'Группа крови',
|
||||||
@@ -56,6 +70,7 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
let current: string | null = null; // id вошедшего (одна сессия на тест)
|
||||||
let token = 0;
|
let token = 0;
|
||||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
|
||||||
const me = (u: FakeUser): Me => ({
|
const me = (u: FakeUser): Me => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -171,11 +186,31 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
case `GET /api/v1/albums/${albumSummary.id}`:
|
||||||
|
return json({
|
||||||
|
...albumRef,
|
||||||
|
artist: kino,
|
||||||
|
duration_ms: 400000,
|
||||||
|
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
|
||||||
|
});
|
||||||
|
case 'POST /api/v1/events':
|
||||||
|
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
|
||||||
|
case 'GET /api/v1/me/playback-state':
|
||||||
|
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
|
||||||
|
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
|
||||||
|
case 'PUT /api/v1/me/playback-state':
|
||||||
|
return json({ ...body, updated_at: new Date().toISOString() });
|
||||||
case `GET /api/v1/artists/${kino.id}`:
|
case `GET /api/v1/artists/${kino.id}`:
|
||||||
return json({ ...kino, album_count: 1, track_count: 0 });
|
return json({ ...kino, album_count: 1, track_count: 0 });
|
||||||
case `GET /api/v1/artists/${kino.id}/albums`:
|
case `GET /api/v1/artists/${kino.id}/albums`:
|
||||||
return json({ items: [albumSummary] });
|
return json({ items: [albumSummary] });
|
||||||
}
|
}
|
||||||
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
if (reactionPath) {
|
||||||
|
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||||
|
else reactions.delete(reactionPath[1]!);
|
||||||
|
return new Response(null, { status: 204 });
|
||||||
|
}
|
||||||
return problem(404, 'common.not_found');
|
return problem(404, 'common.not_found');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,19 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
|
|
||||||
|
// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер.
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: false });
|
||||||
|
this.dispatchEvent(new Event('playing'));
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
|
||||||
|
configurable: true,
|
||||||
|
value(this: HTMLMediaElement) {
|
||||||
|
Object.defineProperty(this, 'paused', { configurable: true, value: true });
|
||||||
|
this.dispatchEvent(new Event('pause'));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
|
||||||
@@ -129,32 +129,6 @@
|
|||||||
animation: pipuIn 0.2s ease-out;
|
animation: pipuIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
|
||||||
grid-area: player;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 0 20px;
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
background: var(--bar-a);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
width: 52px;
|
|
||||||
height: 52px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--surface2);
|
|
||||||
color: var(--text3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.playerEmpty {
|
|
||||||
color: var(--text3);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 72px 1fr;
|
grid-template-columns: 72px 1fr;
|
||||||
@@ -205,7 +179,4 @@
|
|||||||
padding: 20px 16px;
|
padding: 20px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player {
|
|
||||||
padding: 0 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -12,17 +12,32 @@ import {
|
|||||||
libraryFillIcon,
|
libraryFillIcon,
|
||||||
libraryIcon,
|
libraryIcon,
|
||||||
logoutIcon,
|
logoutIcon,
|
||||||
musicIcon,
|
historyFillIcon,
|
||||||
|
historyIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
dislikeFillIcon,
|
||||||
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
storageFillIcon,
|
storageFillIcon,
|
||||||
storageIcon,
|
storageIcon,
|
||||||
} from '../shared/ui/icons';
|
} from '../shared/ui/icons';
|
||||||
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||||
import { LanguageSwitch } from './LanguageSwitch';
|
import { LanguageSwitch } from './LanguageSwitch';
|
||||||
|
import { PlayerProvider } from '../player/context';
|
||||||
|
import { PlayerBar } from './PlayerBar';
|
||||||
import { SearchBox } from './SearchBox';
|
import { SearchBox } from './SearchBox';
|
||||||
import s from './AppShell.module.css';
|
import s from './AppShell.module.css';
|
||||||
|
|
||||||
type NavPath = '/' | '/admin/users' | '/admin/audit' | '/admin/sources' | '/admin/library';
|
type NavPath =
|
||||||
|
| '/'
|
||||||
|
| '/admin/users'
|
||||||
|
| '/admin/audit'
|
||||||
|
| '/admin/sources'
|
||||||
|
| '/admin/library'
|
||||||
|
| '/collection/history'
|
||||||
|
| '/collection/likes'
|
||||||
|
| '/collection/dislikes';
|
||||||
|
|
||||||
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +54,11 @@ function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string;
|
|||||||
|
|
||||||
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
return useAuthState().status === 'signed_in' ? <Shell /> : null;
|
return useAuthState().status === 'signed_in' ? (
|
||||||
|
<PlayerProvider>
|
||||||
|
<Shell />
|
||||||
|
</PlayerProvider>
|
||||||
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Shell() {
|
function Shell() {
|
||||||
@@ -84,6 +103,10 @@ function Shell() {
|
|||||||
|
|
||||||
<nav className={s.side} aria-label={t('nav.main')}>
|
<nav className={s.side} aria-label={t('nav.main')}>
|
||||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
|
<div className={s.navSection}>{t('nav.collection')}</div>
|
||||||
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||||
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
|
||||||
|
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
|
||||||
{staff && (
|
{staff && (
|
||||||
<>
|
<>
|
||||||
<div className={s.navSection}>{t('nav.admin')}</div>
|
<div className={s.navSection}>{t('nav.admin')}</div>
|
||||||
@@ -103,12 +126,7 @@ function Shell() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className={s.player} aria-label={t('player.empty')}>
|
<PlayerBar />
|
||||||
<span className={s.cover}>
|
|
||||||
<Icon src={musicIcon} size={22} />
|
|
||||||
</span>
|
|
||||||
<span className={s.playerEmpty}>{t('player.empty')}</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
.bar {
|
||||||
|
grid-area: player;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--bar-a);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.now {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artists {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text2);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: none;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon:hover {
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #05070d;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play[aria-busy='true'] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 38px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
appearance: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range:hover::-webkit-slider-thumb,
|
||||||
|
.range:focus-visible::-webkit-slider-thumb {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.range::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume {
|
||||||
|
flex: 0 1 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.bar {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
padding: 0 12px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress,
|
||||||
|
.right,
|
||||||
|
.controls > :first-child,
|
||||||
|
.controls > :last-child,
|
||||||
|
.now > button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import type { CSSProperties } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import {
|
||||||
|
dislikeFillIcon,
|
||||||
|
dislikeIcon,
|
||||||
|
likeFillIcon,
|
||||||
|
likeIcon,
|
||||||
|
musicIcon,
|
||||||
|
nextIcon,
|
||||||
|
pauseIcon,
|
||||||
|
playIcon,
|
||||||
|
prevIcon,
|
||||||
|
repeatIcon,
|
||||||
|
repeatOneIcon,
|
||||||
|
shuffleIcon,
|
||||||
|
volumeIcon,
|
||||||
|
volumeOffIcon,
|
||||||
|
} from '../shared/ui/icons';
|
||||||
|
import { cx, Icon } from '../shared/ui/ui';
|
||||||
|
import s from './PlayerBar.module.css';
|
||||||
|
|
||||||
|
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
|
||||||
|
export function PlayerBar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const player = usePlayer();
|
||||||
|
const st = usePlayerState();
|
||||||
|
const track = st.track;
|
||||||
|
|
||||||
|
if (!track) {
|
||||||
|
return (
|
||||||
|
<footer className={s.bar} aria-label={t('player.label')}>
|
||||||
|
<span className={s.empty}>
|
||||||
|
<span className={s.placeholder}>
|
||||||
|
<Icon src={musicIcon} size={22} />
|
||||||
|
</span>
|
||||||
|
{t('player.empty')}
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const react = (r: 'like' | 'dislike') =>
|
||||||
|
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className={s.bar} aria-label={t('player.label')}>
|
||||||
|
<div className={s.now}>
|
||||||
|
<Cover album={track.album} size={52} />
|
||||||
|
<div className={s.meta}>
|
||||||
|
<span className={s.title}>{track.title}</span>
|
||||||
|
<span className={s.artists}>
|
||||||
|
<ArtistLinks artists={track.artists} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, track.reaction === 'dislike' && s.danger)}
|
||||||
|
aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}
|
||||||
|
aria-pressed={track.reaction === 'dislike'}
|
||||||
|
onClick={() => react('dislike')}
|
||||||
|
>
|
||||||
|
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, track.reaction === 'like' && s.accent)}
|
||||||
|
aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={track.reaction === 'like'}
|
||||||
|
onClick={() => react('like')}
|
||||||
|
>
|
||||||
|
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.center}>
|
||||||
|
<div className={s.controls}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, st.shuffle && s.accent)}
|
||||||
|
aria-label={t('player.shuffle')}
|
||||||
|
aria-pressed={st.shuffle}
|
||||||
|
onClick={() => player.toggleShuffle()}
|
||||||
|
>
|
||||||
|
<Icon src={shuffleIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
|
||||||
|
<Icon src={prevIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.play}
|
||||||
|
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
|
||||||
|
aria-busy={st.loading || undefined}
|
||||||
|
onClick={() => player.toggle()}
|
||||||
|
>
|
||||||
|
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
|
||||||
|
<Icon src={nextIcon} size={26} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
|
||||||
|
aria-label={repeatLabel}
|
||||||
|
title={repeatLabel}
|
||||||
|
onClick={() => player.cycleRepeat()}
|
||||||
|
>
|
||||||
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={s.progress}>
|
||||||
|
<span className={s.time}>{formatDuration(st.positionMs)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={s.range}
|
||||||
|
min={0}
|
||||||
|
max={st.durationMs || track.duration_ms}
|
||||||
|
step={1000}
|
||||||
|
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
|
||||||
|
aria-label={t('player.progress')}
|
||||||
|
aria-valuetext={formatDuration(st.positionMs)}
|
||||||
|
onChange={(e) => player.seek(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
{st.error && (
|
||||||
|
<span className={s.error} role="status">
|
||||||
|
{t(st.error as 'player.error')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.right}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={s.icon}
|
||||||
|
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
|
||||||
|
onClick={() => player.toggleMute()}
|
||||||
|
>
|
||||||
|
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
className={cx(s.range, s.volume)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
value={st.muted ? 0 : st.volume}
|
||||||
|
aria-label={t('player.volume')}
|
||||||
|
onChange={(e) => player.setVolume(Number(e.target.value))}
|
||||||
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user