Refactor color scheme handling to use a fixed 'dark' theme and implement SourceSelectionModal for source management in the player

This commit is contained in:
zimplexing
2025-07-08 20:57:38 +08:00
parent b238ffe3ba
commit 5043b33222
13 changed files with 224 additions and 94 deletions

View File

@@ -3,7 +3,7 @@ import { useFonts } from "expo-font";
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen"; import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react"; import { useEffect } from "react";
import { Platform, useColorScheme } from "react-native"; import { Platform } from "react-native";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
@@ -11,7 +11,7 @@ import { useSettingsStore } from "@/stores/settingsStore";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
export default function RootLayout() { export default function RootLayout() {
const colorScheme = useColorScheme() ?? "dark"; const colorScheme = "dark";
const [loaded, error] = useFonts({ const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
}); });

View File

@@ -5,7 +5,6 @@ import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api"; import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv"; import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from "expo-router"; import { useFocusEffect, useRouter } from "expo-router";
import { useColorScheme } from "react-native";
import { Search, Settings } from "lucide-react-native"; import { Search, Settings } from "lucide-react-native";
import { SettingsModal } from "@/components/SettingsModal"; import { SettingsModal } from "@/components/SettingsModal";
import { StyledButton } from "@/components/StyledButton"; import { StyledButton } from "@/components/StyledButton";
@@ -18,7 +17,7 @@ const ITEM_WIDTH = width / NUM_COLUMNS - 24;
export default function HomeScreen() { export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const colorScheme = useColorScheme(); const colorScheme = "dark";
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const { const {

View File

@@ -6,6 +6,7 @@ import { useKeepAwake } from "expo-keep-awake";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { PlayerControls } from "@/components/PlayerControls"; import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal"; import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar"; import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay"; import { LoadingOverlay } from "@/components/LoadingOverlay";
@@ -30,6 +31,7 @@ export default function PlayScreen() {
isLoading, isLoading,
showControls, showControls,
showEpisodeModal, showEpisodeModal,
showSourceModal,
showNextEpisodeOverlay, showNextEpisodeOverlay,
initialPosition, initialPosition,
setVideoRef, setVideoRef,
@@ -40,6 +42,7 @@ export default function PlayScreen() {
handlePlaybackStatusUpdate, handlePlaybackStatusUpdate,
setShowControls, setShowControls,
setShowEpisodeModal, setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay, setShowNextEpisodeOverlay,
reset, reset,
} = usePlayerStore(); } = usePlayerStore();
@@ -69,7 +72,15 @@ export default function PlayScreen() {
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction); const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove(); return () => backHandler.remove();
}, [showControls, showEpisodeModal, setShowControls, setShowEpisodeModal, router]); }, [
showControls,
showEpisodeModal,
showSourceModal,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
router,
]);
if (!detail && isLoading) { if (!detail && isLoading) {
return ( return (
@@ -111,6 +122,7 @@ export default function PlayScreen() {
</TouchableOpacity> </TouchableOpacity>
<EpisodeSelectionModal /> <EpisodeSelectionModal />
<SourceSelectionModal />
</ThemedView> </ThemedView>
); );
} }

View File

@@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Text, Keyboard, useColorScheme } from "react-native"; import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Text, Keyboard } from "react-native";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import VideoCard from "@/components/VideoCard.tv"; import VideoCard from "@/components/VideoCard.tv";
@@ -13,7 +13,7 @@ export default function SearchScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const textInputRef = useRef<TextInput>(null); const textInputRef = useRef<TextInput>(null);
const colorScheme = useColorScheme(); const colorScheme = "dark"; // Replace with useColorScheme() if needed
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
useEffect(() => { useEffect(() => {

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native"; import { View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { AVPlaybackStatus } from "expo-av"; import { AVPlaybackStatus } from "expo-av";
import { ArrowLeft, Pause, Play, SkipForward, List, ChevronsRight, ChevronsLeft } from "lucide-react-native"; import { ArrowLeft, Pause, Play, SkipForward, List, ChevronsRight, ChevronsLeft, Tv } from "lucide-react-native";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from "@/components/MediaButton"; import { MediaButton } from "@/components/MediaButton";
@@ -18,6 +18,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
const { const {
detail, detail,
currentEpisodeIndex, currentEpisodeIndex,
currentSourceIndex,
status, status,
isSeeking, isSeeking,
seekPosition, seekPosition,
@@ -26,11 +27,14 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
togglePlayPause, togglePlayPause,
playEpisode, playEpisode,
setShowEpisodeModal, setShowEpisodeModal,
setShowSourceModal,
} = usePlayerStore(); } = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || ""; const videoTitle = detail?.videoInfo?.title || "";
const currentEpisode = detail?.episodes[currentEpisodeIndex]; const currentEpisode = detail?.episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title; const currentEpisodeTitle = currentEpisode?.title;
const currentSource = detail?.sources[currentSourceIndex];
const currentSourceName = currentSource?.source_name;
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1; const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => { const formatTime = (milliseconds: number) => {
@@ -51,7 +55,8 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
<View style={styles.controlsOverlay}> <View style={styles.controlsOverlay}>
<View style={styles.topControls}> <View style={styles.topControls}>
<Text style={styles.controlTitle}> <Text style={styles.controlTitle}>
{videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""} {videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}{" "}
{currentSourceName ? `(${currentSourceName})` : ""}
</Text> </Text>
</View> </View>
@@ -99,6 +104,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
<MediaButton onPress={() => setShowEpisodeModal(true)}> <MediaButton onPress={() => setShowEpisodeModal(true)}>
<List color="white" size={24} /> <List color="white" size={24} />
</MediaButton> </MediaButton>
<MediaButton onPress={() => setShowSourceModal(true)}>
<Tv color="white" size={24} />
</MediaButton>
</View> </View>
</View> </View>
</View> </View>

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Modal, View, Text, TextInput, StyleSheet, useColorScheme } from "react-native"; import { Modal, View, Text, TextInput, StyleSheet } from "react-native";
import { ThemedText } from "./ThemedText"; import { ThemedText } from "./ThemedText";
import { ThemedView } from "./ThemedView"; import { ThemedView } from "./ThemedView";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
@@ -9,7 +9,7 @@ export const SettingsModal: React.FC = () => {
const { isModalVisible, hideModal, apiBaseUrl, setApiBaseUrl, saveSettings, loadSettings } = useSettingsStore(); const { isModalVisible, hideModal, apiBaseUrl, setApiBaseUrl, saveSettings, loadSettings } = useSettingsStore();
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const colorScheme = useColorScheme(); const colorScheme = "dark"; // Replace with useColorScheme() if needed
const inputRef = useRef<TextInput>(null); const inputRef = useRef<TextInput>(null);
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,78 @@
import React from "react";
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
import { StyledButton } from "./StyledButton";
import usePlayerStore from "@/stores/playerStore";
export const SourceSelectionModal: React.FC = () => {
const { showSourceModal, sources, currentSourceIndex, switchSource, setShowSourceModal } = usePlayerStore();
const onSelectSource = (index: number) => {
if (index !== currentSourceIndex) {
switchSource(index);
}
setShowSourceModal(false);
};
const onClose = () => {
setShowSourceModal(false);
};
return (
<Modal visible={showSourceModal} transparent={true} animationType="slide" onRequestClose={onClose}>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<FlatList
data={sources}
numColumns={3}
contentContainerStyle={styles.sourceList}
keyExtractor={(item, index) => `source-${item.source}-${item.id}-${index}`}
renderItem={({ item, index }) => (
<StyledButton
text={item.source_name}
onPress={() => onSelectSource(index)}
isSelected={currentSourceIndex === index}
hasTVPreferredFocus={currentSourceIndex === index}
style={styles.sourceItem}
textStyle={styles.sourceItemText}
/>
)}
/>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: 600,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 20,
},
modalTitle: {
color: "white",
marginBottom: 12,
textAlign: "center",
fontSize: 18,
fontWeight: "bold",
},
sourceList: {
justifyContent: "flex-start",
},
sourceItem: {
paddingVertical: 2,
margin: 4,
width: "31%",
},
sourceItemText: {
fontSize: 14,
},
});

View File

@@ -1,14 +1,5 @@
import React from "react"; import React from "react";
import { import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle } from "react-native";
Animated,
Pressable,
StyleSheet,
StyleProp,
ViewStyle,
PressableProps,
TextStyle,
useColorScheme,
} from "react-native";
import { ThemedText } from "./ThemedText"; import { ThemedText } from "./ThemedText";
import { Colors } from "@/constants/Colors"; import { Colors } from "@/constants/Colors";
import { useButtonAnimation } from "@/hooks/useButtonAnimation"; import { useButtonAnimation } from "@/hooks/useButtonAnimation";
@@ -31,7 +22,7 @@ export const StyledButton: React.FC<StyledButtonProps> = ({
textStyle, textStyle,
...rest ...rest
}) => { }) => {
const colorScheme = useColorScheme() ?? "dark"; const colorScheme = "dark";
const colors = Colors[colorScheme]; const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused); const animationStyle = useButtonAnimation(isFocused);

View File

@@ -1,11 +1,11 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from "react";
import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert } from 'react-native'; import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated'; import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { useRouter } from 'expo-router'; import { useRouter } from "expo-router";
import { Heart, Star, Play, Trash2 } from 'lucide-react-native'; import { Heart, Star, Play, Trash2 } from "lucide-react-native";
import { FavoriteManager, PlayRecordManager } from '@/services/storage'; import { FavoriteManager, PlayRecordManager } from "@/services/storage";
import { API, api } from '@/services/api'; import { API, api } from "@/services/api";
import { ThemedText } from '@/components/ThemedText'; import { ThemedText } from "@/components/ThemedText";
interface VideoCardProps { interface VideoCardProps {
id: string; id: string;
@@ -61,12 +61,12 @@ export default function VideoCard({
// 如果有播放进度,直接转到播放页面 // 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) { if (progress !== undefined && episodeIndex !== undefined) {
router.push({ router.push({
pathname: '/play', pathname: "/play",
params: { source, id, episodeIndex, position: playTime }, params: { source, id, episodeIndex, position: playTime },
}); });
} else { } else {
router.push({ router.push({
pathname: '/detail', pathname: "/detail",
params: { source, q: title }, params: { source, q: title },
}); });
} }
@@ -90,14 +90,14 @@ export default function VideoCard({
longPressTriggered.current = true; longPressTriggered.current = true;
// Show confirmation dialog to delete play record // Show confirmation dialog to delete play record
Alert.alert('删除观看记录', `确定要删除"${title}"的观看记录吗?`, [ Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{ {
text: '取消', text: "取消",
style: 'cancel', style: "cancel",
}, },
{ {
text: '删除', text: "删除",
style: 'destructive', style: "destructive",
onPress: async () => { onPress: async () => {
try { try {
// Delete from local storage // Delete from local storage
@@ -109,11 +109,11 @@ export default function VideoCard({
} }
// 如果没有回调函数,则使用导航刷新作为备选方案 // 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) { else if (router.canGoBack()) {
router.replace('/'); router.replace("/");
} }
} catch (error) { } catch (error) {
console.error('Failed to delete play record:', error); console.error("Failed to delete play record:", error);
Alert.alert('错误', '删除观看记录失败,请重试'); Alert.alert("错误", "删除观看记录失败,请重试");
} }
}, },
}, },
@@ -173,7 +173,7 @@ export default function VideoCard({
</View> </View>
<View style={styles.infoContainer}> <View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText> <ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && !isFocused && ( {isContinueWatching && (
<View style={styles.infoRow}> <View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}> <ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}% {episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
@@ -194,126 +194,126 @@ const styles = StyleSheet.create({
marginHorizontal: 8, marginHorizontal: 8,
}, },
pressable: { pressable: {
alignItems: 'center', alignItems: "center",
}, },
card: { card: {
width: CARD_WIDTH, width: CARD_WIDTH,
height: CARD_HEIGHT, height: CARD_HEIGHT,
borderRadius: 8, borderRadius: 8,
backgroundColor: '#222', backgroundColor: "#222",
overflow: 'hidden', overflow: "hidden",
}, },
poster: { poster: {
width: '100%', width: "100%",
height: '100%', height: "100%",
}, },
overlay: { overlay: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.3)', backgroundColor: "rgba(0,0,0,0.3)",
justifyContent: 'center', justifyContent: "center",
alignItems: 'center', alignItems: "center",
}, },
buttonRow: { buttonRow: {
position: 'absolute', position: "absolute",
top: 8, top: 8,
left: 8, left: 8,
flexDirection: 'row', flexDirection: "row",
gap: 8, gap: 8,
}, },
iconButton: { iconButton: {
padding: 4, padding: 4,
}, },
favButton: { favButton: {
position: 'absolute', position: "absolute",
top: 8, top: 8,
left: 8, left: 8,
}, },
ratingContainer: { ratingContainer: {
position: 'absolute', position: "absolute",
top: 8, top: 8,
right: 8, right: 8,
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
}, },
ratingText: { ratingText: {
color: '#FFD700', color: "#FFD700",
fontSize: 12, fontSize: 12,
fontWeight: 'bold', fontWeight: "bold",
marginLeft: 4, marginLeft: 4,
}, },
infoContainer: { infoContainer: {
width: CARD_WIDTH, width: CARD_WIDTH,
marginTop: 8, marginTop: 8,
alignItems: 'flex-start', // Align items to the start alignItems: "flex-start", // Align items to the start
marginBottom: 16, marginBottom: 16,
paddingHorizontal: 4, // Add some padding paddingHorizontal: 4, // Add some padding
}, },
infoRow: { infoRow: {
flexDirection: 'row', flexDirection: "row",
justifyContent: 'space-between', justifyContent: "space-between",
width: '100%', width: "100%",
}, },
title: { title: {
color: 'white', color: "white",
fontSize: 16, fontSize: 16,
fontWeight: 'bold', fontWeight: "bold",
textAlign: 'center', textAlign: "center",
}, },
yearBadge: { yearBadge: {
position: 'absolute', position: "absolute",
top: 8, top: 8,
right: 8, right: 8,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
}, },
sourceNameBadge: { sourceNameBadge: {
position: 'absolute', position: "absolute",
top: 8, top: 8,
left: 8, left: 8,
backgroundColor: 'rgba(0, 0, 0, 0.7)', backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
}, },
badgeText: { badgeText: {
color: 'white', color: "white",
fontSize: 12, fontSize: 12,
fontWeight: 'bold', fontWeight: "bold",
}, },
progressContainer: { progressContainer: {
position: 'absolute', position: "absolute",
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
height: 3, height: 3,
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: "rgba(0, 0, 0, 0.5)",
}, },
progressBar: { progressBar: {
height: 3, height: 3,
backgroundColor: '#ff0000', backgroundColor: "#ff0000",
}, },
continueWatchingBadge: { continueWatchingBadge: {
flexDirection: 'row', flexDirection: "row",
alignItems: 'center', alignItems: "center",
backgroundColor: 'rgba(255, 0, 0, 0.8)', backgroundColor: "rgba(255, 0, 0, 0.8)",
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 5, paddingVertical: 5,
borderRadius: 5, borderRadius: 5,
}, },
continueWatchingText: { continueWatchingText: {
color: 'white', color: "white",
marginLeft: 5, marginLeft: 5,
fontSize: 12, fontSize: 12,
fontWeight: 'bold', fontWeight: "bold",
}, },
continueLabel: { continueLabel: {
color: '#ff5252', color: "#ff5252",
fontSize: 12, fontSize: 12,
}, },
}); });

View File

@@ -1 +0,0 @@
export {useColorScheme} from 'react-native';

View File

@@ -1,8 +0,0 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@@ -3,15 +3,13 @@
* https://docs.expo.dev/guides/color-schemes/ * https://docs.expo.dev/guides/color-schemes/
*/ */
import {useColorScheme} from 'react-native';
import {Colors} from '@/constants/Colors'; import {Colors} from '@/constants/Colors';
export function useThemeColor( export function useThemeColor(
props: {light?: string; dark?: string}, props: {light?: string; dark?: string},
colorName: keyof typeof Colors.light & keyof typeof Colors.dark, colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) { ) {
const theme = useColorScheme() ?? 'dark'; const theme = 'dark';
const colorFromProps = props[theme]; const colorFromProps = props[theme];
if (colorFromProps) { if (colorFromProps) {

View File

@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { AVPlaybackStatus, Video } from "expo-av"; import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react"; import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail } from "@/services/api"; import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
import { PlayRecordManager } from "@/services/storage"; import { PlayRecordManager } from "@/services/storage";
interface Episode { interface Episode {
@@ -12,17 +12,21 @@ interface Episode {
interface VideoDetail { interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"]; videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[]; episodes: Episode[];
sources: SearchResult[];
} }
interface PlayerState { interface PlayerState {
videoRef: RefObject<Video> | null; videoRef: RefObject<Video> | null;
detail: VideoDetail | null; detail: VideoDetail | null;
episodes: Episode[]; episodes: Episode[];
sources: SearchResult[];
currentSourceIndex: number;
currentEpisodeIndex: number; currentEpisodeIndex: number;
status: AVPlaybackStatus | null; status: AVPlaybackStatus | null;
isLoading: boolean; isLoading: boolean;
showControls: boolean; showControls: boolean;
showEpisodeModal: boolean; showEpisodeModal: boolean;
showSourceModal: boolean;
showNextEpisodeOverlay: boolean; showNextEpisodeOverlay: boolean;
isSeeking: boolean; isSeeking: boolean;
seekPosition: number; seekPosition: number;
@@ -30,6 +34,7 @@ interface PlayerState {
initialPosition: number; initialPosition: number;
setVideoRef: (ref: RefObject<Video>) => void; setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>; loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
switchSource: (newSourceIndex: number) => Promise<void>;
playEpisode: (index: number) => void; playEpisode: (index: number) => void;
togglePlayPause: () => void; togglePlayPause: () => void;
seek: (duration: number) => void; seek: (duration: number) => void;
@@ -37,6 +42,7 @@ interface PlayerState {
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
setShowControls: (show: boolean) => void; setShowControls: (show: boolean) => void;
setShowEpisodeModal: (show: boolean) => void; setShowEpisodeModal: (show: boolean) => void;
setShowSourceModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void; setShowNextEpisodeOverlay: (show: boolean) => void;
reset: () => void; reset: () => void;
_seekTimeout?: NodeJS.Timeout; _seekTimeout?: NodeJS.Timeout;
@@ -46,11 +52,14 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null, videoRef: null,
detail: null, detail: null,
episodes: [], episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
showControls: false, showControls: false,
showEpisodeModal: false, showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false, showNextEpisodeOverlay: false,
isSeeking: false, isSeeking: false,
seekPosition: 0, seekPosition: 0,
@@ -65,15 +74,23 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
isLoading: true, isLoading: true,
detail: null, detail: null,
episodes: [], episodes: [],
sources: [],
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
initialPosition: position || 0, initialPosition: position || 0,
}); });
try { try {
const videoDetail = await api.getVideoDetail(source, id); const videoDetail = await api.getVideoDetail(source, id);
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` })); const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
const searchResults = await api.searchVideos(videoDetail.videoInfo.title);
const sources = searchResults.results.filter((r) => r.title === videoDetail.videoInfo.title);
const currentSourceIndex = sources.findIndex((s) => s.source === source && s.id.toString() === id);
set({ set({
detail: { videoInfo: videoDetail.videoInfo, episodes }, detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
episodes, episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex, currentEpisodeIndex: episodeIndex,
isLoading: false, isLoading: false,
}); });
@@ -83,6 +100,37 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
} }
}, },
switchSource: async (newSourceIndex: number) => {
const { sources, currentEpisodeIndex, status, detail } = get();
if (!detail || newSourceIndex < 0 || newSourceIndex >= sources.length) return;
const newSource = sources[newSourceIndex];
const position = status?.isLoaded ? status.positionMillis : 0;
set({ isLoading: true, showSourceModal: false });
try {
const videoDetail = await api.getVideoDetail(newSource.source, newSource.id.toString());
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
set({
detail: {
...detail,
videoInfo: videoDetail.videoInfo,
episodes,
},
episodes,
currentSourceIndex: newSourceIndex,
currentEpisodeIndex: currentEpisodeIndex < episodes.length ? currentEpisodeIndex : 0,
initialPosition: position,
isLoading: false,
});
} catch (error) {
console.error("Failed to switch source", error);
set({ isLoading: false });
}
},
playEpisode: (index) => { playEpisode: (index) => {
const { episodes, videoRef } = get(); const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) { if (index >= 0 && index < episodes.length) {
@@ -170,17 +218,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setLoading: (loading) => set({ isLoading: loading }), setLoading: (loading) => set({ isLoading: loading }),
setShowControls: (show) => set({ showControls: show }), setShowControls: (show) => set({ showControls: show }),
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }), setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
setShowSourceModal: (show) => set({ showSourceModal: show }),
setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }), setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }),
reset: () => { reset: () => {
set({ set({
detail: null, detail: null,
episodes: [], episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0, currentEpisodeIndex: 0,
status: null, status: null,
isLoading: true, isLoading: true,
showControls: false, showControls: false,
showEpisodeModal: false, showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false, showNextEpisodeOverlay: false,
initialPosition: 0, initialPosition: 0,
}); });