mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
fix: ios-tv work ok
This commit is contained in:
6
app.json
6
app.json
@@ -56,15 +56,15 @@
|
||||
"screenOrientation": "unspecified",
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "VIEW",
|
||||
"action": "android.intent.action.VIEW",
|
||||
"data": [
|
||||
{
|
||||
"scheme": "oriontv"
|
||||
}
|
||||
],
|
||||
"category": [
|
||||
"BROWSABLE",
|
||||
"DEFAULT"
|
||||
"android.intent.category.BROWSABLE",
|
||||
"android.intent.category.DEFAULT"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -55,7 +55,6 @@ export default function HomeScreen() {
|
||||
|
||||
// 双击返回退出逻辑(只限当前页面)
|
||||
const backPressTimeRef = useRef<number | null>(null);
|
||||
const exitToastShownRef = useRef(false); // 防止重复显示提示
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -82,7 +81,6 @@ export default function HomeScreen() {
|
||||
return () => {
|
||||
backHandler.remove();
|
||||
backPressTimeRef.current = null;
|
||||
exitToastShownRef.current = false;
|
||||
};
|
||||
}
|
||||
}, [])
|
||||
|
||||
107
app/settings.tsx
107
app/settings.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform, ScrollView } from "react-native";
|
||||
import { View, StyleSheet, Alert, Platform } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
@@ -22,6 +22,18 @@ import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||
|
||||
type SectionItem = {
|
||||
component: React.ReactElement;
|
||||
key: string;
|
||||
};
|
||||
|
||||
/** 过滤掉 false/undefined,帮 TypeScript 推断出真正的数组元素类型 */
|
||||
function isSectionItem(
|
||||
item: false | undefined | SectionItem
|
||||
): item is SectionItem {
|
||||
return !!item;
|
||||
}
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
||||
const { lastMessage, targetPage, clearMessage } = useRemoteControlStore();
|
||||
@@ -87,8 +99,66 @@ export default function SettingsScreen() {
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const sections = [
|
||||
// 远程输入配置 - 仅在非手机端显示
|
||||
// const sections = [
|
||||
// // 远程输入配置 - 仅在非手机端显示
|
||||
// deviceType !== "mobile" && {
|
||||
// component: (
|
||||
// <RemoteInputSection
|
||||
// onChanged={markAsChanged}
|
||||
// onFocus={() => {
|
||||
// setCurrentFocusIndex(0);
|
||||
// setCurrentSection("remote");
|
||||
// }}
|
||||
// />
|
||||
// ),
|
||||
// key: "remote",
|
||||
// },
|
||||
// {
|
||||
// component: (
|
||||
// <APIConfigSection
|
||||
// ref={apiSectionRef}
|
||||
// onChanged={markAsChanged}
|
||||
// hideDescription={deviceType === "mobile"}
|
||||
// onFocus={() => {
|
||||
// setCurrentFocusIndex(1);
|
||||
// setCurrentSection("api");
|
||||
// }}
|
||||
// />
|
||||
// ),
|
||||
// key: "api",
|
||||
// },
|
||||
// // 直播源配置 - 仅在非手机端显示
|
||||
// deviceType !== "mobile" && {
|
||||
// component: (
|
||||
// <LiveStreamSection
|
||||
// ref={liveStreamSectionRef}
|
||||
// onChanged={markAsChanged}
|
||||
// onFocus={() => {
|
||||
// setCurrentFocusIndex(2);
|
||||
// setCurrentSection("livestream");
|
||||
// }}
|
||||
// />
|
||||
// ),
|
||||
// key: "livestream",
|
||||
// },
|
||||
// // {
|
||||
// // component: (
|
||||
// // <VideoSourceSection
|
||||
// // onChanged={markAsChanged}
|
||||
// // onFocus={() => {
|
||||
// // setCurrentFocusIndex(3);
|
||||
// // setCurrentSection("videoSource");
|
||||
// // }}
|
||||
// // />
|
||||
// // ),
|
||||
// // key: "videoSource",
|
||||
// // },
|
||||
// Platform.OS === "android" && {
|
||||
// component: <UpdateSection />,
|
||||
// key: "update",
|
||||
// },
|
||||
// ].filter(Boolean);
|
||||
const rawSections = [
|
||||
deviceType !== "mobile" && {
|
||||
component: (
|
||||
<RemoteInputSection
|
||||
@@ -115,7 +185,6 @@ export default function SettingsScreen() {
|
||||
),
|
||||
key: "api",
|
||||
},
|
||||
// 直播源配置 - 仅在非手机端显示
|
||||
deviceType !== "mobile" && {
|
||||
component: (
|
||||
<LiveStreamSection
|
||||
@@ -129,23 +198,14 @@ export default function SettingsScreen() {
|
||||
),
|
||||
key: "livestream",
|
||||
},
|
||||
// {
|
||||
// component: (
|
||||
// <VideoSourceSection
|
||||
// onChanged={markAsChanged}
|
||||
// onFocus={() => {
|
||||
// setCurrentFocusIndex(3);
|
||||
// setCurrentSection("videoSource");
|
||||
// }}
|
||||
// />
|
||||
// ),
|
||||
// key: "videoSource",
|
||||
// },
|
||||
Platform.OS === "android" && {
|
||||
component: <UpdateSection />,
|
||||
key: "update",
|
||||
},
|
||||
].filter(Boolean);
|
||||
] as const; // 把每个对象都当作字面量保留
|
||||
/** 这里得到的 sections 已经是 SectionItem[](没有 false) */
|
||||
const sections: SectionItem[] = rawSections.filter(isSectionItem);
|
||||
|
||||
|
||||
// TV遥控器事件处理 - 仅在TV设备上启用
|
||||
const handleTVEvent = React.useCallback(
|
||||
@@ -189,7 +249,7 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={dynamicStyles.scrollView}>
|
||||
{/* <View style={dynamicStyles.scrollView}>
|
||||
<FlatList
|
||||
data={sections}
|
||||
renderItem={({ item }) => {
|
||||
@@ -202,6 +262,14 @@ export default function SettingsScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={dynamicStyles.listContent}
|
||||
/>
|
||||
</View> */}
|
||||
<View style={dynamicStyles.scrollView}>
|
||||
{sections.map(item => (
|
||||
// 必须把 key 放在最外层的 View 上
|
||||
<View key={item.key} style={dynamicStyles.itemWrapper}>
|
||||
{item.component}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={dynamicStyles.footer}>
|
||||
@@ -273,5 +341,8 @@ const createResponsiveStyles = (deviceType: string, spacing: number, insets: any
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
itemWrapper: {
|
||||
marginBottom: spacing, // 这里的 spacing 来自 useResponsiveLayout()
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
|
||||
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert, Keyboard, InteractionManager } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import Toast from "react-native-toast-message";
|
||||
import useAuthStore from "@/stores/authStore";
|
||||
@@ -23,9 +23,14 @@ const LoginModal = () => {
|
||||
const pathname = usePathname();
|
||||
const isSettingsPage = pathname.includes("settings");
|
||||
|
||||
const [isModalReady, setIsModalReady] = useState(false);
|
||||
|
||||
// Load saved credentials when modal opens
|
||||
useEffect(() => {
|
||||
if (isLoginModalVisible && !isSettingsPage) {
|
||||
// 先确保键盘状态清理
|
||||
Keyboard.dismiss();
|
||||
|
||||
const loadCredentials = async () => {
|
||||
const savedCredentials = await LoginCredentialsManager.get();
|
||||
if (savedCredentials) {
|
||||
@@ -34,12 +39,22 @@ const LoginModal = () => {
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
|
||||
// 延迟设置 Modal 就绪状态
|
||||
const readyTimeout = setTimeout(() => {
|
||||
setIsModalReady(true);
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(readyTimeout);
|
||||
setIsModalReady(false);
|
||||
};
|
||||
}
|
||||
}, [isLoginModalVisible, isSettingsPage]);
|
||||
|
||||
// Focus management with better TV remote handling
|
||||
useEffect(() => {
|
||||
if (isLoginModalVisible && !isSettingsPage) {
|
||||
if (isModalReady && isLoginModalVisible && !isSettingsPage) {
|
||||
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
|
||||
|
||||
// Use a small delay to ensure the modal is fully rendered
|
||||
@@ -49,11 +64,19 @@ const LoginModal = () => {
|
||||
} else {
|
||||
passwordInputRef.current?.focus();
|
||||
}
|
||||
}, 100);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(focusTimeout);
|
||||
}
|
||||
}, [isLoginModalVisible, serverConfig, isSettingsPage]);
|
||||
}, [isModalReady, isLoginModalVisible, serverConfig, isSettingsPage]);
|
||||
|
||||
// 清理 effect - 确保 Modal 关闭时清理所有状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Keyboard.dismiss();
|
||||
setIsModalReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLogin = async () => {
|
||||
const isLocalStorage = serverConfig?.StorageType === "localstorage";
|
||||
@@ -66,19 +89,38 @@ const LoginModal = () => {
|
||||
await api.login(isLocalStorage ? undefined : username, password);
|
||||
await checkLoginStatus(apiBaseUrl);
|
||||
await refreshPlayRecords();
|
||||
|
||||
|
||||
// Save credentials on successful login
|
||||
await LoginCredentialsManager.save({ username, password });
|
||||
|
||||
Toast.show({ type: "success", text1: "登录成功" });
|
||||
hideLoginModal();
|
||||
|
||||
// Show disclaimer alert after successful login
|
||||
Alert.alert(
|
||||
"免责声明",
|
||||
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
|
||||
[{ text: "确定" }]
|
||||
);
|
||||
Toast.show({ type: "success", text1: "登录成功" });
|
||||
// hideLoginModal();
|
||||
|
||||
// // Show disclaimer alert after successful login
|
||||
// Alert.alert(
|
||||
// "免责声明",
|
||||
// "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
|
||||
// [{ text: "确定" }]
|
||||
// );
|
||||
|
||||
// 在登录成功后清理状态,再显示 Alert
|
||||
const hideAndAlert = () => {
|
||||
hideLoginModal();
|
||||
setIsModalReady(false);
|
||||
Keyboard.dismiss();
|
||||
|
||||
setTimeout(() => {
|
||||
Alert.alert(
|
||||
"免责声明",
|
||||
"本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
|
||||
[{ text: "确定" }]
|
||||
);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// 使用 InteractionManager 确保 UI 稳定后再执行
|
||||
InteractionManager.runAfterInteractions(hideAndAlert);
|
||||
|
||||
} catch (error) {
|
||||
Toast.show({
|
||||
type: "error",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { View, Switch, StyleSheet, Pressable, Animated, Platform } from "react-native";
|
||||
import { View, Switch, StyleSheet, Pressable, Animated, Platform, TouchableOpacity } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
@@ -59,20 +59,31 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
||||
|
||||
return (
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||
{...Platform.isTV||deviceType !=='tv'? undefined :{onPress:handlePress}}
|
||||
>
|
||||
{...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
|
||||
>
|
||||
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<View style={styles.settingInfo}>
|
||||
<ThemedText style={styles.settingName}>启用远程输入</ThemedText>
|
||||
</View>
|
||||
<Animated.View style={animationStyle}>
|
||||
<Switch
|
||||
value={remoteInputEnabled}
|
||||
onValueChange={() => {}} // 禁用Switch的直接交互
|
||||
trackColor={{ false: "#767577", true: Colors.dark.primary }}
|
||||
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
{ Platform.OS === 'ios' && Platform.isTV ? (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => handlePress()}
|
||||
style={styles.statusLabel}
|
||||
>
|
||||
<ThemedText style={styles.statusValue}>{remoteInputEnabled ? '已启用' : '已禁用'}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<Switch
|
||||
value={remoteInputEnabled}
|
||||
onValueChange={() => { }} // 禁用Switch的直接交互
|
||||
trackColor={{ false: "#767577", true: Colors.dark.primary }}
|
||||
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export const useApiConfig = () => {
|
||||
|
||||
const validateConfig = async () => {
|
||||
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||
|
||||
|
||||
try {
|
||||
await api.getServerConfig();
|
||||
setValidationState({
|
||||
@@ -48,7 +48,7 @@ export const useApiConfig = () => {
|
||||
});
|
||||
} catch (error) {
|
||||
let errorMessage = '服务器连接失败';
|
||||
|
||||
|
||||
if (error instanceof Error) {
|
||||
switch (error.message) {
|
||||
case 'API_URL_NOT_SET':
|
||||
@@ -70,7 +70,7 @@ export const useApiConfig = () => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
setValidationState({
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
@@ -98,10 +98,10 @@ export const useApiConfig = () => {
|
||||
if (serverConfig) {
|
||||
setValidationState(prev => ({ ...prev, isValid: true, error: null }));
|
||||
} else {
|
||||
setValidationState(prev => ({
|
||||
...prev,
|
||||
isValid: false,
|
||||
error: prev.error || '无法获取服务器配置'
|
||||
setValidationState(prev => ({
|
||||
...prev,
|
||||
isValid: false,
|
||||
error: prev.error || '无法获取服务器配置'
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -122,18 +122,18 @@ export const getApiConfigErrorMessage = (status: ApiConfigStatus): string => {
|
||||
if (status.needsConfiguration) {
|
||||
return '请点击右上角设置按钮,配置您的服务器地址';
|
||||
}
|
||||
|
||||
|
||||
if (status.error) {
|
||||
return status.error;
|
||||
}
|
||||
|
||||
|
||||
if (status.isValidating) {
|
||||
return '正在验证服务器配置...';
|
||||
}
|
||||
|
||||
|
||||
if (status.isValid === false) {
|
||||
return '服务器配置验证失败,请检查设置';
|
||||
}
|
||||
|
||||
|
||||
return '加载失败,请重试';
|
||||
};
|
||||
@@ -36,6 +36,7 @@
|
||||
"expo-build-properties": "~0.12.3",
|
||||
"expo-constants": "~16.0.2",
|
||||
"expo-font": "~12.0.7",
|
||||
"expo-intent-launcher": "~11.0.1",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-linking": "~6.3.1",
|
||||
"expo-router": "~3.5.16",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
// region: --- Interface Definitions ---
|
||||
export interface DoubanItem {
|
||||
title: string;
|
||||
@@ -104,17 +106,32 @@ export class API {
|
||||
return response;
|
||||
}
|
||||
|
||||
async getServerConfig(): Promise<ServerConfig> {
|
||||
const response = await this._fetch("/api/server-config");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> {
|
||||
const response = await this._fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
// 存储cookie到AsyncStorage
|
||||
const cookies = response.headers.get("Set-Cookie");
|
||||
if (cookies) {
|
||||
await AsyncStorage.setItem("authCookies", cookies);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async logout(): Promise<{ ok: boolean }> {
|
||||
const response = await this._fetch("/api/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
await AsyncStorage.setItem("authCookies", '');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getServerConfig(): Promise<ServerConfig> {
|
||||
const response = await this._fetch("/api/server-config");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import ReactNativeBlobUtil from "react-native-blob-util";
|
||||
import FileViewer from "react-native-file-viewer";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { version as currentVersion } from "../package.json";
|
||||
import { UPDATE_CONFIG } from "../constants/UpdateConfig";
|
||||
// UpdateService.ts
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as IntentLauncher from 'expo-intent-launcher';
|
||||
// import * as Device from 'expo-device';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import { version as currentVersion } from '../package.json';
|
||||
import { UPDATE_CONFIG } from '../constants/UpdateConfig';
|
||||
import Logger from '@/utils/Logger';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const logger = Logger.withTag('UpdateService');
|
||||
|
||||
@@ -12,9 +15,13 @@ interface VersionInfo {
|
||||
downloadUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只在 Android 平台使用的常量(iOS 不会走到下载/安装流程)
|
||||
*/
|
||||
const ANDROID_MIME_TYPE = 'application/vnd.android.package-archive';
|
||||
|
||||
class UpdateService {
|
||||
private static instance: UpdateService;
|
||||
|
||||
static getInstance(): UpdateService {
|
||||
if (!UpdateService.instance) {
|
||||
UpdateService.instance = new UpdateService();
|
||||
@@ -22,203 +29,223 @@ class UpdateService {
|
||||
return UpdateService.instance;
|
||||
}
|
||||
|
||||
/** --------------------------------------------------------------
|
||||
* 1️⃣ 远程版本检查(保持不变,只是把 fetch 包装成 async/await)
|
||||
* --------------------------------------------------------------- */
|
||||
async checkVersion(): Promise<VersionInfo> {
|
||||
let retries = 0;
|
||||
const maxRetries = 3;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10_000);
|
||||
const response = await fetch(UPDATE_CONFIG.GITHUB_RAW_URL, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: Failed to fetch version info`);
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const remotePackage = await response.json();
|
||||
const remoteVersion = remotePackage.version;
|
||||
|
||||
const remoteVersion = remotePackage.version as string;
|
||||
return {
|
||||
version: remoteVersion,
|
||||
downloadUrl: UPDATE_CONFIG.getDownloadUrl(remoteVersion),
|
||||
};
|
||||
} catch (error) {
|
||||
retries++;
|
||||
logger.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error);
|
||||
|
||||
if (retries === maxRetries) {
|
||||
Toast.show({ type: "error", text1: "检查更新失败", text2: "无法获取版本信息,请检查网络连接" });
|
||||
throw error;
|
||||
} catch (e) {
|
||||
logger.warn(`checkVersion attempt ${attempt}/${maxRetries}`, e);
|
||||
if (attempt === maxRetries) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '检查更新失败',
|
||||
text2: '无法获取版本信息,请检查网络',
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 2000 * retries));
|
||||
// 指数退避
|
||||
await new Promise(r => setTimeout(r, 2_000 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Maximum retry attempts exceeded");
|
||||
// 这句永远走不到,仅为 TypeScript 报错
|
||||
throw new Error('Unexpected');
|
||||
}
|
||||
|
||||
// 清理旧的APK文件
|
||||
/** --------------------------------------------------------------
|
||||
* 2️⃣ 清理旧的 APK 文件(使用 expo-file-system 的 API)
|
||||
* --------------------------------------------------------------- */
|
||||
private async cleanOldApkFiles(): Promise<void> {
|
||||
try {
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
// 使用DocumentDir而不是DownloadDir
|
||||
const files = await ReactNativeBlobUtil.fs.ls(dirs.DocumentDir);
|
||||
|
||||
// 查找所有OrionTV APK文件
|
||||
const apkFiles = files.filter(file => file.startsWith('OrionTV_v') && file.endsWith('.apk'));
|
||||
|
||||
// 保留最新的2个文件,删除其他的
|
||||
if (apkFiles.length > 2) {
|
||||
const sortedFiles = apkFiles.sort((a, b) => {
|
||||
// 从文件名中提取时间戳进行排序
|
||||
const timeA = a.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0';
|
||||
const timeB = b.match(/OrionTV_v(\d+)\.apk/)?.[1] || '0';
|
||||
return parseInt(timeB) - parseInt(timeA);
|
||||
});
|
||||
|
||||
// 删除旧文件
|
||||
const filesToDelete = sortedFiles.slice(2);
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await ReactNativeBlobUtil.fs.unlink(`${dirs.DocumentDir}/${file}`);
|
||||
logger.debug(`Cleaned old APK file: ${file}`);
|
||||
} catch (deleteError) {
|
||||
logger.warn(`Failed to delete old APK file ${file}:`, deleteError);
|
||||
}
|
||||
const dirUri = FileSystem.documentDirectory; // e.g. file:///data/user/0/.../files/
|
||||
if (!dirUri) {
|
||||
throw new Error('Document directory is not available');
|
||||
}
|
||||
const listing = await FileSystem.readDirectoryAsync(dirUri);
|
||||
const apkFiles = listing.filter(name => name.startsWith('OrionTV_v') && name.endsWith('.apk'));
|
||||
|
||||
if (apkFiles.length <= 2) return;
|
||||
|
||||
const sorted = apkFiles.sort((a, b) => {
|
||||
const numA = parseInt(a.replace(/[^0-9]/g, ''), 10);
|
||||
const numB = parseInt(b.replace(/[^0-9]/g, ''), 10);
|
||||
return numB - numA; // 倒序(最新在前)
|
||||
});
|
||||
|
||||
const stale = sorted.slice(2); // 保留最新的两个
|
||||
for (const file of stale) {
|
||||
const path = `${dirUri}${file}`;
|
||||
try {
|
||||
await FileSystem.deleteAsync(path, { idempotent: true });
|
||||
logger.debug(`Deleted old APK: ${file}`);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to delete ${file}`, e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to clean old APK files:', error);
|
||||
} catch (e) {
|
||||
logger.warn('cleanOldApkFiles error', e);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadApk(url: string, onProgress?: (progress: number) => void): Promise<string> {
|
||||
let retries = 0;
|
||||
/** --------------------------------------------------------------
|
||||
* 3️⃣ 下载 APK(使用 expo-file-system 的下载 API)
|
||||
* --------------------------------------------------------------- */
|
||||
async downloadApk(
|
||||
url: string,
|
||||
onProgress?: (percent: number) => void,
|
||||
): Promise<string> {
|
||||
const maxRetries = 3;
|
||||
|
||||
// 清理旧文件
|
||||
await this.cleanOldApkFiles();
|
||||
|
||||
while (retries < maxRetries) {
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const { dirs } = ReactNativeBlobUtil.fs;
|
||||
const timestamp = new Date().getTime();
|
||||
const timestamp = Date.now();
|
||||
const fileName = `OrionTV_v${timestamp}.apk`;
|
||||
// 使用应用的外部文件目录,而不是系统下载目录
|
||||
const filePath = `${dirs.DocumentDir}/${fileName}`;
|
||||
const fileUri = `${FileSystem.documentDirectory}${fileName}`;
|
||||
|
||||
const task = ReactNativeBlobUtil.config({
|
||||
fileCache: true,
|
||||
path: filePath,
|
||||
timeout: UPDATE_CONFIG.DOWNLOAD_TIMEOUT,
|
||||
// 移除 addAndroidDownloads 配置,避免使用系统下载管理器
|
||||
// addAndroidDownloads: {
|
||||
// useDownloadManager: true,
|
||||
// notification: true,
|
||||
// title: UPDATE_CONFIG.NOTIFICATION.TITLE,
|
||||
// description: UPDATE_CONFIG.NOTIFICATION.DOWNLOADING_TEXT,
|
||||
// mime: "application/vnd.android.package-archive",
|
||||
// mediaScannable: true,
|
||||
// },
|
||||
}).fetch("GET", url);
|
||||
// expo-file-system 把下载进度回调参数统一为 `{totalBytesWritten, totalBytesExpectedToWrite}`
|
||||
const downloadResumable = FileSystem.createDownloadResumable(
|
||||
url,
|
||||
fileUri,
|
||||
{
|
||||
// Android 需要在 AndroidManifest 中声明 INTERNET、WRITE_EXTERNAL_STORAGE (API 33+ 使用 MANAGE_EXTERNAL_STORAGE)
|
||||
// 这里不使用系统下载管理器,因为我们想自己控制进度回调。
|
||||
},
|
||||
progress => {
|
||||
if (onProgress && progress.totalBytesExpectedToWrite) {
|
||||
const percent = Math.floor(
|
||||
(progress.totalBytesWritten / progress.totalBytesExpectedToWrite) * 100,
|
||||
);
|
||||
onProgress(percent);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 监听下载进度
|
||||
if (onProgress) {
|
||||
task.progress((received: string, total: string) => {
|
||||
const receivedNum = parseInt(received, 10);
|
||||
const totalNum = parseInt(total, 10);
|
||||
const progress = Math.floor((receivedNum / totalNum) * 100);
|
||||
onProgress(progress);
|
||||
const result = await downloadResumable.downloadAsync();
|
||||
if (result && result.uri) {
|
||||
logger.debug(`APK downloaded to ${result.uri}`);
|
||||
return result.uri;
|
||||
} else {
|
||||
throw new Error('Download failed: No URI available');
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`downloadApk attempt ${attempt}/${maxRetries}`, e);
|
||||
if (attempt === maxRetries) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '下载失败',
|
||||
text2: 'APK 下载出现错误,请检查网络',
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
// 指数退避
|
||||
await new Promise(r => setTimeout(r, 3_000 * attempt));
|
||||
}
|
||||
}
|
||||
// 同上,理论不会到这里
|
||||
throw new Error('Download failed');
|
||||
}
|
||||
|
||||
/** --------------------------------------------------------------
|
||||
* 4️⃣ 安装 APK(只在 Android 可用,使用 expo-intent-launcher)
|
||||
* --------------------------------------------------------------- */
|
||||
async installApk(fileUri: string): Promise<void> {
|
||||
// if (!Device.isDevice) {
|
||||
// // 在模拟器里打开文件会报错,直接给用户提示
|
||||
// Toast.show({
|
||||
// type: 'error',
|
||||
// text1: '安装失败',
|
||||
// text2: '模拟器不支持直接安装 APK,请在真机上操作',
|
||||
// });
|
||||
// throw new Error('Cannot install on simulator');
|
||||
// }
|
||||
|
||||
const exists = await FileSystem.getInfoAsync(fileUri);
|
||||
if (!exists.exists) {
|
||||
throw new Error(`APK not found at ${fileUri}`);
|
||||
}
|
||||
|
||||
// Android 需要给 Intent 设置 mime 类型,并且使用 ACTION_VIEW
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
// Android 7+ 需要给出 URI 权限(FileProvider),Expo‑Intent‑Launcher 已经在内部使用了
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: fileUri,
|
||||
type: ANDROID_MIME_TYPE,
|
||||
flags: 1, // FLAG_ACTIVITY_NEW_TASK
|
||||
});
|
||||
} catch (e: any) {
|
||||
// 常见错误:没有“未知来源”权限、或没有安装包管理器
|
||||
if (e.message?.includes('Activity not found')) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '安装失败',
|
||||
text2: '系统没有找到可以打开 APK 的应用,请检查系统设置',
|
||||
});
|
||||
} else if (e.message?.includes('permission')) {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '安装失败',
|
||||
text2: '请在设置里允许“未知来源”安装',
|
||||
});
|
||||
} else {
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '安装失败',
|
||||
text2: '未知错误,请稍后重试',
|
||||
});
|
||||
}
|
||||
|
||||
const res = await task;
|
||||
logger.debug(`APK downloaded successfully: ${filePath}`);
|
||||
return res.path();
|
||||
} catch (error) {
|
||||
retries++;
|
||||
logger.info(`Error downloading APK (attempt ${retries}/${maxRetries}):`, error);
|
||||
|
||||
if (retries === maxRetries) {
|
||||
Toast.show({ type: "error", text1: "下载失败", text2: "APK下载失败,请检查网络连接" });
|
||||
throw new Error(`Download failed after ${maxRetries} attempts: ${error}`);
|
||||
}
|
||||
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 3000 * retries));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("Maximum retry attempts exceeded for download");
|
||||
}
|
||||
|
||||
async installApk(filePath: string): Promise<void> {
|
||||
try {
|
||||
// 首先检查文件是否存在
|
||||
const exists = await ReactNativeBlobUtil.fs.exists(filePath);
|
||||
if (!exists) {
|
||||
throw new Error(`APK file not found: ${filePath}`);
|
||||
}
|
||||
|
||||
// 使用FileViewer打开APK文件进行安装
|
||||
// 这会触发Android的包安装器
|
||||
await FileViewer.open(filePath, {
|
||||
showOpenWithDialog: true, // 显示选择应用对话框
|
||||
showAppsSuggestions: true, // 显示应用建议
|
||||
displayName: "OrionTV Update",
|
||||
} else {
|
||||
// iOS 是不支持的,直接提示用户
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '安装失败',
|
||||
text2: 'iOS 设备无法直接安装 APK',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.info("Error installing APK:", error);
|
||||
|
||||
// 提供更详细的错误信息
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('No app found')) {
|
||||
Toast.show({ type: "error", text1: "安装失败", text2: "未找到可安装APK的应用,请确保允许安装未知来源的应用" });
|
||||
throw new Error('未找到可安装APK的应用,请确保允许安装未知来源的应用');
|
||||
} else if (error.message.includes('permission')) {
|
||||
Toast.show({ type: "error", text1: "安装失败", text2: "没有安装权限,请在设置中允许此应用安装未知来源的应用" });
|
||||
throw new Error('没有安装权限,请在设置中允许此应用安装未知来源的应用');
|
||||
} else {
|
||||
Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现错误" });
|
||||
}
|
||||
} else {
|
||||
Toast.show({ type: "error", text1: "安装失败", text2: "APK安装过程中出现未知错误" });
|
||||
}
|
||||
|
||||
throw error;
|
||||
throw new Error('APK install not supported on iOS');
|
||||
}
|
||||
}
|
||||
|
||||
/** --------------------------------------------------------------
|
||||
* 5️⃣ 版本比对工具(保持原来的实现)
|
||||
* --------------------------------------------------------------- */
|
||||
compareVersions(v1: string, v2: string): number {
|
||||
const parts1 = v1.split(".").map(Number);
|
||||
const parts2 = v2.split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||
const part1 = parts1[i] || 0;
|
||||
const part2 = parts2[i] || 0;
|
||||
|
||||
if (part1 > part2) return 1;
|
||||
if (part1 < part2) return -1;
|
||||
const p1 = v1.split('.').map(Number);
|
||||
const p2 = v2.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(p1.length, p2.length); i++) {
|
||||
const n1 = p1[i] ?? 0;
|
||||
const n2 = p2[i] ?? 0;
|
||||
if (n1 > n2) return 1;
|
||||
if (n1 < n2) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
getCurrentVersion(): string {
|
||||
return currentVersion;
|
||||
}
|
||||
|
||||
isUpdateAvailable(remoteVersion: string): boolean {
|
||||
return this.compareVersions(remoteVersion, currentVersion) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 单例导出 */
|
||||
export default UpdateService.getInstance();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import Cookies from "@react-native-cookies/cookies";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { api } from "@/services/api";
|
||||
import { useSettingsStore } from "./settingsStore";
|
||||
import Toast from "react-native-toast-message";
|
||||
@@ -30,14 +30,14 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
// Wait for server config to be loaded if it's currently loading
|
||||
const settingsState = useSettingsStore.getState();
|
||||
let serverConfig = settingsState.serverConfig;
|
||||
|
||||
|
||||
// If server config is loading, wait a bit for it to complete
|
||||
if (settingsState.isLoadingServerConfig) {
|
||||
// Wait up to 3 seconds for server config to load
|
||||
const maxWaitTime = 3000;
|
||||
const checkInterval = 100;
|
||||
let waitTime = 0;
|
||||
|
||||
|
||||
while (waitTime < maxWaitTime) {
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
waitTime += checkInterval;
|
||||
@@ -48,7 +48,7 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!serverConfig?.StorageType) {
|
||||
// Only show error if we're not loading and have tried to fetch the config
|
||||
if (!settingsState.isLoadingServerConfig) {
|
||||
@@ -56,20 +56,21 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
const cookies = await Cookies.get(api.baseURL);
|
||||
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) {
|
||||
const loginResult = await api.login().catch(() => {
|
||||
|
||||
const authToken = await AsyncStorage.getItem('authCookies');
|
||||
if (!authToken) {
|
||||
if (serverConfig && serverConfig.StorageType === "localstorage") {
|
||||
const loginResult = await api.login().catch(() => {
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
});
|
||||
if (loginResult && loginResult.ok) {
|
||||
set({ isLoggedIn: true });
|
||||
}
|
||||
} else {
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
});
|
||||
if (loginResult && loginResult.ok) {
|
||||
set({ isLoggedIn: true });
|
||||
}
|
||||
} else {
|
||||
const isLoggedIn = cookies && !!cookies.auth;
|
||||
set({ isLoggedIn });
|
||||
if (!isLoggedIn) {
|
||||
set({ isLoginModalVisible: true });
|
||||
}
|
||||
set({ isLoggedIn: true, isLoginModalVisible: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to check login status:", error);
|
||||
@@ -82,7 +83,7 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await Cookies.clearAll();
|
||||
await api.logout();
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
} catch (error) {
|
||||
logger.error("Failed to logout:", error);
|
||||
|
||||
@@ -4592,6 +4592,11 @@ expo-font@~12.0.10, expo-font@~12.0.7:
|
||||
dependencies:
|
||||
fontfaceobserver "^2.1.0"
|
||||
|
||||
expo-intent-launcher@~11.0.1:
|
||||
version "11.0.1"
|
||||
resolved "https://registry.yarnpkg.com/expo-intent-launcher/-/expo-intent-launcher-11.0.1.tgz#297dc4d084b1e3e2fab431afc847800f87cd1dc2"
|
||||
integrity sha512-nUmTTa/HG4jUyRc5YHngdpP5bMyGSRZPi2RX9kpILd3vbMWQeVnwzqAfC+uI34W8uKhEk+9b9Dytzmm7bBND1Q==
|
||||
|
||||
expo-keep-awake@~13.0.2:
|
||||
version "13.0.2"
|
||||
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"
|
||||
|
||||
Reference in New Issue
Block a user