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

+40
View File
@@ -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'],
+22
View File
@@ -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,
+58
View File
@@ -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;
+56 -8
View File
@@ -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>
); );
+1 -1
View File
@@ -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>
); );
} }
+175
View File
@@ -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>
)}
</>
);
}
+1 -1
View File
@@ -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 && (
+4
View File
@@ -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();
+10 -1
View File
@@ -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();
} }
} }
+35
View File
@@ -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');
}; };
+18
View File
@@ -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() {} });
-29
View File
@@ -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;
}
} }
+27 -9
View File
@@ -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>
); );
} }
+194
View File
@@ -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;
}
}
+162
View File
@@ -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>
);
}