diff --git a/api/openapi.yaml b/api/openapi.yaml index a3b9f2f..12073be 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1763,7 +1763,7 @@ components: type: string Track: type: object - required: [id, title, duration_ms, artists, album, genres, disc_no, track_no, available, stream_url, reaction] + required: [id, title, duration_ms, artists, album, genres, disc_no, track_no, available, stream_url, reaction, features] properties: id: type: string @@ -1799,6 +1799,11 @@ components: nullable: true allOf: - $ref: "#/components/schemas/Reaction" + features: + nullable: true + description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости + allOf: + - $ref: "#/components/schemas/TrackFeatures" Artist: type: object required: [id, name, album_count, track_count] @@ -1904,6 +1909,19 @@ components: next_cursor: type: string nullable: true + TrackFeatures: + type: object + required: [bpm, energy, loudness_lufs] + properties: + bpm: + type: number + nullable: true + energy: + type: number + nullable: true + loudness_lufs: + type: number + nullable: true StreamQuality: type: string enum: [auto, original, high, medium, low] diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx index 306eb78..1ac3b2f 100644 --- a/apps/web/src/App.test.tsx +++ b/apps/web/src/App.test.tsx @@ -79,7 +79,7 @@ describe('sign-in', () => { await user.type(screen.getByLabelText(label), 'a-strong-owner-pass'); } await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' })); - await screen.findByText('Привет, owner!'); + await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); expect(router.state.location.pathname).toBe('/'); expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({ new_username: 'owner', @@ -93,7 +93,7 @@ describe('sign-in', () => { await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass'); await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass'); await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' })); - await screen.findByText('Привет, bob!'); + await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); }); it('signs out from the profile menu', async () => { @@ -110,7 +110,7 @@ describe('navigation by role', () => { it('hides the admin section from listeners and blocks the route', async () => { const alice = makeUser('alice', 'user'); const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice }); - await screen.findByText('Привет, alice!'); + await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); expect(router.state.location.pathname).toBe('/'); expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument(); }); @@ -285,6 +285,34 @@ describe('player', () => { }); }); +describe('my wave', () => { + it('starts the wave and switches the mood on the fly', async () => { + const alice = makeUser('alice', 'user'); + const { user, api } = await renderApp([alice], '/', { signedInAs: alice }); + + await user.click(await screen.findByRole('button', { name: 'Бегаю' })); + await user.click(screen.getByRole('button', { name: 'Запустить «Мою волну»' })); + + const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); + await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument()); + expect(api.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/wave/sessions')?.body).toEqual({ + activity: 'running', + mood: null, + }); + expect(screen.getByRole('button', { name: 'Пауза «Моей волны»' })).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Спокойное' })); + await waitFor(() => + expect(api.calls.find((c) => c.method === 'PATCH' && c.path.startsWith('/api/v1/wave/sessions/'))?.body).toEqual({ + activity: 'running', + mood: 'calm', + }), + ); + expect(within(bar).getByText('Группа крови')).toBeInTheDocument(); // музыка не прервалась + expect(screen.getByText('Бегаю · Спокойное')).toBeInTheDocument(); + }); +}); + describe('safeRedirect', () => { it.each([ ['/settings', '/settings'], diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 9c0224e..2a3cf0c 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,22 +1,170 @@ +import { unwrap } from '@pipu/core'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useUser } from '../app/session'; -import { playIcon } from '../shared/ui/icons'; -import { Icon } from '../shared/ui/ui'; +import { useSession, useUser } from '../app/session'; +import { usePlayer, usePlayerState } from '../player/context'; +import { useErrorText } from '../shared/lib/errors'; +import { + pauseIcon, + playIcon, + waveBedtimeIcon, + waveBoltIcon, + waveCarIcon, + waveFitnessIcon, + waveHappyIcon, + waveHeadphonesIcon, + waveRainyIcon, + waveRunIcon, + waveSpaIcon, + waveSunIcon, + waveWeekendIcon, +} from '../shared/ui/icons'; +import { cx, FormError, Icon } from '../shared/ui/ui'; +import { WaveButton } from '../widgets/wave/WaveButton'; import s from './home.module.css'; -/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */ +/** Иконки Material Symbols по именам из /wave/options. */ +const ICONS: Record = { + directions_run: waveRunIcon, + fitness_center: waveFitnessIcon, + wb_sunny: waveSunIcon, + headphones: waveHeadphonesIcon, + directions_car: waveCarIcon, + weekend: waveWeekendIcon, + bedtime: waveBedtimeIcon, + bolt: waveBoltIcon, + sentiment_satisfied: waveHappyIcon, + spa: waveSpaIcon, + rainy: waveRainyIcon, +}; + +const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF']; + +type Tr = (key: string) => string; + +/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */ export function HomePage() { const { t } = useTranslation(); + const tr = t as unknown as Tr; + const session = useSession(); const user = useUser(); + const player = usePlayer(); + const st = usePlayerState(); + const errorText = useErrorText(); + const [error, setError] = useState(null); + const [activity, setActivity] = useState(st.wave?.activity ?? null); + const [mood, setMood] = useState(st.wave?.mood ?? null); + + const options = useQuery({ + queryKey: ['wave-options'], + queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')), + staleTime: Infinity, + }); + + const waveOn = st.wave !== null; + const playing = waveOn && st.playing; + const track = waveOn ? st.track : null; + const colors: [string, string, string] = + track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS; + + const run = (fn: () => Promise | void) => { + setError(null); + void Promise.resolve(fn()).catch(setError); + }; + const onWave = () => { + if (!waveOn) run(() => player.startWave(activity, mood)); + else run(() => player.toggle()); + }; + const choose = (kind: 'activity' | 'mood', key: string) => { + const nextActivity = kind === 'activity' ? (activity === key ? null : key) : activity; + const nextMood = kind === 'mood' ? (mood === key ? null : key) : mood; + setActivity(nextActivity); + setMood(nextMood); + // Смена контекста во время игры — без остановки музыки. + if (waveOn) run(() => player.setWaveContext(nextActivity, nextMood)); + }; + + const ctxLabel = + [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || + t('wave.for_you'); + return (
-

