feat(player): enhance video playback with SSL error fallback and performance optimizations

- Add comprehensive SSL certificate error detection and automatic source switching
- Implement smart video source fallback strategy with failed source tracking
- Enhance video component with optimized event handlers and useCallback patterns
- Add explicit playAsync() call in onLoad to improve auto-play reliability
- Integrate performance monitoring with detailed logging throughout playback chain
- Optimize Video component props with useMemo and custom useVideoHandlers hook
- Add source matching fixes for fallback scenarios in DetailStore
- Enhance error handling with user-friendly messages and recovery strategies
This commit is contained in:
zimplexing
2025-08-15 22:41:18 +08:00
parent 172815f926
commit 836285dbd5
7 changed files with 761 additions and 116 deletions

View File

@@ -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,55 @@ 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";
// 优化的加载动画组件
const LoadingContainer = memo(
({ style, currentEpisode }: { style: any; currentEpisode: { url: string; title: string } | undefined }) => {
console.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 +110,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();
console.info(`[PERF] PlayScreen useEffect START - source: ${source}, id: ${id}, title: ${title}`);
setVideoRef(videoRef);
if (source && id && title) {
console.info(`[PERF] Calling loadVideo with episodeIndex: ${episodeIndex}, position: ${position}`);
loadVideo({ source, id, episodeIndex, position, title });
} else {
console.info(`[PERF] Missing required params - source: ${!!source}, id: ${!!id}, title: ${!!title}`);
}
const perfEnd = performance.now();
console.info(`[PERF] PlayScreen useEffect END - took ${(perfEnd - perfStart).toFixed(2)}ms`);
return () => {
console.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 +172,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 +210,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 +247,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,
},
});
};

131
hooks/useVideoHandlers.ts Normal file
View 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,
};
};

View File

@@ -204,7 +204,8 @@ 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[]> {

View File

@@ -10,21 +10,33 @@ export const getResolutionFromM3U8 = async (
url: string,
signal?: AbortSignal
): Promise<string | null> => {
const perfStart = performance.now();
console.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();
console.info(`[PERF] M3U8 resolution detection CACHED - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${cachedEntry.resolution}`);
return cachedEntry.resolution;
}
if (!url.toLowerCase().endsWith(".m3u8")) {
console.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();
console.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 +54,9 @@ export const getResolutionFromM3U8 = async (
}
}
}
const parseEnd = performance.now();
console.info(`[PERF] M3U8 parsing took ${(parseEnd - parseStart).toFixed(2)}ms, lines: ${lines.length}`);
// 2. Store result in cache
resolutionCache[url] = {
@@ -49,8 +64,13 @@ export const getResolutionFromM3U8 = async (
timestamp: Date.now(),
};
const perfEnd = performance.now();
console.info(`[PERF] M3U8 resolution detection COMPLETE - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${resolutionString}`);
return resolutionString;
} catch {
} catch (error) {
const perfEnd = performance.now();
console.info(`[PERF] M3U8 resolution detection ERROR - took ${(perfEnd - perfStart).toFixed(2)}ms, error: ${error}`);
return null;
}
};

View File

