feat(web): My Wave button, mood picker and endless wave playback (M4)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 41s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Successful in 1m14s
ci / api / api (push) Successful in 36s

- WaveButton: TypeScript port of the prototype's canvas animation
  (orb and liquid), breathing at rest, pulsing to the track's BPM and
  energy in the cover palette, following the cursor, pressing with a
  spring; reduced motion and hidden tabs slow or stop it
- home: the wave in the centre with activity and mood chips; changing
  them while playing rebuilds the upcoming tracks without stopping
- player wave mode: asks for the next batch when two tracks are left,
  sends complete / skip / early skip / like / dislike as feedback and
  refreshes the queue right after a reaction or an early skip
- tracks expose bpm, energy and loudness once analysed; library page
  shows analysis progress and 7-day wave metrics; wave style setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:29:38 +03:00
1 parent 3908814cda
commit eaedd1fc62
24 files changed
+1193 -244

No files matched your search

+31 -3
View File
@@ -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'],
+158 -10
View File
@@ -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>
</div>
<p className={s.soon}>{t('home.wave_soon')}</p>
<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>
)}
</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>
);
}
+19 -4
View File
@@ -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 />
+50
View File
@@ -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>
);
}
+75 -41
View File
@@ -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;
}
+120
View File
@@ -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() {
+11
View File
@@ -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';
+18
View File
@@ -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':
+3
View File
@@ -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 });
+188
View File
@@ -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>
);
}
+158
View File
@@ -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;
}
}