{t('home.greeting', { name: user.display_name ?? user.username })}

-
- - {t('home.wave')} -
-

{t('home.wave_soon')}

+ + + {t('wave.title')} + {ctxLabel} + + + {st.wave?.fallback &&

{t('wave.fallback')}

} + {st.error === 'wave.empty' &&

{t('wave.empty')}

} + {error ? errorText(error) : null} + + {options.data && ( +
+ tr(`wave.activities.${k}`)} + onPick={(k) => choose('activity', k)} + /> + tr(`wave.moods.${k}`)} + onPick={(k) => choose('mood', k)} + /> +
+ )}
); } + +/** MoodPicker: одно занятие и одно настроение; повторный клик снимает выбор. */ +function Chips({ + title, + options, + value, + label, + onPick, +}: { + title: string; + options: { key: string; icon: string }[]; + value: string | null; + label: (key: string) => string; + onPick: (key: string) => void; +}) { + return ( +
+

{title}

+
+ {options.map((o) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx index 388932f..6484f02 100644 --- a/apps/web/src/pages/SettingsPage.tsx +++ b/apps/web/src/pages/SettingsPage.tsx @@ -12,6 +12,7 @@ import { LanguageSwitch } from '../widgets/LanguageSwitch'; import s from './page.module.css'; type Theme = NonNullable; +type WaveVariant = NonNullable; export function SettingsPage() { const { t } = useTranslation(); @@ -74,15 +75,17 @@ function AppearanceSection() { const session = useSession(); const user = useUser(); const theme: Theme = user.ui_prefs.theme ?? 'dark'; - const setTheme = async (value: Theme) => { - // Оптимистично: тема применяется сразу, сервер подтверждает. - session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } }); + const wave: WaveVariant = user.ui_prefs.wave_variant ?? 'orb'; + // Оптимистично: настройка применяется сразу, сервер подтверждает. + const setPrefs = async (prefs: components['schemas']['UiPrefs']) => { + session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } }); try { - session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } }))); + session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } }))); } catch { session.setUser(user); } }; + const setTheme = (value: Theme) => setPrefs({ theme: value }); return (

{t('settings.appearance')}

@@ -99,6 +102,18 @@ function AppearanceSection() { ]} /> +
+ {t('settings.wave_variant')} + + label={t('settings.wave_variant')} + value={wave} + onChange={(v) => void setPrefs({ wave_variant: v })} + options={[ + { value: 'orb', label: t('settings.wave_orb') }, + { value: 'liquid', label: t('settings.wave_liquid') }, + ]} + /> +
{t('language.label')} diff --git a/apps/web/src/pages/admin/LibraryPage.tsx b/apps/web/src/pages/admin/LibraryPage.tsx index 4f1a0b3..155795b 100644 --- a/apps/web/src/pages/admin/LibraryPage.tsx +++ b/apps/web/src/pages/admin/LibraryPage.tsx @@ -65,6 +65,42 @@ export function LibraryPage() {
))} + {d && ( +
+

{t('library.analysis')}

+ + {d.analysis.failed > 0 && {t('library.analysis_failed', { count: d.analysis.failed })}} + {d.analysis.analyzer_ver && {t('library.analyzer', { ver: d.analysis.analyzer_ver })}} +
+ )} + {d && ( +
+

{t('library.wave')}

+
+ {( + [ + ['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]) => ( +
+ {v} + {t(`library.${k}`)} +
+ ))} +
+
+ )} {d && d.sources.length > 0 && (

{t('library.sources')}

@@ -92,3 +128,17 @@ export function LibraryPage() { ); } + +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 ( +
+
{label}
+
+ +
+
+ ); +} diff --git a/apps/web/src/pages/home.module.css b/apps/web/src/pages/home.module.css index 7a64bfd..49d6b80 100644 --- a/apps/web/src/pages/home.module.css +++ b/apps/web/src/pages/home.module.css @@ -4,60 +4,94 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 24px; + gap: 28px; + padding: 24px 0; text-align: center; } -.greeting { - margin: 0; +.orbLabel { + margin-top: 4px; font-family: var(--font-display); - font-size: 24px; + font-size: 22px; font-weight: 600; letter-spacing: -0.01em; + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); } -.orb { +.orbContext { + font-size: 13px; + opacity: 0.85; +} + +.note { + margin: -12px 0 0; + color: var(--text2); + font-size: 13px; +} + +.picker { + display: flex; + flex-direction: column; + gap: 18px; + max-width: 720px; +} + +.group { display: flex; flex-direction: column; align-items: center; - justify-content: center; - gap: 6px; - width: 300px; - height: 300px; - max-width: 70vw; - max-height: 70vw; - border-radius: 50%; - color: #fff; - background: - radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%), - radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%), - radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%); - box-shadow: 0 0 80px rgba(47, 128, 255, 0.35); - opacity: 0.85; - animation: breathe 6s ease-in-out infinite; + gap: 10px; } -.orbLabel { - font-family: var(--font-display); - font-size: 18px; - font-weight: 600; -} - -@keyframes breathe { - 50% { - transform: scale(1.03); - } -} - -@media (prefers-reduced-motion: reduce) { - .orb { - animation: none; - } -} - -.soon { - max-width: 420px; +.groupTitle { margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text3); +} + +.chips { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 8px; + height: 38px; + padding: 0 16px; + border-radius: var(--radius-pill); + border: 1px solid var(--line); + background: var(--surface2); + color: var(--text); + font-size: 14px; + font-weight: 500; + transition: + background 0.15s, + box-shadow 0.15s; +} + +.chip > span { color: var(--text2); - line-height: 1.5; +} + +.chip:hover { + background: var(--surface3); +} + +.chipOn, +.chipOn:hover { + background: var(--accent); + border-color: var(--accent); + color: #fff; + box-shadow: var(--glow); +} + +.chipOn > span { + color: #fff; } diff --git a/apps/web/src/player/player.ts b/apps/web/src/player/player.ts index 9c7dbf8..b3a09a4 100644 --- a/apps/web/src/player/player.ts +++ b/apps/web/src/player/player.ts @@ -11,7 +11,23 @@ import { export type { PlayContext, Track }; +export interface WaveInfo { + sessionId: string; + activity: string | null; + mood: string | null; + /** Рекомендатель недоступен — играет запасная волна. */ + fallback: boolean; +} + +type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike'; + +/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */ +const WAVE_REFILL_AT = 2; +/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */ +const EARLY_SKIP_MS = 30_000; + export interface PlayerState { + wave: WaveInfo | null; track: Track | null; queue: Track[]; index: number; @@ -75,6 +91,8 @@ export class Player { private preloaded: string | null = null; private refreshedFor: string | null = null; private timers: ReturnType[] = []; + private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; + private waveLoading = false; /** Реакция сохранена на сервере — списки на экране нужно перечитать. */ onReaction: (() => void) | null = null; @@ -90,6 +108,7 @@ export class Player { }, }); this.state = { + wave: null, track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0, durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null, }; @@ -135,12 +154,102 @@ export class Player { const target = tracks[index]; if (!target || !playable.length) return; this.finishAttempt('skip'); + this.leaveWave(); this.context = context; this.queue.set(playable, Math.max(playable.indexOf(target), 0)); this.syncQueue(); void this.load(true); } + // --- «Моя волна» --- + + /** Запустить волну (или перезапустить с новым контекстом). */ + async startWave(activity: string | null, mood: string | null) { + this.finishAttempt('skip'); + this.leaveWave(); + const res = unwrap( + await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }), + ); + const tracks = res.tracks.filter((t) => t.available && t.stream_url); + if (!tracks.length) { + this.update({ error: 'wave.empty' }); + return; + } + this.context = { type: 'wave', id: res.session_id }; + this.queue.shuffle = false; + this.queue.repeat = 'off'; + this.queue.set(tracks, 0); + this.update({ wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback } }); + this.syncQueue(); + void this.load(true); + } + + /** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */ + async setWaveContext(activity: string | null, mood: string | null) { + const wave = this.state.wave; + if (!wave) return this.startWave(activity, mood); + this.update({ wave: { ...wave, activity, mood } }); + const res = unwrap( + await this.session.client.PATCH('/api/v1/wave/sessions/{id}', { + params: { path: { id: wave.sessionId } }, + body: { activity, mood }, + }), + ); + this.queue.dropUpcoming(); + this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); + this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); + this.syncQueue(); + } + + stopWave() { + this.leaveWave(); + this.pause(); + } + + private leaveWave() { + const wave = this.state.wave; + if (!wave) return; + this.waveFeedback = []; + this.update({ wave: null }); + void this.session.client + .DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } }) + .catch(() => undefined); + } + + private noteWave(trackId: string, outcome: WaveOutcome) { + if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome }); + } + + /** + * Следующая пачка волны с накопленными реакциями. refresh — сначала выбросить ещё не начатые треки: + * после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию. + */ + private async refillWave(refresh = false) { + const wave = this.state.wave; + if (!wave || this.waveLoading) return; + if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return; + this.waveLoading = true; + const feedback = this.waveFeedback; + this.waveFeedback = []; + try { + const res = unwrap( + await this.session.client.POST('/api/v1/wave/sessions/{id}/next', { + params: { path: { id: wave.sessionId } }, + body: { feedback }, + }), + ); + if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили + if (refresh) this.queue.dropUpcoming(); + this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); + this.update({ wave: { ...this.state.wave, fallback: res.fallback } }); + this.syncQueue(); + } catch { + this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом + } finally { + this.waveLoading = false; + } + } + toggle() { if (this.state.playing) this.pause(); else this.play(); @@ -246,6 +355,10 @@ export class Player { throw e; } this.onReaction?.(); + if (reaction) { + this.noteWave(track.id, reaction); + void this.refillWave(true); + } if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next(); } @@ -268,6 +381,7 @@ export class Player { }); this.updateMediaMetadata(track); this.saveServer(); + void this.refillWave(); if (autoplay) { try { await this.audio.play(); @@ -287,6 +401,12 @@ export class Player { const kind = type === 'skip' ? a.outcome(pos) : 'play_end'; this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); void this.events.flush(); + if (kind === 'play_end') this.noteWave(a.track.id, 'complete'); + else { + const early = a.playedMs < EARLY_SKIP_MS; + this.noteWave(a.track.id, early ? 'skip_early' : 'skip'); + if (early) void this.refillWave(true); + } } private bindAudio() { diff --git a/apps/web/src/shared/ui/icons.ts b/apps/web/src/shared/ui/icons.ts index 500b587..ae371e5 100644 --- a/apps/web/src/shared/ui/icons.ts +++ b/apps/web/src/shared/ui/icons.ts @@ -46,3 +46,14 @@ export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat. export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg'; export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg'; export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg'; +export { default as waveBoltIcon } from '@material-symbols/svg-400/rounded/bolt.svg'; +export { default as waveBedtimeIcon } from '@material-symbols/svg-400/rounded/bedtime.svg'; +export { default as waveCarIcon } from '@material-symbols/svg-400/rounded/directions_car.svg'; +export { default as waveRunIcon } from '@material-symbols/svg-400/rounded/directions_run.svg'; +export { default as waveFitnessIcon } from '@material-symbols/svg-400/rounded/fitness_center.svg'; +export { default as waveHeadphonesIcon } from '@material-symbols/svg-400/rounded/headphones.svg'; +export { default as waveRainyIcon } from '@material-symbols/svg-400/rounded/rainy.svg'; +export { default as waveHappyIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg'; +export { default as waveSpaIcon } from '@material-symbols/svg-400/rounded/spa.svg'; +export { default as waveSunIcon } from '@material-symbols/svg-400/rounded/wb_sunny.svg'; +export { default as waveWeekendIcon } from '@material-symbols/svg-400/rounded/weekend.svg'; diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 9187b8d..eceb44c 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -17,6 +17,8 @@ const problem = (status: number, code: string, extra: object = {}) => json({ sta let seq = 0; +const WAVE_ID = '00000000-0000-4000-8000-0000000000ee'; + const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }; const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] }; export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({ @@ -31,6 +33,7 @@ export const albumTracks = ['Группа крови', 'Закрой за мно available: true, stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`, reaction: null as 'like' | 'dislike' | null, + features: { bpm: 120, energy: 0.6, loudness_lufs: -12 }, })); const albumSummary = { id: '00000000-0000-4000-8000-0000000000bb', @@ -193,6 +196,21 @@ export function fakeApi(initial: FakeUser[]) { duration_ms: 400000, tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })), }); + case 'GET /api/v1/wave/options': + return json({ + activities: [{ key: 'running', icon: 'directions_run' }, { key: 'focus', icon: 'headphones' }], + moods: [{ key: 'calm', icon: 'spa' }, { key: 'happy', icon: 'sentiment_satisfied' }], + }); + case 'POST /api/v1/wave/sessions': + return json( + { session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: albumTracks, fallback: false }, + 201, + ); + case `PATCH /api/v1/wave/sessions/${WAVE_ID}`: + case `POST /api/v1/wave/sessions/${WAVE_ID}/next`: + return json({ session_id: WAVE_ID, activity: body?.activity ?? null, mood: body?.mood ?? null, tracks: [], fallback: false }); + case `DELETE /api/v1/wave/sessions/${WAVE_ID}`: + return new Response(null, { status: 204 }); case 'POST /api/v1/events': return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 }); case 'GET /api/v1/me/playback-state': diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts index fdefaa4..282cc5e 100644 --- a/apps/web/src/test/setup.ts +++ b/apps/web/src/test/setup.ts @@ -17,3 +17,6 @@ Object.defineProperty(HTMLMediaElement.prototype, 'pause', { }, }); Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} }); + +// Canvas в jsdom нет: кнопка волны без контекста просто не рисует (как в старых браузерах). +Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { configurable: true, value: () => null }); diff --git a/apps/web/src/widgets/wave/WaveButton.tsx b/apps/web/src/widgets/wave/WaveButton.tsx new file mode 100644 index 0000000..6e7beed --- /dev/null +++ b/apps/web/src/widgets/wave/WaveButton.tsx @@ -0,0 +1,188 @@ +import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; + +import { drawLiquid, drawOrb, step, type WaveState } from './draw'; + +export interface WaveButtonProps { + variant?: 'orb' | 'liquid'; + playing?: boolean; + bpm?: number; + energy?: number; + colors?: [string, string, string]; + theme?: 'dark' | 'light'; + interactive?: boolean; + size?: number; + label: string; + onClick?: () => void; + children?: ReactNode; +} + +const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF']; + +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; +} + +/** + * Кнопка «Моя волна» (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy, + * тянется к курсору, при нажатии сжимается с отскоком. Enter / пробел — то же, что клик. + */ +export function WaveButton(props: WaveButtonProps) { + const size = props.size ?? 320; + const canvasRef = useRef(null); + const labelRef = useRef(null); + const wrapRef = useRef(null); + const st = useRef({ + hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0, + speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS, + dark: true, reduced: false, size, + }); + // Пропсы читаются в цикле анимации через ref — перерисовка React не нужна. + const s = st.current; + s.variant = props.variant ?? 'orb'; + s.playing = !!props.playing; + s.bpm = props.bpm ?? 100; + s.energy = props.energy ?? 0.6; + s.colors = props.colors ?? DEFAULT_COLORS; + s.dark = props.theme !== 'light'; + s.size = size; + + useEffect(() => { + const cv = canvasRef.current; + const ctx = cv?.getContext('2d'); + if (!cv || !ctx) return; + const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)'); + st.current.reduced = prefersReducedMotion(); + const onMq = () => (st.current.reduced = prefersReducedMotion()); + mq?.addEventListener?.('change', onMq); + let raf = 0; + let last = performance.now(); + const loop = (now: number) => { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + const w = st.current; + const S = w.size * 1.6; + const dpr = window.devicePixelRatio || 1; + if (cv.width !== Math.round(S * dpr)) { + cv.width = Math.round(S * dpr); + cv.height = Math.round(S * dpr); + } + step(w, dt); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, S, S); + const sc = 1 - w.press * 0.07 + w.hover * 0.02; + const r = w.size * 0.5 * sc; + const cx = S / 2 + w.mx * w.hover * 6; + const cy = S / 2 + w.my * w.hover * 6; + if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t); + else drawOrb(ctx, cx, cy, r, w, w.t); + if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`; + raf = requestAnimationFrame(loop); + }; + // Во фоновой вкладке анимация не нужна — останавливаемся до возвращения. + const onVisibility = () => { + cancelAnimationFrame(raf); + if (document.visibilityState === 'visible') { + last = performance.now(); + raf = requestAnimationFrame(loop); + } + }; + raf = requestAnimationFrame(loop); + document.addEventListener('visibilitychange', onVisibility); + return () => { + cancelAnimationFrame(raf); + document.removeEventListener('visibilitychange', onVisibility); + mq?.removeEventListener?.('change', onMq); + }; + }, []); + + const interactive = props.interactive !== false; + const onMove = (e: ReactMouseEvent) => { + const b = wrapRef.current!.getBoundingClientRect(); + const x = ((e.clientX - b.left) / b.width) * 2 - 1; + const y = ((e.clientY - b.top) / b.height) * 2 - 1; + s.mx = x; + s.my = y; + s.ang = Math.atan2(y, x); + }; + + return ( +
(s.hoverT = 1) : undefined} + onMouseLeave={ + interactive + ? () => { + s.hoverT = 0; + s.pressT = 0; + } + : undefined + } + onMouseDown={interactive ? () => (s.pressT = 1) : undefined} + onMouseUp={ + interactive + ? () => { + s.pressT = 0; + s.pressV -= 0.12; + } + : undefined + } + onClick={interactive ? props.onClick : undefined} + onKeyDown={ + interactive + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); // пробел здесь — кнопка волны, а не пауза плеера + props.onClick?.(); + } + } + : undefined + } + style={{ + position: 'relative', + width: size, + height: size, + maxWidth: '78vw', + maxHeight: '78vw', + borderRadius: '50%', + cursor: interactive ? 'pointer' : 'default', + flexShrink: 0, + pointerEvents: interactive ? 'auto' : 'none', + }} + > + +
+ {props.children} +
+
+ ); +} diff --git a/apps/web/src/widgets/wave/draw.ts b/apps/web/src/widgets/wave/draw.ts new file mode 100644 index 0000000..7e75d0d --- /dev/null +++ b/apps/web/src/widgets/wave/draw.ts @@ -0,0 +1,158 @@ +// Отрисовка кнопки «Моя волна» на Canvas 2D — порт прототипа docs/Разработка дизайна приложения/wave.js +// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md). + +export type Rgb = [number, number, number]; + +export interface WaveState { + hover: number; + hoverT: number; + press: number; + pressV: number; + pressT: number; + mx: number; + my: number; + ang: number; + t: number; + amp: number; + pulse: number; + speed: number; + phase: number; + variant: 'orb' | 'liquid'; + playing: boolean; + bpm: number; + energy: number; + colors: [string, string, string]; + dark: boolean; + reduced: boolean; + size: number; +} + +export function rgb(hex: string): Rgb { + const h = hex.replace('#', ''); + const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h; + const n = parseInt(full, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +const rgba = (c: Rgb, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`; +const mix = (a: Rgb, b: Rgb, k: number): Rgb => [ + Math.round(a[0] + (b[0] - a[0]) * k), + Math.round(a[1] + (b[1] - a[1]) * k), + Math.round(a[2] + (b[2] - a[2]) * k), +]; + +export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) { + const [c1, c2, c3] = s.colors.map(rgb) as [Rgb, Rgb, Rgb]; + const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65); + glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12)); + glow.addColorStop(0.5, rgba(c2, 0.1 + s.pulse * 0.12)); + glow.addColorStop(1, rgba(c1, 0)); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2); + ctx.fill(); + + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.clip(); + ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533'; + ctx.fillRect(cx - r, cy - r, r * 2, r * 2); + ctx.globalCompositeOperation = 'lighter'; + const blobs = [c1, c2, c3, c1, c2]; + blobs.forEach((c, i) => { + const a = t * (0.25 + i * 0.07) + i * 1.7; + const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover; + const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover; + const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2); + const g = ctx.createRadialGradient(bx, by, 0, bx, by, br); + g.addColorStop(0, rgba(c, 0.55)); + g.addColorStop(1, rgba(c, 0)); + ctx.fillStyle = g; + ctx.fillRect(cx - r, cy - r, r * 2, r * 2); + }); + const lines = 9; + for (let l = 0; l < lines; l++) { + const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5; + const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover); + ctx.beginPath(); + for (let x = -r; x <= r; x += 4) { + const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4); + if (x === -r) ctx.moveTo(cx + x, y); + else ctx.lineTo(cx + x, y); + } + ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + ctx.globalCompositeOperation = 'source-over'; + const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9); + hl.addColorStop(0, 'rgba(255,255,255,0.18)'); + hl.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = hl; + ctx.fillRect(cx - r, cy - r, r * 2, r * 2); + ctx.restore(); + ctx.beginPath(); + ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2); + ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3); + ctx.lineWidth = 1; + ctx.stroke(); +} + +export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) { + const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb]; + const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6); + glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3)); + glow.addColorStop(1, rgba(cols[0], 0)); + ctx.fillStyle = glow; + ctx.beginPath(); + ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2); + ctx.fill(); + ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over'; + for (let L = 0; L < 3; L++) { + ctx.beginPath(); + const P = 140; + for (let j = 0; j <= P; j++) { + const th = (j / P) * Math.PI * 2; + const w = + 1 + + 0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) + + 0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) + + 0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) + + 0.08 * s.pulse * Math.sin(2 * th + L) + + 0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3); + const rr = r * (0.86 - L * 0.07) * w; + const x = cx + Math.cos(th) * rr; + const y = cy + Math.sin(th) * rr; + if (j) ctx.lineTo(x, y); + else ctx.moveTo(x, y); + } + const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3; + const oy = Math.sin(t * 0.5 + L) * r * 0.3; + const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1); + g.addColorStop(0, rgba(mix(cols[L]!, [255, 255, 255], 0.25), 0.85)); + g.addColorStop(1, rgba(cols[L]!, 0.25)); + ctx.fillStyle = g; + ctx.globalAlpha = s.dark ? 0.6 : 0.75; + ctx.fill(); + } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; +} + +/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */ +export function step(s: WaveState, dt: number) { + s.hover += (s.hoverT - s.hover) * 0.08; + s.pressV += (s.pressT - s.press) * 0.35; + s.pressV *= 0.62; + s.press += s.pressV; + s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04; + s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7; + s.t += dt * s.speed; + if (s.playing && !s.reduced) { + s.phase = (s.phase + (dt * s.bpm) / 60) % 1; + s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5; + } else { + s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1; + } +} diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index 8099f2a..1f12590 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -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} */ diff --git a/packages/core/src/player.test.ts b/packages/core/src/player.test.ts index 41791b0..70b2e46 100644 --- a/packages/core/src/player.test.ts +++ b/packages/core/src/player.test.ts @@ -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')); diff --git a/packages/core/src/player.ts b/packages/core/src/player.ts index 9868450..c341556 100644 --- a/packages/core/src/player.ts +++ b/packages/core/src/player.ts @@ -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; diff --git a/packages/i18n/locales/en/admin.json b/packages/i18n/locales/en/admin.json index 795b61a..d2f0c48 100644 --- a/packages/i18n/locales/en/admin.json +++ b/packages/i18n/locales/en/admin.json @@ -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" } } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 6ef6cf9..0c91742 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -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" + } } } diff --git a/packages/i18n/locales/en/errors.json b/packages/i18n/locales/en/errors.json index 6f8f4c4..8ee6899 100644 --- a/packages/i18n/locales/en/errors.json +++ b/packages/i18n/locales/en/errors.json @@ -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" } } diff --git a/packages/i18n/locales/ru/admin.json b/packages/i18n/locales/ru/admin.json index 7f16396..62f95b4 100644 --- a/packages/i18n/locales/ru/admin.json +++ b/packages/i18n/locales/ru/admin.json @@ -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" } } diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index 9a8e792..6be7cb8 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -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": "Грустное" + } } } diff --git a/packages/i18n/locales/ru/errors.json b/packages/i18n/locales/ru/errors.json index 8ec77bd..ea44cea 100644 --- a/packages/i18n/locales/ru/errors.json +++ b/packages/i18n/locales/ru/errors.json @@ -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": "Сессия волны завершена" } } diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index b0a2147..14ae5bb 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -762,12 +762,15 @@ type Track struct { Artists []ArtistRef `json:"artists"` // Available Есть доступный файл (хранилище подключено, файл на месте) - Available bool `json:"available"` - DiscNo *int `json:"disc_no"` - DurationMs int `json:"duration_ms"` - Genres []string `json:"genres"` - Id openapi_types.UUID `json:"id"` - Reaction *Reaction `json:"reaction"` + Available bool `json:"available"` + DiscNo *int `json:"disc_no"` + DurationMs int `json:"duration_ms"` + + // Features Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости + Features *TrackFeatures `json:"features"` + Genres []string `json:"genres"` + Id openapi_types.UUID `json:"id"` + Reaction *Reaction `json:"reaction"` // StreamUrl Подписанная ссылка на аудио (12 ч); null — трек недоступен StreamUrl *string `json:"stream_url"` @@ -775,6 +778,13 @@ type Track struct { TrackNo *int `json:"track_no"` } +// TrackFeatures defines model for TrackFeatures. +type TrackFeatures struct { + Bpm *float32 `json:"bpm"` + Energy *float32 `json:"energy"` + LoudnessLufs *float32 `json:"loudness_lufs"` +} + // TrackIds defines model for TrackIds. type TrackIds struct { Ids []openapi_types.UUID `json:"ids"` @@ -6915,176 +6925,178 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/library/catalog.go b/services/api/internal/library/catalog.go index a21e08f..d4c393a 100644 --- a/services/api/internal/library/catalog.go +++ b/services/api/internal/library/catalog.go @@ -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 } diff --git a/services/api/internal/library/handler.go b/services/api/internal/library/handler.go index b09a91f..7663e14 100644 --- a/services/api/internal/library/handler.go +++ b/services/api/internal/library/handler.go @@ -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.