Files
OrionTV/app/index.tsx
2025-09-22 15:16:13 +08:00

382 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform, BackHandler, ToastAndroid } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard";
import { useFocusEffect, useRouter } from "expo-router";
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";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
import { Colors } from "@/constants/Colors";
const LOAD_MORE_THRESHOLD = 200;
export default function HomeScreen() {
const router = useRouter();
const colorScheme = "dark";
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
const insets = useSafeAreaInsets();
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
const {
categories,
selectedCategory,
contentData,
loading,
loadingMore,
error,
fetchInitialData,
loadMoreData,
selectCategory,
refreshPlayRecords,
clearError,
} = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
const apiConfigStatus = useApiConfig();
useFocusEffect(
useCallback(() => {
refreshPlayRecords();
}, [refreshPlayRecords])
);
// 双击返回退出逻辑(只限当前页面)
const backPressTimeRef = useRef<number | null>(null);
useFocusEffect(
useCallback(() => {
const handleBackPress = () => {
const now = Date.now();
// 如果还没按过返回键或距离上次超过2秒
if (!backPressTimeRef.current || now - backPressTimeRef.current > 2000) {
backPressTimeRef.current = now;
ToastAndroid.show("再按一次返回键退出", ToastAndroid.SHORT);
return true; // 拦截返回事件,不退出
}
// 两次返回键间隔小于2秒退出应用
BackHandler.exitApp();
return true;
};
// 仅限 Android 平台启用此功能
if (Platform.OS === "android") {
const backHandler = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
// 返回首页时重置状态
return () => {
backHandler.remove();
backPressTimeRef.current = null;
};
}
}, [])
);
// 统一的数据获取逻辑
useEffect(() => {
if (!selectedCategory) return;
// 如果是容器分类且没有选择标签,设置默认标签
if (selectedCategory.tags && !selectedCategory.tag) {
const defaultTag = selectedCategory.tags[0];
setSelectedTag(defaultTag);
selectCategory({ ...selectedCategory, tag: defaultTag });
return;
}
// 只有在API配置完成且分类有效时才获取数据
if (apiConfigStatus.isConfigured && !apiConfigStatus.needsConfiguration) {
// 对于有标签的分类,需要确保有标签才获取数据
if (selectedCategory.tags && selectedCategory.tag) {
fetchInitialData();
}
// 对于无标签的分类,直接获取数据
else if (!selectedCategory.tags) {
fetchInitialData();
}
}
}, [
selectedCategory,
selectedCategory?.tag,
apiConfigStatus.isConfigured,
apiConfigStatus.needsConfiguration,
fetchInitialData,
selectCategory,
]);
// 清除错误状态的逻辑
useEffect(() => {
if (apiConfigStatus.needsConfiguration && error) {
clearError();
}
}, [apiConfigStatus.needsConfiguration, error, clearError]);
useEffect(() => {
if (!loading && contentData.length > 0) {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
} else if (loading) {
fadeAnim.setValue(0);
}
}, [loading, contentData.length, fadeAnim]);
const handleCategorySelect = (category: Category) => {
setSelectedTag(null);
selectCategory(category);
};
const handleTagSelect = (tag: string) => {
setSelectedTag(tag);
if (selectedCategory) {
const categoryWithTag = { ...selectedCategory, tag: tag };
selectCategory(categoryWithTag);
}
};
const renderCategory = ({ item }: { item: Category }) => {
const isSelected = selectedCategory?.title === item.title;
return (
<StyledButton
text={item.title}
onPress={() => handleCategorySelect(item)}
isSelected={isSelected}
style={dynamicStyles.categoryButton}
textStyle={dynamicStyles.categoryText}
/>
);
};
const renderContentItem = ({ item }: { item: RowItem; index: number }) => (
<VideoCard
id={item.id}
source={item.source}
title={item.title}
poster={item.poster}
year={item.year}
rate={item.rate}
progress={item.progress}
playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}
api={api}
onRecordDeleted={fetchInitialData}
/>
);
const renderFooter = () => {
if (!loadingMore) return null;
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
};
// 检查是否需要显示API配置提示
const shouldShowApiConfig = apiConfigStatus.needsConfiguration && selectedCategory && !selectedCategory.tags;
// TV端和平板端的顶部导航
const renderHeader = () => {
if (deviceType === "mobile") {
// 移动端不显示顶部导航使用底部Tab导航
return null;
}
return (
<View style={dynamicStyles.headerContainer}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={dynamicStyles.headerTitle}></ThemedText>
<Pressable android_ripple={Platform.isTV || deviceType !== 'tv'? { color: 'transparent' } : { color: Colors.dark.link }} style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
{({ focused }) => (
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}></ThemedText>
)}
</Pressable>
</View>
<View style={dynamicStyles.rightHeaderButtons}>
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton
style={dynamicStyles.iconButton}
onPress={() => router.push({ pathname: "/search" })}
variant="ghost"
>
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
{isLoggedIn && (
<StyledButton style={dynamicStyles.iconButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View>
</View>
);
};
// 动态样式
const dynamicStyles = StyleSheet.create({
container: {
flex: 1,
paddingTop: deviceType === "mobile" ? insets.top : deviceType === "tablet" ? insets.top + 20 : 40,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing * 1.5,
marginBottom: spacing,
},
headerTitle: {
fontSize: deviceType === "mobile" ? 24 : deviceType === "tablet" ? 28 : 32,
fontWeight: "bold",
paddingTop: 16,
},
rightHeaderButtons: {
flexDirection: "row",
alignItems: "center",
},
iconButton: {
borderRadius: 30,
marginLeft: spacing / 2,
},
categoryContainer: {
paddingBottom: spacing / 2,
},
categoryListContent: {
paddingHorizontal: spacing,
},
categoryButton: {
paddingHorizontal: deviceType === "tv" ? spacing / 4 : spacing / 2,
paddingVertical: spacing / 2,
borderRadius: deviceType === "mobile" ? 6 : 8,
marginHorizontal: deviceType === "tv" ? spacing / 4 : spacing / 2, // TV端使用更小的间距
},
categoryText: {
fontSize: deviceType === "mobile" ? 14 : 16,
fontWeight: "500",
},
contentContainer: {
flex: 1,
},
});
const content = (
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{/* 状态栏 */}
{deviceType === "mobile" && <StatusBar barStyle="light-content" />}
{/* 顶部导航 */}
{renderHeader()}
{/* 分类选择器 */}
<View style={dynamicStyles.categoryContainer}>
<FlatList
data={categories}
renderItem={renderCategory}
keyExtractor={(item) => item.title}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={dynamicStyles.categoryListContent}
/>
</View>
{/* 子分类标签 */}
{selectedCategory && selectedCategory.tags && (
<View style={dynamicStyles.categoryContainer}>
<FlatList
data={selectedCategory.tags}
renderItem={({ item, index }) => {
const isSelected = selectedTag === item;
return (
<StyledButton
hasTVPreferredFocus={index === 0}
text={item}
onPress={() => handleTagSelect(item)}
isSelected={isSelected}
style={dynamicStyles.categoryButton}
textStyle={dynamicStyles.categoryText}
variant="ghost"
/>
);
}}
keyExtractor={(item) => item}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={dynamicStyles.categoryListContent}
/>
</View>
)}
{/* 内容网格 */}
{shouldShowApiConfig ? (
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
{getApiConfigErrorMessage(apiConfigStatus)}
</ThemedText>
</View>
) : apiConfigStatus.isValidating ? (
<View style={commonStyles.center}>
<ActivityIndicator size="large" />
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
...
</ThemedText>
</View>
) : apiConfigStatus.error && !apiConfigStatus.isValid ? (
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
{apiConfigStatus.error}
</ThemedText>
</View>
) : loading ? (
<View style={commonStyles.center}>
<ActivityIndicator size="large" />
</View>
) : error ? (
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing }}>
{error}
</ThemedText>
</View>
) : (
<Animated.View style={[dynamicStyles.contentContainer, { opacity: fadeAnim }]}>
<CustomScrollView
data={contentData}
renderItem={renderContentItem}
loading={loading}
loadingMore={loadingMore}
error={error}
onEndReached={loadMoreData}
loadMoreThreshold={LOAD_MORE_THRESHOLD}
emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}
ListFooterComponent={renderFooter}
/>
</Animated.View>
)}
</ThemedView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === "tv") {
return content;
}
return <ResponsiveNavigation>{content}</ResponsiveNavigation>;
}