feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:30:21 +03:00
1 parent 756e915ff9
commit ed063d3763
28 files changed
+923 -188

No files matched your search

+80 -29
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Image,
Modal,
Pressable,
StyleSheet,
Text,
@@ -20,15 +21,17 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { absolute } from '../player/controller';
import { colors, radius } from '../theme';
import { radius, useColors, type Colors } from '../theme';
export type IconName = ComponentProps<typeof MaterialIcons>['name'];
export function Icon({ name, size = 22, color = colors.text2 }: { name: IconName; size?: number; color?: string }) {
return <MaterialIcons name={name} size={size} color={color} />;
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']}>
@@ -38,10 +41,12 @@ export function Screen({ children, scroll = false, style }: { children: ReactNod
}
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>;
}
@@ -52,6 +57,8 @@ export function Field({
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 (
@@ -98,6 +105,8 @@ export function Button({
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 (
@@ -115,6 +124,8 @@ export function Button({
}
export function ErrorBox({ children }: { children?: ReactNode }) {
const s = useStyles();
const colors = useColors();
if (!children) return null;
return (
<View style={s.error} accessibilityRole="alert">
@@ -143,6 +154,7 @@ export function Cover({
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;
@@ -183,6 +195,8 @@ export function TrackRow({
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));
@@ -225,6 +239,30 @@ export function TrackRow({
);
}
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
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,
@@ -234,6 +272,8 @@ export function Segmented<T extends string>({
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) => (
@@ -251,29 +291,40 @@ export function Segmented<T extends string>({
);
}
export const s = 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' },
});
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' },
});