diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..52f01a6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "tabWidth": 2, + "useTabs": false, + "singleQuote": false, + "printWidth": 120 +} diff --git a/app/+not-found.tsx b/app/+not-found.tsx index d057dbc..af77bca 100644 --- a/app/+not-found.tsx +++ b/app/+not-found.tsx @@ -1,13 +1,14 @@ -import {Link, Stack} from 'expo-router'; -import {StyleSheet} from 'react-native'; +import { Link, Stack } from "expo-router"; +import { StyleSheet } from "react-native"; -import {ThemedText} from '@/components/ThemedText'; -import {ThemedView} from '@/components/ThemedView'; +import { ThemedText } from "@/components/ThemedText"; +import { ThemedView } from "@/components/ThemedView"; +import React from "react"; export default function NotFoundScreen() { return ( <> - + This screen doesn't exist. @@ -21,8 +22,8 @@ export default function NotFoundScreen() { const styles = StyleSheet.create({ container: { flex: 1, - alignItems: 'center', - justifyContent: 'center', + alignItems: "center", + justifyContent: "center", padding: 20, }, link: { diff --git a/app/_layout.tsx b/app/_layout.tsx index d97f022..700de07 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,25 +1,26 @@ -import { - DarkTheme, - DefaultTheme, - ThemeProvider, -} from "@react-navigation/native"; +import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"; import { useFonts } from "expo-font"; import { Stack } from "expo-router"; import * as SplashScreen from "expo-splash-screen"; import { useEffect } from "react"; import { Platform } from "react-native"; +import Toast from "react-native-toast-message"; -import { useColorScheme } from "@/hooks/useColorScheme"; -import { initializeApi } from "@/services/api"; +import { useSettingsStore } from "@/stores/settingsStore"; // Prevent the splash screen from auto-hiding before asset loading is complete. SplashScreen.preventAutoHideAsync(); export default function RootLayout() { - const colorScheme = useColorScheme(); + const colorScheme = "dark"; const [loaded, error] = useFonts({ SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), }); + const initializeSettings = useSettingsStore((state) => state.loadSettings); + + useEffect(() => { + initializeSettings(); + }, [initializeSettings]); useEffect(() => { if (loaded || error) { @@ -30,10 +31,6 @@ export default function RootLayout() { } }, [loaded, error]); - useEffect(() => { - initializeApi(); - }, []); - if (!loaded && !error) { return null; } @@ -43,12 +40,11 @@ export default function RootLayout() { - {Platform.OS !== "web" && ( - - )} + {Platform.OS !== "web" && } + ); } diff --git a/app/detail.tsx b/app/detail.tsx index ed9915b..4ce1f08 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -1,11 +1,11 @@ -import React, { useEffect, useState, useRef } from 'react'; -import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from 'react-native'; -import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ThemedView } from '@/components/ThemedView'; -import { ThemedText } from '@/components/ThemedText'; -import { api, SearchResult } from '@/services/api'; -import { getResolutionFromM3U8 } from '@/services/m3u8'; -import { DetailButton } from '@/components/DetailButton'; +import React, { useEffect, useState, useRef } from "react"; +import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; +import { ThemedView } from "@/components/ThemedView"; +import { ThemedText } from "@/components/ThemedText"; +import { api, SearchResult } from "@/services/api"; +import { getResolutionFromM3U8 } from "@/services/m3u8"; +import { StyledButton } from "@/components/StyledButton"; export default function DetailScreen() { const { source, q } = useLocalSearchParams(); @@ -24,7 +24,7 @@ export default function DetailScreen() { controllerRef.current = new AbortController(); const signal = controllerRef.current.signal; - if (typeof q === 'string') { + if (typeof q === "string") { const fetchDetailData = async () => { setLoading(true); setSearchResults([]); @@ -35,15 +35,15 @@ export default function DetailScreen() { try { const resources = await api.getResources(signal); if (!resources || resources.length === 0) { - setError('没有可用的播放源'); + setError("没有可用的播放源"); setLoading(false); return; } let foundFirstResult = false; // Prioritize source from params if available - if (typeof source === 'string') { - const index = resources.findIndex(r => r.key === source); + if (typeof source === "string") { + const index = resources.findIndex((r) => r.key === source); if (index > 0) { resources.unshift(resources.splice(index, 1)[0]); } @@ -61,14 +61,14 @@ export default function DetailScreen() { resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal); } } catch (e) { - if ((e as Error).name !== 'AbortError') { + if ((e as Error).name !== "AbortError") { console.error(`Failed to get resolution for ${resource.name}`, e); } } const resultWithResolution = { ...searchResult, resolution }; - setSearchResults(prev => [...prev, resultWithResolution]); + setSearchResults((prev) => [...prev, resultWithResolution]); if (!foundFirstResult) { setDetail(resultWithResolution); @@ -77,19 +77,19 @@ export default function DetailScreen() { } } } catch (e) { - if ((e as Error).name !== 'AbortError') { + if ((e as Error).name !== "AbortError") { console.error(`Error searching in resource ${resource.name}:`, e); } } } if (!foundFirstResult) { - setError('未找到播放源'); + setError("未找到播放源"); setLoading(false); } } catch (e) { - if ((e as Error).name !== 'AbortError') { - setError(e instanceof Error ? e.message : '获取资源列表失败'); + if ((e as Error).name !== "AbortError") { + setError(e instanceof Error ? e.message : "获取资源列表失败"); setLoading(false); } } finally { @@ -108,7 +108,7 @@ export default function DetailScreen() { if (!detail) return; controllerRef.current?.abort(); // Cancel any ongoing fetches router.push({ - pathname: '/play', + pathname: "/play", params: { source: detail.source, id: detail.id.toString(), @@ -171,26 +171,27 @@ export default function DetailScreen() { {searchResults.map((item, index) => ( - setDetail(item)} hasTVPreferredFocus={index === 0} - style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]} + isSelected={detail?.source === item.source} + style={styles.sourceButton} > {item.source_name} {item.episodes.length > 1 && ( - {item.episodes.length > 99 ? '99+' : `${item.episodes.length}`} + {item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} 集 )} {item.resolution && ( - + {item.resolution} )} - + ))} @@ -198,9 +199,13 @@ export default function DetailScreen() { 播放列表 {detail.episodes.map((episode, index) => ( - handlePlay(episode, index)}> - {`第 ${index + 1} 集`} - + handlePlay(episode, index)} + text={`第 ${index + 1} 集`} + textStyle={styles.episodeButtonText} + /> ))} @@ -212,9 +217,9 @@ export default function DetailScreen() { const styles = StyleSheet.create({ container: { flex: 1 }, - centered: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + centered: { flex: 1, justifyContent: "center", alignItems: "center" }, topContainer: { - flexDirection: 'row', + flexDirection: "row", padding: 20, }, poster: { @@ -225,20 +230,20 @@ const styles = StyleSheet.create({ infoContainer: { flex: 1, marginLeft: 20, - justifyContent: 'flex-start', + justifyContent: "flex-start", }, title: { fontSize: 28, - fontWeight: 'bold', + fontWeight: "bold", marginBottom: 10, paddingTop: 20, }, metaContainer: { - flexDirection: 'row', + flexDirection: "row", marginBottom: 10, }, metaText: { - color: '#aaa', + color: "#aaa", marginRight: 10, fontSize: 14, }, @@ -247,7 +252,7 @@ const styles = StyleSheet.create({ }, description: { fontSize: 14, - color: '#ccc', + color: "#ccc", lineHeight: 22, }, bottomContainer: { @@ -257,70 +262,53 @@ const styles = StyleSheet.create({ marginTop: 20, }, sourcesTitleContainer: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", marginBottom: 10, }, sourcesTitle: { fontSize: 20, - fontWeight: 'bold', + fontWeight: "bold", }, sourceList: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", }, sourceButton: { - backgroundColor: '#333', - paddingHorizontal: 15, - paddingVertical: 10, - borderRadius: 8, - margin: 5, - flexDirection: 'row', - alignItems: 'center', - borderWidth: 2, - borderColor: 'transparent', - }, - sourceButtonSelected: { - backgroundColor: '#007bff', + margin: 8, }, sourceButtonText: { - color: 'white', + color: "white", fontSize: 16, }, badge: { - backgroundColor: 'red', + backgroundColor: "red", borderRadius: 10, paddingHorizontal: 6, paddingVertical: 2, marginLeft: 8, }, badgeText: { - color: 'white', + color: "white", fontSize: 12, - fontWeight: 'bold', + fontWeight: "bold", }, episodesContainer: { marginTop: 20, }, episodesTitle: { fontSize: 20, - fontWeight: 'bold', + fontWeight: "bold", marginBottom: 10, }, episodeList: { - flexDirection: 'row', - flexWrap: 'wrap', + flexDirection: "row", + flexWrap: "wrap", }, episodeButton: { - backgroundColor: '#333', - paddingHorizontal: 20, - paddingVertical: 10, - borderRadius: 8, margin: 5, - borderWidth: 2, - borderColor: 'transparent', }, episodeButtonText: { - color: 'white', + color: "white", }, }); diff --git a/app/index.tsx b/app/index.tsx index f0d64fa..68b2e86 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,209 +1,65 @@ -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: '日本动画' }, -]; +import React, { 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 VideoCard from "@/components/VideoCard.tv"; +import { useFocusEffect, useRouter } from "expo-router"; +import { Search, Settings } from "lucide-react-native"; +import { SettingsModal } from "@/components/SettingsModal"; +import { StyledButton } from "@/components/StyledButton"; +import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; +import { useSettingsStore } from "@/stores/settingsStore"; const NUM_COLUMNS = 5; -const { width } = Dimensions.get('window'); +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(initialCategories); - const [selectedCategory, setSelectedCategory] = useState(categories[0]); - const [contentData, setContentData] = useState([]); - - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const [isSettingsVisible, setSettingsVisible] = useState(false); - - const [pageStart, setPageStart] = useState(0); - const [hasMore, setHasMore] = useState(true); - + const colorScheme = "dark"; const flatListRef = useRef(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 { + categories, + selectedCategory, + contentData, + loading, + loadingMore, + error, + fetchInitialData, + loadMoreData, + selectCategory, + refreshPlayRecords, + } = useHomeStore(); - 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; - } + const showSettingsModal = useSettingsStore((state) => state.showModal); - 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]) + refreshPlayRecords(); + }, [refreshPlayRecords]) ); useEffect(() => { - loadInitialData(); - }, [selectedCategory]); - - const loadInitialData = (records?: RowItem[]) => { - setLoading(true); - setContentData([]); - setPageStart(0); - setHasMore(true); + fetchInitialData(); flatListRef.current?.scrollToOffset({ animated: false, offset: 0 }); - fetchData(selectedCategory, 0, records); - }; - - const loadMoreData = () => { - if (loading || loadingMore || !hasMore || selectedCategory.type === 'record') return; - fetchData(selectedCategory, pageStart); - }; + }, [selectedCategory, fetchInitialData]); const handleCategorySelect = (category: Category) => { - setSelectedCategory(category); + selectCategory(category); }; - // --- 渲染组件 --- const renderCategory = ({ item }: { item: Category }) => { - const isSelected = selectedCategory.title === item.title; + const isSelected = selectedCategory?.title === item.title; return ( - [ - styles.categoryButton, - isSelected && styles.categoryButtonSelected, - focused && styles.categoryButtonFocused, - ]} + handleCategorySelect(item)} - > - {item.title} - + isSelected={isSelected} + style={styles.categoryButton} + textStyle={styles.categoryText} + /> ); }; @@ -217,11 +73,12 @@ export default function HomeScreen() { 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={loadInitialData} // For "Recent Plays" + onRecordDeleted={fetchInitialData} // For "Recent Plays" /> ); @@ -237,18 +94,16 @@ export default function HomeScreen() { 首页 - [styles.searchButton, focused && styles.searchButtonFocused]} - onPress={() => router.push({ pathname: '/search' })} + router.push({ pathname: "/search" })} + variant="ghost" > - - - [styles.searchButton, focused && styles.searchButtonFocused]} - onPress={() => setSettingsVisible(true)} - > - - + + + + + @@ -257,7 +112,7 @@ export default function HomeScreen() { item.title} + keyExtractor={(item) => item.title} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.categoryListContent} @@ -293,14 +148,7 @@ export default function HomeScreen() { } /> )} - setSettingsVisible(false)} - onSave={() => { - setSettingsVisible(false); - loadInitialData(); - }} - /> + ); } @@ -313,61 +161,47 @@ const styles = StyleSheet.create({ centerContainer: { flex: 1, paddingTop: 20, - justifyContent: 'center', - alignItems: 'center', + justifyContent: "center", + alignItems: "center", }, // Header headerContainer: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", paddingHorizontal: 24, marginBottom: 10, }, headerTitle: { fontSize: 32, - fontWeight: 'bold', + fontWeight: "bold", paddingTop: 16, }, rightHeaderButtons: { - flexDirection: 'row', - alignItems: 'center', + flexDirection: "row", + alignItems: "center", }, searchButton: { padding: 10, borderRadius: 30, marginLeft: 10, }, - searchButtonFocused: { - backgroundColor: '#007AFF', - transform: [{ scale: 1.1 }], - }, // Category Selector categoryContainer: { - paddingBottom: 10, + paddingBottom: 6, }, categoryListContent: { paddingHorizontal: 16, }, categoryButton: { - paddingHorizontal: 12, + paddingHorizontal: 2, 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, + marginHorizontal: 6, }, categoryText: { fontSize: 16, - fontWeight: '500', - }, - categoryTextSelected: { - color: '#FFFFFF', + fontWeight: "500", }, // Content Grid listContent: { @@ -377,6 +211,6 @@ const styles = StyleSheet.create({ itemContainer: { margin: 8, width: ITEM_WIDTH, - alignItems: 'center', + alignItems: "center", }, }); diff --git a/app/play.tsx b/app/play.tsx index 94d45ec..655068b 100644 --- a/app/play.tsx +++ b/app/play.tsx @@ -1,101 +1,87 @@ -import React, { useState, useRef } from "react"; -import { - View, - StyleSheet, - TouchableOpacity, - ActivityIndicator, -} from "react-native"; -import { useRouter } from "expo-router"; +import React, { useEffect, useRef } from "react"; +import { View, StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native"; +import { useLocalSearchParams, useRouter } from "expo-router"; import { Video, ResizeMode } from "expo-av"; import { useKeepAwake } from "expo-keep-awake"; import { ThemedView } from "@/components/ThemedView"; import { PlayerControls } from "@/components/PlayerControls"; import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal"; +import { SourceSelectionModal } from "@/components/SourceSelectionModal"; +import { SeekingBar } from "@/components/SeekingBar"; import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; import { LoadingOverlay } from "@/components/LoadingOverlay"; -import { usePlaybackManager } from "@/hooks/usePlaybackManager"; +import usePlayerStore from "@/stores/playerStore"; import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler"; export default function PlayScreen() { - const router = useRouter(); const videoRef = useRef