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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:48:55 +03:00
1 parent eaedd1fc62
commit 9fd616da57
28 files changed
+2590 -197

No files matched your search

+126
View File
@@ -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<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
export default function TabsLayout() {
const { t } = useTranslation();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
};
return (
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
);
}
function TabBar({
routes,
active,
labels,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
onPress: (name: string) => void;
}) {
const insets = useSafeAreaInsets();
return (
<View
accessibilityRole="tablist"
style={{
flexDirection: 'row',
paddingBottom: insets.bottom,
backgroundColor: colors.bg2,
borderTopWidth: 1,
borderTopColor: colors.line,
}}
>
{routes.map((name, i) => {
const on = i === active;
return (
<Pressable
key={name}
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]}
</Text>
</Pressable>
);
})}
</View>
);
}
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
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 (
<Pressable
onPress={() => router.push('/player')}
accessibilityRole="button"
accessibilityLabel={t('mobile.open_player')}
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
>
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
<Cover album={track.album} size={42} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
</View>
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={28} color={colors.text2} />
</Pressable>
</View>
</Pressable>
);
}
+80
View File
@@ -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<Tab>('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 (
<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>
<Segmented<Tab>
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 && (
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
)}
</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>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, Easing, Pressable, ScrollView, 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 { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
function iconFor(name: string): IconName {
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
}
/**
* Главная: кнопка «Моя волна» и выбор занятия / настроения. На Android спектр недоступен,
* поэтому пульс берётся из BPM и energy трека (docs/mobile.md). Полный порт Canvas-анимации на Skia — отдельно.
*/
export default function HomeScreen() {
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
const session = useSession();
const st = usePlayerState();
const errorText = useErrorText();
const [error, setError] = useState<unknown>(null);
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
const options = useQuery({
queryKey: ['wave-options'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
staleTime: Infinity,
});
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const run = (fn: () => Promise<void>) => {
setError(null);
fn().catch(setError);
};
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
const choose = (kind: 'activity' | 'mood', key: string) => {
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
setActivity(a);
setMood(m);
if (waveOn) run(() => player.setWaveContext(a, m));
};
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
const colorsWave = waveOn && st.track?.album.cover_colors.length === 3 ? st.track.album.cover_colors : ['#2F80FF', '#7C5CFF', '#35D3FF'];
return (
<Screen>
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
<WaveCircle
playing={playing}
bpm={st.track?.features?.bpm ?? 100}
energy={st.track?.features?.energy ?? 0.6}
colors={colorsWave}
onPress={onWave}
label={t(playing ? 'wave.pause' : 'wave.start')}
>
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
</WaveCircle>
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
{options.data && (
<>
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
</>
)}
</ScrollView>
</Screen>
);
}
function WaveCircle({
playing,
bpm,
energy,
colors: palette,
onPress,
label,
children,
}: {
playing: boolean;
bpm: number;
energy: number;
colors: string[];
onPress: () => void;
label: string;
children: React.ReactNode;
}) {
const scale = useRef(new Animated.Value(1)).current;
const [reduced, setReduced] = useState(false);
useEffect(() => {
void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);
}, []);
useEffect(() => {
if (reduced) return;
// Играет — удар в такт BPM с силой от energy; в покое — медленное «дыхание».
const beat = playing ? 60000 / Math.max(bpm, 40) : 3000;
const peak = playing ? 1 + 0.03 + energy * 0.05 : 1.03;
const anim = Animated.loop(
Animated.sequence([
Animated.timing(scale, { toValue: peak, duration: playing ? beat * 0.15 : beat / 2, easing: Easing.out(Easing.quad), useNativeDriver: true }),
Animated.timing(scale, { toValue: 1, duration: playing ? beat * 0.85 : beat / 2, easing: Easing.inOut(Easing.quad), useNativeDriver: true }),
]),
);
anim.start();
return () => anim.stop();
}, [playing, bpm, energy, reduced, scale]);
return (
<Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ selected: playing }}>
<Animated.View
style={{
width: 260,
height: 260,
borderRadius: 130,
backgroundColor: palette[0],
alignItems: 'center',
justifyContent: 'center',
gap: 4,
overflow: 'hidden',
shadowColor: palette[0],
shadowOpacity: 0.6,
shadowRadius: 40,
elevation: 16,
transform: [{ scale }],
}}
>
<View style={{ position: 'absolute', width: 200, height: 200, borderRadius: 100, backgroundColor: palette[1], opacity: 0.55, top: 90, left: 70 }} />
<View style={{ position: 'absolute', width: 140, height: 140, borderRadius: 70, backgroundColor: palette[2], opacity: 0.45, top: 10, left: 20 }} />
{children}
</Animated.View>
</Pressable>
);
}
function Chips({
title,
options,
value,
label,
onPick,
}: {
title: string;
options: { key: string; icon: string }[];
value: string | null;
label: (k: string) => string;
onPick: (k: string) => void;
}) {
return (
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{options.map((o) => {
const on = value === o.key;
return (
<Pressable
key={o.key}
onPress={() => onPick(o.key)}
accessibilityRole="button"
accessibilityState={{ selected: on }}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
>
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import Constants from 'expo-constants';
import * as Linking from 'expo-linking';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { colors, radius } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
export default function ProfileScreen() {
const { t, i18n } = useTranslation();
const session = useSession();
const user = useUser();
const { server, disconnect } = useSessionCtx();
const staff = user.role !== 'user';
const setLocale = async (l: Locale) => {
await i18n.changeLanguage(l);
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// язык уже сменился локально
}
};
const logout = async () => {
await player.reset();
await session.logout();
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
<H1>{user.display_name ?? user.username}</H1>
<Muted>
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void setLocale(l)}
/>
</Row>
</Section>
<Section>
<Row label={t('mobile.server')}>
<Text style={{ color: colors.text2 }} numberOfLines={1}>
{server?.replace(/^https?:\/\//, '')}
</Text>
</Row>
{staff && server && (
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
<Icon name="open-in-new" size={20} color={colors.accent2} />
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
</Pressable>
)}
</Section>
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
</Text>
</ScrollView>
</Screen>
);
}
function Section({ children }: { children: React.ReactNode }) {
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Text style={{ color: colors.text }}>{label}</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
);
}
+141
View File
@@ -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>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider, useTranslation } from 'react-i18next';
import { ActivityIndicator, AppState, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
import { player } from '../player/controller';
import { colors } from '../theme';
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
export default function RootLayout() {
const [i18n, setI18n] = useState<I18n | null>(null);
useEffect(() => {
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
void createI18n(pickLocale([deviceLocale])).then(setI18n);
}, []);
if (!i18n) return <Splash />;
return (
<SafeAreaProvider>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<StatusBar style="light" />
<Gate />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>
</SafeAreaProvider>
);
}
function Splash() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/**
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
*/
function Gate() {
const { server, session, ready } = useSessionCtx();
const auth = useAuthState();
const segments = useSegments();
const router = useRouter();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
useEffect(() => {
player.attach(user ? session : null, server ?? '');
if (user) void player.restore();
}, [user?.id, session, server]);
useEffect(() => {
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
}, [user?.locale, i18n]);
// Свёрнутое приложение досылает события и позицию.
useEffect(() => {
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
return () => sub.remove();
}, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, 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, Cover, Icon, Screen, TrackRow, formatDuration, useErrorText } from '../../ui/kit';
export default function AlbumScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
const session = useSession();
const current = usePlayerState().track;
const errorText = useErrorText();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
const a = album.data;
const ctx = { type: 'album' as const, id };
return (
<Screen>
<FlatList
data={a?.tracks ?? []}
keyExtractor={(tr) => tr.id}
ListHeaderComponent={
<View style={{ padding: 16, gap: 12, alignItems: 'center' }}>
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="arrow-back" color={colors.text} />
</Pressable>
{album.error ? <Text style={{ color: colors.danger }}>{errorText(album.error)}</Text> : null}
{a && (
<>
<Cover album={a} size={220} />
<Text style={{ color: colors.text, fontSize: 22, fontWeight: '700', textAlign: 'center' }}>{a.title}</Text>
<Pressable onPress={() => router.push(`/artist/${a.artist.id}`)}>
<Text style={{ color: colors.text2 }}>
{a.artist.name}
{a.year ? ` · ${a.year}` : ''} · {formatDuration(a.duration_ms)}
</Text>
</Pressable>
<Button title={t('player.play')} icon="play-arrow" onPress={() => void player.playList(a.tracks, 0, ctx)} />
</>
)}
</View>
}
renderItem={({ item, index }) => (
<TrackRow
track={item}
numbered={item.track_no ?? index + 1}
current={current?.id === item.id}
onPress={() => void player.playList(a?.tracks ?? [], index, ctx)}
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => album.refetch())}
/>
)}
/>
</Screen>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { colors } from '../../theme';
import { Cover, Icon, Screen } from '../../ui/kit';
export default function ArtistScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
const session = useSession();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
return (
<Screen>
<FlatList
data={albums.data ?? []}
keyExtractor={(a) => a.id}
numColumns={2}
columnWrapperStyle={{ gap: 12, paddingHorizontal: 16 }}
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
ListHeaderComponent={
<View style={{ padding: 16, gap: 10, alignItems: 'center' }}>
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="arrow-back" color={colors.text} />
</Pressable>
<Cover album={{ id }} size={140} round />
<Text style={{ color: colors.text, fontSize: 24, fontWeight: '700' }}>{artist.data?.name ?? '…'}</Text>
{artist.data && (
<Text style={{ color: colors.text2 }}>
{t('catalog.album_count', { count: artist.data.album_count })} · {t('catalog.track_count', { count: artist.data.track_count })}
</Text>
)}
</View>
}
renderItem={({ item }) => (
<Pressable onPress={() => router.push(`/album/${item.id}`)} style={{ flex: 1, gap: 6 }} accessibilityRole="button">
<Cover album={item} size={160} />
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{item.title}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{item.year ?? ''}</Text>
</Pressable>
)}
/>
</Screen>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { createApiClient } from '@pipu/api-client';
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import Constants from 'expo-constants';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSessionCtx } from '../lib/session';
import { forgetServer, loadServers } from '../lib/storage';
import { colors, radius } from '../theme';
import { Button, ErrorBox, Field, H1, Icon, Muted, Screen } from '../ui/kit';
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
/** Подключение: адрес → проверка /server-info (версия, совместимость) → вход. */
export default function ConnectScreen() {
const { t } = useTranslation();
const { connect } = useSessionCtx();
const [address, setAddress] = useState('');
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<ServerCheck | 'invalid' | null>(null);
const servers = useQuery({ queryKey: ['saved-servers'], queryFn: loadServers });
const onConnect = async (raw = address) => {
const baseUrl = normalizeServerUrl(raw);
if (!baseUrl) return setResult('invalid');
setChecking(true);
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
setChecking(false);
setResult(res);
if (res.status === 'ok') await connect(baseUrl, res.info.version);
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t('app.name')}</H1>
<Muted>{t('app.tagline')}</Muted>
<Field
label={t('server.address')}
value={address}
onChangeText={setAddress}
onSubmitEditing={() => void onConnect()}
placeholder="https://music.home.lan"
keyboardType="url"
editable={!checking}
/>
{result === 'invalid' && <ErrorBox>{t('server.invalid_address')}</ErrorBox>}
{result !== null && result !== 'invalid' && result.status === 'unsupported_app' && (
<ErrorBox>{t('server.unsupported_app')}</ErrorBox>
)}
{result !== null && result !== 'invalid' && result.status === 'offline' && <ErrorBox>{t('server.offline')}</ErrorBox>}
<Button title={t('mobile.connect')} onPress={() => void onConnect()} busy={checking} disabled={!address.trim()} />
{!!servers.data?.length && (
<View style={{ gap: 8, marginTop: 16 }}>
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>
{t('mobile.servers')}
</Text>
{servers.data.map((sv) => (
<View key={sv.url} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderRadius: radius.md, backgroundColor: colors.surface }}>
<Pressable style={{ flex: 1 }} onPress={() => void onConnect(sv.url)} accessibilityRole="button">
<Text style={{ color: colors.text, fontWeight: '600' }}>{sv.url.replace(/^https?:\/\//, '')}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>pipu {sv.version}</Text>
</Pressable>
<Pressable
onPress={() => void forgetServer(sv.url).then(() => servers.refetch())}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('mobile.forget_server')}
>
<Icon name="close" size={20} color={colors.text3} />
</Pressable>
</View>
))}
</View>
)}
</ScrollView>
</Screen>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useSessionCtx } from '../lib/session';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
export default function LoginScreen() {
const { t } = useTranslation(['auth', 'common']);
const { server, session, disconnect } = useSessionCtx();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const submit = async () => {
if (!session) return;
setBusy(true);
setError(null);
try {
await session.login(username.trim(), password); // переход сделает Gate
} catch (e) {
setError(e);
setPassword('');
} finally {
setBusy(false);
}
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t('login.title')}</H1>
<Muted>{server?.replace(/^https?:\/\//, '')}</Muted>
<Field label={t('login.username')} value={username} onChangeText={setUsername} autoComplete="username" />
<Field
label={t('login.password')}
value={password}
onChangeText={setPassword}
secure
autoComplete="password"
onSubmitEditing={() => void submit()}
/>
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
<Button title={t('login.submit')} onPress={() => void submit()} busy={busy} disabled={!username.trim() || !password} />
<Button title={t('common:mobile.change_server')} variant="ghost" onPress={() => void disconnect()} />
</ScrollView>
</Screen>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { colors } from '../theme';
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
/** Полноэкранный плеер (вид cover): обложка, прогресс с перемоткой, лайк / дизлайк, управление. */
export default function PlayerScreen() {
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
if (!track) {
return (
<Screen>
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
</Screen>
);
}
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
return (
<Screen>
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<View style={{ width: 32 }} />
</View>
<View style={{ alignItems: 'center' }}>
<Cover album={track.album} size={300} />
</View>
<View style={{ gap: 4 }}>
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
</View>
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
style={{ height: 24, justifyContent: 'center' }}
>
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
</Pressable>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
onPress={() => player.toggle()}
accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color="#05070D" />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
</Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
</Pressable>
</View>
</View>
</Screen>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { ApiError, unwrap } from '@pipu/core';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useSession, useUser } from '../lib/session';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
/** Обязательный шаг: новые логин и пароль админа после первого запуска или замена временного пароля. */
export default function SetupScreen() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const admin = user.must_change_credentials;
const min = user.role === 'user' ? 8 : 12;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const mismatch = repeat !== '' && repeat !== password;
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = admin
? await session.client.POST('/api/v1/me/bootstrap-credentials', { body: { new_username: username.trim(), new_password: password } })
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
await session.apply(unwrap(res));
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</H1>
<Muted>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</Muted>
{admin && <Field label={t('setup.new_username')} value={username} onChangeText={setUsername} error={fieldErr('new_username')} />}
<Field
label={t('setup.new_password')}
value={password}
onChangeText={setPassword}
secure
hint={t('setup.min_length', { min })}
error={fieldErr('new_password')}
/>
<Field
label={t('setup.repeat_password')}
value={repeat}
onChangeText={setRepeat}
secure
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
/>
<ErrorBox>{error && !fieldErr('new_password') && !fieldErr('new_username') ? errorText(error) : null}</ErrorBox>
<Button
title={t('setup.submit')}
onPress={() => void submit()}
busy={busy}
disabled={!password || password !== repeat || (admin && !username.trim())}
/>
<Button title={t('setup.logout')} variant="ghost" onPress={() => void session.logout()} />
</ScrollView>
</Screen>
);
}