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 && (