119 lines
4.6 KiB
TypeScript
119 lines
4.6 KiB
TypeScript
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { Stack } 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 { 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();
|
|
}, []);
|
|
|
|
if (!ready) return <Splash />;
|
|
return (
|
|
<ThemeContext.Provider value={theme}>
|
|
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
|
|
<Screens />
|
|
</ThemeContext.Provider>
|
|
);
|
|
}
|
|
|
|
function Screens() {
|
|
const colors = useColors();
|
|
const { server, session } = useSessionCtx();
|
|
const auth = useAuthState();
|
|
const connected = !!server && !!session;
|
|
const user = connected && auth.status === 'signed_in' ? auth.user : null;
|
|
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
|
|
return (
|
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
|
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
|
|
<Stack.Protected guard={!connected}>
|
|
<Stack.Screen name="connect" />
|
|
</Stack.Protected>
|
|
<Stack.Protected guard={connected && !user}>
|
|
<Stack.Screen name="login" />
|
|
</Stack.Protected>
|
|
<Stack.Protected guard={needsSetup}>
|
|
<Stack.Screen name="setup" />
|
|
</Stack.Protected>
|
|
<Stack.Protected guard={!!user && !needsSetup}>
|
|
<Stack.Screen name="(tabs)" />
|
|
<Stack.Screen name="album/[id]" />
|
|
<Stack.Screen name="artist/[id]" />
|
|
<Stack.Screen name="playlists" />
|
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
|
</Stack.Protected>
|
|
</Stack>
|
|
);
|
|
}
|
|
|