- 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>
171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
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';
|
|
|
|
/** Иконки 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}>
|
|
<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>
|
|
);
|
|
}
|