Files
OrionTV/stores/playerStore.ts
zimplexing 2bed3a4d00 feat: implement user authentication and logout functionality
- Added login/logout buttons to the HomeScreen and SettingsScreen.
- Integrated authentication state management using Zustand and cookies.
- Updated API to support username and password for login.
- Enhanced PlayScreen to handle video playback based on user authentication.
- Created a new detailStore to manage video details and sources.
- Refactored playerStore to utilize detailStore for episode management.
- Added sourceStore to manage video source toggling.
- Updated settingsStore to fetch server configuration.
- Improved error handling and user feedback with Toast notifications.
- Cleaned up unused code and optimized imports across components.
2025-07-14 22:55:55 +08:00

292 lines
8.4 KiB
TypeScript

import { create } from "zustand";
import Toast from "react-native-toast-message";
import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react";
import { PlayRecord, PlayRecordManager } from "@/services/storage";
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
interface Episode {
url: string;
title: string;
}
interface PlayerState {
videoRef: RefObject<Video> | null;
currentEpisodeIndex: number;
episodes: Episode[];
status: AVPlaybackStatus | null;
isLoading: boolean;
showControls: boolean;
showEpisodeModal: boolean;
showSourceModal: boolean;
showNextEpisodeOverlay: boolean;
isSeeking: boolean;
seekPosition: number;
progressPosition: number;
initialPosition: number;
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (
source: string,
episodeIndex: number,
position?: number
) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (duration: number) => void;
handlePlaybackStatusUpdate: (newStatus: AVPlaybackStatus) => void;
setLoading: (loading: boolean) => void;
setShowControls: (show: boolean) => void;
setShowEpisodeModal: (show: boolean) => void;
setShowSourceModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void;
setIntroEndTime: () => void;
setOutroStartTime: () => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
// Internal helper
_savePlayRecord: (updates?: Partial<PlayRecord>) => void;
}
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
episodes: [],
currentEpisodeIndex: 0,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
isSeeking: false,
seekPosition: 0,
progressPosition: 0,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
_seekTimeout: undefined,
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;
set({
isLoading: true,
currentEpisodeIndex: episodeIndex,
initialPosition: position || 0,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
})),
});
try {
const playRecord = await PlayRecordManager.get(
detail.source,
detail.id.toString()
);
set({
isLoading: false,
introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime,
});
} catch (error) {
console.error("Failed to load play record", error);
set({ isLoading: false });
}
},
playEpisode: (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({
currentEpisodeIndex: index,
showNextEpisodeOverlay: false,
initialPosition: 0,
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();
}
}
},
seek: (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);
set({
isSeeking: true,
seekPosition: newPosition / status.durationMillis,
});
if (get()._seekTimeout) {
clearTimeout(get()._seekTimeout);
}
const timeoutId = setTimeout(() => set({ isSeeking: false }), 1000);
set({ _seekTimeout: timeoutId });
},
setIntroEndTime: () => {
const { status, introEndTime: existingIntroEndTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingIntroEndTime) {
// Clear the time
set({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined });
Toast.show({
type: "info",
text1: "已清除片头时间",
});
} else {
// Set the time
const newIntroEndTime = status.positionMillis;
set({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片头时间已记录。",
});
}
},
setOutroStartTime: () => {
const { status, outroStartTime: existingOutroStartTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingOutroStartTime) {
// Clear the time
set({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined });
Toast.show({
type: "info",
text1: "已清除片尾时间",
});
} else {
// Set the time
if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片尾时间已记录。",
});
}
},
_savePlayRecord: (updates = {}) => {
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const existingRecord = {
introEndTime,
outroStartTime,
};
PlayRecordManager.save(detail.source, detail.id.toString(), {
title: detail.title,
poster: detail.poster || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: status.positionMillis,
total_time: status.durationMillis || 0,
source_name: detail.source_name,
...existingRecord,
...updates,
});
}
},
handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) {
if (newStatus.error) {
console.error(`Playback Error: ${newStatus.error}`);
}
set({ status: newStatus });
return;
}
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const detail = useDetailStore.getState().detail;
if (
outroStartTime &&
newStatus.durationMillis &&
newStatus.positionMillis >= newStatus.durationMillis - outroStartTime
) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update
}
}
if (detail && newStatus.durationMillis) {
get()._savePlayRecord();
const isNearEnd = newStatus.positionMillis / newStatus.durationMillis > 0.95;
if (isNearEnd && currentEpisodeIndex < episodes.length - 1 && !outroStartTime) {
set({ showNextEpisodeOverlay: true });
} else {
set({ showNextEpisodeOverlay: false });
}
}
if (newStatus.didJustFinish) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
}
const progressPosition = newStatus.durationMillis ? newStatus.positionMillis / newStatus.durationMillis : 0;
set({ status: newStatus, progressPosition });
},
setLoading: (loading) => set({ isLoading: loading }),
setShowControls: (show) => set({ showControls: show }),
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
setShowSourceModal: (show) => set({ showSourceModal: show }),
setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }),
reset: () => {
set({
episodes: [],
currentEpisodeIndex: 0,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
});
},
}));
export default usePlayerStore;