ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile. Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
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<typeof MaterialIcons>['name'];
|
|
|
|
export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) {
|
|
const colors = useColors();
|
|
return <MaterialIcons name={name} size={size} color={color ?? colors.text2} />;
|
|
}
|
|
|
|
export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
|
|
const s = useStyles();
|
|
void scroll;
|
|
return (
|
|
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
|
|
{children}
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
export function H1({ children }: { children: ReactNode }) {
|
|
const s = useStyles();
|
|
return <Text style={s.h1}>{children}</Text>;
|
|
}
|
|
|
|
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
|
|
const s = useStyles();
|
|
return <Text style={[s.muted, style as object]}>{children}</Text>;
|
|
}
|
|
|
|
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 (
|
|
<View style={{ gap: 6 }}>
|
|
<Text style={s.label}>{label}</Text>
|
|
<View>
|
|
<TextInput
|
|
placeholderTextColor={colors.text3}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
secureTextEntry={secure && !shown}
|
|
accessibilityLabel={label}
|
|
style={[s.input, error ? { borderColor: colors.danger } : null, secure ? { paddingRight: 48 } : null]}
|
|
{...rest}
|
|
/>
|
|
{secure && (
|
|
<Pressable
|
|
onPress={() => setShown((v) => !v)}
|
|
style={s.reveal}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')}
|
|
>
|
|
<Icon name={shown ? 'visibility-off' : 'visibility'} size={20} color={colors.text3} />
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
{(error ?? hint) && <Text style={{ color: error ? colors.danger : colors.text3, fontSize: 12 }}>{error ?? hint}</Text>}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Pressable
|
|
onPress={onPress}
|
|
disabled={disabled || busy}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ disabled: disabled || busy, busy }}
|
|
style={({ pressed }) => [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
|
|
>
|
|
{busy ? <ActivityIndicator color={fg} /> : icon ? <Icon name={icon} size={20} color={fg} /> : null}
|
|
<Text style={{ color: fg, fontWeight: '600', fontSize: 15 }}>{title}</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function ErrorBox({ children }: { children?: ReactNode }) {
|
|
const s = useStyles();
|
|
const colors = useColors();
|
|
if (!children) return null;
|
|
return (
|
|
<View style={s.error} accessibilityRole="alert">
|
|
<Text style={{ color: colors.danger, fontWeight: '500' }}>{children}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
/** Текст ошибки API: перевод errors:<code>. */
|
|
export function useErrorText(): (err: unknown) => string {
|
|
const { t, i18n } = useTranslation();
|
|
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => 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 (
|
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
|
{uri ? (
|
|
<Image source={{ uri }} style={{ width: size, height: size }} />
|
|
) : (
|
|
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
|
|
)}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Pressable
|
|
onPress={onPress}
|
|
disabled={!track.available}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`${track.title}, ${track.artists.map((a) => a.name).join(', ')}`}
|
|
style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]}
|
|
>
|
|
{numbered !== undefined ? (
|
|
<Text style={s.num}>{numbered}</Text>
|
|
) : (
|
|
<Cover album={track.album} size={44} />
|
|
)}
|
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
<Text numberOfLines={1} style={[s.rowTitle, current && { color: colors.accent2 }]}>
|
|
{track.title}
|
|
</Text>
|
|
<Text numberOfLines={1} style={s.rowSub}>
|
|
{track.artists.map((a) => a.name).join(', ')}
|
|
{meta ? ` · ${meta}` : ''}
|
|
</Text>
|
|
</View>
|
|
{onLike && (
|
|
<Pressable
|
|
onPress={onLike}
|
|
hitSlop={10}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t(liked ? 'player.unlike' : 'player.like')}
|
|
>
|
|
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
|
|
</Pressable>
|
|
)}
|
|
{trailing}
|
|
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
|
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
|
<Icon name="playlist-add" size={22} />
|
|
</Pressable>
|
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
|
|
export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
|
<Pressable style={{ flex: 1, backgroundColor: colors.overlay }} onPress={onClose} accessibilityLabel={title} />
|
|
<SafeAreaView edges={['bottom']} style={{ backgroundColor: colors.surface, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }}>
|
|
<View style={{ padding: 20, gap: 16 }}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }} accessibilityRole="header">
|
|
{title}
|
|
</Text>
|
|
<Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
|
<Icon name="close" color={colors.text2} />
|
|
</Pressable>
|
|
</View>
|
|
{children}
|
|
</View>
|
|
</SafeAreaView>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export function Segmented<T extends string>({
|
|
value,
|
|
options,
|
|
onChange,
|
|
}: {
|
|
value: T;
|
|
options: { value: T; label: string }[];
|
|
onChange: (v: T) => void;
|
|
}) {
|
|
const s = useStyles();
|
|
const colors = useColors();
|
|
return (
|
|
<View style={s.segmented} accessibilityRole="tablist">
|
|
{options.map((o) => (
|
|
<Pressable
|
|
key={o.value}
|
|
onPress={() => onChange(o.value)}
|
|
accessibilityRole="tab"
|
|
accessibilityState={{ selected: value === o.value }}
|
|
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
|
|
>
|
|
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = new Map<Colors, ReturnType<typeof makeStyles>>();
|
|
|
|
/** Стили кита под активную тему (создаются один раз на тему). */
|
|
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' },
|
|
});
|