Files
pipu-music/apps/mobile/src/ui/kit.tsx
T
mrleo1nidandClaude Opus 5.5 ed063d3763
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
feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
- 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>
2026-10-20 13:30:21 +03:00

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' },
});