feat: enhance PlayScreen and VideoCard with improved video loading and app state handling; update player store for better episode management

This commit is contained in:
zimplexing
2025-07-16 21:26:37 +08:00
parent daba164998
commit e0aa40eea0
7 changed files with 88 additions and 41 deletions

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler, AppState, AppStateStatus } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
@@ -13,7 +13,7 @@ import { LoadingOverlay } from "@/components/LoadingOverlay";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore from "@/stores/playerStore";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
export default function PlayScreen() {
const videoRef = useRef<Video>(null);
@@ -23,16 +23,22 @@ export default function PlayScreen() {
episodeIndex: episodeIndexStr,
position: positionStr,
source: sourceStr,
id: videoId,
title: videoTitle,
} = useLocalSearchParams<{
episodeIndex: string;
position?: string;
source?: string;
id?: string;
title?: string;
}>();
const episodeIndex = parseInt(episodeIndexStr || "0", 10);
const position = positionStr ? parseInt(positionStr, 10) : undefined;
const { detail } = useDetailStore();
const source = sourceStr || detail?.source;
const id = videoId || detail?.id.toString();
const title = videoTitle || detail?.title;
const {
isLoading,
showControls,
@@ -46,17 +52,32 @@ export default function PlayScreen() {
reset,
loadVideo,
} = usePlayerStore();
const currentEpisode = usePlayerStore(selectCurrentEpisode);
useEffect(() => {
setVideoRef(videoRef);
if (source) {
loadVideo(source, episodeIndex, position);
if (source && id && title) {
loadVideo({ source, id, episodeIndex, position, title });
}
return () => {
reset(); // Reset state when component unmounts
};
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo]);
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "background" || nextAppState === "inactive") {
videoRef.current?.pauseAsync();
}
};
const subscription = AppState.addEventListener("change", handleAppStateChange);
return () => {
subscription.remove();
};
}, []);
const { onScreenPress } = useTVRemoteHandler();
@@ -83,15 +104,13 @@ export default function PlayScreen() {
);
}
const currentEpisode = detail.episodes[episodeIndex];
return (
<ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<Video
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode }}
source={{ uri: currentEpisode?.url || "" }}
usePoster
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}

View File

@@ -34,11 +34,10 @@ export default function VideoCard({
sourceName,
progress,
episodeIndex,
totalEpisodes,
onFocus,
onRecordDeleted,
api,
playTime,
playTime = 0,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
@@ -62,7 +61,7 @@ export default function VideoCard({
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex, position: playTime },
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({

View File

@@ -1,6 +1,6 @@
import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import { api, ServerConfig } from "@/services/api";
import { api } from "@/services/api";
interface AuthState {
isLoggedIn: boolean;

View File

@@ -17,7 +17,7 @@ interface DetailState {
controller: AbortController | null;
isFavorited: boolean;
init: (q: string, preferredSource?: string, id?: string) => void;
init: (q: string, preferredSource?: string, id?: string) => Promise<void>;
setDetail: (detail: SearchResultWithResolution) => void;
abort: () => void;
toggleFavorite: () => Promise<void>;

View File

@@ -119,7 +119,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
play_time: record.play_time,
};
})
.filter((record) => record.progress !== undefined && record.progress > 0 && record.progress < 1)
// .filter((record) => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false });

View File

@@ -27,7 +27,7 @@ interface PlayerState {
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, episodeIndex: number, position?: number) => Promise<void>;
loadVideo: (options: {source: string, id: string, title: string; episodeIndex: number, position?: number}) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (duration: number) => void;
@@ -48,7 +48,7 @@ interface PlayerState {
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
episodes: [],
currentEpisodeIndex: 0,
currentEpisodeIndex: -1,
status: null,
isLoading: true,
showControls: false,
@@ -65,22 +65,31 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async (source, episodeIndex, position) => {
const detail = useDetailStore.getState().detail;
const episodes = episodesSelectorBySource(source)(useDetailStore.getState());
if (!detail || !episodes || episodes.length === 0) return;
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
let detail = useDetailStore.getState().detail;
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
set({
isLoading: true,
});
if (!detail || !episodes || episodes.length === 0 || detail.title !== title) {
await useDetailStore.getState().init(title, source, id);
detail = useDetailStore.getState().detail;
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
if (!detail) {
console.info("Detail not found after initialization");
return;
}
};
try {
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
set({
isLoading: false,
currentEpisodeIndex: episodeIndex,
initialPosition: position || 0,
initialPosition: position || initialPositionFromRecord,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
@@ -94,7 +103,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}
},
playEpisode: (index) => {
playEpisode: async (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({
@@ -104,27 +113,42 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
progressPosition: 0,
seekPosition: 0,
});
videoRef?.current?.replayAsync();
}
},
togglePlayPause: () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.current?.pauseAsync();
} else {
videoRef?.current?.playAsync();
try {
await videoRef?.current?.replayAsync();
} catch (error) {
console.error("Failed to replay video:", error);
Toast.show({ type: "error", text1: "播放失败" });
}
}
},
seek: (duration) => {
togglePlayPause: async () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
try {
if (status.isPlaying) {
await videoRef?.current?.pauseAsync();
} else {
await videoRef?.current?.playAsync();
}
} catch (error) {
console.error("Failed to toggle play/pause:", error);
Toast.show({ type: "error", text1: "操作失败" });
}
}
},
seek: async (duration) => {
const { status, videoRef } = get();
if (!status?.isLoaded || !status.durationMillis) return;
const newPosition = Math.max(0, Math.min(status.positionMillis + duration, status.durationMillis));
videoRef?.current?.setPositionAsync(newPosition);
try {
await videoRef?.current?.setPositionAsync(newPosition);
} catch (error) {
console.error("Failed to seek video:", error);
Toast.show({ type: "error", text1: "快进/快退失败" });
}
set({
isSeeking: true,
@@ -202,10 +226,10 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
PlayRecordManager.save(detail.source, detail.id.toString(), {
title: detail.title,
cover: detail.poster || "",
index: currentEpisodeIndex,
index: currentEpisodeIndex + 1,
total_episodes: episodes.length,
play_time: status.positionMillis,
total_time: status.durationMillis || 0,
play_time: Math.floor(status.positionMillis / 1000),
total_time: status.durationMillis ? Math.floor(status.durationMillis / 1000) : 0,
source_name: detail.source_name,
year: detail.year || "",
...existingRecord,
@@ -282,3 +306,9 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}));
export default usePlayerStore;
export const selectCurrentEpisode = (state: PlayerState) => {
if (state.episodes.length > state.currentEpisodeIndex) {
return state.episodes[state.currentEpisodeIndex];
}
};

View File

@@ -2,7 +2,6 @@ import { create } from "zustand";
import { SettingsManager } from "@/services/storage";
import { api, ServerConfig } from "@/services/api";
import { storageConfig } from "@/services/storageConfig";
// import useHomeStore from './homeStore';
interface SettingsState {
apiBaseUrl: string;
@@ -77,7 +76,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
});
api.setBaseUrl(apiBaseUrl);
set({ isModalVisible: false });
// useHomeStore.getState().fetchInitialData();
await get().fetchServerConfig();
},
showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }),