feat(web): My Wave button, mood picker and endless wave playback (M4)
- WaveButton: TypeScript port of the prototype's canvas animation (orb and liquid), breathing at rest, pulsing to the track's BPM and energy in the cover palette, following the cursor, pressing with a spring; reduced motion and hidden tabs slow or stop it - home: the wave in the centre with activity and mood chips; changing them while playing rebuilds the upcoming tracks without stopping - player wave mode: asks for the next batch when two tracks are left, sends complete / skip / early skip / like / dislike as feedback and refreshes the queue right after a reaction or an early skip - tracks expose bpm, energy and loudness once analysed; library page shows analysis progress and 7-day wave metrics; wave style setting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3908814cda
commit
eaedd1fc62
24 files changed
+1193
-244
No files matched your search
+158
-10
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user