mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 11:44:44 +08:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf854c3c9f | ||
|
|
a86eb8ca5c | ||
|
|
487c15d8b6 | ||
|
|
3526189e32 | ||
|
|
c473581c26 | ||
|
|
826380714d | ||
|
|
3caa9af11a | ||
|
|
e6194a50ab | ||
|
|
aa7efb0dfb | ||
|
|
01cf3b9a07 | ||
|
|
37d8580b9c | ||
|
|
79308607b8 | ||
|
|
11cbcf08c1 | ||
|
|
a13d528cbe | ||
|
|
a2fd16ede5 | ||
|
|
2fbbca21e7 | ||
|
|
25e7db084a | ||
|
|
d14fc941c1 | ||
|
|
7af9bf2b4c | ||
|
|
0d9f552ede | ||
|
|
62d8141178 | ||
|
|
023fa591ec | ||
|
|
b401d535ce | ||
|
|
23647f7329 | ||
|
|
67275988bd | ||
|
|
f7ae93bd3d | ||
|
|
f124f7e1e2 | ||
|
|
9bcdeaa44d | ||
|
|
4c93736c5e | ||
|
|
7de3c135e4 | ||
|
|
9665ee3ba3 | ||
|
|
a53dde92eb | ||
|
|
e57466c8c1 | ||
|
|
836285dbd5 | ||
|
|
172815f926 | ||
|
|
e83f9d68fc | ||
|
|
04e0d0ac17 | ||
|
|
3e6bcb4920 | ||
|
|
6db0c5d888 |
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Version**
|
||||
- OrionTV:
|
||||
- LunaTV or MoonTV:
|
||||
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"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,6 +15,9 @@ import { useUpdateStore, initUpdateStore } from "@/stores/updateStore";
|
||||
import { UpdateModal } from "@/components/UpdateModal";
|
||||
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RootLayout');
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -48,7 +51,7 @@ export default function RootLayout() {
|
||||
if (loaded || error) {
|
||||
SplashScreen.hideAsync();
|
||||
if (error) {
|
||||
console.warn(`Error in loading fonts: ${error}`);
|
||||
logger.warn(`Error in loading fonts: ${error}`);
|
||||
}
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar } from "react-native";
|
||||
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform, BackHandler, ToastAndroid } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
@@ -15,6 +15,7 @@ import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
|
||||
const LOAD_MORE_THRESHOLD = 200;
|
||||
|
||||
@@ -52,6 +53,39 @@ export default function HomeScreen() {
|
||||
}, [refreshPlayRecords])
|
||||
);
|
||||
|
||||
// 双击返回退出逻辑(只限当前页面)
|
||||
const backPressTimeRef = useRef<number | null>(null);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const handleBackPress = () => {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果还没按过返回键,或距离上次超过2秒
|
||||
if (!backPressTimeRef.current || now - backPressTimeRef.current > 2000) {
|
||||
backPressTimeRef.current = now;
|
||||
ToastAndroid.show("再按一次返回键退出", ToastAndroid.SHORT);
|
||||
return true; // 拦截返回事件,不退出
|
||||
}
|
||||
|
||||
// 两次返回键间隔小于2秒,退出应用
|
||||
BackHandler.exitApp();
|
||||
return true;
|
||||
};
|
||||
|
||||
// 仅限 Android 平台启用此功能
|
||||
if (Platform.OS === "android") {
|
||||
const backHandler = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
|
||||
|
||||
// 返回首页时重置状态
|
||||
return () => {
|
||||
backHandler.remove();
|
||||
backPressTimeRef.current = null;
|
||||
};
|
||||
}
|
||||
}, [])
|
||||
);
|
||||
|
||||
// 统一的数据获取逻辑
|
||||
useEffect(() => {
|
||||
if (!selectedCategory) return;
|
||||
@@ -166,7 +200,7 @@ export default function HomeScreen() {
|
||||
<View style={dynamicStyles.headerContainer}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<ThemedText style={dynamicStyles.headerTitle}>首页</ThemedText>
|
||||
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
|
||||
<Pressable android_ripple={Platform.isTV || deviceType !== 'tv'? { color: 'transparent' } : { color: Colors.dark.link }} style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
|
||||
{({ focused }) => (
|
||||
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}>直播</ThemedText>
|
||||
)}
|
||||
|
||||
173
app/play.tsx
173
app/play.tsx
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { Video } from "expo-av";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { PlayerControls } from "@/components/PlayerControls";
|
||||
@@ -16,6 +16,58 @@ import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
||||
import Toast from "react-native-toast-message";
|
||||
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { useVideoHandlers } from "@/hooks/useVideoHandlers";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('PlayScreen');
|
||||
|
||||
// 优化的加载动画组件
|
||||
const LoadingContainer = memo(
|
||||
({ style, currentEpisode }: { style: any; currentEpisode: { url: string; title: string } | undefined }) => {
|
||||
logger.info(
|
||||
`[PERF] Video component NOT rendered - waiting for valid URL. currentEpisode: ${!!currentEpisode}, url: ${
|
||||
currentEpisode?.url ? "exists" : "missing"
|
||||
}`
|
||||
);
|
||||
return (
|
||||
<View style={style}>
|
||||
<VideoLoadingAnimation showProgressBar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LoadingContainer.displayName = "LoadingContainer";
|
||||
|
||||
// 移到组件外部避免重复创建
|
||||
const createResponsiveStyles = (deviceType: string) => {
|
||||
const isMobile = deviceType === "mobile";
|
||||
const isTablet = deviceType === "tablet";
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
// 移动端和平板端可能需要状态栏处理
|
||||
...(isMobile || isTablet ? { paddingTop: 0 } : {}),
|
||||
},
|
||||
videoContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
// 为触摸设备添加更多的交互区域
|
||||
...(isMobile || isTablet ? { zIndex: 1 } : {}),
|
||||
},
|
||||
videoPlayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlayScreen() {
|
||||
const videoRef = useRef<Video>(null);
|
||||
@@ -61,17 +113,54 @@ export default function PlayScreen() {
|
||||
} = usePlayerStore();
|
||||
const currentEpisode = usePlayerStore(selectCurrentEpisode);
|
||||
|
||||
// 使用Video事件处理hook
|
||||
const { videoProps } = useVideoHandlers({
|
||||
videoRef,
|
||||
currentEpisode,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
deviceType,
|
||||
detail: detail || undefined,
|
||||
});
|
||||
|
||||
// TV遥控器处理 - 总是调用hook,但根据设备类型决定是否使用结果
|
||||
const tvRemoteHandler = useTVRemoteHandler();
|
||||
|
||||
// 优化的动态样式 - 使用useMemo避免重复计算
|
||||
const dynamicStyles = useMemo(() => createResponsiveStyles(deviceType), [deviceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayScreen useEffect START - source: ${source}, id: ${id}, title: ${title}`);
|
||||
|
||||
setVideoRef(videoRef);
|
||||
if (source && id && title) {
|
||||
logger.info(`[PERF] Calling loadVideo with episodeIndex: ${episodeIndex}, position: ${position}`);
|
||||
loadVideo({ source, id, episodeIndex, position, title });
|
||||
} else {
|
||||
logger.info(`[PERF] Missing required params - source: ${!!source}, id: ${!!id}, title: ${!!title}`);
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayScreen useEffect END - took ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
|
||||
return () => {
|
||||
logger.info(`[PERF] PlayScreen unmounting - calling reset()`);
|
||||
reset(); // Reset state when component unmounts
|
||||
};
|
||||
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
|
||||
|
||||
// 优化的屏幕点击处理
|
||||
const onScreenPress = useCallback(() => {
|
||||
if (deviceType === "tv") {
|
||||
tvRemoteHandler.onScreenPress();
|
||||
} else {
|
||||
setShowControls(!showControls);
|
||||
}
|
||||
}, [deviceType, tvRemoteHandler, setShowControls, showControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === "background" || nextAppState === "inactive") {
|
||||
@@ -86,14 +175,6 @@ export default function PlayScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// TV遥控器处理 - 总是调用hook,但根据设备类型决定是否使用结果
|
||||
const tvRemoteHandler = useTVRemoteHandler();
|
||||
|
||||
// 根据设备类型使用不同的交互处理
|
||||
const onScreenPress = deviceType === 'tv'
|
||||
? tvRemoteHandler.onScreenPress
|
||||
: () => setShowControls(!showControls);
|
||||
|
||||
useEffect(() => {
|
||||
const backAction = () => {
|
||||
if (showControls) {
|
||||
@@ -132,42 +213,29 @@ export default function PlayScreen() {
|
||||
return <VideoLoadingAnimation showProgressBar />;
|
||||
}
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType);
|
||||
|
||||
return (
|
||||
<ThemedView focusable style={dynamicStyles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={dynamicStyles.videoContainer}
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={dynamicStyles.videoContainer}
|
||||
onPress={onScreenPress}
|
||||
disabled={deviceType !== 'tv' && showControls} // 移动端和平板端在显示控制条时禁用触摸
|
||||
disabled={deviceType !== "tv" && showControls} // 移动端和平板端在显示控制条时禁用触摸
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={dynamicStyles.videoPlayer}
|
||||
source={{ uri: currentEpisode?.url || "" }}
|
||||
posterSource={{ uri: detail?.poster ?? "" }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
rate={playbackRate}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
onLoad={() => {
|
||||
const jumpPosition = initialPosition || introEndTime || 0;
|
||||
if (jumpPosition > 0) {
|
||||
videoRef.current?.setPositionAsync(jumpPosition);
|
||||
}
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
}}
|
||||
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
|
||||
useNativeControls={deviceType !== 'tv'}
|
||||
shouldPlay
|
||||
/>
|
||||
{/* 条件渲染Video组件:只有在有有效URL时才渲染 */}
|
||||
{currentEpisode?.url ? (
|
||||
<Video ref={videoRef} style={dynamicStyles.videoPlayer} {...videoProps} />
|
||||
) : (
|
||||
<LoadingContainer style={dynamicStyles.loadingContainer} currentEpisode={currentEpisode} />
|
||||
)}
|
||||
|
||||
{showControls && deviceType === 'tv' && <PlayerControls showControls={showControls} setShowControls={setShowControls} />}
|
||||
{showControls && deviceType === "tv" && (
|
||||
<PlayerControls showControls={showControls} setShowControls={setShowControls} />
|
||||
)}
|
||||
|
||||
<SeekingBar />
|
||||
|
||||
{isLoading && (
|
||||
{/* 只在Video组件存在且正在加载时显示加载动画覆盖层 */}
|
||||
{currentEpisode?.url && isLoading && (
|
||||
<View style={dynamicStyles.loadingContainer}>
|
||||
<VideoLoadingAnimation showProgressBar />
|
||||
</View>
|
||||
@@ -182,32 +250,3 @@ export default function PlayScreen() {
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const createResponsiveStyles = (deviceType: string) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
// 移动端和平板端可能需要状态栏处理
|
||||
...(isMobile || isTablet ? { paddingTop: 0 } : {}),
|
||||
},
|
||||
videoContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
// 为触摸设备添加更多的交互区域
|
||||
...(isMobile || isTablet ? { zIndex: 1 } : {}),
|
||||
},
|
||||
videoPlayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('SearchScreen');
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -37,7 +40,7 @@ export default function SearchScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage && targetPage === 'search') {
|
||||
console.log("Received remote input:", lastMessage);
|
||||
logger.debug("Received remote input:", lastMessage);
|
||||
const realMessage = lastMessage.split("_")[0];
|
||||
setKeyword(realMessage);
|
||||
handleSearch(realMessage);
|
||||
@@ -72,7 +75,7 @@ export default function SearchScreen() {
|
||||
}
|
||||
} catch (err) {
|
||||
setError("搜索失败,请稍后重试。");
|
||||
console.info("Search failed:", err);
|
||||
logger.info("Search failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
125
app/settings.tsx
125
app/settings.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform } 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";
|
||||
@@ -20,6 +20,19 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
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();
|
||||
@@ -50,6 +63,7 @@ export default function SettingsScreen() {
|
||||
const realMessage = lastMessage.split("_")[0];
|
||||
handleRemoteInput(realMessage);
|
||||
clearMessage(); // Clear the message after processing
|
||||
markAsChanged();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [lastMessage, targetPage]);
|
||||
@@ -85,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
|
||||
@@ -113,7 +185,6 @@ export default function SettingsScreen() {
|
||||
),
|
||||
key: "api",
|
||||
},
|
||||
// 直播源配置 - 仅在非手机端显示
|
||||
deviceType !== "mobile" && {
|
||||
component: (
|
||||
<LiveStreamSection
|
||||
@@ -127,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(
|
||||
@@ -164,13 +226,22 @@ export default function SettingsScreen() {
|
||||
[currentFocusIndex, sections.length, deviceType]
|
||||
);
|
||||
|
||||
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => {});
|
||||
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => { });
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing, insets);
|
||||
|
||||
const renderSettingsContent = () => (
|
||||
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
// <KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<KeyboardAwareScrollView
|
||||
enableOnAndroid={true}
|
||||
extraScrollHeight={20}
|
||||
keyboardOpeningTime={0}
|
||||
keyboardShouldPersistTaps="always"
|
||||
scrollEnabled={true}
|
||||
style={{ flex: 1, backgroundColor }}
|
||||
>
|
||||
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{deviceType === "tv" && (
|
||||
<View style={dynamicStyles.header}>
|
||||
@@ -178,7 +249,7 @@ export default function SettingsScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={dynamicStyles.scrollView}>
|
||||
{/* <View style={dynamicStyles.scrollView}>
|
||||
<FlatList
|
||||
data={sections}
|
||||
renderItem={({ item }) => {
|
||||
@@ -191,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}>
|
||||
@@ -203,7 +282,8 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
</KeyboardAwareScrollView>
|
||||
// </KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
@@ -261,5 +341,8 @@ const createResponsiveStyles = (deviceType: string, spacing: number, insets: any
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
itemWrapper: {
|
||||
marginBottom: spacing, // 这里的 spacing 来自 useResponsiveLayout()
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
const plugins = [];
|
||||
|
||||
// 在生产环境移除console调用以优化性能
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
plugins.push('transform-remove-console');
|
||||
}
|
||||
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [],
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
|
||||
import React, { useCallback, useRef, useState, useEffect } from "react";
|
||||
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, BackHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
@@ -20,7 +20,7 @@ interface CustomScrollViewProps {
|
||||
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
data,
|
||||
renderItem,
|
||||
numColumns, // 现在可选,如果不提供将使用响应式默认值
|
||||
numColumns,
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
error = null,
|
||||
@@ -29,9 +29,28 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
emptyMessage = "暂无内容",
|
||||
ListFooterComponent,
|
||||
}) => {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const firstCardRef = useRef<any>(null); // <--- 新增
|
||||
const [showScrollToTop, setShowScrollToTop] = useState(false);
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
|
||||
const { deviceType } = responsiveConfig;
|
||||
|
||||
// 添加返回键处理逻辑
|
||||
useEffect(() => {
|
||||
if (deviceType === 'tv') {
|
||||
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (showScrollToTop) {
|
||||
scrollToTop();
|
||||
return true; // 阻止默认的返回行为
|
||||
}
|
||||
return false; // 允许默认的返回行为
|
||||
});
|
||||
|
||||
return () => backHandler.remove();
|
||||
}
|
||||
}, [showScrollToTop,deviceType]);
|
||||
|
||||
// 使用响应式列数,如果没有明确指定的话
|
||||
const effectiveColumns = numColumns || responsiveConfig.columns;
|
||||
|
||||
@@ -40,6 +59,9 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
|
||||
|
||||
// 显示/隐藏返回顶部按钮
|
||||
setShowScrollToTop(contentOffset.y > 200);
|
||||
|
||||
if (isCloseToBottom && !loadingMore && onEndReached) {
|
||||
onEndReached();
|
||||
}
|
||||
@@ -47,6 +69,14 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
[onEndReached, loadingMore, loadMoreThreshold]
|
||||
);
|
||||
|
||||
const scrollToTop = () => {
|
||||
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||
// 滚动动画结束后聚焦第一个卡片
|
||||
setTimeout(() => {
|
||||
firstCardRef.current?.focus();
|
||||
}, 500); // 500ms 适配大多数动画时长
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (ListFooterComponent) {
|
||||
if (React.isValidElement(ListFooterComponent)) {
|
||||
@@ -111,7 +141,8 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
marginBottom: responsiveConfig.spacing,
|
||||
},
|
||||
fullRowContainer: {
|
||||
justifyContent: "space-between",
|
||||
justifyContent: "space-around",
|
||||
marginRight: responsiveConfig.spacing / 2,
|
||||
},
|
||||
partialRowContainer: {
|
||||
justifyContent: "flex-start",
|
||||
@@ -123,47 +154,72 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
width: responsiveConfig.cardWidth,
|
||||
marginRight: responsiveConfig.spacing,
|
||||
},
|
||||
scrollToTopButton: {
|
||||
position: 'absolute',
|
||||
right: responsiveConfig.spacing,
|
||||
bottom: responsiveConfig.spacing * 2,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
padding: responsiveConfig.spacing,
|
||||
borderRadius: responsiveConfig.spacing,
|
||||
opacity: showScrollToTop ? 1 : 0,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={dynamicStyles.listContent}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isFullRow = row.length === effectiveColumns;
|
||||
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
|
||||
|
||||
return (
|
||||
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
|
||||
{row.map((item, itemIndex) => {
|
||||
const actualIndex = rowIndex * effectiveColumns + itemIndex;
|
||||
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
|
||||
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
|
||||
|
||||
return (
|
||||
<View key={actualIndex} style={isFullRow ? dynamicStyles.itemContainer : itemStyle}>
|
||||
{renderItem({ item, index: actualIndex })}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{renderFooter()}
|
||||
</>
|
||||
) : (
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText>{emptyMessage}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
<View style={{ flex: 1 }}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
contentContainerStyle={dynamicStyles.listContent}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isFullRow = row.length === effectiveColumns;
|
||||
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
|
||||
|
||||
return (
|
||||
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
|
||||
{row.map((item, itemIndex) => {
|
||||
const actualIndex = rowIndex * effectiveColumns + itemIndex;
|
||||
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
|
||||
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
|
||||
|
||||
const cardProps = {
|
||||
key: actualIndex,
|
||||
style: isFullRow ? dynamicStyles.itemContainer : itemStyle,
|
||||
};
|
||||
|
||||
return (
|
||||
<View {...cardProps}>
|
||||
{renderItem({ item, index: actualIndex })}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
{renderFooter()}
|
||||
</>
|
||||
) : (
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText>{emptyMessage}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
{deviceType!=='tv' && (
|
||||
<TouchableOpacity
|
||||
style={dynamicStyles.scrollToTopButton}
|
||||
onPress={scrollToTop}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ThemedText>⬆️</ThemedText>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomScrollView;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('ResponsiveVideoCard');
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -138,7 +141,7 @@ const ResponsiveVideoCard = forwardRef<View, VideoCardProps>(
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,13 +3,16 @@ import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
import useDetailStore from "@/stores/detailStore";
|
||||
import usePlayerStore from "@/stores/playerStore";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('SourceSelectionModal');
|
||||
|
||||
export const SourceSelectionModal: React.FC = () => {
|
||||
const { showSourceModal, setShowSourceModal, loadVideo, currentEpisodeIndex, status } = usePlayerStore();
|
||||
const { searchResults, detail, setDetail } = useDetailStore();
|
||||
|
||||
const onSelectSource = (index: number) => {
|
||||
console.log("onSelectSource", index, searchResults[index].source, detail?.source);
|
||||
logger.debug("onSelectSource", index, searchResults[index].source, detail?.source);
|
||||
if (searchResults[index].source !== detail?.source) {
|
||||
const newDetail = searchResults[index];
|
||||
setDetail(newDetail);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { forwardRef } from "react";
|
||||
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View } from "react-native";
|
||||
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View, Platform } from "react-native";
|
||||
import { ThemedText } from "./ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
interface StyledButtonProps extends PressableProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -19,6 +20,7 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
|
||||
const colors = Colors[colorScheme];
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const animationStyle = useButtonAnimation(isFocused);
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const variantStyles = {
|
||||
default: StyleSheet.create({
|
||||
@@ -108,6 +110,7 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
|
||||
return (
|
||||
<Animated.View style={[animationStyle, style]}>
|
||||
<Pressable
|
||||
android_ripple={Platform.isTV || deviceType !== 'tv'? { color: 'transparent' } : { color: Colors.dark.link }}
|
||||
ref={ref}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('VideoCardMobile');
|
||||
|
||||
interface VideoCardMobileProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -97,7 +100,7 @@ const VideoCardMobile = forwardRef<View, VideoCardMobileProps>(
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('VideoCardTablet');
|
||||
|
||||
interface VideoCardTabletProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -119,7 +122,7 @@ const VideoCardTablet = forwardRef<View, VideoCardTabletProps>(
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
|
||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
|
||||
import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert, Animated, Platform } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Star, Play } from "lucide-react-native";
|
||||
import { PlayRecordManager } from "@/services/storage";
|
||||
import { API } from "@/services/api";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import Logger from '@/utils/Logger';
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
const logger = Logger.withTag('VideoCardTV');
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -51,6 +55,8 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const animatedStyle = {
|
||||
transform: [{ scale }],
|
||||
};
|
||||
@@ -131,7 +137,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
@@ -144,13 +150,19 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity
|
||||
<Pressable
|
||||
android_ripple={Platform.isTV || deviceType !== 'tv' ? { color: 'transparent' } : { color: Colors.dark.link }}
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={styles.pressable}
|
||||
activeOpacity={1}
|
||||
style={({ pressed }) => [
|
||||
styles.pressable,
|
||||
{
|
||||
zIndex: pressed ? 999 : 1, // 确保按下时有最高优先级
|
||||
},
|
||||
]}
|
||||
// activeOpacity={1}
|
||||
delayLongPress={1000}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
@@ -200,7 +212,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
@@ -218,9 +230,14 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: 8,
|
||||
},
|
||||
pressable: {
|
||||
width: CARD_WIDTH + 20,
|
||||
height: CARD_HEIGHT + 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: "center",
|
||||
overflow: "visible",
|
||||
},
|
||||
card: {
|
||||
marginTop: 10,
|
||||
width: CARD_WIDTH,
|
||||
height: CARD_HEIGHT,
|
||||
borderRadius: 8,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
|
||||
import { View, TextInput, StyleSheet, Animated } from "react-native";
|
||||
import { View, TextInput, StyleSheet, Animated, Platform } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
interface APIConfigSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPress?: () => void;
|
||||
hideDescription?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,13 +22,14 @@ export interface APIConfigSectionRef {
|
||||
}
|
||||
|
||||
export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSectionProps>(
|
||||
({ onChanged, onFocus, onBlur, hideDescription = false }, ref) => {
|
||||
({ onChanged, onFocus, onBlur, onPress, hideDescription = false }, ref) => {
|
||||
const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore();
|
||||
const { serverUrl } = useRemoteControlStore();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setApiBaseUrl(url);
|
||||
@@ -60,10 +63,28 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
|
||||
[isSectionFocused]
|
||||
);
|
||||
|
||||
const handlePress = () => {
|
||||
inputRef.current?.focus();
|
||||
onPress?.();
|
||||
}
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
const [selection, setSelection] = useState<{ start: number; end: number }>({
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
// 当用户手动移动光标或选中文本时,同步到 state(可选)
|
||||
const onSelectionChange = ({
|
||||
nativeEvent: { selection },
|
||||
}: any) => {
|
||||
setSelection(selection);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||
{...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
|
||||
>
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={styles.titleContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>API 地址</ThemedText>
|
||||
@@ -81,7 +102,21 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
|
||||
placeholderTextColor="#888"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onFocus={() => {
|
||||
setIsInputFocused(true);
|
||||
// 将光标移动到文本末尾
|
||||
const end = apiBaseUrl.length;
|
||||
setSelection({ start: end, end: end });
|
||||
// 有时需要延迟一下,让系统先完成 focus 再设置 selection
|
||||
//(在 Android 上更可靠)
|
||||
setTimeout(() => {
|
||||
// 对于受控的 selection 已经生效,这里仅作保险
|
||||
inputRef.current?.setNativeProps({ selection: { start: end, end: end } });
|
||||
}, 0);
|
||||
}}
|
||||
selection={selection}
|
||||
onSelectionChange={onSelectionChange} // 可选
|
||||
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
|
||||
import { View, TextInput, StyleSheet, Animated } from "react-native";
|
||||
import { View, TextInput, StyleSheet, Animated, Platform } from "react-native";
|
||||
import { useTVEventHandler } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { SettingsSection } from "./SettingsSection";
|
||||
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
interface LiveStreamSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export interface LiveStreamSectionRef {
|
||||
@@ -19,13 +21,14 @@ export interface LiveStreamSectionRef {
|
||||
}
|
||||
|
||||
export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSectionProps>(
|
||||
({ onChanged, onFocus, onBlur }, ref) => {
|
||||
({ onChanged, onFocus, onBlur, onPress }, ref) => {
|
||||
const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore();
|
||||
const { serverUrl } = useRemoteControlStore();
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const handleUrlChange = (url: string) => {
|
||||
setM3uUrl(url);
|
||||
@@ -49,6 +52,11 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
inputRef.current?.focus();
|
||||
onPress?.();
|
||||
}
|
||||
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (isSectionFocused && event.eventType === "select") {
|
||||
@@ -60,8 +68,22 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
|
||||
const [selection, setSelection] = useState<{ start: number; end: number }>({
|
||||
start: 0,
|
||||
end: 0,
|
||||
});
|
||||
// 当用户手动移动光标或选中文本时,同步到 state(可选)
|
||||
const onSelectionChange = ({
|
||||
nativeEvent: { selection },
|
||||
}: any) => {
|
||||
setSelection(selection);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||
onPress={Platform.isTV || deviceType !== 'tv' ? undefined : handlePress}
|
||||
>
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={styles.titleContainer}>
|
||||
<ThemedText style={styles.sectionTitle}>直播源地址</ThemedText>
|
||||
@@ -79,8 +101,23 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
||||
placeholderTextColor="#888"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onFocus={() => {
|
||||
setIsInputFocused(true);
|
||||
// 将光标移动到文本末尾
|
||||
const end = m3uUrl.length;
|
||||
setSelection({ start: end, end: end });
|
||||
// 有时需要延迟一下,让系统先完成 focus 再设置 selection
|
||||
//(在 Android 上更可靠)
|
||||
setTimeout(() => {
|
||||
// 对于受控的 selection 已经生效,这里仅作保险
|
||||
inputRef.current?.setNativeProps({ selection: { start: end, end: end } });
|
||||
}, 0);
|
||||
}}
|
||||
selection={selection}
|
||||
onSelectionChange={onSelectionChange} // 可选
|
||||
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
// onPress={handlePress}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { View, Switch, StyleSheet, Pressable, Animated } 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";
|
||||
@@ -7,18 +7,21 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
interface RemoteInputSectionProps {
|
||||
onChanged: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur, onPress }) => {
|
||||
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
|
||||
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const animationStyle = useButtonAnimation(isFocused, 1.2);
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
@@ -38,6 +41,10 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
handleToggle(!remoteInputEnabled);
|
||||
}
|
||||
|
||||
// TV遥控器事件处理
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
@@ -51,19 +58,32 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
||||
useTVEventHandler(handleTVEvent);
|
||||
|
||||
return (
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||
{...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>
|
||||
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React, { useState } from "react";
|
||||
import { StyleSheet, Pressable } from "react-native";
|
||||
import { StyleSheet, Pressable, Platform } from "react-native";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
interface SettingsSectionProps {
|
||||
children: React.ReactNode;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onPress?: () => void;
|
||||
focusable?: boolean;
|
||||
}
|
||||
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, onPress, focusable = false }) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const deviceType = useResponsiveLayout().deviceType;
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
@@ -23,13 +26,24 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFo
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
onPress?.();
|
||||
}
|
||||
|
||||
if (!focusable) {
|
||||
return <ThemedView style={styles.section}>{children}</ThemedView>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
|
||||
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
|
||||
<Pressable
|
||||
android_ripple={Platform.isTV||deviceType !=='tv'? {color:'transparent'}:{color:Colors.dark.link}}
|
||||
style={styles.sectionPressable}
|
||||
// {...(Platform.isTV ? {onFocus: handleFocus, onBlur: handleBlur} : {onPress: onPress})}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</Pressable>
|
||||
</ThemedView>
|
||||
|
||||
@@ -7,11 +7,11 @@ export const UPDATE_CONFIG = {
|
||||
|
||||
// GitHub相关URL
|
||||
GITHUB_RAW_URL:
|
||||
"https://gh-proxy.com/https://raw.githubusercontent.com/orion-lib/OrionTV/refs/heads/master/package.json",
|
||||
`https://ghfast.top/https://raw.githubusercontent.com/orion-lib/OrionTV/refs/heads/master/package.json?t=${Date.now()}`,
|
||||
|
||||
// 获取平台特定的下载URL
|
||||
getDownloadUrl(version: string): string {
|
||||
return `https://gh-proxy.com/https://github.com/orion-lib/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
|
||||
return `https://ghfast.top/https://github.com/orion-lib/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
|
||||
},
|
||||
|
||||
// 是否显示更新日志
|
||||
|
||||
@@ -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 '加载失败,请重试';
|
||||
};
|
||||
131
hooks/useVideoHandlers.ts
Normal file
131
hooks/useVideoHandlers.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useCallback, RefObject, useMemo } from 'react';
|
||||
import { Video, ResizeMode } from 'expo-av';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import usePlayerStore from '@/stores/playerStore';
|
||||
|
||||
interface UseVideoHandlersProps {
|
||||
videoRef: RefObject<Video>;
|
||||
currentEpisode: { url: string; title: string } | undefined;
|
||||
initialPosition: number;
|
||||
introEndTime?: number;
|
||||
playbackRate: number;
|
||||
handlePlaybackStatusUpdate: (status: any) => void;
|
||||
deviceType: string;
|
||||
detail?: { poster?: string };
|
||||
}
|
||||
|
||||
export const useVideoHandlers = ({
|
||||
videoRef,
|
||||
currentEpisode,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
deviceType,
|
||||
detail,
|
||||
}: UseVideoHandlersProps) => {
|
||||
|
||||
const onLoad = useCallback(async () => {
|
||||
console.info(`[PERF] Video onLoad - video ready to play`);
|
||||
|
||||
try {
|
||||
// 1. 先设置位置(如果需要)
|
||||
const jumpPosition = initialPosition || introEndTime || 0;
|
||||
if (jumpPosition > 0) {
|
||||
console.info(`[PERF] Setting initial position to ${jumpPosition}ms`);
|
||||
await videoRef.current?.setPositionAsync(jumpPosition);
|
||||
}
|
||||
|
||||
// 2. 显式调用播放以确保自动播放
|
||||
console.info(`[AUTOPLAY] Attempting to start playback after onLoad`);
|
||||
await videoRef.current?.playAsync();
|
||||
console.info(`[AUTOPLAY] Auto-play successful after onLoad`);
|
||||
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
console.info(`[PERF] Video loading complete - isLoading set to false`);
|
||||
} catch (error) {
|
||||
console.warn(`[AUTOPLAY] Failed to auto-play after onLoad:`, error);
|
||||
// 即使自动播放失败,也要设置加载完成状态
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
// 不显示错误提示,因为自动播放失败是常见且预期的情况
|
||||
}
|
||||
}, [videoRef, initialPosition, introEndTime]);
|
||||
|
||||
const onLoadStart = useCallback(() => {
|
||||
if (!currentEpisode?.url) return;
|
||||
|
||||
console.info(`[PERF] Video onLoadStart - starting to load video: ${currentEpisode.url.substring(0, 100)}...`);
|
||||
usePlayerStore.setState({ isLoading: true });
|
||||
}, [currentEpisode?.url]);
|
||||
|
||||
const onError = useCallback((error: any) => {
|
||||
if (!currentEpisode?.url) return;
|
||||
|
||||
console.error(`[ERROR] Video playback error:`, error);
|
||||
|
||||
// 检测SSL证书错误和其他网络错误
|
||||
const errorString = (error as any)?.error?.toString() || error?.toString() || '';
|
||||
const isSSLError = errorString.includes('SSLHandshakeException') ||
|
||||
errorString.includes('CertPathValidatorException') ||
|
||||
errorString.includes('Trust anchor for certification path not found');
|
||||
const isNetworkError = errorString.includes('HttpDataSourceException') ||
|
||||
errorString.includes('IOException') ||
|
||||
errorString.includes('SocketTimeoutException');
|
||||
|
||||
if (isSSLError) {
|
||||
console.error(`[SSL_ERROR] SSL certificate validation failed for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "SSL证书错误,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('ssl', currentEpisode.url);
|
||||
} else if (isNetworkError) {
|
||||
console.error(`[NETWORK_ERROR] Network connection failed for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "网络连接失败,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('network', currentEpisode.url);
|
||||
} else {
|
||||
console.error(`[VIDEO_ERROR] Other video error for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "视频播放失败,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('other', currentEpisode.url);
|
||||
}
|
||||
}, [currentEpisode?.url]);
|
||||
|
||||
// 优化的Video组件props
|
||||
const videoProps = useMemo(() => ({
|
||||
source: { uri: currentEpisode?.url || '' },
|
||||
posterSource: { uri: detail?.poster ?? "" },
|
||||
resizeMode: ResizeMode.CONTAIN,
|
||||
rate: playbackRate,
|
||||
onPlaybackStatusUpdate: handlePlaybackStatusUpdate,
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
useNativeControls: deviceType !== 'tv',
|
||||
shouldPlay: true,
|
||||
}), [
|
||||
currentEpisode?.url,
|
||||
detail?.poster,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
deviceType,
|
||||
]);
|
||||
|
||||
return {
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
videoProps,
|
||||
};
|
||||
};
|
||||
@@ -2,14 +2,14 @@
|
||||
"name": "OrionTV",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.12",
|
||||
"scripts": {
|
||||
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
|
||||
"ios": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
|
||||
"prebuild": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean && yarn copy-config",
|
||||
"copy-config": "cp -r xml/* android/app/src/*",
|
||||
"build": "EXPO_TV=1 yarn prebuild && cd android && ./gradlew assembleRelease",
|
||||
"build": "NODE_ENV=production EXPO_TV=1 yarn prebuild && cd android && ./gradlew assembleRelease",
|
||||
"build-debug": "cd android && ./gradlew assembleDebug",
|
||||
"test": "jest --watchAll",
|
||||
"test-ci": "jest --ci --coverage --no-cache",
|
||||
@@ -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",
|
||||
@@ -50,6 +51,7 @@
|
||||
"react-native-blob-util": "^0.22.2",
|
||||
"react-native-file-viewer": "^2.1.5",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||
"react-native-media-console": "*",
|
||||
"react-native-qrcode-svg": "^6.3.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
@@ -67,6 +69,7 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~7.1.2",
|
||||
"jest": "^29.2.1",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
// region: --- Interface Definitions ---
|
||||
export interface DoubanItem {
|
||||
@@ -105,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();
|
||||
}
|
||||
|
||||
@@ -204,35 +220,14 @@ export class API {
|
||||
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
|
||||
const url = `/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
|
||||
const response = await this._fetch(url, { signal });
|
||||
return response.json();
|
||||
const { results } = await response.json();
|
||||
return { results: results.filter((item: any) => item.title === query )};
|
||||
}
|
||||
|
||||
async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
|
||||
const url = `/api/admin/config`;
|
||||
const url = `/api/search/resources`;
|
||||
const response = await this._fetch(url, { signal });
|
||||
const config = await response.json();
|
||||
|
||||
// 添加安全检查
|
||||
if (!config || !config.Config.SourceConfig) {
|
||||
console.warn('API response missing SourceConfig:', config);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 确保 SourceConfig 是数组
|
||||
if (!Array.isArray(config.Config.SourceConfig)) {
|
||||
console.warn('SourceConfig is not an array:', config.Config.SourceConfig);
|
||||
return [];
|
||||
}
|
||||
|
||||
// 过滤并验证每个站点配置
|
||||
return config.Config.SourceConfig
|
||||
.filter((site: any) => site && !site.disabled)
|
||||
.map((site: any) => ({
|
||||
key: site.key || '',
|
||||
api: site.api || '',
|
||||
name: site.name || '',
|
||||
detail: site.detail
|
||||
}));
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getVideoDetail(source: string, id: string): Promise<VideoDetail> {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('M3U');
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -59,7 +63,7 @@ export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
|
||||
const m3uText = await response.text();
|
||||
return parseM3U(m3uText);
|
||||
} catch (error) {
|
||||
console.info("Error fetching or parsing M3U:", error);
|
||||
logger.info("Error fetching or parsing M3U:", error);
|
||||
return []; // Return empty array on error
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('M3U8');
|
||||
|
||||
interface CacheEntry {
|
||||
resolution: string | null;
|
||||
timestamp: number;
|
||||
@@ -10,21 +14,33 @@ export const getResolutionFromM3U8 = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection START - url: ${url.substring(0, 100)}...`);
|
||||
|
||||
// 1. Check cache first
|
||||
const cachedEntry = resolutionCache[url];
|
||||
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_DURATION) {
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection CACHED - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${cachedEntry.resolution}`);
|
||||
return cachedEntry.resolution;
|
||||
}
|
||||
|
||||
if (!url.toLowerCase().endsWith(".m3u8")) {
|
||||
logger.info(`[PERF] M3U8 resolution detection SKIPPED - not M3U8 file`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchStart = performance.now();
|
||||
const response = await fetch(url, { signal });
|
||||
const fetchEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 fetch took ${(fetchEnd - fetchStart).toFixed(2)}ms, status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseStart = performance.now();
|
||||
const playlist = await response.text();
|
||||
const lines = playlist.split("\n");
|
||||
let highestResolution = 0;
|
||||
@@ -42,6 +58,9 @@ export const getResolutionFromM3U8 = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 parsing took ${(parseEnd - parseStart).toFixed(2)}ms, lines: ${lines.length}`);
|
||||
|
||||
// 2. Store result in cache
|
||||
resolutionCache[url] = {
|
||||
@@ -49,8 +68,13 @@ export const getResolutionFromM3U8 = async (
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection COMPLETE - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${resolutionString}`);
|
||||
|
||||
return resolutionString;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection ERROR - took ${(perfEnd - perfStart).toFixed(2)}ms, error: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import TCPHttpServer from "./tcpHttpServer";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RemoteControl');
|
||||
|
||||
const getRemotePageHTML = () => {
|
||||
return `
|
||||
@@ -25,7 +28,7 @@ const getRemotePageHTML = () => {
|
||||
</div>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/handshake', { method: 'POST' }).catch(console.info);
|
||||
fetch('/handshake', { method: 'POST' }).catch(err => logger.info('Handshake failed:', err));
|
||||
});
|
||||
function send() {
|
||||
const input = document.getElementById("text");
|
||||
@@ -36,7 +39,7 @@ const getRemotePageHTML = () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value })
|
||||
})
|
||||
.catch(err => console.info(err));
|
||||
.catch(err => logger.info('Message send failed:', err));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
@@ -58,7 +61,7 @@ class RemoteControlService {
|
||||
|
||||
private setupRequestHandler() {
|
||||
this.httpServer.setRequestHandler((request) => {
|
||||
console.log("[RemoteControl] Received request:", request.method, request.url);
|
||||
logger.debug("[RemoteControl] Received request:", request.method, request.url);
|
||||
|
||||
try {
|
||||
if (request.method === "GET" && request.url === "/") {
|
||||
@@ -80,7 +83,7 @@ class RemoteControlService {
|
||||
body: JSON.stringify({ status: "ok" }),
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.info("[RemoteControl] Failed to parse message body:", parseError);
|
||||
logger.info("[RemoteControl] Failed to parse message body:", parseError);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -102,7 +105,7 @@ class RemoteControlService {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("[RemoteControl] Request handler error:", error);
|
||||
logger.info("[RemoteControl] Request handler error:", error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -118,20 +121,20 @@ class RemoteControlService {
|
||||
}
|
||||
|
||||
public async startServer(): Promise<string> {
|
||||
console.log("[RemoteControl] Attempting to start server...");
|
||||
logger.debug("[RemoteControl] Attempting to start server...");
|
||||
|
||||
try {
|
||||
const url = await this.httpServer.start();
|
||||
console.log(`[RemoteControl] Server started successfully at: ${url}`);
|
||||
logger.debug(`[RemoteControl] Server started successfully at: ${url}`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.info("[RemoteControl] Failed to start server:", error);
|
||||
logger.info("[RemoteControl] Failed to start server:", error);
|
||||
throw new Error(error instanceof Error ? error.message : "Failed to start server");
|
||||
}
|
||||
}
|
||||
|
||||
public stopServer() {
|
||||
console.log("[RemoteControl] Stopping server...");
|
||||
logger.debug("[RemoteControl] Stopping server...");
|
||||
this.httpServer.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { api, PlayRecord as ApiPlayRecord, Favorite as ApiFavorite } from "./api";
|
||||
import { storageConfig } from "./storageConfig";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('Storage');
|
||||
|
||||
// --- Storage Keys ---
|
||||
const STORAGE_KEYS = {
|
||||
@@ -53,14 +56,22 @@ export class PlayerSettingsManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_SETTINGS);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all player settings:", error);
|
||||
logger.info("Failed to get all player settings:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
static async get(source: string, id: string): Promise<PlayerSettings | null> {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get START - source: ${source}, id: ${id}`);
|
||||
|
||||
const allSettings = await this.getAll();
|
||||
return allSettings[generateKey(source, id)] || null;
|
||||
const result = allSettings[generateKey(source, id)] || null;
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get END - took ${(perfEnd - perfStart).toFixed(2)}ms, found: ${!!result}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async save(source: string, id: string, settings: PlayerSettings): Promise<void> {
|
||||
@@ -99,7 +110,7 @@ export class FavoriteManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all local favorites:", error);
|
||||
logger.info("Failed to get all local favorites:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -165,17 +176,27 @@ export class PlayRecordManager {
|
||||
}
|
||||
|
||||
static async getAll(): Promise<Record<string, PlayRecord>> {
|
||||
const perfStart = performance.now();
|
||||
const storageType = this.getStorageType();
|
||||
logger.info(`[PERF] PlayRecordManager.getAll START - storageType: ${storageType}`);
|
||||
|
||||
let apiRecords: Record<string, PlayRecord> = {};
|
||||
if (this.getStorageType() === "localstorage") {
|
||||
if (storageType === "localstorage") {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
|
||||
apiRecords = data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all local play records:", error);
|
||||
logger.info("Failed to get all local play records:", error);
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
const apiStart = performance.now();
|
||||
logger.info(`[PERF] API getPlayRecords START`);
|
||||
|
||||
apiRecords = await api.getPlayRecords();
|
||||
|
||||
const apiEnd = performance.now();
|
||||
logger.info(`[PERF] API getPlayRecords END - took ${(apiEnd - apiStart).toFixed(2)}ms, records: ${Object.keys(apiRecords).length}`);
|
||||
}
|
||||
|
||||
const localSettings = await PlayerSettingsManager.getAll();
|
||||
@@ -186,6 +207,10 @@ export class PlayRecordManager {
|
||||
...localSettings[key],
|
||||
};
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.getAll END - took ${(perfEnd - perfStart).toFixed(2)}ms, total records: ${Object.keys(mergedRecords).length}`);
|
||||
|
||||
return mergedRecords;
|
||||
}
|
||||
|
||||
@@ -207,9 +232,18 @@ export class PlayRecordManager {
|
||||
}
|
||||
|
||||
static async get(source: string, id: string): Promise<PlayRecord | null> {
|
||||
const perfStart = performance.now();
|
||||
const key = generateKey(source, id);
|
||||
const storageType = this.getStorageType();
|
||||
logger.info(`[PERF] PlayRecordManager.get START - source: ${source}, id: ${id}, storageType: ${storageType}`);
|
||||
|
||||
const records = await this.getAll();
|
||||
return records[key] || null;
|
||||
const result = records[key] || null;
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.get END - took ${(perfEnd - perfStart).toFixed(2)}ms, found: ${!!result}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async remove(source: string, id: string): Promise<void> {
|
||||
@@ -248,7 +282,7 @@ export class SearchHistoryManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (error) {
|
||||
console.info("Failed to get local search history:", error);
|
||||
logger.info("Failed to get local search history:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -293,7 +327,7 @@ export class SettingsManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
return data ? { ...defaultSettings, ...JSON.parse(data) } : defaultSettings;
|
||||
} catch (error) {
|
||||
console.info("Failed to get settings:", error);
|
||||
logger.info("Failed to get settings:", error);
|
||||
return defaultSettings;
|
||||
}
|
||||
}
|
||||
@@ -316,7 +350,7 @@ export class LoginCredentialsManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
console.info("Failed to get login credentials:", error);
|
||||
logger.info("Failed to get login credentials:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -325,7 +359,7 @@ export class LoginCredentialsManager {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.LOGIN_CREDENTIALS, JSON.stringify(credentials));
|
||||
} catch (error) {
|
||||
console.error("Failed to save login credentials:", error);
|
||||
logger.error("Failed to save login credentials:", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +367,7 @@ export class LoginCredentialsManager {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear login credentials:", error);
|
||||
logger.error("Failed to clear login credentials:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import TcpSocket from 'react-native-tcp-socket';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('TCPHttpServer');
|
||||
|
||||
const PORT = 12346;
|
||||
|
||||
@@ -59,7 +62,7 @@ class TCPHttpServer {
|
||||
|
||||
return { method, url, headers, body };
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Error parsing HTTP request:', error);
|
||||
logger.info('[TCPHttpServer] Error parsing HTTP request:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -108,14 +111,14 @@ class TCPHttpServer {
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
console.log('[TCPHttpServer] Server is already running.');
|
||||
logger.debug('[TCPHttpServer] Server is already running.');
|
||||
return `http://${ipAddress}:${PORT}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
|
||||
console.log('[TCPHttpServer] Client connected');
|
||||
logger.debug('[TCPHttpServer] Client connected');
|
||||
|
||||
let requestData = '';
|
||||
|
||||
@@ -140,7 +143,7 @@ class TCPHttpServer {
|
||||
socket.write(errorResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Error handling request:', error);
|
||||
logger.info('[TCPHttpServer] Error handling request:', error);
|
||||
const errorResponse = this.formatHttpResponse({
|
||||
statusCode: 500,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
@@ -155,28 +158,28 @@ class TCPHttpServer {
|
||||
});
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
console.info('[TCPHttpServer] Socket error:', error);
|
||||
logger.info('[TCPHttpServer] Socket error:', error);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('[TCPHttpServer] Client disconnected');
|
||||
logger.debug('[TCPHttpServer] Client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
this.server.listen({ port: PORT, host: '0.0.0.0' }, () => {
|
||||
console.log(`[TCPHttpServer] Server listening on ${ipAddress}:${PORT}`);
|
||||
logger.debug(`[TCPHttpServer] Server listening on ${ipAddress}:${PORT}`);
|
||||
this.isRunning = true;
|
||||
resolve(`http://${ipAddress}:${PORT}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (error: Error) => {
|
||||
console.info('[TCPHttpServer] Server error:', error);
|
||||
logger.info('[TCPHttpServer] Server error:', error);
|
||||
this.isRunning = false;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Failed to start server:', error);
|
||||
logger.info('[TCPHttpServer] Failed to start server:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@@ -187,7 +190,7 @@ class TCPHttpServer {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
this.isRunning = false;
|
||||
console.log('[TCPHttpServer] Server stopped');
|
||||
logger.debug('[TCPHttpServer] Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
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');
|
||||
|
||||
interface VersionInfo {
|
||||
version: string;
|
||||
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();
|
||||
@@ -19,203 +29,220 @@ 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++;
|
||||
console.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}`);
|
||||
console.log(`Cleaned old APK file: ${file}`);
|
||||
} catch (deleteError) {
|
||||
console.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) {
|
||||
console.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> {
|
||||
// ① 先确认文件存在
|
||||
const exists = await FileSystem.getInfoAsync(fileUri);
|
||||
if (!exists.exists) {
|
||||
throw new Error(`APK not found at ${fileUri}`);
|
||||
}
|
||||
|
||||
// ② 把 file:// 转成 content://,Expo‑FileSystem 已经实现了 FileProvider
|
||||
const contentUri = await FileSystem.getContentUriAsync(fileUri);
|
||||
|
||||
// ③ 只在 Android 里执行
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
// FLAG_ACTIVITY_NEW_TASK = 0x10000000 (1)
|
||||
// FLAG_GRANT_READ_URI_PERMISSION = 0x00000010
|
||||
const flags = 1 | 0x00000010; // 1 | 16
|
||||
|
||||
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
|
||||
data: contentUri, // 必须是 content://
|
||||
type: ANDROID_MIME_TYPE, // application/vnd.android.package-archive
|
||||
flags,
|
||||
});
|
||||
} 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;
|
||||
console.log(`APK downloaded successfully: ${filePath}`);
|
||||
return res.path();
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.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 设备不支持直接安装 APK
|
||||
Toast.show({
|
||||
type: 'error',
|
||||
text1: '安装失败',
|
||||
text2: 'iOS 设备无法直接安装 APK',
|
||||
});
|
||||
} catch (error) {
|
||||
console.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,8 +1,11 @@
|
||||
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";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('AuthStore');
|
||||
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
@@ -27,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;
|
||||
@@ -45,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) {
|
||||
@@ -53,23 +56,24 @@ 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) {
|
||||
console.info("Failed to check login status:", error);
|
||||
logger.error("Failed to check login status:", error);
|
||||
if (error instanceof Error && error.message === "UNAUTHORIZED") {
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
} else {
|
||||
@@ -79,10 +83,10 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
},
|
||||
logout: async () => {
|
||||
try {
|
||||
await Cookies.clearAll();
|
||||
await api.logout();
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
} catch (error) {
|
||||
console.info("Failed to logout:", error);
|
||||
logger.error("Failed to logout:", error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,9 @@ import { SearchResult, api } from "@/services/api";
|
||||
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { FavoriteManager } from "@/services/storage";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('DetailStore');
|
||||
|
||||
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
|
||||
|
||||
@@ -16,11 +19,14 @@ interface DetailState {
|
||||
allSourcesLoaded: boolean;
|
||||
controller: AbortController | null;
|
||||
isFavorited: boolean;
|
||||
failedSources: Set<string>; // 记录失败的source列表
|
||||
|
||||
init: (q: string, preferredSource?: string, id?: string) => Promise<void>;
|
||||
setDetail: (detail: SearchResultWithResolution) => void;
|
||||
setDetail: (detail: SearchResultWithResolution) => Promise<void>;
|
||||
abort: () => void;
|
||||
toggleFavorite: () => Promise<void>;
|
||||
markSourceAsFailed: (source: string, reason: string) => void;
|
||||
getNextAvailableSource: (currentSource: string, episodeIndex: number) => SearchResultWithResolution | null;
|
||||
}
|
||||
|
||||
const useDetailStore = create<DetailState>((set, get) => ({
|
||||
@@ -33,8 +39,12 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
allSourcesLoaded: false,
|
||||
controller: null,
|
||||
isFavorited: false,
|
||||
failedSources: new Set(),
|
||||
|
||||
init: async (q, preferredSource, id) => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init START - q: ${q}, preferredSource: ${preferredSource}, id: ${id}`);
|
||||
|
||||
const { controller: oldController } = get();
|
||||
if (oldController) {
|
||||
oldController.abort();
|
||||
@@ -55,21 +65,30 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
const { videoSource } = useSettingsStore.getState();
|
||||
|
||||
const processAndSetResults = async (results: SearchResult[], merge = false) => {
|
||||
const resolutionStart = performance.now();
|
||||
logger.info(`[PERF] Resolution detection START - processing ${results.length} sources`);
|
||||
|
||||
const resultsWithResolution = await Promise.all(
|
||||
results.map(async (searchResult) => {
|
||||
let resolution;
|
||||
const m3u8Start = performance.now();
|
||||
try {
|
||||
if (searchResult.episodes && searchResult.episodes.length > 0) {
|
||||
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
console.info(`Failed to get resolution for ${searchResult.source_name}`, e);
|
||||
logger.info(`Failed to get resolution for ${searchResult.source_name}`, e);
|
||||
}
|
||||
}
|
||||
const m3u8End = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution for ${searchResult.source_name}: ${(m3u8End - m3u8Start).toFixed(2)}ms (${resolution || 'failed'})`);
|
||||
return { ...searchResult, resolution };
|
||||
})
|
||||
);
|
||||
|
||||
const resolutionEnd = performance.now();
|
||||
logger.info(`[PERF] Resolution detection COMPLETE - took ${(resolutionEnd - resolutionStart).toFixed(2)}ms`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -93,59 +112,205 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
try {
|
||||
// Optimization for favorite navigation
|
||||
if (preferredSource && id) {
|
||||
const { results: preferredResult } = await api.searchVideo(q, preferredSource, signal);
|
||||
const searchPreferredStart = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (preferred) START - source: ${preferredSource}, query: "${q}"`);
|
||||
|
||||
let preferredResult: SearchResult[] = [];
|
||||
let preferredSearchError: any = null;
|
||||
|
||||
try {
|
||||
const response = await api.searchVideo(q, preferredSource, signal);
|
||||
preferredResult = response.results;
|
||||
} catch (error) {
|
||||
preferredSearchError = error;
|
||||
logger.error(`[ERROR] API searchVideo (preferred) FAILED - source: ${preferredSource}, error:`, error);
|
||||
}
|
||||
|
||||
const searchPreferredEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (preferred) END - took ${(searchPreferredEnd - searchPreferredStart).toFixed(2)}ms, results: ${preferredResult.length}, error: ${!!preferredSearchError}`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
// 检查preferred source结果
|
||||
if (preferredResult.length > 0) {
|
||||
logger.info(`[SUCCESS] Preferred source "${preferredSource}" found ${preferredResult.length} results for "${q}"`);
|
||||
await processAndSetResults(preferredResult, false);
|
||||
set({ loading: false });
|
||||
} else {
|
||||
// 降级策略:preferred source失败时立即尝试所有源
|
||||
if (preferredSearchError) {
|
||||
logger.warn(`[FALLBACK] Preferred source "${preferredSource}" failed with error, trying all sources immediately`);
|
||||
} else {
|
||||
logger.warn(`[FALLBACK] Preferred source "${preferredSource}" returned 0 results for "${q}", trying all sources immediately`);
|
||||
}
|
||||
|
||||
// 立即尝试所有源,不再依赖后台搜索
|
||||
const fallbackStart = performance.now();
|
||||
logger.info(`[PERF] FALLBACK search (all sources) START - query: "${q}"`);
|
||||
|
||||
try {
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
const fallbackEnd = performance.now();
|
||||
logger.info(`[PERF] FALLBACK search END - took ${(fallbackEnd - fallbackStart).toFixed(2)}ms, total results: ${allResults.length}`);
|
||||
|
||||
const filteredResults = allResults.filter(item => item.title === q);
|
||||
logger.info(`[FALLBACK] Filtered results: ${filteredResults.length} matches for "${q}"`);
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
logger.info(`[SUCCESS] FALLBACK search found results, proceeding with ${filteredResults[0].source_name}`);
|
||||
await processAndSetResults(filteredResults, false);
|
||||
set({ loading: false });
|
||||
} else {
|
||||
logger.error(`[ERROR] FALLBACK search found no matching results for "${q}"`);
|
||||
set({
|
||||
error: `未找到 "${q}" 的播放源,请检查标题或稍后重试`,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
logger.error(`[ERROR] FALLBACK search FAILED:`, fallbackError);
|
||||
set({
|
||||
error: `搜索失败:${fallbackError instanceof Error ? fallbackError.message : '网络错误,请稍后重试'}`,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 后台搜索(如果preferred source成功的话)
|
||||
if (preferredResult.length > 0) {
|
||||
const searchAllStart = performance.now();
|
||||
logger.info(`[PERF] API searchVideos (background) START`);
|
||||
|
||||
try {
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
|
||||
const searchAllEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideos (background) END - took ${(searchAllEnd - searchAllStart).toFixed(2)}ms, results: ${allResults.length}`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
await processAndSetResults(allResults.filter(item => item.title === q), true);
|
||||
} catch (backgroundError) {
|
||||
logger.warn(`[WARN] Background search failed, but preferred source already succeeded:`, backgroundError);
|
||||
}
|
||||
}
|
||||
// Then load all others in background
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
if (signal.aborted) return;
|
||||
await processAndSetResults(allResults, true);
|
||||
} else {
|
||||
// Standard navigation: fetch resources, then fetch details one by one
|
||||
const allResources = await api.getResources(signal);
|
||||
const enabledResources = videoSource.enabledAll
|
||||
? allResources
|
||||
: allResources.filter((r) => videoSource.sources[r.key]);
|
||||
const resourcesStart = performance.now();
|
||||
logger.info(`[PERF] API getResources START - query: "${q}"`);
|
||||
|
||||
try {
|
||||
const allResources = await api.getResources(signal);
|
||||
|
||||
const resourcesEnd = performance.now();
|
||||
logger.info(`[PERF] API getResources END - took ${(resourcesEnd - resourcesStart).toFixed(2)}ms, resources: ${allResources.length}`);
|
||||
|
||||
const enabledResources = videoSource.enabledAll
|
||||
? allResources
|
||||
: allResources.filter((r) => videoSource.sources[r.key]);
|
||||
|
||||
let firstResultFound = false;
|
||||
const searchPromises = enabledResources.map(async (resource) => {
|
||||
try {
|
||||
const { results } = await api.searchVideo(q, resource.key, signal);
|
||||
if (results.length > 0) {
|
||||
await processAndSetResults(results, true);
|
||||
if (!firstResultFound) {
|
||||
set({ loading: false }); // Stop loading indicator on first result
|
||||
firstResultFound = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.info(`Failed to fetch from ${resource.name}:`, error);
|
||||
logger.info(`[PERF] Enabled resources: ${enabledResources.length}/${allResources.length}`);
|
||||
|
||||
if (enabledResources.length === 0) {
|
||||
logger.error(`[ERROR] No enabled resources available for search`);
|
||||
set({
|
||||
error: "没有可用的视频源,请检查设置或联系管理员",
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
let firstResultFound = false;
|
||||
let totalResults = 0;
|
||||
const searchPromises = enabledResources.map(async (resource) => {
|
||||
try {
|
||||
const searchStart = performance.now();
|
||||
const { results } = await api.searchVideo(q, resource.key, signal);
|
||||
const searchEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (${resource.name}) took ${(searchEnd - searchStart).toFixed(2)}ms, results: ${results.length}`);
|
||||
|
||||
if (results.length > 0) {
|
||||
totalResults += results.length;
|
||||
logger.info(`[SUCCESS] Source "${resource.name}" found ${results.length} results for "${q}"`);
|
||||
await processAndSetResults(results, true);
|
||||
if (!firstResultFound) {
|
||||
set({ loading: false }); // Stop loading indicator on first result
|
||||
firstResultFound = true;
|
||||
logger.info(`[SUCCESS] First result found from "${resource.name}", stopping loading indicator`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[WARN] Source "${resource.name}" returned 0 results for "${q}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[ERROR] Failed to fetch from ${resource.name}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
// 检查是否找到任何结果
|
||||
if (totalResults === 0) {
|
||||
logger.error(`[ERROR] All sources returned 0 results for "${q}"`);
|
||||
set({
|
||||
error: `未找到 "${q}" 的播放源,请尝试其他关键词或稍后重试`,
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
logger.info(`[SUCCESS] Standard search completed, total results: ${totalResults}`);
|
||||
}
|
||||
} catch (resourceError) {
|
||||
logger.error(`[ERROR] Failed to get resources:`, resourceError);
|
||||
set({
|
||||
error: `获取视频源失败:${resourceError instanceof Error ? resourceError.message : '网络错误,请稍后重试'}`,
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (get().searchResults.length === 0) {
|
||||
set({ error: "未找到任何播放源" });
|
||||
const favoriteCheckStart = performance.now();
|
||||
const finalState = get();
|
||||
|
||||
// 最终检查:如果所有搜索都完成但仍然没有结果
|
||||
if (finalState.searchResults.length === 0 && !finalState.error) {
|
||||
logger.error(`[ERROR] All search attempts completed but no results found for "${q}"`);
|
||||
set({ error: `未找到 "${q}" 的播放源,请检查标题拼写或稍后重试` });
|
||||
} else if (finalState.searchResults.length > 0) {
|
||||
logger.info(`[SUCCESS] DetailStore.init completed successfully with ${finalState.searchResults.length} sources`);
|
||||
}
|
||||
|
||||
if (get().detail) {
|
||||
const { source, id } = get().detail!;
|
||||
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||
set({ isFavorited });
|
||||
if (finalState.detail) {
|
||||
const { source, id } = finalState.detail;
|
||||
logger.info(`[INFO] Checking favorite status for source: ${source}, id: ${id}`);
|
||||
try {
|
||||
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||
set({ isFavorited });
|
||||
logger.info(`[INFO] Favorite status: ${isFavorited}`);
|
||||
} catch (favoriteError) {
|
||||
logger.warn(`[WARN] Failed to check favorite status:`, favoriteError);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[WARN] No detail found after all search attempts for "${q}"`);
|
||||
}
|
||||
|
||||
const favoriteCheckEnd = performance.now();
|
||||
logger.info(`[PERF] Favorite check took ${(favoriteCheckEnd - favoriteCheckStart).toFixed(2)}ms`);
|
||||
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
set({ error: e instanceof Error ? e.message : "获取数据失败" });
|
||||
logger.error(`[ERROR] DetailStore.init caught unexpected error:`, e);
|
||||
const errorMessage = e instanceof Error ? e.message : "获取数据失败";
|
||||
set({ error: `搜索失败:${errorMessage}` });
|
||||
} else {
|
||||
logger.info(`[INFO] DetailStore.init aborted by user`);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
set({ loading: false, allSourcesLoaded: true });
|
||||
logger.info(`[INFO] DetailStore.init cleanup completed`);
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -178,6 +343,64 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
const newIsFavorited = await FavoriteManager.toggle(source, id.toString(), favoriteItem);
|
||||
set({ isFavorited: newIsFavorited });
|
||||
},
|
||||
|
||||
markSourceAsFailed: (source: string, reason: string) => {
|
||||
const { failedSources } = get();
|
||||
const newFailedSources = new Set(failedSources);
|
||||
newFailedSources.add(source);
|
||||
|
||||
logger.warn(`[SOURCE_FAILED] Marking source "${source}" as failed due to: ${reason}`);
|
||||
logger.info(`[SOURCE_FAILED] Total failed sources: ${newFailedSources.size}`);
|
||||
|
||||
set({ failedSources: newFailedSources });
|
||||
},
|
||||
|
||||
getNextAvailableSource: (currentSource: string, episodeIndex: number) => {
|
||||
const { searchResults, failedSources } = get();
|
||||
|
||||
logger.info(`[SOURCE_SELECTION] Looking for alternative to "${currentSource}" for episode ${episodeIndex + 1}`);
|
||||
logger.info(`[SOURCE_SELECTION] Failed sources: [${Array.from(failedSources).join(', ')}]`);
|
||||
|
||||
// 过滤掉当前source和已失败的sources
|
||||
const availableSources = searchResults.filter(result =>
|
||||
result.source !== currentSource &&
|
||||
!failedSources.has(result.source) &&
|
||||
result.episodes &&
|
||||
result.episodes.length > episodeIndex
|
||||
);
|
||||
|
||||
logger.info(`[SOURCE_SELECTION] Available sources: ${availableSources.length}`);
|
||||
availableSources.forEach(source => {
|
||||
logger.info(`[SOURCE_SELECTION] - ${source.source} (${source.source_name}): ${source.episodes?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
if (availableSources.length === 0) {
|
||||
logger.error(`[SOURCE_SELECTION] No available sources for episode ${episodeIndex + 1}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先选择有高分辨率的source
|
||||
const sortedSources = availableSources.sort((a, b) => {
|
||||
const aResolution = a.resolution || '';
|
||||
const bResolution = b.resolution || '';
|
||||
|
||||
// 优先级: 1080p > 720p > 其他 > 无分辨率
|
||||
const resolutionPriority = (res: string) => {
|
||||
if (res.includes('1080')) return 4;
|
||||
if (res.includes('720')) return 3;
|
||||
if (res.includes('480')) return 2;
|
||||
if (res.includes('360')) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
return resolutionPriority(bResolution) - resolutionPriority(aResolution);
|
||||
});
|
||||
|
||||
const selectedSource = sortedSources[0];
|
||||
logger.info(`[SOURCE_SELECTION] Selected fallback source: ${selectedSource.source} (${selectedSource.source_name}) with resolution: ${selectedSource.resolution || 'unknown'}`);
|
||||
|
||||
return selectedSource;
|
||||
},
|
||||
}));
|
||||
|
||||
export const sourcesSelector = (state: DetailState) => state.sources;
|
||||
|
||||
@@ -55,6 +55,26 @@ const initialCategories: Category[] = [
|
||||
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
|
||||
];
|
||||
|
||||
// 添加缓存项接口
|
||||
interface CacheItem {
|
||||
data: RowItem[];
|
||||
timestamp: number;
|
||||
type: 'movie' | 'tv' | 'record';
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
const CACHE_EXPIRE_TIME = 5 * 60 * 1000; // 5分钟过期
|
||||
const MAX_CACHE_SIZE = 10; // 最大缓存容量
|
||||
const MAX_ITEMS_PER_CACHE = 40; // 每个缓存最大条目数
|
||||
|
||||
const getCacheKey = (category: Category) => {
|
||||
return `${category.type || 'unknown'}-${category.title}-${category.tag || ''}`;
|
||||
};
|
||||
|
||||
const isValidCache = (cacheItem: CacheItem) => {
|
||||
return Date.now() - cacheItem.timestamp < CACHE_EXPIRE_TIME;
|
||||
};
|
||||
|
||||
interface HomeState {
|
||||
categories: Category[];
|
||||
selectedCategory: Category;
|
||||
@@ -72,7 +92,7 @@ interface HomeState {
|
||||
}
|
||||
|
||||
// 内存缓存,应用生命周期内有效
|
||||
const dataCache = new Map<string, RowItem[]>();
|
||||
const dataCache = new Map<string, CacheItem>();
|
||||
|
||||
const useHomeStore = create<HomeState>((set, get) => ({
|
||||
categories: initialCategories,
|
||||
@@ -87,29 +107,30 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
fetchInitialData: async () => {
|
||||
const { apiBaseUrl } = useSettingsStore.getState();
|
||||
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
|
||||
|
||||
|
||||
const { selectedCategory } = get();
|
||||
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
|
||||
|
||||
const cacheKey = getCacheKey(selectedCategory);
|
||||
|
||||
// 最近播放不缓存,始终实时获取
|
||||
if (selectedCategory.type === 'record') {
|
||||
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
|
||||
await get().loadMoreData();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// 检查缓存
|
||||
if (dataCache.has(cacheKey)) {
|
||||
set({
|
||||
loading: false,
|
||||
contentData: dataCache.get(cacheKey)!,
|
||||
pageStart: dataCache.get(cacheKey)!.length,
|
||||
hasMore: false,
|
||||
error: null
|
||||
if (dataCache.has(cacheKey) && isValidCache(dataCache.get(cacheKey)!)) {
|
||||
const cachedData = dataCache.get(cacheKey)!;
|
||||
set({
|
||||
loading: false,
|
||||
contentData: cachedData.data,
|
||||
pageStart: cachedData.data.length,
|
||||
hasMore: cachedData.hasMore,
|
||||
error: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
|
||||
await get().loadMoreData();
|
||||
},
|
||||
@@ -151,34 +172,74 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
|
||||
set({ contentData: rowItems, hasMore: false });
|
||||
} else if (selectedCategory.type && selectedCategory.tag) {
|
||||
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
|
||||
if (result.list.length === 0) {
|
||||
set({ hasMore: false });
|
||||
} else {
|
||||
const newItems = result.list.map((item) => ({
|
||||
...item,
|
||||
id: item.title,
|
||||
source: "douban",
|
||||
})) as RowItem[];
|
||||
|
||||
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
|
||||
|
||||
if (pageStart === 0) {
|
||||
// 缓存新数据
|
||||
dataCache.set(cacheKey, newItems);
|
||||
set({
|
||||
contentData: newItems,
|
||||
pageStart: result.list.length,
|
||||
hasMore: true,
|
||||
});
|
||||
} else {
|
||||
// 增量加载时不缓存,直接追加
|
||||
set((state) => ({
|
||||
contentData: [...state.contentData, ...newItems],
|
||||
pageStart: state.pageStart + result.list.length,
|
||||
hasMore: true,
|
||||
}));
|
||||
const result = await api.getDoubanData(
|
||||
selectedCategory.type,
|
||||
selectedCategory.tag,
|
||||
20,
|
||||
pageStart
|
||||
);
|
||||
|
||||
const newItems = result.list.map((item) => ({
|
||||
...item,
|
||||
id: item.title,
|
||||
source: "douban",
|
||||
})) as RowItem[];
|
||||
|
||||
const cacheKey = getCacheKey(selectedCategory);
|
||||
|
||||
if (pageStart === 0) {
|
||||
// 清理过期缓存
|
||||
for (const [key, value] of dataCache.entries()) {
|
||||
if (!isValidCache(value)) {
|
||||
dataCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果缓存太大,删除最旧的项
|
||||
if (dataCache.size >= MAX_CACHE_SIZE) {
|
||||
const oldestKey = Array.from(dataCache.keys())[0];
|
||||
dataCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
// 限制缓存的数据条目数,但不限制显示的数据
|
||||
const cacheItems = newItems.slice(0, MAX_ITEMS_PER_CACHE);
|
||||
|
||||
// 存储新缓存
|
||||
dataCache.set(cacheKey, {
|
||||
data: cacheItems,
|
||||
timestamp: Date.now(),
|
||||
type: selectedCategory.type,
|
||||
hasMore: true // 始终为 true,因为我们允许继续加载
|
||||
});
|
||||
|
||||
set({
|
||||
contentData: newItems, // 使用完整的新数据
|
||||
pageStart: newItems.length,
|
||||
hasMore: result.list.length !== 0,
|
||||
});
|
||||
} else {
|
||||
// 增量加载时更新缓存
|
||||
const existingCache = dataCache.get(cacheKey);
|
||||
if (existingCache) {
|
||||
// 只有当缓存数据少于最大限制时才更新缓存
|
||||
if (existingCache.data.length < MAX_ITEMS_PER_CACHE) {
|
||||
const updatedData = [...existingCache.data, ...newItems];
|
||||
const limitedCacheData = updatedData.slice(0, MAX_ITEMS_PER_CACHE);
|
||||
|
||||
dataCache.set(cacheKey, {
|
||||
...existingCache,
|
||||
data: limitedCacheData,
|
||||
hasMore: true // 始终为 true,因为我们允许继续加载
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新状态时使用所有数据
|
||||
set((state) => ({
|
||||
contentData: [...state.contentData, ...newItems],
|
||||
pageStart: state.pageStart + newItems.length,
|
||||
hasMore: result.list.length !== 0,
|
||||
}));
|
||||
}
|
||||
} else if (selectedCategory.tags) {
|
||||
// It's a container category, do not load content, but clear current content
|
||||
@@ -188,7 +249,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = "加载失败,请重试";
|
||||
|
||||
|
||||
if (err.message === "API_URL_NOT_SET") {
|
||||
errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
|
||||
} else if (err.message === "UNAUTHORIZED") {
|
||||
@@ -204,7 +265,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
} else if (err.message.includes("403")) {
|
||||
errorMessage = "访问被拒绝,请检查权限设置";
|
||||
}
|
||||
|
||||
|
||||
set({ error: errorMessage });
|
||||
} finally {
|
||||
set({ loading: false, loadingMore: false });
|
||||
@@ -213,27 +274,35 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
|
||||
selectCategory: (category: Category) => {
|
||||
const currentCategory = get().selectedCategory;
|
||||
const cacheKey = `${category.title}-${category.tag || ''}`;
|
||||
|
||||
// 只有当分类或标签真正变化时才处理
|
||||
const cacheKey = getCacheKey(category);
|
||||
|
||||
if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) {
|
||||
set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null });
|
||||
|
||||
// 最近播放始终实时获取
|
||||
set({
|
||||
selectedCategory: category,
|
||||
contentData: [],
|
||||
pageStart: 0,
|
||||
hasMore: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
if (category.type === 'record') {
|
||||
get().fetchInitialData();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存,有则直接使用,无则请求
|
||||
if (dataCache.has(cacheKey)) {
|
||||
set({
|
||||
contentData: dataCache.get(cacheKey)!,
|
||||
pageStart: dataCache.get(cacheKey)!.length,
|
||||
hasMore: false,
|
||||
loading: false
|
||||
|
||||
const cachedData = dataCache.get(cacheKey);
|
||||
if (cachedData && isValidCache(cachedData)) {
|
||||
set({
|
||||
contentData: cachedData.data,
|
||||
pageStart: cachedData.data.length,
|
||||
hasMore: cachedData.hasMore,
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
// 删除过期缓存
|
||||
if (cachedData) {
|
||||
dataCache.delete(cacheKey);
|
||||
}
|
||||
get().fetchInitialData();
|
||||
}
|
||||
}
|
||||
@@ -273,10 +342,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
|
||||
get().fetchInitialData();
|
||||
},
|
||||
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@ import { AVPlaybackStatus, Video } from "expo-av";
|
||||
import { RefObject } from "react";
|
||||
import { PlayRecord, PlayRecordManager, PlayerSettingsManager } from "@/services/storage";
|
||||
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('PlayerStore');
|
||||
|
||||
interface Episode {
|
||||
url: string;
|
||||
@@ -54,6 +57,7 @@ interface PlayerState {
|
||||
_isRecordSaveThrottled: boolean;
|
||||
// Internal helper
|
||||
_savePlayRecord: (updates?: Partial<PlayRecord>, options?: { immediate?: boolean }) => void;
|
||||
handleVideoError: (errorType: 'ssl' | 'network' | 'other', failedUrl: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
@@ -80,44 +84,156 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setVideoRef: (ref) => set({ videoRef: ref }),
|
||||
|
||||
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo START - source: ${source}, id: ${id}, title: ${title}`);
|
||||
|
||||
let detail = useDetailStore.getState().detail;
|
||||
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 如果有detail,使用detail的source获取episodes;否则使用传入的source
|
||||
if (detail && detail.source) {
|
||||
logger.info(`[INFO] Using existing detail source "${detail.source}" to get episodes`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
} else {
|
||||
logger.info(`[INFO] No existing detail, using provided source "${source}" to get episodes`);
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
if (!detail || !episodes || episodes.length === 0 || detail.title !== title) {
|
||||
const needsDetailInit = !detail || !episodes || episodes.length === 0 || detail.title !== title;
|
||||
logger.info(`[PERF] Detail check - needsInit: ${needsDetailInit}, hasDetail: ${!!detail}, episodesCount: ${episodes?.length || 0}`);
|
||||
|
||||
if (needsDetailInit) {
|
||||
const detailInitStart = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init START - ${title}`);
|
||||
|
||||
await useDetailStore.getState().init(title, source, id);
|
||||
|
||||
const detailInitEnd = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init END - took ${(detailInitEnd - detailInitStart).toFixed(2)}ms`);
|
||||
|
||||
detail = useDetailStore.getState().detail;
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
|
||||
if (!detail) {
|
||||
console.info("Detail not found after initialization");
|
||||
logger.error(`[ERROR] Detail not found after initialization for "${title}" (source: ${source}, id: ${id})`);
|
||||
|
||||
// 检查DetailStore的错误状态
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
if (detailStoreState.error) {
|
||||
logger.error(`[ERROR] DetailStore error: ${detailStoreState.error}`);
|
||||
set({
|
||||
isLoading: false,
|
||||
// 可以选择在这里设置一个错误状态,但playerStore可能没有error字段
|
||||
});
|
||||
} else {
|
||||
logger.error(`[ERROR] DetailStore init completed but no detail found and no error reported`);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用DetailStore找到的实际source来获取episodes,而不是原始的preferredSource
|
||||
logger.info(`[INFO] Using actual source "${detail.source}" instead of preferred source "${source}"`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.error(`[ERROR] No episodes found for "${title}" from source "${detail.source}" (${detail.source_name})`);
|
||||
|
||||
// 尝试从searchResults中直接获取episodes
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
logger.info(`[INFO] Available sources in searchResults: ${detailStoreState.searchResults.map(r => `${r.source}(${r.episodes?.length || 0} episodes)`).join(', ')}`);
|
||||
|
||||
// 如果当前source没有episodes,尝试使用第一个有episodes的source
|
||||
const sourceWithEpisodes = detailStoreState.searchResults.find(r => r.episodes && r.episodes.length > 0);
|
||||
if (sourceWithEpisodes) {
|
||||
logger.info(`[FALLBACK] Using alternative source "${sourceWithEpisodes.source}" with ${sourceWithEpisodes.episodes.length} episodes`);
|
||||
episodes = sourceWithEpisodes.episodes;
|
||||
// 更新detail为有episodes的source
|
||||
detail = sourceWithEpisodes;
|
||||
} else {
|
||||
logger.error(`[ERROR] No source with episodes found in searchResults`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[SUCCESS] Detail and episodes loaded - source: ${detail.source_name}, episodes: ${episodes.length}`);
|
||||
} else {
|
||||
logger.info(`[PERF] Skipping DetailStore.init - using cached data`);
|
||||
|
||||
// 即使是缓存的数据,也要确保使用正确的source获取episodes
|
||||
if (detail && detail.source && detail.source !== source) {
|
||||
logger.info(`[INFO] Cached detail source "${detail.source}" differs from provided source "${source}", updating episodes`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.warn(`[WARN] Cached detail source "${detail.source}" has no episodes, trying provided source "${source}"`);
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证:确保我们有有效的detail和episodes数据
|
||||
if (!detail) {
|
||||
logger.error(`[ERROR] Final check failed: detail is null`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.error(`[ERROR] Final check failed: no episodes available for source "${detail.source}" (${detail.source_name})`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[SUCCESS] Final validation passed - detail: ${detail.source_name}, episodes: ${episodes.length}`);
|
||||
|
||||
try {
|
||||
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
|
||||
const playerSettings = await PlayerSettingsManager.get(detail.source, detail.id.toString());
|
||||
const storageStart = performance.now();
|
||||
logger.info(`[PERF] Storage operations START`);
|
||||
|
||||
const playRecord = await PlayRecordManager.get(detail!.source, detail!.id.toString());
|
||||
const storagePlayRecordEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.get took ${(storagePlayRecordEnd - storageStart).toFixed(2)}ms`);
|
||||
|
||||
const playerSettings = await PlayerSettingsManager.get(detail!.source, detail!.id.toString());
|
||||
const storageEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get took ${(storageEnd - storagePlayRecordEnd).toFixed(2)}ms`);
|
||||
logger.info(`[PERF] Total storage operations took ${(storageEnd - storageStart).toFixed(2)}ms`);
|
||||
|
||||
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
|
||||
const savedPlaybackRate = playerSettings?.playbackRate || 1.0;
|
||||
|
||||
const episodesMappingStart = performance.now();
|
||||
const mappedEpisodes = episodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
}));
|
||||
const episodesMappingEnd = performance.now();
|
||||
logger.info(`[PERF] Episodes mapping (${episodes.length} episodes) took ${(episodesMappingEnd - episodesMappingStart).toFixed(2)}ms`);
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
currentEpisodeIndex: episodeIndex,
|
||||
initialPosition: position || initialPositionFromRecord,
|
||||
playbackRate: savedPlaybackRate,
|
||||
episodes: episodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
})),
|
||||
episodes: mappedEpisodes,
|
||||
introEndTime: playRecord?.introEndTime || playerSettings?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime || playerSettings?.outroStartTime,
|
||||
});
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
|
||||
} catch (error) {
|
||||
console.info("Failed to load play record", error);
|
||||
logger.debug("Failed to load play record", error);
|
||||
set({ isLoading: false });
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo ERROR - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,7 +250,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
try {
|
||||
await videoRef?.current?.replayAsync();
|
||||
} catch (error) {
|
||||
console.info("Failed to replay video:", error);
|
||||
logger.debug("Failed to replay video:", error);
|
||||
Toast.show({ type: "error", text1: "播放失败" });
|
||||
}
|
||||
}
|
||||
@@ -150,7 +266,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
await videoRef?.current?.playAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to toggle play/pause:", error);
|
||||
logger.debug("Failed to toggle play/pause:", error);
|
||||
Toast.show({ type: "error", text1: "操作失败" });
|
||||
}
|
||||
}
|
||||
@@ -164,7 +280,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
try {
|
||||
await videoRef?.current?.setPositionAsync(newPosition);
|
||||
} catch (error) {
|
||||
console.info("Failed to seek video:", error);
|
||||
logger.debug("Failed to seek video:", error);
|
||||
Toast.show({ type: "error", text1: "快进/快退失败" });
|
||||
}
|
||||
|
||||
@@ -270,7 +386,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
handlePlaybackStatusUpdate: (newStatus) => {
|
||||
if (!newStatus.isLoaded) {
|
||||
if (newStatus.error) {
|
||||
console.info(`Playback Error: ${newStatus.error}`);
|
||||
logger.debug(`Playback Error: ${newStatus.error}`);
|
||||
}
|
||||
set({ status: newStatus });
|
||||
return;
|
||||
@@ -331,7 +447,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
await PlayerSettingsManager.save(detail.source, detail.id.toString(), { playbackRate: rate });
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to set playback rate:", error);
|
||||
logger.debug("Failed to set playback rate:", error);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -352,12 +468,105 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
outroStartTime: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
handleVideoError: async (errorType: 'ssl' | 'network' | 'other', failedUrl: string) => {
|
||||
const perfStart = performance.now();
|
||||
logger.error(`[VIDEO_ERROR] Handling ${errorType} error for URL: ${failedUrl}`);
|
||||
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
const { detail } = detailStoreState;
|
||||
const { currentEpisodeIndex } = get();
|
||||
|
||||
if (!detail) {
|
||||
logger.error(`[VIDEO_ERROR] Cannot fallback - no detail available`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记当前source为失败
|
||||
const currentSource = detail.source;
|
||||
const errorReason = `${errorType} error: ${failedUrl.substring(0, 100)}...`;
|
||||
useDetailStore.getState().markSourceAsFailed(currentSource, errorReason);
|
||||
|
||||
// 获取下一个可用的source
|
||||
const fallbackSource = useDetailStore.getState().getNextAvailableSource(currentSource, currentEpisodeIndex);
|
||||
|
||||
if (!fallbackSource) {
|
||||
logger.error(`[VIDEO_ERROR] No fallback sources available for episode ${currentEpisodeIndex + 1}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "播放失败",
|
||||
text2: "所有播放源都不可用,请稍后重试"
|
||||
});
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[VIDEO_ERROR] Switching to fallback source: ${fallbackSource.source} (${fallbackSource.source_name})`);
|
||||
|
||||
try {
|
||||
// 更新DetailStore的当前detail为fallback source
|
||||
await useDetailStore.getState().setDetail(fallbackSource);
|
||||
|
||||
// 重新加载当前集数的episodes
|
||||
const newEpisodes = fallbackSource.episodes || [];
|
||||
if (newEpisodes.length > currentEpisodeIndex) {
|
||||
const mappedEpisodes = newEpisodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
}));
|
||||
|
||||
set({
|
||||
episodes: mappedEpisodes,
|
||||
isLoading: false, // 让Video组件重新渲染
|
||||
});
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[VIDEO_ERROR] Successfully switched to fallback source in ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
logger.info(`[VIDEO_ERROR] New episode URL: ${newEpisodes[currentEpisodeIndex].substring(0, 100)}...`);
|
||||
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "已切换播放源",
|
||||
text2: `正在使用 ${fallbackSource.source_name}`
|
||||
});
|
||||
} else {
|
||||
logger.error(`[VIDEO_ERROR] Fallback source doesn't have episode ${currentEpisodeIndex + 1}`);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[VIDEO_ERROR] Failed to switch to fallback source:`, error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default usePlayerStore;
|
||||
|
||||
export const selectCurrentEpisode = (state: PlayerState) => {
|
||||
if (state.episodes.length > state.currentEpisodeIndex) {
|
||||
return state.episodes[state.currentEpisodeIndex];
|
||||
// 增强数据安全性检查
|
||||
if (
|
||||
state.episodes &&
|
||||
Array.isArray(state.episodes) &&
|
||||
state.episodes.length > 0 &&
|
||||
state.currentEpisodeIndex >= 0 &&
|
||||
state.currentEpisodeIndex < state.episodes.length
|
||||
) {
|
||||
const episode = state.episodes[state.currentEpisodeIndex];
|
||||
// 确保episode有有效的URL
|
||||
if (episode && episode.url && episode.url.trim() !== "") {
|
||||
return episode;
|
||||
} else {
|
||||
// 仅在调试模式下打印
|
||||
if (__DEV__) {
|
||||
logger.debug(`[PERF] selectCurrentEpisode - episode found but invalid URL: ${episode?.url}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 仅在调试模式下打印
|
||||
if (__DEV__) {
|
||||
logger.debug(`[PERF] selectCurrentEpisode - no valid episode: episodes.length=${state.episodes?.length}, currentIndex=${state.currentEpisodeIndex}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { remoteControlService } from '@/services/remoteControlService';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RemoteControlStore');
|
||||
|
||||
interface RemoteControlState {
|
||||
isServerRunning: boolean;
|
||||
@@ -30,23 +33,23 @@ export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
|
||||
}
|
||||
remoteControlService.init({
|
||||
onMessage: (message: string) => {
|
||||
console.log('[RemoteControlStore] Received message:', message);
|
||||
logger.debug('Received message:', message);
|
||||
const currentState = get();
|
||||
// Use the current targetPage from the store
|
||||
set({ lastMessage: message, targetPage: currentState.targetPage });
|
||||
},
|
||||
onHandshake: () => {
|
||||
console.log('[RemoteControlStore] Handshake successful');
|
||||
logger.debug('Handshake successful');
|
||||
set({ isModalVisible: false })
|
||||
},
|
||||
});
|
||||
try {
|
||||
const url = await remoteControlService.startServer();
|
||||
console.log(`[RemoteControlStore] Server started, URL: ${url}`);
|
||||
logger.info('Server started, URL:', url);
|
||||
set({ isServerRunning: true, serverUrl: url, error: null });
|
||||
} catch {
|
||||
const errorMessage = '启动失败,请强制退应用后重试。';
|
||||
console.info('[RemoteControlStore] Failed to start server:', errorMessage);
|
||||
logger.error('Failed to start server:', errorMessage);
|
||||
set({ error: errorMessage });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,10 @@ import { create } from "zustand";
|
||||
import { SettingsManager } from "@/services/storage";
|
||||
import { api, ServerConfig } from "@/services/api";
|
||||
import { storageConfig } from "@/services/storageConfig";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('SettingsStore');
|
||||
|
||||
interface SettingsState {
|
||||
apiBaseUrl: string;
|
||||
@@ -65,7 +69,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
}
|
||||
} catch (error) {
|
||||
set({ serverConfig: null });
|
||||
console.info("Failed to fetch server config:", error);
|
||||
logger.error("Failed to fetch server config:", error);
|
||||
} finally {
|
||||
set({ isLoadingServerConfig: false });
|
||||
}
|
||||
@@ -76,7 +80,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
setVideoSource: (config) => set({ videoSource: config }),
|
||||
saveSettings: async () => {
|
||||
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
|
||||
|
||||
const currentSettings = await SettingsManager.get()
|
||||
const currentApiBaseUrl = currentSettings.apiBaseUrl;
|
||||
let processedApiBaseUrl = apiBaseUrl.trim();
|
||||
if (processedApiBaseUrl.endsWith("/")) {
|
||||
processedApiBaseUrl = processedApiBaseUrl.slice(0, -1);
|
||||
@@ -102,6 +107,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
remoteInputEnabled,
|
||||
videoSource,
|
||||
});
|
||||
if ( currentApiBaseUrl !== processedApiBaseUrl) {
|
||||
await AsyncStorage.setItem('authCookies', '');
|
||||
}
|
||||
api.setBaseUrl(processedApiBaseUrl);
|
||||
// Also update the URL in the state so the input field shows the processed URL
|
||||
set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl });
|
||||
|
||||
@@ -2,6 +2,9 @@ import { create } from 'zustand';
|
||||
import updateService from '../services/updateService';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('UpdateStore');
|
||||
|
||||
interface UpdateState {
|
||||
// 状态
|
||||
@@ -151,7 +154,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
// 安装开始后,关闭弹窗
|
||||
set({ showUpdateModal: false });
|
||||
} catch (error) {
|
||||
console.info('安装失败:', error);
|
||||
logger.error('安装失败:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : '安装失败',
|
||||
});
|
||||
@@ -200,6 +203,6 @@ export const initUpdateStore = async () => {
|
||||
skipVersion: skipVersion || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.info('初始化更新存储失败:', error);
|
||||
logger.error('初始化更新存储失败:', error);
|
||||
}
|
||||
};
|
||||
149
utils/Logger.ts
Normal file
149
utils/Logger.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 统一日志管理器
|
||||
* 在开发环境输出完整日志,生产环境移除所有日志代码
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
interface LoggerOptions {
|
||||
tag?: string;
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
class LoggerClass {
|
||||
private minLevel: LogLevel = LogLevel.DEBUG;
|
||||
|
||||
/**
|
||||
* 设置最小日志级别
|
||||
*/
|
||||
setMinLevel(level: LogLevel): void {
|
||||
if (__DEV__) {
|
||||
this.minLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志输出
|
||||
*/
|
||||
private formatMessage(level: string, tag: string | undefined, message: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
const timestamp = new Date().toISOString().substr(11, 12);
|
||||
const prefix = tag ? `[${timestamp}][${level}][${tag}]` : `[${timestamp}][${level}]`;
|
||||
|
||||
switch (level) {
|
||||
case 'DEBUG':
|
||||
console.log(prefix, message, ...args);
|
||||
break;
|
||||
case 'INFO':
|
||||
console.info(prefix, message, ...args);
|
||||
break;
|
||||
case 'WARN':
|
||||
console.warn(prefix, message, ...args);
|
||||
break;
|
||||
case 'ERROR':
|
||||
console.error(prefix, message, ...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试级别日志
|
||||
*/
|
||||
debug(message: any, ...args: any[]): void;
|
||||
debug(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
debug(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.DEBUG) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('DEBUG', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('DEBUG', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息级别日志
|
||||
*/
|
||||
info(message: any, ...args: any[]): void;
|
||||
info(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
info(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.INFO) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('INFO', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('INFO', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告级别日志
|
||||
*/
|
||||
warn(message: any, ...args: any[]): void;
|
||||
warn(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
warn(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.WARN) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('WARN', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('WARN', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误级别日志
|
||||
*/
|
||||
error(message: any, ...args: any[]): void;
|
||||
error(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
error(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.ERROR) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('ERROR', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('ERROR', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带标签的日志实例
|
||||
*/
|
||||
withTag(tag: string): LoggerClass {
|
||||
const taggedLogger = new LoggerClass();
|
||||
taggedLogger.minLevel = this.minLevel;
|
||||
|
||||
const originalDebug = taggedLogger.debug.bind(taggedLogger);
|
||||
const originalInfo = taggedLogger.info.bind(taggedLogger);
|
||||
const originalWarn = taggedLogger.warn.bind(taggedLogger);
|
||||
const originalError = taggedLogger.error.bind(taggedLogger);
|
||||
|
||||
taggedLogger.debug = (message: any, ...args: any[]) => originalDebug({ tag }, message, ...args);
|
||||
taggedLogger.info = (message: any, ...args: any[]) => originalInfo({ tag }, message, ...args);
|
||||
taggedLogger.warn = (message: any, ...args: any[]) => originalWarn({ tag }, message, ...args);
|
||||
taggedLogger.error = (message: any, ...args: any[]) => originalError({ tag }, message, ...args);
|
||||
|
||||
return taggedLogger;
|
||||
}
|
||||
}
|
||||
|
||||
export const Logger = new LoggerClass();
|
||||
export default Logger;
|
||||
25
yarn.lock
25
yarn.lock
@@ -3064,6 +3064,11 @@ babel-plugin-transform-flow-enums@^0.0.2:
|
||||
dependencies:
|
||||
"@babel/plugin-syntax-flow" "^7.12.1"
|
||||
|
||||
babel-plugin-transform-remove-console@^6.9.4:
|
||||
version "6.9.4"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
|
||||
integrity sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==
|
||||
|
||||
babel-preset-current-node-syntax@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz#9a929eafece419612ef4ae4f60b1862ebad8ef30"
|
||||
@@ -4587,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"
|
||||
@@ -7770,7 +7780,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.2:
|
||||
kleur "^3.0.3"
|
||||
sisteransi "^1.0.5"
|
||||
|
||||
prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
|
||||
prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -7939,6 +7949,19 @@ react-native-helmet-async@2.0.4:
|
||||
react-fast-compare "^3.2.2"
|
||||
shallowequal "^1.1.0"
|
||||
|
||||
react-native-iphone-x-helper@^1.0.3:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010"
|
||||
integrity sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg==
|
||||
|
||||
react-native-keyboard-aware-scroll-view@^0.9.5:
|
||||
version "0.9.5"
|
||||
resolved "https://registry.yarnpkg.com/react-native-keyboard-aware-scroll-view/-/react-native-keyboard-aware-scroll-view-0.9.5.tgz#e2e9665d320c188e6b1f22f151b94eb358bf9b71"
|
||||
integrity sha512-XwfRn+T/qBH9WjTWIBiJD2hPWg0yJvtaEw6RtPCa5/PYHabzBaWxYBOl0usXN/368BL1XktnZPh8C2lmTpOREA==
|
||||
dependencies:
|
||||
prop-types "^15.6.2"
|
||||
react-native-iphone-x-helper "^1.0.3"
|
||||
|
||||
react-native-media-console@*:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native-media-console/-/react-native-media-console-2.2.4.tgz#76a232cdcb645cfdb25bacddee514f360eb4947d"
|
||||
|
||||
Reference in New Issue
Block a user