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

+10 -2
View File
@@ -38,6 +38,11 @@ type TrackRow struct {
Album AlbumRow
Genres []string
Available bool
// Признаки анализа; Analyzed = false — трек ещё не проанализирован.
Analyzed bool
BPM *float32
Energy *float32
LoudnessLUFS *float32
}
// ArtistRow — исполнитель со счётчиками.
@@ -58,8 +63,10 @@ const trackSelect = `
COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE tg.track_id = t.id), '{}'),
EXISTS (SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.id = t.primary_file_id AND s.status = 'mounted')
WHERE f.id = t.primary_file_id AND s.status = 'mounted'),
tf.track_id IS NOT NULL, tf.bpm, tf.energy, tf.loudness_lufs
FROM tracks t
LEFT JOIN track_features tf ON tf.track_id = t.id
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id`
@@ -70,7 +77,8 @@ func scanTrack(row pgx.Row) (TrackRow, error) {
)
err := row.Scan(&t.ID, &t.Title, &t.DurationMs, &t.DiscNo, &t.TrackNo, &artists,
&t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.CoverColors,
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available)
&t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available, &t.Analyzed, &t.BPM, &t.Energy,
&t.LoudnessLUFS)
if errors.Is(err, pgx.ErrNoRows) {
return TrackRow{}, ErrNotFound
}
+5 -1
View File
@@ -362,10 +362,14 @@ func (d DTO) Track(t TrackRow) gen.Track {
if genres == nil {
genres = []string{}
}
return gen.Track{
out := gen.Track{
Id: t.ID, Title: t.Title, DurationMs: t.DurationMs, Artists: artists, Album: d.albumRef(t.Album),
Genres: genres, DiscNo: t.DiscNo, TrackNo: t.TrackNo, Available: t.Available,
}
if t.Analyzed {
out.Features = &gen.TrackFeatures{Bpm: t.BPM, Energy: t.Energy, LoudnessLufs: t.LoudnessLUFS}
}
return out
}
// AlbumSummary переводит альбом в схему AlbumSummary.