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

@@ -1235,6 +1235,8 @@ export interface components {
/** @description Подписанная ссылка на аудио (12 ч); null — трек недоступен */
stream_url: string | null;
reaction: components["schemas"]["Reaction"] | null;
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
features: components["schemas"]["TrackFeatures"] | null;
};
Artist: {
/** Format: uuid */
@@ -1281,6 +1283,11 @@ export interface components {
corrected_query: string | null;
next_cursor: string | null;
};
TrackFeatures: {
bpm: number | null;
energy: number | null;
loudness_lufs: number | null;
};
/** @enum {string} */
StreamQuality: "auto" | "original" | "high" | "medium" | "low";
/** @enum {string} */
+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;
+11 -1
View File
@@ -148,6 +148,16 @@
"sources": "Storage",
"error_files": "Files with errors",
"reindex": "Rebuild search index",
"reindex_started": "Rebuild started"
"reindex_started": "Rebuild started",
"analysis": "Audio analysis",
"analysis_progress": "Analysed {{done}} of {{total}}",
"analysis_failed": "Could not analyse: {{count}}",
"analyzer": "Analyzer version: {{ver}}",
"wave": "My Wave, last 7 days",
"wave_served": "Tracks served",
"wave_sessions": "Sessions",
"wave_listen": "Played ≥ 50%",
"wave_early_skip": "Early skips",
"wave_likes": "Likes per 100"
}
}
+30 -1
View File
@@ -89,7 +89,10 @@
"session_current": "This device",
"session_last_used": "Active: {{date}}",
"session_end": "Sign out",
"logout_all": "Sign out everywhere"
"logout_all": "Sign out everywhere",
"wave_variant": "Wave style",
"wave_orb": "Orb",
"wave_liquid": "Liquid"
},
"form": {
"show_password": "Show password",
@@ -155,5 +158,31 @@
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
"play_all": "Play all",
"more": "Show more"
},
"wave": {
"title": "My Wave",
"for_you": "Tuned to your taste",
"start": "Start My Wave",
"pause": "Pause My Wave",
"activity_label": "Activity",
"mood_label": "Mood",
"reset": "Reset",
"empty": "Nothing to suggest yet — the library is empty",
"fallback": "Recommendations are simplified for now",
"activities": {
"running": "Running",
"workout": "Workout",
"waking_up": "Waking up",
"focus": "Focus",
"commute": "Commute",
"chill": "Chilling",
"sleep": "Falling asleep"
},
"moods": {
"energetic": "Energetic",
"happy": "Happy",
"calm": "Calm",
"sad": "Sad"
}
}
}
+6 -1
View File
@@ -45,7 +45,9 @@
"name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
"query_empty": "Enter a query"
"query_empty": "Enter a query",
"wave_activity_invalid": "Unknown activity",
"wave_mood_invalid": "Unknown mood"
},
"users": {
"not_found": "User not found",
@@ -66,5 +68,8 @@
"link_invalid": "The link has expired — reload the page",
"track_unavailable": "Track is unavailable",
"transcode_failed": "Could not prepare the track"
},
"wave": {
"session_not_found": "The wave session has ended"
}
}
+11 -1
View File
@@ -148,6 +148,16 @@
"sources": "Хранилища",
"error_files": "Файлы с ошибками",
"reindex": "Переиндексировать поиск",
"reindex_started": "Переиндексация запущена"
"reindex_started": "Переиндексация запущена",
"analysis": "Аудиоанализ",
"analysis_progress": "Проанализировано {{done}} из {{total}}",
"analysis_failed": "Не удалось проанализировать: {{count}}",
"analyzer": "Версия анализатора: {{ver}}",
"wave": "Моя волна за 7 дней",
"wave_served": "Выдано треков",
"wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100"
}
}
+30 -1
View File
@@ -89,7 +89,10 @@
"session_current": "Это устройство",
"session_last_used": "Активность: {{date}}",
"session_end": "Завершить",
"logout_all": "Выйти на всех устройствах"
"logout_all": "Выйти на всех устройствах",
"wave_variant": "Вид волны",
"wave_orb": "Сфера",
"wave_liquid": "Жидкая"
},
"form": {
"show_password": "Показать пароль",
@@ -155,5 +158,31 @@
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
"play_all": "Слушать всё",
"more": "Показать ещё"
},
"wave": {
"title": "Моя волна",
"for_you": "Под ваш вкус",
"start": "Запустить «Мою волну»",
"pause": "Пауза «Моей волны»",
"activity_label": "Занятие",
"mood_label": "Настроение",
"reset": "Сбросить",
"empty": "В библиотеке пока нечего предложить",
"fallback": "Рекомендации временно упрощены",
"activities": {
"running": "Бегаю",
"workout": "Тренировка",
"waking_up": "Просыпаюсь",
"focus": "Работаю",
"commute": "В дороге",
"chill": "Отдыхаю",
"sleep": "Засыпаю"
},
"moods": {
"energetic": "Энергичное",
"happy": "Весёлое",
"calm": "Спокойное",
"sad": "Грустное"
}
}
}
+6 -1
View File
@@ -45,7 +45,9 @@
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
"query_empty": "Введите запрос"
"query_empty": "Введите запрос",
"wave_activity_invalid": "Неизвестное занятие",
"wave_mood_invalid": "Неизвестное настроение"
},
"users": {
"not_found": "Пользователь не найден",
@@ -66,5 +68,8 @@
"link_invalid": "Ссылка устарела — обновите страницу",
"track_unavailable": "Трек недоступен",
"transcode_failed": "Не удалось подготовить трек"
},
"wave": {
"session_not_found": "Сессия волны завершена"
}
}