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>
84 lines
3.8 KiB
TypeScript
84 lines
3.8 KiB
TypeScript
import { createApiClient } from '@pipu/api-client';
|
|
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import Constants from 'expo-constants';
|
|
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
|
|
import { useSessionCtx } from '../lib/session';
|
|
import { forgetServer, loadServers } from '../lib/storage';
|
|
import { radius, useColors } from '../theme';
|
|
import { Button, ErrorBox, Field, H1, Icon, Muted, Screen } from '../ui/kit';
|
|
|
|
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
|
|
|
|
/** Подключение: адрес → проверка /server-info (версия, совместимость) → вход. */
|
|
export default function ConnectScreen() {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const { connect } = useSessionCtx();
|
|
const [address, setAddress] = useState('');
|
|
const [checking, setChecking] = useState(false);
|
|
const [result, setResult] = useState<ServerCheck | 'invalid' | null>(null);
|
|
const servers = useQuery({ queryKey: ['saved-servers'], queryFn: loadServers });
|
|
|
|
const onConnect = async (raw = address) => {
|
|
const baseUrl = normalizeServerUrl(raw);
|
|
if (!baseUrl) return setResult('invalid');
|
|
setChecking(true);
|
|
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
|
|
setChecking(false);
|
|
setResult(res);
|
|
if (res.status === 'ok') await connect(baseUrl, res.info.version);
|
|
};
|
|
|
|
return (
|
|
<Screen>
|
|
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
|
|
<H1>{t('app.name')}</H1>
|
|
<Muted>{t('app.tagline')}</Muted>
|
|
<Field
|
|
label={t('server.address')}
|
|
value={address}
|
|
onChangeText={setAddress}
|
|
onSubmitEditing={() => void onConnect()}
|
|
placeholder="https://music.home.lan"
|
|
keyboardType="url"
|
|
editable={!checking}
|
|
/>
|
|
{result === 'invalid' && <ErrorBox>{t('server.invalid_address')}</ErrorBox>}
|
|
{result !== null && result !== 'invalid' && result.status === 'unsupported_app' && (
|
|
<ErrorBox>{t('server.unsupported_app')}</ErrorBox>
|
|
)}
|
|
{result !== null && result !== 'invalid' && result.status === 'offline' && <ErrorBox>{t('server.offline')}</ErrorBox>}
|
|
<Button title={t('mobile.connect')} onPress={() => void onConnect()} busy={checking} disabled={!address.trim()} />
|
|
|
|
{!!servers.data?.length && (
|
|
<View style={{ gap: 8, marginTop: 16 }}>
|
|
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>
|
|
{t('mobile.servers')}
|
|
</Text>
|
|
{servers.data.map((sv) => (
|
|
<View key={sv.url} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderRadius: radius.md, backgroundColor: colors.surface }}>
|
|
<Pressable style={{ flex: 1 }} onPress={() => void onConnect(sv.url)} accessibilityRole="button">
|
|
<Text style={{ color: colors.text, fontWeight: '600' }}>{sv.url.replace(/^https?:\/\//, '')}</Text>
|
|
<Text style={{ color: colors.text3, fontSize: 12 }}>pipu {sv.version}</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
onPress={() => void forgetServer(sv.url).then(() => servers.refetch())}
|
|
hitSlop={10}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.forget_server')}
|
|
>
|
|
<Icon name="close" size={20} color={colors.text3} />
|
|
</Pressable>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
</ScrollView>
|
|
</Screen>
|
|
);
|
|
}
|