diff --git a/app/_layout.tsx b/app/_layout.tsx index 36adf11..f592f51 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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"), }); diff --git a/app/index.tsx b/app/index.tsx index 6128a3b..68b2e86 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -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(null); const { diff --git a/app/play.tsx b/app/play.tsx index e14b75d..94e13b2 100644 --- a/app/play.tsx +++ b/app/play.tsx @@ -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() { + ); } diff --git a/app/search.tsx b/app/search.tsx index 5cd00bc..003fa03 100644 --- a/app/search.tsx +++ b/app/search.tsx @@ -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(null); const textInputRef = useRef(null); - const colorScheme = useColorScheme(); + const colorScheme = "dark"; // Replace with useColorScheme() if needed const [isInputFocused, setIsInputFocused] = useState(false); useEffect(() => { diff --git a/components/PlayerControls.tsx b/components/PlayerControls.tsx index c41ce42..90f25c1 100644 --- a/components/PlayerControls.tsx +++ b/components/PlayerControls.tsx @@ -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 = ({ showControls, se const { detail, currentEpisodeIndex, + currentSourceIndex, status, isSeeking, seekPosition, @@ -26,11 +27,14 @@ export const PlayerControls: React.FC = ({ 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 = ({ showControls, se - {videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""} + {videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}{" "} + {currentSourceName ? `(${currentSourceName})` : ""} @@ -99,6 +104,10 @@ export const PlayerControls: React.FC = ({ showControls, se setShowEpisodeModal(true)}> + + setShowSourceModal(true)}> + + diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 3e675fd..995281b 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -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(null); useEffect(() => { diff --git a/components/SourceSelectionModal.tsx b/components/SourceSelectionModal.tsx new file mode 100644 index 0000000..a35dd3e --- /dev/null +++ b/components/SourceSelectionModal.tsx @@ -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 ( + + + + 选择播放源 + `source-${item.source}-${item.id}-${index}`} + renderItem={({ item, index }) => ( + onSelectSource(index)} + isSelected={currentSourceIndex === index} + hasTVPreferredFocus={currentSourceIndex === index} + style={styles.sourceItem} + textStyle={styles.sourceItemText} + /> + )} + /> + + + + ); +}; + +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, + }, +}); diff --git a/components/StyledButton.tsx b/components/StyledButton.tsx index ec71912..751fb8a 100644 --- a/components/StyledButton.tsx +++ b/components/StyledButton.tsx @@ -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 = ({ textStyle, ...rest }) => { - const colorScheme = useColorScheme() ?? "dark"; + const colorScheme = "dark"; const colors = Colors[colorScheme]; const [isFocused, setIsFocused] = React.useState(false); const animationStyle = useButtonAnimation(isFocused); diff --git a/components/VideoCard.tv.tsx b/components/VideoCard.tv.tsx index 486060b..3fca8b5 100644 --- a/components/VideoCard.tv.tsx +++ b/components/VideoCard.tv.tsx @@ -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({ {title} - {isContinueWatching && !isFocused && ( + {isContinueWatching && ( 第{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, }, }); diff --git a/hooks/useColorScheme.ts b/hooks/useColorScheme.ts deleted file mode 100644 index fac8a01..0000000 --- a/hooks/useColorScheme.ts +++ /dev/null @@ -1 +0,0 @@ -export {useColorScheme} from 'react-native'; diff --git a/hooks/useColorScheme.web.ts b/hooks/useColorScheme.web.ts deleted file mode 100644 index 6dcd80d..0000000 --- a/hooks/useColorScheme.web.ts +++ /dev/null @@ -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'; -} diff --git a/hooks/useThemeColor.ts b/hooks/useThemeColor.ts index 3f019d1..89f62d9 100644 --- a/hooks/useThemeColor.ts +++ b/hooks/useThemeColor.ts @@ -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) { diff --git a/stores/playerStore.ts b/stores/playerStore.ts index d33d701..c3fc641 100644 --- a/stores/playerStore.ts +++ b/stores/playerStore.ts @@ -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