mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from 'react-native';
|
|
import { ThemedView } from '@/components/ThemedView';
|
|
import { ThemedText } from '@/components/ThemedText';
|
|
import { api } from '@/services/api';
|
|
import { SearchResult } from '@/services/api';
|
|
import { PlayRecord } from '@/services/storage';
|
|
|
|
export type RowItem = (SearchResult | PlayRecord) & {
|
|
id: string;
|
|
source: string;
|
|
title: string;
|
|
poster: string;
|
|
progress?: number;
|
|
lastPlayed?: number;
|
|
episodeIndex?: number;
|
|
sourceName?: string;
|
|
totalEpisodes?: number;
|
|
year?: string;
|
|
rate?: string;
|
|
};
|
|
import VideoCard from '@/components/VideoCard.tv';
|
|
import { PlayRecordManager } from '@/services/storage';
|
|
import { useFocusEffect, useRouter } from 'expo-router';
|
|
import { useColorScheme } from 'react-native';
|
|
import { Search, Settings } from 'lucide-react-native';
|
|
import { SettingsModal } from '@/components/SettingsModal';
|
|
|
|
// --- 类别定义 ---
|
|
interface Category {
|
|
title: string;
|
|
type?: 'movie' | 'tv' | 'record';
|
|
tag?: string;
|
|
}
|
|
|
|
const initialCategories: Category[] = [
|
|
{ title: '最近播放', type: 'record' },
|
|
{ title: '热门剧集', type: 'tv', tag: '热门' },
|
|
{ title: '综艺', type: 'tv', tag: '综艺' },
|
|
{ title: '热门电影', type: 'movie', tag: '热门' },
|
|
{ title: '豆瓣 Top250', type: 'movie', tag: 'top250' },
|
|
{ title: '儿童', type: 'movie', tag: '少儿' },
|
|
{ title: '美剧', type: 'tv', tag: '美剧' },
|
|
{ title: '韩剧', type: 'tv', tag: '韩剧' },
|
|
{ title: '日剧', type: 'tv', tag: '日剧' },
|
|
{ title: '日漫', type: 'tv', tag: '日本动画' },
|
|
];
|
|
|
|
const NUM_COLUMNS = 5;
|
|
const { width } = Dimensions.get('window');
|
|
const ITEM_WIDTH = width / NUM_COLUMNS - 24;
|
|
|
|
export default function HomeScreen() {
|
|
const router = useRouter();
|
|
const colorScheme = useColorScheme();
|
|
|
|
const [categories, setCategories] = useState<Category[]>(initialCategories);
|
|
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0]);
|
|
const [contentData, setContentData] = useState<RowItem[]>([]);
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isSettingsVisible, setSettingsVisible] = useState(false);
|
|
|
|
const [pageStart, setPageStart] = useState(0);
|
|
const [hasMore, setHasMore] = useState(true);
|
|
|
|
const flatListRef = useRef<FlatList>(null);
|
|
|
|
// --- 数据获取逻辑 ---
|
|
const fetchPlayRecords = async () => {
|
|
const records = await PlayRecordManager.getAll();
|
|
return Object.entries(records)
|
|
.map(([key, record]) => {
|
|
const [source, id] = key.split('+');
|
|
return {
|
|
id,
|
|
source,
|
|
title: record.title,
|
|
poster: record.cover,
|
|
progress: record.play_time / record.total_time,
|
|
lastPlayed: record.save_time,
|
|
episodeIndex: record.index,
|
|
sourceName: record.source_name,
|
|
totalEpisodes: record.total_episodes,
|
|
} as RowItem;
|
|
})
|
|
.filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
|
|
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
|
|
};
|
|
|
|
const fetchData = async (category: Category, start: number, preloadedRecords?: RowItem[]) => {
|
|
if (category.type === 'record') {
|
|
const records = preloadedRecords ?? (await fetchPlayRecords());
|
|
if (records.length === 0 && categories.some(c => c.type === 'record')) {
|
|
// 如果没有播放记录,则移除"最近播放"分类并选择第一个真实分类
|
|
const newCategories = categories.filter(c => c.type !== 'record');
|
|
setCategories(newCategories);
|
|
if (newCategories.length > 0) {
|
|
handleCategorySelect(newCategories[0]);
|
|
}
|
|
} else {
|
|
setContentData(records);
|
|
setHasMore(false);
|
|
}
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (!category.type || !category.tag) return;
|
|
|
|
setLoadingMore(start > 0);
|
|
setError(null);
|
|
|
|
try {
|
|
const result = await api.getDoubanData(category.type, category.tag, 20, start);
|
|
|
|
if (result.list.length === 0) {
|
|
setHasMore(false);
|
|
} else {
|
|
const newItems = result.list.map(item => ({
|
|
...item,
|
|
id: item.title, // 临时ID
|
|
source: 'douban',
|
|
})) as RowItem[];
|
|
|
|
setContentData(prev => (start === 0 ? newItems : [...prev, ...newItems]));
|
|
setPageStart(prev => prev + result.list.length);
|
|
setHasMore(true);
|
|
}
|
|
} catch (err: any) {
|
|
if (err.message === 'API_URL_NOT_SET') {
|
|
setError('请点击右上角设置按钮,配置您的 API 地址');
|
|
} else {
|
|
setError('加载失败,请重试');
|
|
}
|
|
} finally {
|
|
setLoading(false);
|
|
setLoadingMore(false);
|
|
}
|
|
};
|
|
|
|
// --- Effects ---
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
const manageRecordCategory = async () => {
|
|
const records = await fetchPlayRecords();
|
|
const hasRecords = records.length > 0;
|
|
|
|
setCategories(currentCategories => {
|
|
const recordCategoryExists = currentCategories.some(c => c.type === 'record');
|
|
if (hasRecords && !recordCategoryExists) {
|
|
// Add 'Recent Plays' if records exist and the tab doesn't
|
|
return [initialCategories[0], ...currentCategories];
|
|
}
|
|
return currentCategories;
|
|
});
|
|
|
|
// If 'Recent Plays' is selected, always refresh its data.
|
|
// This will also handle removing the tab if records have disappeared.
|
|
if (selectedCategory.type === 'record') {
|
|
loadInitialData(records);
|
|
}
|
|
};
|
|
|
|
manageRecordCategory();
|
|
}, [selectedCategory])
|
|
);
|
|
|
|
useEffect(() => {
|
|
loadInitialData();
|
|
}, [selectedCategory]);
|
|
|
|
const loadInitialData = (records?: RowItem[]) => {
|
|
setLoading(true);
|
|
setContentData([]);
|
|
setPageStart(0);
|
|
setHasMore(true);
|
|
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
|
|
fetchData(selectedCategory, 0, records);
|
|
};
|
|
|
|
const loadMoreData = () => {
|
|
if (loading || loadingMore || !hasMore || selectedCategory.type === 'record') return;
|
|
fetchData(selectedCategory, pageStart);
|
|
};
|
|
|
|
const handleCategorySelect = (category: Category) => {
|
|
setSelectedCategory(category);
|
|
};
|
|
|
|
// --- 渲染组件 ---
|
|
const renderCategory = ({ item }: { item: Category }) => {
|
|
const isSelected = selectedCategory.title === item.title;
|
|
return (
|
|
<Pressable
|
|
style={({ focused }) => [
|
|
styles.categoryButton,
|
|
isSelected && styles.categoryButtonSelected,
|
|
focused && styles.categoryButtonFocused,
|
|
]}
|
|
onPress={() => handleCategorySelect(item)}
|
|
>
|
|
<ThemedText style={[styles.categoryText, isSelected && styles.categoryTextSelected]}>{item.title}</ThemedText>
|
|
</Pressable>
|
|
);
|
|
};
|
|
|
|
const renderContentItem = ({ item }: { item: RowItem }) => (
|
|
<View style={styles.itemContainer}>
|
|
<VideoCard
|
|
id={item.id}
|
|
source={item.source}
|
|
title={item.title}
|
|
poster={item.poster}
|
|
year={item.year}
|
|
rate={item.rate}
|
|
progress={item.progress}
|
|
episodeIndex={item.episodeIndex}
|
|
sourceName={item.sourceName}
|
|
totalEpisodes={item.totalEpisodes}
|
|
api={api}
|
|
onRecordDeleted={loadInitialData} // For "Recent Plays"
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
const renderFooter = () => {
|
|
if (!loadingMore) return null;
|
|
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
|
|
};
|
|
|
|
return (
|
|
<ThemedView style={styles.container}>
|
|
{/* 顶部导航 */}
|
|
<View style={styles.headerContainer}>
|
|
<ThemedText style={styles.headerTitle}>首页</ThemedText>
|
|
<View style={styles.rightHeaderButtons}>
|
|
<Pressable
|
|
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
|
|
onPress={() => router.push({ pathname: '/search' })}
|
|
>
|
|
<Search color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
|
|
</Pressable>
|
|
<Pressable
|
|
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
|
|
onPress={() => setSettingsVisible(true)}
|
|
>
|
|
<Settings color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* 分类选择器 */}
|
|
<View style={styles.categoryContainer}>
|
|
<FlatList
|
|
data={categories}
|
|
renderItem={renderCategory}
|
|
keyExtractor={item => item.title}
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
contentContainerStyle={styles.categoryListContent}
|
|
/>
|
|
</View>
|
|
|
|
{/* 内容网格 */}
|
|
{loading ? (
|
|
<View style={styles.centerContainer}>
|
|
<ActivityIndicator size="large" />
|
|
</View>
|
|
) : error ? (
|
|
<View style={styles.centerContainer}>
|
|
<ThemedText type="subtitle" style={{ padding: 10 }}>
|
|
{error}
|
|
</ThemedText>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={contentData}
|
|
renderItem={renderContentItem}
|
|
keyExtractor={(item, index) => `${item.source}-${item.id}-${index}`}
|
|
numColumns={NUM_COLUMNS}
|
|
contentContainerStyle={styles.listContent}
|
|
onEndReached={loadMoreData}
|
|
onEndReachedThreshold={0.5}
|
|
ListFooterComponent={renderFooter}
|
|
ListEmptyComponent={
|
|
<View style={styles.centerContainer}>
|
|
<ThemedText>该分类下暂无内容</ThemedText>
|
|
</View>
|
|
}
|
|
/>
|
|
)}
|
|
<SettingsModal
|
|
visible={isSettingsVisible}
|
|
onCancel={() => setSettingsVisible(false)}
|
|
onSave={() => {
|
|
setSettingsVisible(false);
|
|
loadInitialData();
|
|
}}
|
|
/>
|
|
</ThemedView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
paddingTop: 40,
|
|
},
|
|
centerContainer: {
|
|
flex: 1,
|
|
paddingTop: 20,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
// Header
|
|
headerContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 24,
|
|
marginBottom: 10,
|
|
},
|
|
headerTitle: {
|
|
fontSize: 32,
|
|
fontWeight: 'bold',
|
|
paddingTop: 16,
|
|
},
|
|
rightHeaderButtons: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
searchButton: {
|
|
padding: 10,
|
|
borderRadius: 30,
|
|
marginLeft: 10,
|
|
},
|
|
searchButtonFocused: {
|
|
backgroundColor: '#007AFF',
|
|
transform: [{ scale: 1.1 }],
|
|
},
|
|
// Category Selector
|
|
categoryContainer: {
|
|
paddingBottom: 10,
|
|
},
|
|
categoryListContent: {
|
|
paddingHorizontal: 16,
|
|
},
|
|
categoryButton: {
|
|
paddingHorizontal: 12,
|
|
paddingVertical: 6,
|
|
borderRadius: 8,
|
|
marginHorizontal: 5,
|
|
},
|
|
categoryButtonSelected: {
|
|
backgroundColor: '#007AFF', // A bright blue for selected state
|
|
},
|
|
categoryButtonFocused: {
|
|
backgroundColor: '#0056b3', // A darker blue for focused state
|
|
elevation: 5,
|
|
},
|
|
categoryText: {
|
|
fontSize: 16,
|
|
fontWeight: '500',
|
|
},
|
|
categoryTextSelected: {
|
|
color: '#FFFFFF',
|
|
},
|
|
// Content Grid
|
|
listContent: {
|
|
paddingHorizontal: 16,
|
|
paddingBottom: 20,
|
|
},
|
|
itemContainer: {
|
|
margin: 8,
|
|
width: ITEM_WIDTH,
|
|
alignItems: 'center',
|
|
},
|
|
});
|