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

+35
View File
@@ -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<string, 'like' | 'dislike'>();
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');
};