Files
pipu-music/apps/mobile/src/app/(tabs)/profile.tsx
T
mrleo1nidandClaude Opus 5.5 ed063d3763
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
- My Wave is rendered with react-native-skia on the UI thread; physics
  and colour helpers moved to @pipu/core (wave-anim) and shared with web.
- Full-screen player honours ui_prefs.mobile_player (cover / wave).
- Theme switching (dark / light / system) via a theme context and
  useColors() across all screens.
- Profile: theme, wave variant, player view, sound quality sheet
  (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next
  cold start, and a change-password sheet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:30:21 +03:00

198 lines
8.2 KiB
TypeScript

import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import Constants from 'expo-constants';
import * as Linking from 'expo-linking';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Switch, Text, View } from 'react-native';
import { playerCache } from '../../lib/prefs';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { updater } from '../../lib/update';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { radius, useColors } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog';
type UiPrefs = components['schemas']['UiPrefs'];
const MB = 1024 * 1024;
export default function ProfileScreen() {
const colors = useColors();
const { t, i18n } = useTranslation();
const session = useSession();
const user = useUser();
const { server, disconnect } = useSessionCtx();
const staff = user.role !== 'user';
const update = useUpdateState();
const dl = useDownloads();
const quality = useQualityPrefs();
const [sheet, setSheet] = useState<'quality' | 'password' | null>(null);
const [cache, setCache] = useState<{ bytes: number; pending: boolean } | null>(null);
useEffect(() => {
void Promise.all([playerCache.size(), playerCache.clearPending()]).then(([bytes, pending]) => setCache({ bytes, pending }));
}, []);
// Оптимистично, как в вебе: настройка применяется сразу, сервер подтверждает.
const setPrefs = async (prefs: UiPrefs) => {
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } })));
} catch {
session.setUser(user);
}
};
const clearCache = async () => {
await playerCache.requestClear();
setCache((c) => (c ? { ...c, pending: true } : c));
};
const setLocale = async (l: Locale) => {
await i18n.changeLanguage(l);
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// язык уже сменился локально
}
};
const logout = async () => {
await player.reset();
await session.logout();
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
<H1>{user.display_name ?? user.username}</H1>
<Muted>
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void setLocale(l)}
/>
</Row>
</Section>
<Section>
<Row label={t('settings.theme')}>
<Segmented<NonNullable<UiPrefs['theme']>>
value={user.ui_prefs.theme ?? 'system'}
options={[
{ value: 'dark', label: t('settings.theme_dark') },
{ value: 'light', label: t('settings.theme_light') },
{ value: 'system', label: t('settings.theme_system') },
]}
onChange={(v) => void setPrefs({ theme: v })}
/>
</Row>
<Row label={t('settings.wave_variant')}>
<Segmented<NonNullable<UiPrefs['wave_variant']>>
value={user.ui_prefs.wave_variant ?? 'orb'}
options={[
{ value: 'orb', label: t('settings.wave_orb') },
{ value: 'liquid', label: t('settings.wave_liquid') },
]}
onChange={(v) => void setPrefs({ wave_variant: v })}
/>
</Row>
<Row label={t('mobile.player_view')}>
<Segmented<NonNullable<UiPrefs['mobile_player']>>
value={user.ui_prefs.mobile_player ?? 'cover'}
options={[
{ value: 'cover', label: t('mobile.player_cover') },
{ value: 'wave', label: t('mobile.player_wave') },
]}
onChange={(v) => void setPrefs({ mobile_player: v })}
/>
</Row>
</Section>
<Section>
<Pressable onPress={() => setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}>
<Row label={t('mobile.quality')}>
<Text style={{ color: colors.text2 }}>
{t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)}
</Text>
</Row>
</Pressable>
<Row label={t('mobile.cache')}>
{cache?.pending ? (
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.cache_pending')}</Text>
) : (
<Pressable onPress={() => void clearCache()} accessibilityRole="button" hitSlop={8}>
<Text style={{ color: colors.accent2, fontWeight: '500' }}>
{t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })}
</Text>
</Pressable>
)}
</Row>
<Row label={t('mobile.dl_wifi_only')}>
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
</Row>
<Row label={t('mobile.dl_auto_likes')}>
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
</Row>
</Section>
<Section>
<Row label={t('mobile.server')}>
<Text style={{ color: colors.text2 }} numberOfLines={1}>
{server?.replace(/^https?:\/\//, '')}
</Text>
</Row>
{staff && server && (
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
<Icon name="open-in-new" size={20} color={colors.accent2} />
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
</Pressable>
)}
</Section>
<Button title={t('settings.change_password')} variant="secondary" icon="lock" onPress={() => setSheet('password')} />
<Button
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
variant="secondary"
icon="system-update"
busy={update.status === 'checking'}
onPress={() => void updater.check(session, true)}
/>
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
</Text>
</ScrollView>
<QualitySheet visible={sheet === 'quality'} onClose={() => setSheet(null)} />
<PasswordSheet visible={sheet === 'password'} onClose={() => setSheet(null)} />
</Screen>
);
}
function Section({ children }: { children: React.ReactNode }) {
const colors = useColors();
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
const colors = useColors();
return (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Text style={{ color: colors.text }}>{label}</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
);
}