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;