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
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:
1 parent
eaedd1fc62
commit
9fd616da57
28 files changed
+2590
-197
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
|
||||
|
||||
import { useSession } from '../../lib/session';
|
||||
import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { colors, radius } from '../../theme';
|
||||
import { Cover, Icon, Screen, TrackRow, useErrorText } from '../../ui/kit';
|
||||
|
||||
const RECENT_KEY = 'pipu.recent_searches';
|
||||
|
||||
/** Поиск: подсказки по мере ввода (debounce 250 мс), недавние запросы — локально. */
|
||||
export default function SearchScreen() {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const router = useRouter();
|
||||
const current = usePlayerState().track;
|
||||
const errorText = useErrorText();
|
||||
const [q, setQ] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void SecureStore.getItemAsync(RECENT_KEY).then((v) => setRecent(v ? (JSON.parse(v) as string[]) : []));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(q.trim()), 250);
|
||||
return () => clearTimeout(id);
|
||||
}, [q]);
|
||||
|
||||
const remember = (query: string) => {
|
||||
const next = [query, ...recent.filter((r) => r !== query)].slice(0, 10);
|
||||
setRecent(next);
|
||||
void SecureStore.setItemAsync(RECENT_KEY, JSON.stringify(next));
|
||||
};
|
||||
|
||||
const res = useQuery({
|
||||
queryKey: ['m-search', debounced],
|
||||
enabled: debounced !== '',
|
||||
placeholderData: keepPreviousData,
|
||||
queryFn: async ({ signal }) =>
|
||||
unwrap(await session.client.GET('/api/v1/search', { params: { query: { q: debounced } }, signal })),
|
||||
});
|
||||
const data = debounced ? res.data : undefined;
|
||||
|
||||
return (
|
||||
<Screen>
|
||||
<View style={{ padding: 16, gap: 8 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, height: 46, paddingHorizontal: 12, borderRadius: radius.md, backgroundColor: colors.surface2 }}>
|
||||
<Icon name="search" color={colors.text3} />
|
||||
<TextInput
|
||||
value={q}
|
||||
onChangeText={setQ}
|
||||
onSubmitEditing={() => q.trim() && remember(q.trim())}
|
||||
placeholder={t('search.placeholder')}
|
||||
placeholderTextColor={colors.text3}
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
accessibilityLabel={t('search.label')}
|
||||
style={{ flex: 1, color: colors.text, fontSize: 16 }}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{!!q && (
|
||||
<Pressable onPress={() => setQ('')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||
<Icon name="close" color={colors.text3} size={20} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
{data?.corrected_query && <Text style={{ color: colors.warn, fontSize: 13 }}>{t('search.corrected', { query: data.corrected_query })}</Text>}
|
||||
{res.error && <Text style={{ color: colors.danger }}>{errorText(res.error)}</Text>}
|
||||
</View>
|
||||
|
||||
{!debounced ? (
|
||||
<View style={{ paddingHorizontal: 16, gap: 4 }}>
|
||||
{recent.length > 0 && (
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>{t('mobile.recent_searches')}</Text>
|
||||
<Pressable onPress={() => { setRecent([]); void SecureStore.deleteItemAsync(RECENT_KEY); }} accessibilityRole="button">
|
||||
<Text style={{ color: colors.accent2 }}>{t('mobile.clear_recent')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{recent.map((r) => (
|
||||
<Pressable key={r} onPress={() => setQ(r)} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }}>
|
||||
<Icon name="history" size={20} color={colors.text3} />
|
||||
<Text style={{ color: colors.text }}>{r}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={data?.tracks ?? []}
|
||||
keyExtractor={(tr) => tr.id}
|
||||
ListHeaderComponent={
|
||||
<View style={{ gap: 4 }}>
|
||||
{data?.artists.map((a) => (
|
||||
<Pressable key={a.id} onPress={() => { remember(debounced); router.push(`/artist/${a.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<Cover album={{ id: a.id }} size={44} round />
|
||||
<View>
|
||||
<Text style={{ color: colors.text, fontWeight: '600' }}>{a.name}</Text>
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('search.artist_kind')}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
{data?.albums.map((al) => (
|
||||
<Pressable key={al.id} onPress={() => { remember(debounced); router.push(`/album/${al.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<Cover album={al} size={44} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{al.title}</Text>
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>{[al.artist.name, al.year].filter(Boolean).join(' · ')}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
res.isSuccess && !data?.artists.length && !data?.albums.length ? (
|
||||
<Text style={{ color: colors.text3, textAlign: 'center', marginTop: 24 }}>{t('search.nothing')}</Text>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<TrackRow
|
||||
track={item}
|
||||
current={current?.id === item.id}
|
||||
onPress={() => {
|
||||
remember(debounced);
|
||||
void player.playList(data?.tracks ?? [], index, { type: 'search', id: debounced });
|
||||
}}
|
||||
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => res.refetch())}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user