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>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
|
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 } 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, 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 ? `${remote}&size=${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 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}
|
|
{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 }]}
|
|
>
|
|
<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' },
|
|
});
|