+
{header && {t('catalog.disc', { n: disc })}
}
{
+ if (!(e.target as HTMLElement).closest('a, button')) play();
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && e.target === e.currentTarget) play();
+ }}
>
-
{numbered ? (tr.track_no ?? i + 1) : }
+
+ {numbered ? (tr.track_no ?? i + 1) : }
+ {tr.available && (
+
+
+
+ )}
+
{tr.title}
+ {meta && · {meta}}
{showAlbum && (
@@ -134,7 +171,18 @@ export function TrackList({ tracks, showAlbum, numbered }: { tracks: Track[]; sh
{tr.album.title}
)}
-
{formatDuration(tr.duration_ms)}
+
+
+ {formatDuration(tr.duration_ms)}
+
);
diff --git a/apps/web/src/pages/AlbumPage.tsx b/apps/web/src/pages/AlbumPage.tsx
index 7c92e76..3f2db59 100644
--- a/apps/web/src/pages/AlbumPage.tsx
+++ b/apps/web/src/pages/AlbumPage.tsx
@@ -47,7 +47,7 @@ export function AlbumPage({ id }: { id: string }) {
)}
- {a && }
+ {a && }
);
}
diff --git a/apps/web/src/pages/CollectionPage.tsx b/apps/web/src/pages/CollectionPage.tsx
new file mode 100644
index 0000000..77bc48b
--- /dev/null
+++ b/apps/web/src/pages/CollectionPage.tsx
@@ -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 (
+
+
+
{t('collection.title')}
+
+
+ {TABS.map((k) => (
+
+ {t(`collection.${k}`)}
+
+ ))}
+
+ {tab === 'history' ?
:
}
+
+ );
+}
+
+/** Ключ дня в локальном времени: история группируется на клиенте. */
+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 && (
+
+
+
+
+ )}
+ {history.error || clear.error ? errorText(history.error ?? clear.error) : null}
+ {history.isSuccess && items.length === 0 && {t('collection.empty_history')}
}
+ {groups.map((g) => (
+
+ {g.title}
+ i.track)}
+ context={{ type: 'history' }}
+ rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
+ />
+
+ ))}
+ {history.hasNextPage && (
+
+ )}
+ {confirm && (
+
+ )}
+ >
+ );
+}
+
+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 && (
+
+
+ {t('catalog.track_count', { count: total })}
+
+ )}
+ {list.error ? errorText(list.error) : null}
+ {list.isSuccess && tracks.length === 0 && (
+ {t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
+ )}
+ {tracks.length > 0 && }
+ {list.hasNextPage && (
+
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/pages/SearchPage.tsx b/apps/web/src/pages/SearchPage.tsx
index 6a93e6f..d7018c4 100644
--- a/apps/web/src/pages/SearchPage.tsx
+++ b/apps/web/src/pages/SearchPage.tsx
@@ -89,7 +89,7 @@ export function SearchPage({ q, type }: { q: string; type: SearchType }) {
{tracks.length > 0 && (
{type === 'all' && {t('search.tracks')}
}
-
+
)}
{results.hasNextPage && (
diff --git a/apps/web/src/player/context.tsx b/apps/web/src/player/context.tsx
index 36c034a..c060e41 100644
--- a/apps/web/src/player/context.tsx
+++ b/apps/web/src/player/context.tsx
@@ -1,3 +1,4 @@
+import { useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useSession } from '../app/session';
@@ -8,9 +9,12 @@ const PlayerContext = createContext(null);
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
export function PlayerProvider({ children }: { children: ReactNode }) {
const session = useSession();
+ const queryClient = useQueryClient();
const [player] = useState(() => new Player(session));
+ player.onReaction = () => void queryClient.invalidateQueries();
useEffect(() => {
+ player.start();
void player.restore().catch(() => undefined);
const onHide = () => player.onHide();
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts
index 8538bdc..9c7dbf8 100644
--- a/apps/web/src/player/player.ts
+++ b/apps/web/src/player/player.ts
@@ -75,6 +75,8 @@ export class Player {
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType[] = [];
+ /** Реакция сохранена на сервере — списки на экране нужно перечитать. */
+ onReaction: (() => void) | null = null;
constructor(
private readonly session: AuthSession,
@@ -93,6 +95,10 @@ export class Player {
};
this.bindAudio();
this.bindMediaSession();
+ }
+
+ /** Запуск фоновой отправки событий (парный вызов — dispose). */
+ start() {
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);
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() {
@@ -238,6 +245,7 @@ export class Player {
apply(prev);
throw e;
}
+ this.onReaction?.();
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
}
@@ -521,6 +529,7 @@ export class Player {
this.audio.pause();
this.audio.removeAttribute('src');
for (const t of this.timers) clearInterval(t);
+ this.timers = [];
void this.events.flush();
}
}
diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts
index cfd9eed..9187b8d 100644
--- a/apps/web/src/test/fakeApi.ts
+++ b/apps/web/src/test/fakeApi.ts
@@ -18,6 +18,20 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta
let seq = 0;
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 = {
id: '00000000-0000-4000-8000-0000000000bb',
title: 'Группа крови',
@@ -56,6 +70,7 @@ export function fakeApi(initial: FakeUser[]) {
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
const calls: { method: string; path: string; body: unknown }[] = [];
+ const reactions = new Map();
const me = (u: FakeUser): Me => ({
id: u.id,
@@ -171,11 +186,31 @@ export function fakeApi(initial: FakeUser[]) {
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}`:
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
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');
};
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
index bb02c60..fdefaa4 100644
--- a/apps/web/src/test/setup.ts
+++ b/apps/web/src/test/setup.ts
@@ -1 +1,19 @@
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() {} });
diff --git a/apps/web/src/widgets/AppShell.module.css b/apps/web/src/widgets/AppShell.module.css
index 0aea899..db83706 100644
--- a/apps/web/src/widgets/AppShell.module.css
+++ b/apps/web/src/widgets/AppShell.module.css
@@ -129,32 +129,6 @@
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) {
.shell {
grid-template-columns: 72px 1fr;
@@ -205,7 +179,4 @@
padding: 20px 16px;
}
- .player {
- padding: 0 16px;
- }
}
diff --git a/apps/web/src/widgets/AppShell.tsx b/apps/web/src/widgets/AppShell.tsx
index 667e135..3f9122e 100644
--- a/apps/web/src/widgets/AppShell.tsx
+++ b/apps/web/src/widgets/AppShell.tsx
@@ -12,17 +12,32 @@ import {
libraryFillIcon,
libraryIcon,
logoutIcon,
- musicIcon,
+ historyFillIcon,
+ historyIcon,
+ likeFillIcon,
+ likeIcon,
+ dislikeFillIcon,
+ dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
+import { PlayerProvider } from '../player/context';
+import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
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 }) {
return (
@@ -39,7 +54,11 @@ function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string;
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
- return useAuthState().status === 'signed_in' ? : null;
+ return useAuthState().status === 'signed_in' ? (
+
+
+
+ ) : null;
}
function Shell() {
@@ -84,6 +103,10 @@ function Shell() {