diff --git a/app/_layout.tsx b/app/_layout.tsx index 6068172..7db48cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -59,6 +59,7 @@ export default function RootLayout() { + diff --git a/app/detail.tsx b/app/detail.tsx index 111ab2e..1f8e88e 100644 --- a/app/detail.tsx +++ b/app/detail.tsx @@ -1,25 +1,37 @@ import React, { useEffect } from "react"; -import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native"; +import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native"; import { useLocalSearchParams, useRouter } from "expo-router"; import { ThemedView } from "@/components/ThemedView"; import { ThemedText } from "@/components/ThemedText"; import { StyledButton } from "@/components/StyledButton"; import useDetailStore from "@/stores/detailStore"; +import { FontAwesome } from "@expo/vector-icons"; export default function DetailScreen() { - const { q } = useLocalSearchParams<{ q: string }>(); + const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>(); const router = useRouter(); - const { detail, searchResults, loading, error, allSourcesLoaded, init, setDetail, abort } = useDetailStore(); + const { + detail, + searchResults, + loading, + error, + allSourcesLoaded, + init, + setDetail, + abort, + isFavorited, + toggleFavorite, + } = useDetailStore(); useEffect(() => { if (q) { - init(q); + init(q, source, id); } return () => { abort(); }; - }, [abort, init, q]); + }, [abort, init, q, source, id]); const handlePlay = (episodeIndex: number) => { if (!detail) return; @@ -75,6 +87,10 @@ export default function DetailScreen() { {detail.year} {detail.type_name} + + + {isFavorited ? "已收藏" : "收藏"} + {detail.desc} @@ -177,6 +193,19 @@ const styles = StyleSheet.create({ color: "#ccc", lineHeight: 22, }, + favoriteButton: { + flexDirection: "row", + alignItems: "center", + marginTop: 10, + padding: 10, + backgroundColor: "rgba(255, 255, 255, 0.1)", + borderRadius: 5, + alignSelf: "flex-start", + }, + favoriteButtonText: { + marginLeft: 8, + fontSize: 16, + }, bottomContainer: { paddingHorizontal: 20, }, diff --git a/app/favorites.tsx b/app/favorites.tsx new file mode 100644 index 0000000..f656caa --- /dev/null +++ b/app/favorites.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from "react"; +import { View, FlatList, StyleSheet, ActivityIndicator, Image, Pressable } from "react-native"; +import { useRouter } from "expo-router"; +import { ThemedView } from "@/components/ThemedView"; +import { ThemedText } from "@/components/ThemedText"; +import useFavoritesStore from "@/stores/favoritesStore"; +import { Favorite } from "@/services/storage"; + +export default function FavoritesScreen() { + const router = useRouter(); + const { favorites, loading, error, fetchFavorites } = useFavoritesStore(); + + useEffect(() => { + fetchFavorites(); + }, [fetchFavorites]); + + const handlePress = (favorite: Favorite & { key: string }) => { + const [source, id] = favorite.key.split("+"); + router.push({ + pathname: "/detail", + params: { q: favorite.title, source, id }, + }); + }; + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (favorites.length === 0) { + return ( + + 暂无收藏 + + ); + } + + const renderItem = ({ item }: { item: Favorite & { key: string } }) => ( + handlePress(item)} style={styles.itemContainer}> + + + + {item.title} + + {item.year} + + + ); + + return ( + + + 我的收藏 + + item.key} + numColumns={3} + contentContainerStyle={styles.list} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingTop: 40, + }, + headerContainer: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: 24, + marginBottom: 10, + }, + headerTitle: { + fontSize: 32, + fontWeight: "bold", + paddingTop: 16, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + }, + list: { + padding: 10, + }, + itemContainer: { + flex: 1, + margin: 10, + alignItems: "center", + }, + poster: { + width: 120, + height: 180, + borderRadius: 8, + }, + infoContainer: { + marginTop: 8, + alignItems: "center", + }, + title: { + fontSize: 16, + fontWeight: "bold", + }, + year: { + fontSize: 14, + color: "#888", + }, +}); diff --git a/app/index.tsx b/app/index.tsx index e449b55..fc35900 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -5,7 +5,7 @@ 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, LogOut } from "lucide-react-native"; +import { Search, Settings, LogOut, Star } from "lucide-react-native"; import { StyledButton } from "@/components/StyledButton"; import useHomeStore, { RowItem, Category } from "@/stores/homeStore"; import useAuthStore from "@/stores/authStore"; @@ -96,7 +96,7 @@ export default function HomeScreen() { year={item.year} rate={item.rate} progress={item.progress} - playTime={item.time} + playTime={item.play_time} episodeIndex={item.episodeIndex} sourceName={item.sourceName} totalEpisodes={item.totalEpisodes} @@ -124,6 +124,9 @@ export default function HomeScreen() { + router.push("/favorites")} variant="ghost"> + + router.push({ pathname: "/search" })} diff --git a/services/api.ts b/services/api.ts index 8ca7db3..a736f66 100644 --- a/services/api.ts +++ b/services/api.ts @@ -49,6 +49,7 @@ export interface Favorite { total_episodes: number; search_title: string; year: string; + save_time?: number; } export interface PlayRecord { diff --git a/stores/detailStore.ts b/stores/detailStore.ts index 74eb480..09bbd1c 100644 --- a/stores/detailStore.ts +++ b/stores/detailStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { SearchResult, api } from "@/services/api"; import { getResolutionFromM3U8 } from "@/services/m3u8"; import { useSettingsStore } from "@/stores/settingsStore"; +import { FavoriteManager } from "@/services/storage"; export type SearchResultWithResolution = SearchResult & { resolution?: string | null }; @@ -13,11 +14,13 @@ interface DetailState { loading: boolean; error: string | null; allSourcesLoaded: boolean; - controller: AbortController | null + controller: AbortController | null; + isFavorited: boolean; - init: (q: string) => void; + init: (q: string, preferredSource?: string, id?: string) => void; setDetail: (detail: SearchResultWithResolution) => void; abort: () => void; + toggleFavorite: () => Promise; } const useDetailStore = create((set, get) => ({ @@ -29,8 +32,9 @@ const useDetailStore = create((set, get) => ({ error: null, allSourcesLoaded: false, controller: null, + isFavorited: false, - init: async (q) => { + init: async (q, preferredSource, id) => { const { controller: oldController } = get(); if (oldController) { oldController.abort(); @@ -50,70 +54,89 @@ const useDetailStore = create((set, get) => ({ const { videoSource } = useSettingsStore.getState(); - try { - const processAndSetResults = async ( - results: SearchResult[] - ) => { - const resultsWithResolution = await Promise.all( - results.map(async (searchResult) => { - let resolution; - try { - if (searchResult.episodes && searchResult.episodes.length > 0) { - resolution = await getResolutionFromM3U8( - searchResult.episodes[0], - signal - ); - } - } catch (e) { - if ((e as Error).name !== "AbortError") { - console.error( - `Failed to get resolution for ${searchResult.source_name}`, - e - ); - } + const processAndSetResults = async (results: SearchResult[], merge = false) => { + const resultsWithResolution = await Promise.all( + results.map(async (searchResult) => { + let resolution; + try { + if (searchResult.episodes && searchResult.episodes.length > 0) { + resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal); } - return { ...searchResult, resolution }; - }) - ); + } catch (e) { + if ((e as Error).name !== "AbortError") { + console.error(`Failed to get resolution for ${searchResult.source_name}`, e); + } + } + return { ...searchResult, resolution }; + }) + ); - if (signal.aborted) return; - - set((state) => { - const existingSources = new Set(state.searchResults.map((r) => r.source)); - const newResults = resultsWithResolution.filter( - (r) => !existingSources.has(r.source) - ); - const finalResults = [...state.searchResults, ...newResults]; - return { - searchResults: finalResults, - sources: finalResults.map((r) => ({ - source: r.source, - source_name: r.source_name, - resolution: r.resolution, - })), - detail: state.detail ?? finalResults[0] ?? null, - }; - }); - }; - - // Background fetch for all sources - const { results: allResults } = await api.searchVideos(q); if (signal.aborted) return; - const filteredResults = videoSource.enabledAll - ? allResults - : allResults.filter((result) => videoSource.sources[result.source]); + set((state) => { + const existingSources = new Set(state.searchResults.map((r) => r.source)); + const newResults = resultsWithResolution.filter((r) => !existingSources.has(r.source)); + const finalResults = merge ? [...state.searchResults, ...newResults] : resultsWithResolution; - if (filteredResults.length > 0) { - await processAndSetResults(filteredResults); + return { + searchResults: finalResults, + sources: finalResults.map((r) => ({ + source: r.source, + source_name: r.source_name, + resolution: r.resolution, + })), + detail: state.detail ?? finalResults[0] ?? null, + }; + }); + }; + + try { + // Optimization for favorite navigation + if (preferredSource && id) { + const { results: preferredResult } = await api.searchVideo(q, preferredSource, signal); + if (signal.aborted) return; + if (preferredResult.length > 0) { + await processAndSetResults(preferredResult, false); + set({ loading: false }); + } + // Then load all others in background + const { results: allResults } = await api.searchVideos(q); + if (signal.aborted) return; + await processAndSetResults(allResults, true); + } else { + // Standard navigation: fetch resources, then fetch details one by one + const allResources = await api.getResources(signal); + const enabledResources = videoSource.enabledAll + ? allResources + : allResources.filter((r) => videoSource.sources[r.key]); + + let firstResultFound = false; + const searchPromises = enabledResources.map(async (resource) => { + try { + const { results } = await api.searchVideo(q, resource.key, signal); + if (results.length > 0) { + await processAndSetResults(results, true); + if (!firstResultFound) { + set({ loading: false }); // Stop loading indicator on first result + firstResultFound = true; + } + } + } catch (error) { + console.warn(`Failed to fetch from ${resource.name}:`, error); + } + }); + + await Promise.all(searchPromises); } if (get().searchResults.length === 0) { - if (!videoSource.enabledAll) { - set({ error: "请到设置页面启用的播放源" }); - } else { - set({ error: "未找到播放源" }); - } + set({ error: "未找到任何播放源" }); + } + + if (get().detail) { + const { source, id } = get().detail!; + const isFavorited = await FavoriteManager.isFavorited(source, id.toString()); + set({ isFavorited }); } } catch (e) { if ((e as Error).name !== "AbortError") { @@ -126,16 +149,38 @@ const useDetailStore = create((set, get) => ({ } }, - setDetail: (detail) => { + setDetail: async (detail) => { set({ detail }); + const { source, id } = detail; + const isFavorited = await FavoriteManager.isFavorited(source, id.toString()); + set({ isFavorited }); }, abort: () => { get().controller?.abort(); }, + + toggleFavorite: async () => { + const { detail } = get(); + if (!detail) return; + + const { source, id, title, poster, source_name, episodes, year } = detail; + const favoriteItem = { + cover: poster, + title, + poster, + source_name, + total_episodes: episodes.length, + search_title: get().q!, + year: year || "", + }; + + const newIsFavorited = await FavoriteManager.toggle(source, id.toString(), favoriteItem); + set({ isFavorited: newIsFavorited }); + }, })); export const sourcesSelector = (state: DetailState) => state.sources; export default useDetailStore; export const episodesSelectorBySource = (source: string) => (state: DetailState) => - state.searchResults.find((r) => r.source === source)?.episodes || []; \ No newline at end of file + state.searchResults.find((r) => r.source === source)?.episodes || []; diff --git a/stores/favoritesStore.ts b/stores/favoritesStore.ts new file mode 100644 index 0000000..a6dbcc8 --- /dev/null +++ b/stores/favoritesStore.ts @@ -0,0 +1,32 @@ +import { create } from "zustand"; +import { Favorite, FavoriteManager } from "@/services/storage"; + +interface FavoritesState { + favorites: (Favorite & { key: string })[]; + loading: boolean; + error: string | null; + fetchFavorites: () => Promise; +} + +const useFavoritesStore = create((set) => ({ + favorites: [], + loading: false, + error: null, + fetchFavorites: async () => { + set({ loading: true, error: null }); + try { + const favoritesData = await FavoriteManager.getAll(); + const favoritesArray = Object.entries(favoritesData).map(([key, value]) => ({ + ...value, + key, + })); + // favoritesArray.sort((a, b) => (b.save_time || 0) - (a.save_time || 0)); + set({ favorites: favoritesArray, loading: false }); + } catch (e) { + const error = e instanceof Error ? e.message : "获取收藏列表失败"; + set({ error, loading: false }); + } + }, +})); + +export default useFavoritesStore;