feat(web): player bar, playable track lists and Collection (M3)
ci / ts / ts (packages + web) (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 2m33s

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:11:12 +03:00
1 parent ec3c2810c8
commit 976ffb574b
15 files changed
+803 -49

No files matched your search

+58
View File
@@ -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;
+56 -8
View File
@@ -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>
);