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;