diff --git a/app/favorites.tsx b/app/favorites.tsx index 0a83091..a8fc3b5 100644 --- a/app/favorites.tsx +++ b/app/favorites.tsx @@ -1,11 +1,12 @@ import React, { useEffect } from "react"; -import { View, FlatList, StyleSheet, ActivityIndicator } from "react-native"; +import { View, StyleSheet, ActivityIndicator } from "react-native"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; import useFavoritesStore from "@/stores/favoritesStore"; import { Favorite } from "@/services/storage"; import VideoCard from "@/components/VideoCard.tv"; import { api } from "@/services/api"; +import CustomScrollView from "@/components/CustomScrollView"; export default function FavoritesScreen() { const { favorites, loading, error, fetchFavorites } = useFavoritesStore(); @@ -14,31 +15,7 @@ export default function FavoritesScreen() { fetchFavorites(); }, [fetchFavorites]); - if (loading) { - return ( - - - - ); - } - - if (error) { - return ( - - {error} - - ); - } - - if (favorites.length === 0) { - return ( - - 暂无收藏 - - ); - } - - const renderItem = ({ item }: { item: Favorite & { key: string } }) => { + const renderItem = ({ item }: { item: Favorite & { key: string }; index: number }) => { const [source, id] = item.key.split("+"); return ( 我的收藏 - item.key} numColumns={5} - contentContainerStyle={styles.list} + loading={loading} + error={error} + emptyMessage="暂无收藏" /> ); diff --git a/app/index.tsx b/app/index.tsx index 0ba550a..34222f4 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -9,15 +9,17 @@ import { Search, Settings, LogOut, Heart } from "lucide-react-native"; import { StyledButton } from "@/components/StyledButton"; import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; import useAuthStore from "@/stores/authStore"; +import CustomScrollView from "@/components/CustomScrollView"; const NUM_COLUMNS = 5; const { width } = Dimensions.get("window"); -const ITEM_WIDTH = width / NUM_COLUMNS - 24; + +// Threshold for triggering load more data (in pixels) +const LOAD_MORE_THRESHOLD = 200; export default function HomeScreen() { const router = useRouter(); const colorScheme = "dark"; - const flatListRef = useRef(null); const [selectedTag, setSelectedTag] = useState(null); const { @@ -43,7 +45,6 @@ export default function HomeScreen() { useEffect(() => { if (selectedCategory && !selectedCategory.tags) { fetchInitialData(); - flatListRef.current?.scrollToOffset({ animated: false, offset: 0 }); } else if (selectedCategory?.tags && !selectedCategory.tag) { // Category with tags selected, but no specific tag yet. Select the first one. const defaultTag = selectedCategory.tags[0]; @@ -55,7 +56,6 @@ export default function HomeScreen() { useEffect(() => { if (selectedCategory && selectedCategory.tag) { fetchInitialData(); - flatListRef.current?.scrollToOffset({ animated: false, offset: 0 }); } }, [fetchInitialData, selectedCategory, selectedCategory.tag]); @@ -86,7 +86,7 @@ export default function HomeScreen() { ); }; - const renderContentItem = ({ item }: { item: RowItem }) => ( + const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => ( ) : ( - `${item.source}-${item.id}-${index}`} numColumns={NUM_COLUMNS} - contentContainerStyle={styles.listContent} + loading={loading} + loadingMore={loadingMore} + error={error} onEndReached={loadMoreData} - onEndReachedThreshold={0.5} + loadMoreThreshold={LOAD_MORE_THRESHOLD} + emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"} ListFooterComponent={renderFooter} - ListEmptyComponent={ - - {selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"} - - } /> )} @@ -272,7 +268,6 @@ const styles = StyleSheet.create({ }, itemContainer: { margin: 8, - width: ITEM_WIDTH, alignItems: "center", }, }); diff --git a/app/search.tsx b/app/search.tsx index 01b3edd..227186e 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, Alert, Keyboard } from "react-native"; +import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; import VideoCard from "@/components/VideoCard.tv"; @@ -12,6 +12,7 @@ import { RemoteControlModal } from "@/components/RemoteControlModal"; import { useSettingsStore } from "@/stores/settingsStore"; import { useRouter } from "expo-router"; import { Colors } from "@/constants/Colors"; +import CustomScrollView from "@/components/CustomScrollView"; export default function SearchScreen() { const [keyword, setKeyword] = useState(""); @@ -35,13 +36,13 @@ export default function SearchScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastMessage]); - useEffect(() => { - // Focus the text input when the screen loads - const timer = setTimeout(() => { - textInputRef.current?.focus(); - }, 200); - return () => clearTimeout(timer); - }, []); + // useEffect(() => { + // // Focus the text input when the screen loads + // const timer = setTimeout(() => { + // textInputRef.current?.focus(); + // }, 200); + // return () => clearTimeout(timer); + // }, []); const handleSearch = async (searchText?: string) => { const term = typeof searchText === "string" ? searchText : keyword; @@ -80,7 +81,7 @@ export default function SearchScreen() { showRemoteModal(); }; - const renderItem = ({ item }: { item: SearchResult }) => ( + const renderItem = ({ item, index }: { item: SearchResult; index: number }) => ( - textInputRef.current?.focus()} onFocus={() => setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} - onSubmitEditing={onSearchPress} - returnKeyType="search" - /> + > + + @@ -129,17 +141,13 @@ export default function SearchScreen() { {error} ) : ( - `${item.id}-${item.source}-${index}`} - numColumns={5} // Adjust based on your card size and desired layout - contentContainerStyle={styles.listContent} - ListEmptyComponent={ - - 输入关键词开始搜索 - - } + numColumns={5} + loading={loading} + error={error} + emptyMessage="输入关键词开始搜索" /> )} diff --git a/components/CustomScrollView.tsx b/components/CustomScrollView.tsx new file mode 100644 index 0000000..ab45e42 --- /dev/null +++ b/components/CustomScrollView.tsx @@ -0,0 +1,130 @@ +import React, { useState, useCallback } from "react"; +import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native"; +import { ThemedText } from "@/components/ThemedText"; + +interface CustomScrollViewProps { + data: any[]; + renderItem: ({ item, index }: { item: any; index: number }) => React.ReactNode; + numColumns?: number; + loading?: boolean; + loadingMore?: boolean; + error?: string | null; + onEndReached?: () => void; + loadMoreThreshold?: number; + emptyMessage?: string; + ListFooterComponent?: React.ComponentType | React.ReactElement | null; +} + +const { width } = Dimensions.get("window"); + +const CustomScrollView: React.FC = ({ + data, + renderItem, + numColumns = 1, + loading = false, + loadingMore = false, + error = null, + onEndReached, + loadMoreThreshold = 200, + emptyMessage = "暂无内容", + ListFooterComponent, +}) => { + const ITEM_WIDTH = numColumns > 0 ? width / numColumns - 24 : width - 24; + + const handleScroll = useCallback( + ({ nativeEvent }: { nativeEvent: any }) => { + const { layoutMeasurement, contentOffset, contentSize } = nativeEvent; + const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold; + + if (isCloseToBottom && !loadingMore && onEndReached) { + onEndReached(); + } + }, + [onEndReached, loadingMore, loadMoreThreshold] + ); + + const renderFooter = () => { + if (ListFooterComponent) { + if (React.isValidElement(ListFooterComponent)) { + return ListFooterComponent; + } else if (typeof ListFooterComponent === "function") { + const Component = ListFooterComponent as React.ComponentType; + return ; + } + return null; + } + if (loadingMore) { + return ; + } + return null; + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + {error} + + + ); + } + + if (data.length === 0) { + return ( + + {emptyMessage} + + ); + } + + return ( + + {data.length > 0 ? ( + <> + {/* Render content in a grid layout */} + {Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => ( + + {data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => ( + + {renderItem({ item, index: rowIndex * numColumns + index })} + + ))} + + ))} + {renderFooter()} + + ) : ( + + {emptyMessage} + + )} + + ); +}; + +const styles = StyleSheet.create({ + centerContainer: { + flex: 1, + paddingTop: 20, + justifyContent: "center", + alignItems: "center", + }, + listContent: { + paddingHorizontal: 16, + paddingBottom: 20, + }, + itemContainer: { + margin: 8, + alignItems: "center", + }, +}); + +export default CustomScrollView; diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx index fed6838..4802b2f 100644 --- a/components/LoginModal.tsx +++ b/components/LoginModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from "react"; -import { Modal, View, TextInput, StyleSheet, ActivityIndicator, useTVEventHandler } from "react-native"; +import { Modal, View, TextInput, StyleSheet, ActivityIndicator } from "react-native"; import { usePathname } from "expo-router"; import Toast from "react-native-toast-message"; import useAuthStore from "@/stores/authStore"; @@ -19,47 +19,24 @@ const LoginModal = () => { const [isLoading, setIsLoading] = useState(false); const usernameInputRef = useRef(null); const passwordInputRef = useRef(null); - const loginButtonRef = useRef(null); - const [focused, setFocused] = useState("username"); const pathname = usePathname(); const isSettingsPage = pathname.includes("settings"); - const tvEventHandler = (evt: any) => { - if (!evt || !isLoginModalVisible || isSettingsPage) { - return; - } - - const isUsernameVisible = serverConfig?.StorageType !== "localstorage"; - - if (evt.eventType === "down") { - if (focused === "username" && isUsernameVisible) { - passwordInputRef.current?.focus(); - } else if (focused === "password") { - loginButtonRef.current?.focus(); - } - } - - if (evt.eventType === "up") { - if (focused === "button") { - passwordInputRef.current?.focus(); - } else if (focused === "password" && isUsernameVisible) { - usernameInputRef.current?.focus(); - } - } - }; - - useTVEventHandler(tvEventHandler); - + // Focus management with better TV remote handling useEffect(() => { if (isLoginModalVisible && !isSettingsPage) { const isUsernameVisible = serverConfig?.StorageType !== "localstorage"; - setTimeout(() => { + + // Use a small delay to ensure the modal is fully rendered + const focusTimeout = setTimeout(() => { if (isUsernameVisible) { usernameInputRef.current?.focus(); } else { passwordInputRef.current?.focus(); } - }, 200); + }, 100); + + return () => clearTimeout(focusTimeout); } }, [isLoginModalVisible, serverConfig, isSettingsPage]); @@ -85,6 +62,11 @@ const LoginModal = () => { } }; + // Handle navigation between inputs using returnKeyType + const handleUsernameSubmit = () => { + passwordInputRef.current?.focus(); + }; + return ( { value={username} onChangeText={setUsername} returnKeyType="next" - onFocus={() => setFocused("username")} + onSubmitEditing={handleUsernameSubmit} + blurOnSubmit={false} /> )} { value={password} onChangeText={setPassword} returnKeyType="go" - onFocus={() => setFocused("password")} onSubmitEditing={handleLogin} /> setFocused("button")} text={isLoading ? "" : "登录"} onPress={handleLogin} disabled={isLoading} style={styles.button} + hasTVPreferredFocus={!serverConfig || serverConfig.StorageType === "localstorage"} > {isLoading && } diff --git a/components/SourceSelectionModal.tsx b/components/SourceSelectionModal.tsx index af43570..a5ced62 100644 --- a/components/SourceSelectionModal.tsx +++ b/components/SourceSelectionModal.tsx @@ -5,12 +5,24 @@ import useDetailStore from "@/stores/detailStore"; import usePlayerStore from "@/stores/playerStore"; export const SourceSelectionModal: React.FC = () => { - const { showSourceModal, setShowSourceModal } = usePlayerStore(); + const { showSourceModal, setShowSourceModal, loadVideo, currentEpisodeIndex, status } = usePlayerStore(); const { searchResults, detail, setDetail } = useDetailStore(); const onSelectSource = (index: number) => { + console.log("onSelectSource", index, searchResults[index].source, detail?.source); if (searchResults[index].source !== detail?.source) { - setDetail(searchResults[index]); + const newDetail = searchResults[index]; + setDetail(newDetail); + + // Reload the video with the new source, preserving current position + const currentPosition = status?.isLoaded ? status.positionMillis : undefined; + loadVideo({ + source: newDetail.source, + id: newDetail.id.toString(), + episodeIndex: currentEpisodeIndex, + title: newDetail.title, + position: currentPosition + }); } setShowSourceModal(false); }; diff --git a/components/VideoCard.tv.tsx b/components/VideoCard.tv.tsx index accc8ee..e248514 100644 --- a/components/VideoCard.tv.tsx +++ b/components/VideoCard.tv.tsx @@ -1,14 +1,14 @@ -import React, { useState, useEffect, useCallback, useRef } from "react"; -import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert } from "react-native"; +import React, { useState, useCallback, useRef, forwardRef } from "react"; +import { View, Text, Image, StyleSheet, 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 { Star, Play } from "lucide-react-native"; +import { PlayRecordManager } from "@/services/storage"; +import { API } from "@/services/api"; import { ThemedText } from "@/components/ThemedText"; import { Colors } from "@/constants/Colors"; -interface VideoCardProps { +interface VideoCardProps extends React.ComponentProps { id: string; source: string; title: string; @@ -25,166 +25,175 @@ interface VideoCardProps { api: API; } -export default function VideoCard({ - id, - source, - title, - poster, - year, - rate, - sourceName, - progress, - episodeIndex, - onFocus, - onRecordDeleted, - api, - playTime = 0, -}: VideoCardProps) { - const router = useRouter(); - const [isFocused, setIsFocused] = useState(false); +const VideoCard = forwardRef( + ( + { + id, + source, + title, + poster, + year, + rate, + sourceName, + progress, + episodeIndex, + onFocus, + onRecordDeleted, + api, + playTime = 0, + }: VideoCardProps, + ref + ) => { + const router = useRouter(); + const [isFocused, setIsFocused] = useState(false); - const longPressTriggered = useRef(false); + const longPressTriggered = useRef(false); - const scale = useSharedValue(1); + const scale = useSharedValue(1); - const animatedStyle = useAnimatedStyle(() => { - return { - transform: [{ scale: scale.value }], + const animatedStyle = useAnimatedStyle(() => { + return { + transform: [{ scale: scale.value }], + }; + }); + + const handlePress = () => { + if (longPressTriggered.current) { + longPressTriggered.current = false; + return; + } + // 如果有播放进度,直接转到播放页面 + if (progress !== undefined && episodeIndex !== undefined) { + router.push({ + pathname: "/play", + params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 }, + }); + } else { + router.push({ + pathname: "/detail", + params: { source, q: title }, + }); + } }; - }); - const handlePress = () => { - if (longPressTriggered.current) { - longPressTriggered.current = false; - return; - } - // 如果有播放进度,直接转到播放页面 - if (progress !== undefined && episodeIndex !== undefined) { - router.push({ - pathname: "/play", - params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 }, - }); - } else { - router.push({ - pathname: "/detail", - params: { source, q: title }, - }); - } - }; + const handleFocus = useCallback(() => { + setIsFocused(true); + scale.value = withSpring(1.05, { damping: 15, stiffness: 200 }); + onFocus?.(); + }, [scale, onFocus]); - const handleFocus = useCallback(() => { - setIsFocused(true); - scale.value = withSpring(1.05, { damping: 15, stiffness: 200 }); - onFocus?.(); - }, [scale, onFocus]); + const handleBlur = useCallback(() => { + setIsFocused(false); + scale.value = withSpring(1.0); + }, [scale]); - const handleBlur = useCallback(() => { - setIsFocused(false); - scale.value = withSpring(1.0); - }, [scale]); + const handleLongPress = () => { + // Only allow long press for items with progress (play records) + if (progress === undefined) return; - const handleLongPress = () => { - // Only allow long press for items with progress (play records) - if (progress === undefined) return; + longPressTriggered.current = true; - longPressTriggered.current = true; - - // Show confirmation dialog to delete play record - Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [ - { - text: "取消", - style: "cancel", - }, - { - text: "删除", - style: "destructive", - onPress: async () => { - try { - // Delete from local storage - await PlayRecordManager.remove(source, id); - - // Call the onRecordDeleted callback - if (onRecordDeleted) { - onRecordDeleted(); - } - // 如果没有回调函数,则使用导航刷新作为备选方案 - else if (router.canGoBack()) { - router.replace("/"); - } - } catch (error) { - console.info("Failed to delete play record:", error); - Alert.alert("错误", "删除观看记录失败,请重试"); - } + // Show confirmation dialog to delete play record + Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [ + { + text: "取消", + style: "cancel", }, - }, - ]); - }; + { + text: "删除", + style: "destructive", + onPress: async () => { + try { + // Delete from local storage + await PlayRecordManager.remove(source, id); - // 是否是继续观看的视频 - const isContinueWatching = progress !== undefined && progress > 0 && progress < 1; + // Call the onRecordDeleted callback + if (onRecordDeleted) { + onRecordDeleted(); + } + // 如果没有回调函数,则使用导航刷新作为备选方案 + else if (router.canGoBack()) { + router.replace("/"); + } + } catch (error) { + console.info("Failed to delete play record:", error); + Alert.alert("错误", "删除观看记录失败,请重试"); + } + }, + }, + ]); + }; - return ( - - - - - {isFocused && ( - - {isContinueWatching && ( - - - 继续观看 - - )} - - )} + // 是否是继续观看的视频 + const isContinueWatching = progress !== undefined && progress > 0 && progress < 1; - {/* 进度条 */} - {isContinueWatching && ( - - - - )} + return ( + + + + + {isFocused && ( + + {isContinueWatching && ( + + + 继续观看 + + )} + + )} - {rate && ( - - - {rate} - - )} - {year && ( - - {year} - - )} - {sourceName && ( - - {sourceName} - - )} - - - {title} - {isContinueWatching && ( - - - 第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}% - - - )} - - - - ); -} + {/* 进度条 */} + {isContinueWatching && ( + + + + )} + + {rate && ( + + + {rate} + + )} + {year && ( + + {year} + + )} + {sourceName && ( + + {sourceName} + + )} + + + {title} + {isContinueWatching && ( + + + 第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}% + + + )} + + + + ); + } +); + +VideoCard.displayName = "VideoCard"; + +export default VideoCard; const CARD_WIDTH = 160; const CARD_HEIGHT = 240; diff --git a/package.json b/package.json index a73538e..2f2b83c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "OrionTV", "private": true, "main": "expo-router/entry", - "version": "1.2.7", + "version": "1.2.8", "scripts": { "start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", "start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start", @@ -80,4 +80,4 @@ } }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" -} +} \ No newline at end of file diff --git a/stores/authStore.ts b/stores/authStore.ts index 1bc6181..c971efa 100644 --- a/stores/authStore.ts +++ b/stores/authStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import Cookies from "@react-native-cookies/cookies"; import { api } from "@/services/api"; import { useSettingsStore } from "./settingsStore"; +import Toast from "react-native-toast-message"; interface AuthState { isLoggedIn: boolean; @@ -24,6 +25,10 @@ const useAuthStore = create((set) => ({ } try { const serverConfig = useSettingsStore.getState().serverConfig; + if (!serverConfig?.StorageType) { + Toast.show({ type: "error", text1: "请检查网络或者 API 地址是否可用" }); + return + } const cookies = await Cookies.get(api.baseURL); if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) { const loginResult = await api.login().catch(() => { diff --git a/stores/playerStore.ts b/stores/playerStore.ts index 3c94fa0..3e22a4b 100644 --- a/stores/playerStore.ts +++ b/stores/playerStore.ts @@ -254,7 +254,6 @@ const usePlayerStore = create((set, get) => ({ ...existingRecord, ...updates, }); - console.log("Play record saved") } }, diff --git a/stores/settingsStore.ts b/stores/settingsStore.ts index 576d8a0..dc16a72 100644 --- a/stores/settingsStore.ts +++ b/stores/settingsStore.ts @@ -56,9 +56,10 @@ export const useSettingsStore = create((set, get) => ({ const config = await api.getServerConfig(); if (config) { storageConfig.setStorageType(config.StorageType); + set({ serverConfig: config }); } - set({ serverConfig: config }); } catch (error) { + set({ serverConfig: null }); console.info("Failed to fetch server config:", error); } },