From be34ee53618b47919bdcb8b69da5ff52e425470e Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:02:47 +0300 Subject: [PATCH] feat(mobile): offline downloads, persistent event queue and offline start (M5) - Downloads queue (2 parallel, Wi-Fi only by default) stores files under the app's document directory and track metadata in SQLite; Collection gets a Downloads tab, album and player screens get download buttons. - Player plays downloaded files and local covers; My Wave falls back to a shuffle of downloaded tracks when the server is unreachable. - Playback events that fail on the network are kept in SQLite and sent when the connection returns. - AuthSession can open offline with the cached user and fetches an access token before the first request once online. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/app/(tabs)/_layout.tsx | 133 ++++------- apps/mobile/src/app/(tabs)/collection.tsx | 98 +++++++- apps/mobile/src/app/(tabs)/profile.tsx | 139 ++++------- apps/mobile/src/app/_layout.tsx | 4 +- apps/mobile/src/app/album/[id].tsx | 2 + apps/mobile/src/app/player.tsx | 3 +- apps/mobile/src/lib/storage.ts | 28 ++- apps/mobile/src/lib/update.ts | 111 ++++----- apps/mobile/src/offline/db.ts | 32 +++ apps/mobile/src/offline/downloads.ts | 277 ++++++++++++++++++++++ apps/mobile/src/offline/events.ts | 72 ++++++ apps/mobile/src/player/controller.ts | 68 ++++-- apps/mobile/src/ui/UpdateDialog.tsx | 125 +++------- apps/mobile/src/ui/downloads.tsx | 43 ++++ apps/mobile/src/ui/kit.tsx | 15 +- docs/mobile.md | 10 + packages/core/src/auth.test.ts | 28 ++- packages/core/src/auth.ts | 21 +- packages/i18n/locales/en/common.json | 18 +- packages/i18n/locales/ru/common.json | 18 +- pnpm-lock.yaml | 22 ++ 22 files changed, 881 insertions(+), 387 deletions(-) create mode 100644 apps/mobile/src/offline/db.ts create mode 100644 apps/mobile/src/offline/downloads.ts create mode 100644 apps/mobile/src/offline/events.ts create mode 100644 apps/mobile/src/ui/downloads.tsx diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f4efd63..d5def6c 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -26,6 +26,7 @@ "expo-linking": "~7.1.7", "expo-router": "~5.1.11", "expo-secure-store": "~14.2.4", + "expo-sqlite": "~15.2.14", "expo-status-bar": "~2.2.3", "i18next": "^25.2.1", "js-sha256": "^1.0.0", diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index f4b64af..623dbd1 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -1,36 +1,31 @@ -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 { useNetInfo } from '@react-native-community/netinfo'; +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"; -import { UpdateDialog } from "../../ui/UpdateDialog"; +import { player } from '../../player/controller'; +import { usePlayerState } from '../../player/hooks'; +import { colors } from '../../theme'; +import { Cover, Icon, type IconName } from '../../ui/kit'; +import { UpdateDialog } from '../../ui/UpdateDialog'; -const ICONS: Record = { - index: "home", - search: "search", - collection: "library-music", - profile: "person", -}; +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"), + index: t('nav.home'), + search: t('mobile.search'), + collection: t('nav.collection'), + profile: t('mobile.profile'), }; + const net = useNetInfo(); return ( <> + {net.isConnected === false && } ( @@ -69,7 +64,7 @@ function TabBar({ - - + + {labels[name]} @@ -119,71 +100,45 @@ function MiniPlayer() { const st = usePlayerState(); const track = st.track; if (!track) return null; - const progress = st.durationMs - ? Math.min(st.positionMs / st.durationMs, 1) - : 0; + const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0; return ( router.push("/player")} + onPress={() => router.push('/player')} accessibilityRole="button" - accessibilityLabel={t("mobile.open_player")} - style={{ - backgroundColor: colors.surface, - borderTopWidth: 1, - borderTopColor: colors.line, - }} + accessibilityLabel={t('mobile.open_player')} + style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }} > - + - + - + {track.title} - {track.artists.map((a) => a.name).join(", ")} + {track.artists.map((a) => a.name).join(', ')} - player.toggle()} - hitSlop={10} - accessibilityRole="button" - accessibilityLabel={t(st.playing ? "player.pause" : "player.play")} - > - + player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}> + - player.next()} - hitSlop={10} - accessibilityRole="button" - accessibilityLabel={t("player.next")} - > + player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}> ); } + +/** Нет сети: доступны «Скачанное» и офлайн-волна. */ +function OfflineBanner({ text }: { text: string }) { + const insets = useSafeAreaInsets(); + return ( + + + {text} + + ); +} diff --git a/apps/mobile/src/app/(tabs)/collection.tsx b/apps/mobile/src/app/(tabs)/collection.tsx index 2a273c0..7741657 100644 --- a/apps/mobile/src/app/(tabs)/collection.tsx +++ b/apps/mobile/src/app/(tabs)/collection.tsx @@ -1,16 +1,18 @@ import { unwrap, type Track } from '@pipu/core'; import { useInfiniteQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FlatList, Text, View } from 'react-native'; import { useSession } from '../../lib/session'; +import { downloads } from '../../offline/downloads'; import { player } from '../../player/controller'; import { usePlayerState } from '../../player/hooks'; import { colors } from '../../theme'; -import { Button, H1, Screen, Segmented, TrackRow } from '../../ui/kit'; +import { DownloadButton, useDownloads } from '../../ui/downloads'; +import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit'; -type Tab = 'history' | 'likes' | 'dislikes'; +type Tab = 'history' | 'likes' | 'dislikes' | 'downloads'; export default function CollectionScreen() { const { t, i18n } = useTranslation(); @@ -20,6 +22,7 @@ export default function CollectionScreen() { const list = useInfiniteQuery({ queryKey: ['m-collection', tab], + enabled: tab !== 'downloads', initialPageParam: undefined as string | undefined, queryFn: async ({ pageParam }) => { const query = { query: { cursor: pageParam, limit: 100 } }; @@ -34,11 +37,35 @@ export default function CollectionScreen() { getNextPageParam: (p) => p.next ?? undefined, }); const items = list.data?.pages.flatMap((p) => p.items) ?? []; + + /** Все лайки целиком, а не только загруженные страницы списка. */ + const downloadAllLikes = async () => { + let cursor: string | undefined; + do { + const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } })); + await downloads.enqueue(page.items.map((i) => i.track)); + cursor = page.next_cursor ?? undefined; + } while (cursor); + }; 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; + const tabs = ( + + value={tab} + onChange={setTab} + options={[ + { value: 'likes', label: t('collection.likes') }, + { value: 'history', label: t('collection.history') }, + { value: 'downloads', label: t('mobile.dl_title') }, + { value: 'dislikes', label: t('collection.dislikes') }, + ]} + /> + ); + if (tab === 'downloads') return ; + return (

{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') }, - ]} - /> + {tabs} {tab === 'likes' && tracks.length > 0 && (