feat(web): My Wave button, mood picker and endless wave playback (M4)
- 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:
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} */
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Грустное"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "Сессия волны завершена"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user