@@ -59,8 +59,16 @@ export class PlayerSettingsManager {
}
static async get(source: string, id: string): Promise<PlayerSettings | null> {
const perfStart = performance.now();
console.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();
console.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> {
@@ -165,8 +173,12 @@ export class PlayRecordManager {
}
static async getAll(): Promise<Record<string, PlayRecord>> {
const perfStart = performance.now();
const storageType = this.getStorageType();
console.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) : {};
@@ -175,7 +187,13 @@ export class PlayRecordManager {
return {};
}
} else {
const apiStart = performance.now();
console.info(`[PERF] API getPlayRecords START`);
apiRecords = await api.getPlayRecords();
const apiEnd = performance.now();
console.info(`[PERF] API getPlayRecords END - took ${(apiEnd - apiStart).toFixed(2)}ms, records: ${Object.keys(apiRecords).length}`);
}
const localSettings = await PlayerSettingsManager.getAll();
@@ -186,6 +204,10 @@ export class PlayRecordManager {
...localSettings[key],
};
}
const perfEnd = performance.now();
console.info(`[PERF] PlayRecordManager.getAll END - took ${(perfEnd - perfStart).toFixed(2)}ms, total records: ${Object.keys(mergedRecords).length}`);
return mergedRecords;
}
@@ -207,9 +229,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();
console.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();
console.info(`[PERF] PlayRecordManager.get END - took ${(perfEnd - perfStart).toFixed(2)}ms, found: ${!!result}`);
return result;
}
static async remove(source: string, id: string): Promise<void> {

View File

@@ -16,11 +16,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 +36,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();
console.info(`[PERF] DetailStore.init START - q: ${q}, preferredSource: ${preferredSource}, id: ${id}`);
const { controller: oldController } = get();
if (oldController) {
oldController.abort();
@@ -55,9 +62,13 @@ const useDetailStore = create<DetailState>((set, get) => ({
const { videoSource } = useSettingsStore.getState();
const processAndSetResults = async (results: SearchResult[], merge = false) => {
const resolutionStart = performance.now();
console.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);
@@ -67,9 +78,14 @@ const useDetailStore = create<DetailState>((set, get) => ({
console.info(`Failed to get resolution for ${searchResult.source_name}`, e);
}
}
const m3u8End = performance.now();
console.info(`[PERF] M3U8 resolution for ${searchResult.source_name}: ${(m3u8End - m3u8Start).toFixed(2)}ms (${resolution || 'failed'})`);
return { ...searchResult, resolution };
})
);
const resolutionEnd = performance.now();
console.info(`[PERF] Resolution detection COMPLETE - took ${(resolutionEnd - resolutionStart).toFixed(2)}ms`);
if (signal.aborted) return;
@@ -93,59 +109,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();
console.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;
console.error(`[ERROR] API searchVideo (preferred) FAILED - source: ${preferredSource}, error:`, error);
}
const searchPreferredEnd = performance.now();
console.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) {
console.info(`[SUCCESS] Preferred source "${preferredSource}" found ${preferredResult.length} results for "${q}"`);
await processAndSetResults(preferredResult, false);
set({ loading: false });
} else {
// 降级策略preferred source失败时立即尝试所有源
if (preferredSearchError) {
console.warn(`[FALLBACK] Preferred source "${preferredSource}" failed with error, trying all sources immediately`);
} else {
console.warn(`[FALLBACK] Preferred source "${preferredSource}" returned 0 results for "${q}", trying all sources immediately`);
}
// 立即尝试所有源,不再依赖后台搜索
const fallbackStart = performance.now();
console.info(`[PERF] FALLBACK search (all sources) START - query: "${q}"`);
try {
const { results: allResults } = await api.searchVideos(q);
const fallbackEnd = performance.now();
console.info(`[PERF] FALLBACK search END - took ${(fallbackEnd - fallbackStart).toFixed(2)}ms, total results: ${allResults.length}`);
const filteredResults = allResults.filter(item => item.title === q);
console.info(`[FALLBACK] Filtered results: ${filteredResults.length} matches for "${q}"`);
if (filteredResults.length > 0) {
console.info(`[SUCCESS] FALLBACK search found results, proceeding with ${filteredResults[0].source_name}`);
await processAndSetResults(filteredResults, false);
set({ loading: false });
} else {
console.error(`[ERROR] FALLBACK search found no matching results for "${q}"`);
set({
error: `未找到 "${q}" 的播放源,请检查标题或稍后重试`,
loading: false
});
}
} catch (fallbackError) {
console.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();
console.info(`[PERF] API searchVideos (background) START`);
try {
const { results: allResults } = await api.searchVideos(q);
const searchAllEnd = performance.now();
console.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) {
console.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();
console.info(`[PERF] API getResources START - query: "${q}"`);
try {
const allResources = await api.getResources(signal);
const resourcesEnd = performance.now();
console.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);
console.info(`[PERF] Enabled resources: ${enabledResources.length}/${allResources.length}`);
if (enabledResources.length === 0) {
console.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();
console.info(`[PERF] API searchVideo (${resource.name}) took ${(searchEnd - searchStart).toFixed(2)}ms, results: ${results.length}`);
if (results.length > 0) {
totalResults += results.length;
console.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;
console.info(`[SUCCESS] First result found from "${resource.name}", stopping loading indicator`);
}
} else {
console.warn(`[WARN] Source "${resource.name}" returned 0 results for "${q}"`);
}
} catch (error) {
console.error(`[ERROR] Failed to fetch from ${resource.name}:`, error);
}
});
await Promise.all(searchPromises);
// 检查是否找到任何结果
if (totalResults === 0) {
console.error(`[ERROR] All sources returned 0 results for "${q}"`);
set({
error: `未找到 "${q}" 的播放源,请尝试其他关键词或稍后重试`,
loading: false
});
} else {
console.info(`[SUCCESS] Standard search completed, total results: ${totalResults}`);
}
} catch (resourceError) {
console.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) {
console.error(`[ERROR] All search attempts completed but no results found for "${q}"`);
set({ error: `未找到 "${q}" 的播放源,请检查标题拼写或稍后重试` });
} else if (finalState.searchResults.length > 0) {
console.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;
console.info(`[INFO] Checking favorite status for source: ${source}, id: ${id}`);
try {
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
set({ isFavorited });
console.info(`[INFO] Favorite status: ${isFavorited}`);
} catch (favoriteError) {
console.warn(`[WARN] Failed to check favorite status:`, favoriteError);
}
} else {
console.warn(`[WARN] No detail found after all search attempts for "${q}"`);
}
const favoriteCheckEnd = performance.now();
console.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 : "获取数据失败" });
console.error(`[ERROR] DetailStore.init caught unexpected error:`, e);
const errorMessage = e instanceof Error ? e.message : "获取数据失败";
set({ error: `搜索失败:${errorMessage}` });
} else {
console.info(`[INFO] DetailStore.init aborted by user`);
}
} finally {
if (!signal.aborted) {
set({ loading: false, allSourcesLoaded: true });
console.info(`[INFO] DetailStore.init cleanup completed`);
}
const perfEnd = performance.now();
console.info(`[PERF] DetailStore.init COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
}
},
@@ -178,6 +340,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);
console.warn(`[SOURCE_FAILED] Marking source "${source}" as failed due to: ${reason}`);
console.info(`[SOURCE_FAILED] Total failed sources: ${newFailedSources.size}`);
set({ failedSources: newFailedSources });
},
getNextAvailableSource: (currentSource: string, episodeIndex: number) => {
const { searchResults, failedSources } = get();
console.info(`[SOURCE_SELECTION] Looking for alternative to "${currentSource}" for episode ${episodeIndex + 1}`);
console.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
);
console.info(`[SOURCE_SELECTION] Available sources: ${availableSources.length}`);
availableSources.forEach(source => {
console.info(`[SOURCE_SELECTION] - ${source.source} (${source.source_name}): ${source.episodes?.length || 0} episodes`);
});
if (availableSources.length === 0) {
console.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];
console.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;

View File

@@ -54,6 +54,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 +81,156 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
const perfStart = performance.now();
console.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) {
console.info(`[INFO] Using existing detail source "${detail.source}" to get episodes`);
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
} else {
console.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;
console.info(`[PERF] Detail check - needsInit: ${needsDetailInit}, hasDetail: ${!!detail}, episodesCount: ${episodes?.length || 0}`);
if (needsDetailInit) {
const detailInitStart = performance.now();
console.info(`[PERF] DetailStore.init START - ${title}`);
await useDetailStore.getState().init(title, source, id);
const detailInitEnd = performance.now();
console.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");
console.error(`[ERROR] Detail not found after initialization for "${title}" (source: ${source}, id: ${id})`);
// 检查DetailStore的错误状态
const detailStoreState = useDetailStore.getState();
if (detailStoreState.error) {
console.error(`[ERROR] DetailStore error: ${detailStoreState.error}`);
set({
isLoading: false,
// 可以选择在这里设置一个错误状态但playerStore可能没有error字段
});
} else {
console.error(`[ERROR] DetailStore init completed but no detail found and no error reported`);
set({ isLoading: false });
}
return;
}
// 使用DetailStore找到的实际source来获取episodes而不是原始的preferredSource
console.info(`[INFO] Using actual source "${detail.source}" instead of preferred source "${source}"`);
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
if (!episodes || episodes.length === 0) {
console.error(`[ERROR] No episodes found for "${title}" from source "${detail.source}" (${detail.source_name})`);
// 尝试从searchResults中直接获取episodes
const detailStoreState = useDetailStore.getState();
console.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) {
console.info(`[FALLBACK] Using alternative source "${sourceWithEpisodes.source}" with ${sourceWithEpisodes.episodes.length} episodes`);
episodes = sourceWithEpisodes.episodes;
// 更新detail为有episodes的source
detail = sourceWithEpisodes;
} else {
console.error(`[ERROR] No source with episodes found in searchResults`);
set({ isLoading: false });
return;
}
}
console.info(`[SUCCESS] Detail and episodes loaded - source: ${detail.source_name}, episodes: ${episodes.length}`);
} else {
console.info(`[PERF] Skipping DetailStore.init - using cached data`);
// 即使是缓存的数据也要确保使用正确的source获取episodes
if (detail && detail.source && detail.source !== source) {
console.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) {
console.warn(`[WARN] Cached detail source "${detail.source}" has no episodes, trying provided source "${source}"`);
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
}
}
}
// 最终验证确保我们有有效的detail和episodes数据
if (!detail) {
console.error(`[ERROR] Final check failed: detail is null`);
set({ isLoading: false });
return;
}
if (!episodes || episodes.length === 0) {
console.error(`[ERROR] Final check failed: no episodes available for source "${detail.source}" (${detail.source_name})`);
set({ isLoading: false });
return;
}
console.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();
console.info(`[PERF] Storage operations START`);
const playRecord = await PlayRecordManager.get(detail!.source, detail!.id.toString());
const storagePlayRecordEnd = performance.now();
console.info(`[PERF] PlayRecordManager.get took ${(storagePlayRecordEnd - storageStart).toFixed(2)}ms`);
const playerSettings = await PlayerSettingsManager.get(detail!.source, detail!.id.toString());
const storageEnd = performance.now();
console.info(`[PERF] PlayerSettingsManager.get took ${(storageEnd - storagePlayRecordEnd).toFixed(2)}ms`);
console.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();
console.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();
console.info(`[PERF] PlayerStore.loadVideo COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
} catch (error) {
console.info("Failed to load play record", error);
set({ isLoading: false });
const perfEnd = performance.now();
console.info(`[PERF] PlayerStore.loadVideo ERROR - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
}
},
@@ -352,12 +465,105 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
outroStartTime: undefined,
});
},
handleVideoError: async (errorType: 'ssl' | 'network' | 'other', failedUrl: string) => {
const perfStart = performance.now();
console.error(`[VIDEO_ERROR] Handling ${errorType} error for URL: ${failedUrl}`);
const detailStoreState = useDetailStore.getState();
const { detail } = detailStoreState;
const { currentEpisodeIndex } = get();
if (!detail) {
console.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) {
console.error(`[VIDEO_ERROR] No fallback sources available for episode ${currentEpisodeIndex + 1}`);
Toast.show({
type: "error",
text1: "播放失败",
text2: "所有播放源都不可用,请稍后重试"
});
set({ isLoading: false });
return;
}
console.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();
console.info(`[VIDEO_ERROR] Successfully switched to fallback source in ${(perfEnd - perfStart).toFixed(2)}ms`);
console.info(`[VIDEO_ERROR] New episode URL: ${newEpisodes[currentEpisodeIndex].substring(0, 100)}...`);
Toast.show({
type: "success",
text1: "已切换播放源",
text2: `正在使用 ${fallbackSource.source_name}`
});
} else {
console.error(`[VIDEO_ERROR] Fallback source doesn't have episode ${currentEpisodeIndex + 1}`);
set({ isLoading: false });
}
} catch (error) {
console.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__) {
console.info(`[PERF] selectCurrentEpisode - episode found but invalid URL: ${episode?.url}`);
}
}
} else {
// 仅在调试模式下打印
if (__DEV__) {
console.info(`[PERF] selectCurrentEpisode - no valid episode: episodes.length=${state.episodes?.length}, currentIndex=${state.currentEpisodeIndex}`);
}
}
return undefined;
};