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>
157 lines
6.9 KiB
TypeScript
157 lines
6.9 KiB
TypeScript
import { unwrap, type Track } from '@pipu/core';
|
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
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 { useColors } from '../../theme';
|
|
import { DownloadButton, useDownloads } from '../../ui/downloads';
|
|
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
|
|
|
|
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
|
|
|
|
export default function CollectionScreen() {
|
|
const colors = useColors();
|
|
const { t, i18n } = useTranslation();
|
|
const session = useSession();
|
|
const current = usePlayerState().track;
|
|
const [tab, setTab] = useState<Tab>('likes');
|
|
|
|
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 } };
|
|
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 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 = (
|
|
<Segmented<Tab>
|
|
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 <DownloadsTab tabs={tabs} />;
|
|
|
|
return (
|
|
<Screen>
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={(it, i) => `${it.track.id}-${i}`}
|
|
onEndReached={() => list.hasNextPage && void list.fetchNextPage()}
|
|
onRefresh={() => void list.refetch()}
|
|
refreshing={list.isRefetching}
|
|
ListHeaderComponent={
|
|
<View style={{ padding: 16, gap: 14 }}>
|
|
<H1>{t('collection.title')}</H1>
|
|
{tabs}
|
|
{tab === 'likes' && tracks.length > 0 && (
|
|
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
|
|
)}
|
|
{tab === 'likes' && tracks.length > 0 && (
|
|
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
|
|
)}
|
|
</View>
|
|
}
|
|
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
|
|
renderItem={({ item, index }) => (
|
|
<TrackRow
|
|
track={item.track}
|
|
current={current?.id === item.track.id}
|
|
meta={tab === 'history' ? time.format(new Date(item.at)) : undefined}
|
|
onPress={() => void player.playList(tracks, index, context)}
|
|
onLike={tab === 'dislikes' ? undefined : () => void player.react(item.track, item.track.reaction === 'like' ? null : 'like').then(() => list.refetch())}
|
|
/>
|
|
)}
|
|
/>
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
|
|
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const dl = useDownloads();
|
|
const current = usePlayerState().track;
|
|
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
|
|
const items = [...dl.items.values()];
|
|
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
|
|
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
|
|
const failed = items.some((i) => i.status === 'error');
|
|
const context = { type: 'collection', id: 'downloads' } as const;
|
|
|
|
useEffect(() => {
|
|
void downloads.usage().then(setUsage);
|
|
}, [done.length]);
|
|
|
|
return (
|
|
<Screen>
|
|
<FlatList
|
|
data={items}
|
|
keyExtractor={(it) => it.track.id}
|
|
ListHeaderComponent={
|
|
<View style={{ padding: 16, gap: 14 }}>
|
|
<H1>{t('collection.title')}</H1>
|
|
{tabs}
|
|
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
|
|
{pending > 0 && (
|
|
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
|
|
)}
|
|
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
|
|
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
|
|
</View>
|
|
}
|
|
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
|
|
renderItem={({ item }) => (
|
|
<TrackRow
|
|
track={item.track}
|
|
current={current?.id === item.track.id}
|
|
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
|
|
onPress={() => {
|
|
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
|
|
}}
|
|
trailing={<DownloadButton track={item.track} size={20} />}
|
|
/>
|
|
)}
|
|
/>
|
|
</Screen>
|
|
);
|
|
}
|