feat(web): My Wave button, mood picker and endless wave playback (M4)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s

- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:29:38 +03:00
1 parent 3908814cda
commit eaedd1fc62
24 files changed
+1193 -244

No files matched your search

+18
View File
@@ -14,6 +14,7 @@ const track = (id: string, duration_ms = 200_000): Track => ({
available: true,
stream_url: `/s/${id}`,
reaction: null,
features: null,
});
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
@@ -62,6 +63,23 @@ describe('PlayQueue', () => {
});
});
describe('PlayQueue in the wave', () => {
it('appends batches and drops upcoming tracks', () => {
const q = new PlayQueue();
q.set([track('a'), track('b'), track('c')]);
q.next();
expect(q.remaining).toBe(1);
q.append([track('d'), track('e')]);
expect(q.remaining).toBe(3);
q.dropUpcoming();
expect(q.current?.id).toBe('b');
expect(q.remaining).toBe(0);
expect(q.tracks.map((t) => t.id)).toEqual(['a', 'b']);
q.append([track('x')]);
expect(q.next()?.id).toBe('x');
});
});
describe('PlayAttempt', () => {
it('counts only time actually played', () => {
const a = new PlayAttempt(track('a'));
+21
View File
@@ -101,6 +101,27 @@ export class PlayQueue {
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
}
/** Сколько треков осталось после текущего (без учёта повтора). */
get remaining(): number {
return Math.max(this.order.length - this.pos - 1, 0);
}
/** Добавить треки в конец (бесконечная волна). */
append(tracks: Track[]) {
const start = this.tracks.length;
this.tracks = [...this.tracks, ...tracks];
this.order = [...this.order, ...tracks.map((_, i) => start + i)];
}
/** Убрать ещё не начатые треки после текущего (волна пересобирается после реакции). */
dropUpcoming() {
const keep = this.order.slice(0, this.pos + 1);
const tracks = keep.map((i) => this.tracks[i]!);
this.tracks = tracks;
this.order = tracks.map((_, i) => i);
this.pos = Math.max(tracks.length - 1, 0);
}
jump(index: number) {
const p = this.order.indexOf(index);
if (p >= 0) this.pos = p;