ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
- My Wave is rendered with react-native-skia on the UI thread; physics and colour helpers moved to @pipu/core (wave-anim) and shared with web. - Full-screen player honours ui_prefs.mobile_player (cover / wave). - Theme switching (dark / light / system) via a theme context and useColors() across all screens. - Profile: theme, wave variant, player view, sound quality sheet (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next cold start, and a change-password sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
import { useNetInfo } from '@react-native-community/netinfo';
|
|
import { Tabs, useRouter } from 'expo-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Pressable, Text, View } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
|
import { player } from '../../player/controller';
|
|
import { usePlayerState } from '../../player/hooks';
|
|
import { useColors } from '../../theme';
|
|
import { Cover, Icon, type IconName } from '../../ui/kit';
|
|
import { UpdateDialog } from '../../ui/UpdateDialog';
|
|
|
|
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
|
|
|
|
export default function TabsLayout() {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const labels: Record<string, string> = {
|
|
index: t('nav.home'),
|
|
search: t('mobile.search'),
|
|
collection: t('nav.collection'),
|
|
profile: t('mobile.profile'),
|
|
};
|
|
const net = useNetInfo();
|
|
return (
|
|
<>
|
|
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
|
|
<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>
|
|
<UpdateDialog />
|
|
</>
|
|
);
|
|
}
|
|
|
|
function TabBar({
|
|
routes,
|
|
active,
|
|
labels,
|
|
onPress,
|
|
}: {
|
|
routes: string[];
|
|
active: number;
|
|
labels: Record<string, string>;
|
|
onPress: (name: string) => void;
|
|
}) {
|
|
const colors = useColors();
|
|
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 colors = useColors();
|
|
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>
|
|
);
|
|
}
|
|
|
|
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
|
|
function OfflineBanner({ text }: { text: string }) {
|
|
const colors = useColors();
|
|
const insets = useSafeAreaInsets();
|
|
return (
|
|
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
|
<Icon name="cloud-off" size={16} color={colors.text2} />
|
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
|
|
</View>
|
|
);
|
|
}
|