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

+50
View File
@@ -65,6 +65,42 @@ export function LibraryPage() {
</div>
))}
</div>
{d && (
<section className={s.section}>
<h2>{t('library.analysis')}</h2>
<ScanLineBar
done={d.analysis.done}
total={d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed}
label={t('library.analysis_progress', {
done: d.analysis.done,
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})}
/>
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section>
)}
{d && (
<section className={s.section}>
<h2>{t('library.wave')}</h2>
<div className={l.tiles}>
{(
[
['wave_served', d.wave.served.toLocaleString()],
['wave_sessions', d.wave.sessions.toLocaleString()],
['wave_listen', pct(d.wave.listen_through_rate)],
['wave_early_skip', pct(d.wave.early_skip_rate)],
['wave_likes', d.wave.like_rate === null ? '—' : d.wave.like_rate.toFixed(1)],
] as const
).map(([k, v]) => (
<div key={k} className={l.tile}>
<span className={l.tileValue}>{v}</span>
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
</div>
))}
</div>
</section>
)}
{d && d.sources.length > 0 && (
<section className={s.section}>
<h2>{t('library.sources')}</h2>
@@ -92,3 +128,17 @@ export function LibraryPage() {
</div>
);
}
const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%`);
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<div className={l.scan}>
<div className={l.scanText}>{label}</div>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} />
</div>
</div>
);
}