From 08e24dd7483e67b19817471a0060645a7dca6d6a Mon Sep 17 00:00:00 2001 From: zimplexing Date: Sun, 6 Jul 2025 20:45:42 +0800 Subject: [PATCH] Refactor components to use Zustand for state management - Updated EpisodeSelectionModal to utilize Zustand for episode selection state. - Refactored PlayerControls to manage playback state and controls using Zustand. - Simplified SettingsModal to handle settings state with Zustand. - Introduced homeStore for managing home screen categories and content data. - Created playerStore for managing video playback and episode details. - Added settingsStore for managing API settings and modal visibility. - Updated package.json to include Zustand as a dependency. - Cleaned up code formatting and improved readability across components. --- app/_layout.tsx | 36 ++--- app/index.tsx | 198 ++++--------------------- app/play.tsx | 156 +++++--------------- components/EpisodeSelectionModal.tsx | 164 +++++++++------------ components/PlayerControls.tsx | 209 +++++++++++---------------- components/SettingsModal.tsx | 34 ++--- package.json | 3 +- stores/homeStore.ts | 146 +++++++++++++++++++ stores/playerStore.ts | 163 +++++++++++++++++++++ stores/settingsStore.ts | 32 ++++ yarn.lock | 36 +---- 11 files changed, 592 insertions(+), 585 deletions(-) create mode 100644 stores/homeStore.ts create mode 100644 stores/playerStore.ts create mode 100644 stores/settingsStore.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index d97f022..85b7af4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,16 +1,11 @@ -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 { 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, useColorScheme } from 'react-native'; -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(); @@ -18,8 +13,13 @@ SplashScreen.preventAutoHideAsync(); export default function RootLayout() { const colorScheme = useColorScheme(); const [loaded, error] = useFonts({ - SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), + SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'), }); + const initializeSettings = useSettingsStore(state => state.loadSettings); + + useEffect(() => { + initializeSettings(); + }, [initializeSettings]); useEffect(() => { if (loaded || error) { @@ -30,22 +30,16 @@ export default function RootLayout() { } }, [loaded, error]); - useEffect(() => { - initializeApi(); - }, []); - if (!loaded && !error) { return null; } return ( - + - {Platform.OS !== "web" && ( - - )} + {Platform.OS !== 'web' && } diff --git a/app/index.tsx b/app/index.tsx index f0d64fa..0884951 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,50 +1,15 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +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 { 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 useHomeStore, { RowItem, Category } from '@/stores/homeStore'; +import { useSettingsStore } from '@/stores/settingsStore'; const NUM_COLUMNS = 5; const { width } = Dimensions.get('window'); @@ -53,146 +18,40 @@ 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 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 ( [ @@ -221,7 +80,7 @@ export default function HomeScreen() { sourceName={item.sourceName} totalEpisodes={item.totalEpisodes} api={api} - onRecordDeleted={loadInitialData} // For "Recent Plays" + onRecordDeleted={fetchInitialData} // For "Recent Plays" /> ); @@ -245,7 +104,7 @@ export default function HomeScreen() { [styles.searchButton, focused && styles.searchButtonFocused]} - onPress={() => setSettingsVisible(true)} + onPress={showSettingsModal} > @@ -293,14 +152,7 @@ export default function HomeScreen() { } /> )} - setSettingsVisible(false)} - onSave={() => { - setSettingsVisible(false); - loadInitialData(); - }} - /> + ); } diff --git a/app/play.tsx b/app/play.tsx index 94d45ec..6c80888 100644 --- a/app/play.tsx +++ b/app/play.tsx @@ -1,49 +1,52 @@ -import React, { useState, useRef } from "react"; -import { - View, - StyleSheet, - TouchableOpacity, - ActivityIndicator, -} from "react-native"; -import { 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 { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay"; -import { LoadingOverlay } from "@/components/LoadingOverlay"; -import { usePlaybackManager } from "@/hooks/usePlaybackManager"; -import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler"; +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { useLocalSearchParams } 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 { NextEpisodeOverlay } from '@/components/NextEpisodeOverlay'; +import { LoadingOverlay } from '@/components/LoadingOverlay'; +import usePlayerStore from '@/stores/playerStore'; +import { useTVRemoteHandler } from '@/hooks/useTVRemoteHandler'; export default function PlayScreen() { - const router = useRouter(); const videoRef = useRef