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
@@ -47,7 +47,7 @@ export function AlbumPage({ id }: { id: string }) {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{a && <TrackList tracks={a.tracks} numbered />}
|
||||
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
|
||||
</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 && (
|
||||
<section className={c.section}>
|
||||
{type === 'all' && <h2>{t('search.tracks')}</h2>}
|
||||
<TrackList tracks={tracks} showAlbum />
|
||||
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
|
||||
</section>
|
||||
)}
|
||||
{results.hasNextPage && (
|
||||
|
||||
Reference in new issue
Block a user