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
@@ -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');
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user