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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user