Files
pipu-music/apps/mobile/src/app/(tabs)/profile.tsx
T
mrleo1nidandCursor 066ba24834
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
feat: finish the upload, playback, and test gaps from the docs
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00

208 lines
8.8 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>
<Row label={t('settings.normalize')}>
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
</Row>
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
<Segmented
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
onChange={(v) => void setPrefs({ crossfade_s: Number(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>
);
}