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
@@ -119,12 +119,70 @@
|
||||
}
|
||||
|
||||
.num {
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text3);
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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 s from './catalog.module.css';
|
||||
|
||||
@@ -101,11 +103,26 @@ export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Список треков. showAlbum — колонка альбома (поиск); иначе номер трека (страница альбома).
|
||||
* Воспроизведение появится в M3: пока строка только показывает трек.
|
||||
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
|
||||
* Клик по строке запускает весь список с этого трека в контексте 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 player = usePlayer();
|
||||
const current = usePlayerState().track;
|
||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||
let lastDisc: number | null = null;
|
||||
return (
|
||||
@@ -114,19 +131,39 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
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 (
|
||||
<Fragment key={tr.id}>
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
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')}
|
||||
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.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
@@ -134,7 +171,18 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh
|
||||
{tr.album.title}
|
||||
</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>
|
||||
</Fragment>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user