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
- 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>
198 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|