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>
111 lines
4.4 KiB
TypeScript
111 lines
4.4 KiB
TypeScript
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { Stack, useRouter, useSegments } from 'expo-router';
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import type { i18n as I18n } from 'i18next';
|
|
import { useEffect, useState } from 'react';
|
|
import { I18nextProvider, useTranslation } from 'react-i18next';
|
|
import { ActivityIndicator, AppState, useColorScheme, View } from 'react-native';
|
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
|
|
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
|
|
import { downloads } from '../offline/downloads';
|
|
import { player } from '../player/controller';
|
|
import { ThemeContext, useColors } from '../theme';
|
|
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
|
|
|
|
export default function RootLayout() {
|
|
const [i18n, setI18n] = useState<I18n | null>(null);
|
|
useEffect(() => {
|
|
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
void createI18n(pickLocale([deviceLocale])).then(setI18n);
|
|
}, []);
|
|
|
|
if (!i18n) return <Splash />;
|
|
return (
|
|
<SafeAreaProvider>
|
|
<I18nextProvider i18n={i18n}>
|
|
<QueryClientProvider client={queryClient}>
|
|
<SessionProvider>
|
|
<Gate />
|
|
</SessionProvider>
|
|
</QueryClientProvider>
|
|
</I18nextProvider>
|
|
</SafeAreaProvider>
|
|
);
|
|
}
|
|
|
|
function Splash() {
|
|
const colors = useColors();
|
|
return (
|
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
|
|
<ActivityIndicator color={colors.accent} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
|
|
|
|
/**
|
|
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
|
|
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
|
|
*/
|
|
function Gate() {
|
|
const { server, session, ready } = useSessionCtx();
|
|
const auth = useAuthState();
|
|
const segments = useSegments();
|
|
const router = useRouter();
|
|
const { i18n } = useTranslation();
|
|
const user = auth.status === 'signed_in' ? auth.user : null;
|
|
const system = useColorScheme();
|
|
// Тема из профиля; «system» и экраны до входа — по настройке телефона.
|
|
const pref = user?.ui_prefs.theme ?? 'system';
|
|
const theme = pref === 'system' ? (system === 'light' ? 'light' : 'dark') : pref;
|
|
|
|
useEffect(() => {
|
|
player.attach(user ? session : null, server ?? '', user?.id ?? null);
|
|
void downloads.attach(user ? session : null, server ?? '', user?.id ?? null);
|
|
if (user) void player.restore();
|
|
}, [user?.id, session, server]);
|
|
|
|
useEffect(() => {
|
|
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
|
|
}, [user?.locale, i18n]);
|
|
|
|
// Свёрнутое приложение досылает события и позицию.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
|
|
return () => sub.remove();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!ready) return;
|
|
const first = segments[0] as string | undefined;
|
|
let target: string | null = null;
|
|
if (!server) target = first === 'connect' ? null : '/connect';
|
|
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
|
|
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
|
|
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
|
|
if (target) router.replace(target as never);
|
|
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
|
|
|
|
if (!ready) return <Splash />;
|
|
return (
|
|
<ThemeContext.Provider value={theme}>
|
|
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
|
|
<Screens />
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
function Screens() {
|
|
const colors = useColors();
|
|
return (
|
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
|
</Stack>
|
|
);
|
|
}
|
|
|