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>
128 lines
4.8 KiB
TypeScript
128 lines
4.8 KiB
TypeScript
import { useEffect, useSyncExternalStore } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
|
|
|
import { useSession, useSessionCtx } from '../lib/session';
|
|
import { updater } from '../lib/update';
|
|
import { radius, useColors } from '../theme';
|
|
import { Button } from './kit';
|
|
|
|
export function useUpdateState() {
|
|
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
|
}
|
|
|
|
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
|
export function UpdateDialog() {
|
|
const colors = useColors();
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const { server } = useSessionCtx();
|
|
const st = useUpdateState();
|
|
|
|
useEffect(() => {
|
|
void updater.check(session);
|
|
const sub = AppState.addEventListener('change', (s) => {
|
|
if (s === 'active') void updater.check(session);
|
|
});
|
|
return () => sub.remove();
|
|
}, [session]);
|
|
|
|
if (st.status === 'idle' || st.status === 'checking' || st.status === 'latest') return null;
|
|
if (st.status === 'available' && st.dismissed) return null;
|
|
|
|
const { release, required } = st;
|
|
const busy = st.status === 'downloading' || st.status === 'verifying';
|
|
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
|
|
|
return (
|
|
<Modal transparent animationType="fade" visible onRequestClose={() => updater.dismiss()}>
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
backgroundColor: 'rgba(0,0,0,0.6)',
|
|
justifyContent: 'center',
|
|
padding: 24,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: radius.lg,
|
|
padding: 20,
|
|
gap: 12,
|
|
maxHeight: '80%',
|
|
}}
|
|
>
|
|
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }}>
|
|
{t('mobile.update_title', { version: release.version })}
|
|
</Text>
|
|
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
|
{release.notes_md.trim() !== '' && (
|
|
<ScrollView style={{ maxHeight: 240 }}>
|
|
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
|
</ScrollView>
|
|
)}
|
|
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
|
{t('mobile.update_size', {
|
|
size: (release.size / 1048576).toFixed(1),
|
|
})}
|
|
</Text>
|
|
|
|
{st.status === 'downloading' && (
|
|
<View style={{ gap: 6 }}>
|
|
<View
|
|
style={{
|
|
height: 4,
|
|
borderRadius: 2,
|
|
backgroundColor: colors.surface3,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
height: 4,
|
|
borderRadius: 2,
|
|
width: `${Math.round(st.progress * 100)}%`,
|
|
backgroundColor: colors.accent2,
|
|
}}
|
|
/>
|
|
</View>
|
|
<Text style={{ color: colors.text2, fontSize: 12 }}>
|
|
{t('mobile.update_downloading', {
|
|
percent: Math.round(st.progress * 100),
|
|
})}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{st.status === 'verifying' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_verifying')}</Text>}
|
|
{st.status === 'error' && (
|
|
<Text style={{ color: colors.danger }}>
|
|
{t(st.error === 'bad_checksum' ? 'mobile.update_bad_checksum' : 'mobile.update_failed')}
|
|
</Text>
|
|
)}
|
|
{st.status === 'ready' && (
|
|
<View style={{ gap: 4 }}>
|
|
<Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_allow')}</Text>
|
|
<Pressable onPress={() => void updater.openInstallSettings()} accessibilityRole="link">
|
|
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.update_open_settings')}</Text>
|
|
</Pressable>
|
|
</View>
|
|
)}
|
|
|
|
<Button title={t('mobile.update_install')} icon="system-update" busy={busy} onPress={action} />
|
|
{!required && !busy && <Button title={t('mobile.update_later')} variant="ghost" onPress={() => updater.dismiss()} />}
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
|
function plainNotes(md: string): string {
|
|
return md
|
|
.replace(/^#{1,6}\s*/gm, '')
|
|
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
.replace(/^\s*[-*]\s+/gm, '• ')
|
|
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
|
.trim();
|
|
}
|