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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 { ThemedView } from "./ThemedView";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -9,7 +9,7 @@ export const SettingsModal: React.FC = () => {
const { isModalVisible, hideModal, apiBaseUrl, setApiBaseUrl, saveSettings, loadSettings } = useSettingsStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const colorScheme = useColorScheme();
const colorScheme = "dark"; // Replace with useColorScheme() if needed
const inputRef = useRef<TextInput>(null);
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 {
Animated,
Pressable,
StyleSheet,
StyleProp,
ViewStyle,
PressableProps,
TextStyle,
useColorScheme,
} from "react-native";
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle } from "react-native";
import { ThemedText } from "./ThemedText";
import { Colors } from "@/constants/Colors";
import { useButtonAnimation } from "@/hooks/useButtonAnimation";
@@ -31,7 +22,7 @@ export const StyledButton: React.FC<StyledButtonProps> = ({
textStyle,
...rest
}) => {
const colorScheme = useColorScheme() ?? "dark";
const colorScheme = "dark";
const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused);

View File

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

View File

@@ -1,7 +1,7 @@
import { create } from "zustand";
import { AVPlaybackStatus, Video } from "expo-av";
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";
interface Episode {
@@ -12,17 +12,21 @@ interface Episode {
interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[];
sources: SearchResult[];
}
interface PlayerState {
videoRef: RefObject<Video> | null;
detail: VideoDetail | null;
episodes: Episode[];
sources: SearchResult[];
currentSourceIndex: number;
currentEpisodeIndex: number;
status: AVPlaybackStatus | null;
isLoading: boolean;
showControls: boolean;
showEpisodeModal: boolean;
showSourceModal: boolean;
showNextEpisodeOverlay: boolean;
isSeeking: boolean;
seekPosition: number;
@@ -30,6 +34,7 @@ interface PlayerState {
initialPosition: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
switchSource: (newSourceIndex: number) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (duration: number) => void;
@@ -37,6 +42,7 @@ interface PlayerState {
setLoading: (loading: boolean) => void;
setShowControls: (show: boolean) => void;
setShowEpisodeModal: (show: boolean) => void;
setShowSourceModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
@@ -46,11 +52,14 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
isSeeking: false,
seekPosition: 0,
@@ -65,15 +74,23 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
isLoading: true,
detail: null,
episodes: [],
sources: [],
currentEpisodeIndex: 0,
initialPosition: position || 0,
});
try {
const videoDetail = await api.getVideoDetail(source, id);
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({
detail: { videoInfo: videoDetail.videoInfo, episodes },
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex,
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) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
@@ -170,17 +218,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
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({
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
initialPosition: 0,
});