From 5992a89db4c53c385ab8730c3c779395110161d8 Mon Sep 17 00:00:00 2001 From: zimplexing Date: Fri, 25 Jul 2025 15:39:23 +0800 Subject: [PATCH] feat: Update scroll experience --- app/favorites.tsx | 36 +--- app/index.tsx | 27 ++- app/search.tsx | 19 +- components/CustomScrollView.tsx | 130 +++++++++++++ components/VideoCard.tv.tsx | 313 ++++++++++++++++---------------- 5 files changed, 317 insertions(+), 208 deletions(-) create mode 100644 components/CustomScrollView.tsx 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..3576a79 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, ActivityIndicator } 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(""); @@ -80,7 +81,7 @@ export default function SearchScreen() { showRemoteModal(); }; - const renderItem = ({ item }: { item: SearchResult }) => ( + const renderItem = ({ item, index }: { item: SearchResult; index: number }) => ( {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/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;