feat(mobile): sign-in, tabs, search, collection and background player (M5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m5s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 39s

- expo-router app in src/app: connect (server check, recent servers),
  login, mandatory credential / password change, tabs Home / Search /
  Collection / Profile, album and artist pages, full-screen player
- AuthSession per server with the refresh token in expo-secure-store
  (Android Keystore); language from the profile
- player on react-native-track-player: ExoPlayer queue with a
  notification and lock-screen controls via a playback service, 500 MB
  stream cache, quality by network (Wi-Fi high, cellular medium),
  play events with played time, reactions, playback state restore and
  the endless My Wave with feedback
- home: pulsing wave circle driven by BPM / energy in the cover
  palette, activity and mood chips that switch the wave on the fly
- New Architecture is off until react-native-track-player supports it;
  the release build runs codegen only when it is on; query-string is a
  direct dependency because expo-router 5.1 imports it undeclared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:48:55 +03:00
1 parent eaedd1fc62
commit 9fd616da57
28 files changed
+2590 -197

No files matched your search

+82
View File
@@ -0,0 +1,82 @@
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 { colors, radius } 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 { 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>
);
}