import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useRouter } from 'expo-router'; import { ApiError, type Track } from '@pipu/core'; import type { ComponentProps, ReactNode } from 'react'; import { useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { ActivityIndicator, Image, Modal, Pressable, StyleSheet, Text, TextInput, View, type StyleProp, type TextInputProps, type ViewStyle, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSessionCtx } from '../lib/session'; import { downloads, sizedCover } from '../offline/downloads'; import { absolute } from '../player/controller'; import { radius, useColors, type Colors } from '../theme'; export type IconName = ComponentProps['name']; export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) { const colors = useColors(); return ; } export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp }) { const s = useStyles(); void scroll; return ( {children} ); } export function H1({ children }: { children: ReactNode }) { const s = useStyles(); return {children}; } export function Muted({ children, style }: { children: ReactNode; style?: StyleProp }) { const s = useStyles(); return {children}; } export function Field({ label, error, hint, secure, ...rest }: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) { const s = useStyles(); const colors = useColors(); const { t } = useTranslation(); const [shown, setShown] = useState(false); return ( {label} {secure && ( setShown((v) => !v)} style={s.reveal} accessibilityRole="button" accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')} > )} {(error ?? hint) && {error ?? hint}} ); } export function Button({ title, onPress, variant = 'primary', busy, disabled, icon, }: { title: string; onPress: () => void; variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; busy?: boolean; disabled?: boolean; icon?: IconName; }) { const s = useStyles(); const colors = useColors(); const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant]; const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant]; return ( [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]} > {busy ? : icon ? : null} {title} ); } export function ErrorBox({ children }: { children?: ReactNode }) { const s = useStyles(); const colors = useColors(); if (!children) return null; return ( {children} ); } /** Текст ошибки API: перевод errors:. */ export function useErrorText(): (err: unknown) => string { const { t, i18n } = useTranslation(); const tr = t as unknown as (k: string, o?: Record) => string; return (err) => { const code = err instanceof ApiError ? err.code : 'common.internal'; const key = `errors:${code}`; return i18n.exists(key) ? tr(key, err instanceof ApiError ? err.params : {}) : tr('errors:common.internal'); }; } export function Cover({ album, size, round, }: { album: { id: string; cover_url?: string | null; cover_colors?: string[] }; size: number; round?: boolean; }) { const colors = useColors(); const { server } = useSessionCtx(); const remote = server ? absolute(server, album.cover_url) : undefined; const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200; // У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети. const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined)); const uri = local ?? (remote ? sizedCover(remote, px) : undefined); const bg = album.cover_colors?.[0] ?? colors.surface3; return ( {uri ? ( ) : ( )} ); } export function formatDuration(ms: number): string { const sec = Math.max(0, Math.round(ms / 1000)); return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`; } export function TrackRow({ track, current, onPress, onLike, numbered, meta, trailing, }: { track: Track; current?: boolean; onPress: () => void; onLike?: () => void; numbered?: number; meta?: string; trailing?: ReactNode; }) { const s = useStyles(); const colors = useColors(); const { t } = useTranslation(); const liked = track.reaction === 'like'; const router = useRouter(); const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id)); return ( a.name).join(', ')}`} style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]} > {numbered !== undefined ? ( {numbered} ) : ( )} {track.title} {track.artists.map((a) => a.name).join(', ')} {meta ? ` · ${meta}` : ''} {onLike && ( )} {trailing} { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}> {saved && !trailing && } {formatDuration(track.duration_ms)} ); } /** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */ export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) { const colors = useColors(); const { t } = useTranslation(); return ( {title} {children} ); } export function Segmented({ value, options, onChange, }: { value: T; options: { value: T; label: string }[]; onChange: (v: T) => void; }) { const s = useStyles(); const colors = useColors(); return ( {options.map((o) => ( onChange(o.value)} accessibilityRole="tab" accessibilityState={{ selected: value === o.value }} style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]} > {o.label} ))} ); } const styles = new Map>(); /** Стили кита под активную тему (создаются один раз на тему). */ function useStyles() { const colors = useColors(); let st = styles.get(colors); if (!st) styles.set(colors, (st = makeStyles(colors))); return st; } const makeStyles = (colors: Colors) => StyleSheet.create({ screen: { flex: 1, backgroundColor: colors.bg }, h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 }, muted: { color: colors.text2, lineHeight: 21 }, label: { color: colors.text2, fontSize: 13, fontWeight: '600' }, input: { height: 48, paddingHorizontal: 14, borderRadius: radius.md, backgroundColor: colors.surface2, borderWidth: 1, borderColor: colors.line2, color: colors.text, fontSize: 15, }, reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' }, button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 }, error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 }, num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] }, rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' }, rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 }, duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] }, segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' }, segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' }, });