From 9fd616da5721a711d72a54a82b38fbb7e633282b Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:48:55 +0300 Subject: [PATCH] feat(mobile): sign-in, tabs, search, collection and background player (M5) - 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 --- .gitea/workflows/release.yml | 5 +- apps/mobile/app.config.ts | 8 +- apps/mobile/index.ts | 10 +- apps/mobile/package.json | 24 +- apps/mobile/src/App.tsx | 33 -- apps/mobile/src/ConnectScreen.tsx | 122 ----- apps/mobile/src/app/(tabs)/_layout.tsx | 126 +++++ apps/mobile/src/app/(tabs)/collection.tsx | 80 ++++ apps/mobile/src/app/(tabs)/index.tsx | 192 ++++++++ apps/mobile/src/app/(tabs)/profile.tsx | 87 ++++ apps/mobile/src/app/(tabs)/search.tsx | 141 ++++++ apps/mobile/src/app/_layout.tsx | 94 ++++ apps/mobile/src/app/album/[id].tsx | 65 +++ apps/mobile/src/app/artist/[id].tsx | 57 +++ apps/mobile/src/app/connect.tsx | 82 ++++ apps/mobile/src/app/login.tsx | 51 ++ apps/mobile/src/app/player.tsx | 102 ++++ apps/mobile/src/app/setup.tsx | 72 +++ apps/mobile/src/lib/session.tsx | 91 ++++ apps/mobile/src/lib/storage.ts | 61 +++ apps/mobile/src/player/controller.ts | 395 ++++++++++++++++ apps/mobile/src/player/hooks.ts | 9 + apps/mobile/src/player/service.ts | 21 + apps/mobile/src/ui/kit.tsx | 270 +++++++++++ docs/mobile.md | 6 +- packages/i18n/locales/en/common.json | 18 + packages/i18n/locales/ru/common.json | 18 + pnpm-lock.yaml | 547 ++++++++++++++++++++-- 28 files changed, 2590 insertions(+), 197 deletions(-) delete mode 100644 apps/mobile/src/App.tsx delete mode 100644 apps/mobile/src/ConnectScreen.tsx create mode 100644 apps/mobile/src/app/(tabs)/_layout.tsx create mode 100644 apps/mobile/src/app/(tabs)/collection.tsx create mode 100644 apps/mobile/src/app/(tabs)/index.tsx create mode 100644 apps/mobile/src/app/(tabs)/profile.tsx create mode 100644 apps/mobile/src/app/(tabs)/search.tsx create mode 100644 apps/mobile/src/app/_layout.tsx create mode 100644 apps/mobile/src/app/album/[id].tsx create mode 100644 apps/mobile/src/app/artist/[id].tsx create mode 100644 apps/mobile/src/app/connect.tsx create mode 100644 apps/mobile/src/app/login.tsx create mode 100644 apps/mobile/src/app/player.tsx create mode 100644 apps/mobile/src/app/setup.tsx create mode 100644 apps/mobile/src/lib/session.tsx create mode 100644 apps/mobile/src/lib/storage.ts create mode 100644 apps/mobile/src/player/controller.ts create mode 100644 apps/mobile/src/player/hooks.ts create mode 100644 apps/mobile/src/player/service.ts create mode 100644 apps/mobile/src/ui/kit.tsx diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 174445e..7dddcf9 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -113,7 +113,10 @@ jobs: cd android gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m") - "${gradle[@]}" generateCodegenArtifactsFromSchema + # Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен. + if grep -q '^newArchEnabled=true' gradle.properties; then + "${gradle[@]}" generateCodegenArtifactsFromSchema + fi "${gradle[@]}" assembleRelease -x lintVitalRelease ./gradlew --stop diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index e7ecbf4..c4ec223 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -12,7 +12,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ version, orientation: 'portrait', userInterfaceStyle: 'automatic', - newArchEnabled: true, + // react-native-track-player 4 не поддерживает New Architecture (React Native Directory) — фоновое + // воспроизведение и уведомление важнее; вернём, когда плеер её поддержит (docs/mobile.md). + newArchEnabled: false, backgroundColor: '#05070D', android: { package: 'app.pipu.music', @@ -23,9 +25,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, platforms: ['android'], plugins: [ + // Маршруты — файлы в src/app (docs/mobile.md). + ['expo-router', { root: './src/app' }], + 'expo-secure-store', // Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка // Android по умолчанию запрещает незашифрованный HTTP. ['expo-build-properties', { android: { usesCleartextTraffic: true } }], ], extra: { versionCode }, + experiments: { typedRoutes: false }, }); diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts index b6fe111..9d01962 100644 --- a/apps/mobile/index.ts +++ b/apps/mobile/index.ts @@ -1,5 +1,9 @@ -import { registerRootComponent } from 'expo'; +// Фоновый сервис плеера регистрируется до запуска UI: кнопки уведомления и гарнитуры +// работают, даже когда интерфейс выгружен (docs/mobile.md#воспроизведение). +import TrackPlayer from 'react-native-track-player'; -import { App } from './src/App'; +import { PlaybackService } from './src/player/service'; -registerRootComponent(App); +TrackPlayer.registerPlaybackService(() => PlaybackService); + +import 'expo-router/entry'; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index c88c772..7e364d5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -10,20 +10,42 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@expo/vector-icons": "^14.1.0", "@pipu/api-client": "workspace:*", "@pipu/core": "workspace:*", "@pipu/design-tokens": "workspace:*", "@pipu/i18n": "workspace:*", + "@react-native-community/netinfo": "11.4.1", + "@tanstack/react-query": "^5.104.4", "expo": "~53.0.20", "expo-build-properties": "~0.14.8", "expo-constants": "~17.1.7", + "expo-font": "~13.3.2", + "expo-linking": "~7.1.7", + "expo-router": "~5.1.11", + "expo-secure-store": "~14.2.4", "expo-status-bar": "~2.2.3", "i18next": "^25.2.1", + "query-string": "^7.1.3", "react": "19.0.0", "react-i18next": "^15.5.3", - "react-native": "0.79.6" + "react-native": "0.79.6", + "react-native-safe-area-context": "5.4.0", + "react-native-screens": "~4.11.1", + "react-native-track-player": "^4.1.2" }, "devDependencies": { + "@types/node": "^22.20.5", "@types/react": "~19.0.10" + }, + "expo": { + "doctor": { + "reactNativeDirectoryCheck": { + "exclude": [ + "react-native-track-player" + ], + "listUnknownPackages": false + } + } } } diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx deleted file mode 100644 index a2d2fce..0000000 --- a/apps/mobile/src/App.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createI18n, pickLocale } from '@pipu/i18n'; -import { StatusBar } from 'expo-status-bar'; -import type { i18n as I18n } from 'i18next'; -import { useEffect, useState } from 'react'; -import { I18nextProvider } from 'react-i18next'; -import { ActivityIndicator, View } from 'react-native'; - -import { ConnectScreen } from './ConnectScreen'; -import { colors } from './theme'; - -export function App() { - const [i18n, setI18n] = useState(null); - - useEffect(() => { - const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale; - void createI18n(pickLocale([deviceLocale])).then(setI18n); - }, []); - - if (!i18n) { - return ( - - - - ); - } - - return ( - - - - - ); -} diff --git a/apps/mobile/src/ConnectScreen.tsx b/apps/mobile/src/ConnectScreen.tsx deleted file mode 100644 index 2f6142a..0000000 --- a/apps/mobile/src/ConnectScreen.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { createApiClient } from '@pipu/api-client'; -import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core'; -import Constants from 'expo-constants'; -import { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; - -import { colors, radius } from './theme'; - -const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0); - -type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck); - -/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */ -export function ConnectScreen() { - const { t } = useTranslation(); - const [address, setAddress] = useState(''); - const [state, setState] = useState({ kind: 'idle' }); - - const checking = state.kind === 'checking'; - - async function onCheck() { - if (checking) return; - const baseUrl = normalizeServerUrl(address); - if (!baseUrl) { - setState({ kind: 'invalid' }); - return; - } - setState({ kind: 'checking' }); - const res = await checkServer(createApiClient({ baseUrl }), appVersionCode); - setState({ kind: 'done', ...res }); - } - - return ( - - - {t('app.name')} - {t('app.tagline')} - - {t('server.address')} - - - {state.kind === 'checking' && ( - - - {t('server.checking')} - - )} - {state.kind === 'invalid' && } - {state.kind === 'done' && state.status === 'ok' && ( - - )} - {state.kind === 'done' && state.status === 'unsupported_app' && ( - - )} - {state.kind === 'done' && state.status === 'offline' && } - - [styles.button, (pressed || checking) && { opacity: 0.6 }]} - > - {t('server.check')} - - - - ); -} - -function Status({ ok, text }: { ok: boolean; text: string }) { - return ( - - {text} - - ); -} - -const styles = StyleSheet.create({ - screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 }, - card: { - gap: 12, - padding: 24, - borderRadius: radius.xl, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.line2, - }, - title: { color: colors.text, fontSize: 26, fontWeight: '600' }, - muted: { color: colors.text2, lineHeight: 21 }, - label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 }, - input: { - height: 48, - paddingHorizontal: 14, - borderRadius: radius.md, - backgroundColor: colors.surface2, - borderWidth: 1, - borderColor: colors.line2, - color: colors.text, - fontSize: 15, - }, - status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 }, - button: { - height: 48, - borderRadius: radius.md, - backgroundColor: colors.accent, - alignItems: 'center', - justifyContent: 'center', - }, - buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 }, -}); diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..68b14a9 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -0,0 +1,126 @@ +import { Tabs, useRouter } from 'expo-router'; +import { useTranslation } from 'react-i18next'; +import { Pressable, Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { player } from '../../player/controller'; +import { usePlayerState } from '../../player/hooks'; +import { colors } from '../../theme'; +import { Cover, Icon, type IconName } from '../../ui/kit'; + +const ICONS: Record = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' }; + +export default function TabsLayout() { + const { t } = useTranslation(); + const labels: Record = { + index: t('nav.home'), + search: t('mobile.search'), + collection: t('nav.collection'), + profile: t('mobile.profile'), + }; + return ( + ( + + + r.name)} + active={state.index} + labels={labels} + onPress={(name) => navigation.navigate(name)} + /> + + )} + > + + + + + + ); +} + +function TabBar({ + routes, + active, + labels, + onPress, +}: { + routes: string[]; + active: number; + labels: Record; + onPress: (name: string) => void; +}) { + const insets = useSafeAreaInsets(); + return ( + + {routes.map((name, i) => { + const on = i === active; + return ( + onPress(name)} + accessibilityRole="tab" + accessibilityState={{ selected: on }} + accessibilityLabel={labels[name]} + style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }} + > + + + {labels[name]} + + + ); + })} + + ); +} + +/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */ +function MiniPlayer() { + const { t } = useTranslation(); + const router = useRouter(); + const st = usePlayerState(); + const track = st.track; + if (!track) return null; + const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0; + return ( + router.push('/player')} + accessibilityRole="button" + accessibilityLabel={t('mobile.open_player')} + style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }} + > + + + + + + + + {track.title} + + + {track.artists.map((a) => a.name).join(', ')} + + + player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}> + + + player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> + + + + + ); +} diff --git a/apps/mobile/src/app/(tabs)/collection.tsx b/apps/mobile/src/app/(tabs)/collection.tsx new file mode 100644 index 0000000..2a273c0 --- /dev/null +++ b/apps/mobile/src/app/(tabs)/collection.tsx @@ -0,0 +1,80 @@ +import { unwrap, type Track } from '@pipu/core'; +import { useInfiniteQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FlatList, Text, View } from 'react-native'; + +import { useSession } from '../../lib/session'; +import { player } from '../../player/controller'; +import { usePlayerState } from '../../player/hooks'; +import { colors } from '../../theme'; +import { Button, H1, Screen, Segmented, TrackRow } from '../../ui/kit'; + +type Tab = 'history' | 'likes' | 'dislikes'; + +export default function CollectionScreen() { + const { t, i18n } = useTranslation(); + const session = useSession(); + const current = usePlayerState().track; + const [tab, setTab] = useState('likes'); + + const list = useInfiniteQuery({ + queryKey: ['m-collection', tab], + initialPageParam: undefined as string | undefined, + queryFn: async ({ pageParam }) => { + const query = { query: { cursor: pageParam, limit: 100 } }; + if (tab === 'history') { + const page = unwrap(await session.client.GET('/api/v1/me/history', { params: query })); + return { items: page.items.map((i) => ({ track: i.track, at: i.played_at })), next: page.next_cursor }; + } + const path = tab === 'likes' ? '/api/v1/me/likes' : '/api/v1/me/dislikes'; + const page = unwrap(await session.client.GET(path, { params: query })); + return { items: page.items.map((i) => ({ track: i.track, at: i.created_at })), next: page.next_cursor }; + }, + getNextPageParam: (p) => p.next ?? undefined, + }); + const items = list.data?.pages.flatMap((p) => p.items) ?? []; + const tracks: Track[] = items.map((i) => i.track); + const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const); + const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const; + + return ( + + `${it.track.id}-${i}`} + onEndReached={() => list.hasNextPage && void list.fetchNextPage()} + onRefresh={() => void list.refetch()} + refreshing={list.isRefetching} + ListHeaderComponent={ + +

{t('collection.title')}

+ + value={tab} + onChange={setTab} + options={[ + { value: 'likes', label: t('collection.likes') }, + { value: 'history', label: t('collection.history') }, + { value: 'dislikes', label: t('collection.dislikes') }, + ]} + /> + {tab === 'likes' && tracks.length > 0 && ( +