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
+1186
-237
No files matched your search
+19
-1
@@ -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]
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<unknown>(null);
|
||||
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
|
||||
const [mood, setMood] = useState<string | null>(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> | 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 (
|
||||
<div className={s.home}>
|
||||
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
|
||||
<div className={s.orb} aria-disabled="true">
|
||||
<Icon src={playIcon} size={44} />
|
||||
<span className={s.orbLabel}>{t('home.wave')}</span>
|
||||
<WaveButton
|
||||
variant={user.ui_prefs.wave_variant ?? 'orb'}
|
||||
playing={playing}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||
size={320}
|
||||
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||
onClick={onWave}
|
||||
>
|
||||
<Icon src={playing ? pauseIcon : playIcon} size={44} />
|
||||
<span className={s.orbLabel}>{t('wave.title')}</span>
|
||||
<span className={s.orbContext}>{ctxLabel}</span>
|
||||
</WaveButton>
|
||||
|
||||
{st.wave?.fallback && <p className={s.note}>{t('wave.fallback')}</p>}
|
||||
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
|
||||
<FormError>{error ? errorText(error) : null}</FormError>
|
||||
|
||||
{options.data && (
|
||||
<div className={s.picker}>
|
||||
<Chips
|
||||
title={t('wave.activity_label')}
|
||||
options={options.data.activities}
|
||||
value={activity}
|
||||
label={(k) => tr(`wave.activities.${k}`)}
|
||||
onPick={(k) => choose('activity', k)}
|
||||
/>
|
||||
<Chips
|
||||
title={t('wave.mood_label')}
|
||||
options={options.data.moods}
|
||||
value={mood}
|
||||
label={(k) => tr(`wave.moods.${k}`)}
|
||||
onPick={(k) => choose('mood', k)}
|
||||
/>
|
||||
</div>
|
||||
<p className={s.soon}>{t('home.wave_soon')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section className={s.group} aria-label={title}>
|
||||
<h2 className={s.groupTitle}>{title}</h2>
|
||||
<div className={s.chips}>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={o.key}
|
||||
type="button"
|
||||
className={cx(s.chip, value === o.key && s.chipOn)}
|
||||
aria-pressed={value === o.key}
|
||||
onClick={() => onPick(o.key)}
|
||||
>
|
||||
{ICONS[o.icon] && <Icon src={ICONS[o.icon]!} size={18} />}
|
||||
{label(o.key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||
import s from './page.module.css';
|
||||
|
||||
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
|
||||
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
|
||||
|
||||
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 (
|
||||
<section className={s.section}>
|
||||
<h2>{t('settings.appearance')}</h2>
|
||||
@@ -99,6 +102,18 @@ function AppearanceSection() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('settings.wave_variant')}</span>
|
||||
<Segmented<WaveVariant>
|
||||
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') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.settingRow}>
|
||||
<span>{t('language.label')}</span>
|
||||
<LanguageSwitch />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<typeof setInterval>[] = [];
|
||||
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() {
|
||||
|
||||
@@ -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';
|
||||
@@ -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':
|
||||
|
||||
@@ -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 });
|
||||
@@ -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<HTMLCanvasElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const st = useRef<WaveState>({
|
||||
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 (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={interactive ? props.label : undefined}
|
||||
aria-pressed={interactive ? !!props.playing : undefined}
|
||||
aria-hidden={interactive ? undefined : true}
|
||||
onMouseMove={interactive ? onMove : undefined}
|
||||
onMouseEnter={interactive ? () => (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',
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-30%',
|
||||
top: '-30%',
|
||||
width: '160%',
|
||||
height: '160%',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
ref={labelRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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": "Сессия волны завершена"
|
||||
}
|
||||
}
|
||||
@@ -765,6 +765,9 @@ type Track struct {
|
||||
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"`
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user