mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-06-12 08:53:10 +08:00
feat: add favorites functionality and enhance detail screen
This commit is contained in:
@@ -59,6 +59,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="live" options={{ headerShown: false }} />
|
<Stack.Screen name="live" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen name="favorites" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="+not-found" />
|
<Stack.Screen name="+not-found" />
|
||||||
</Stack>
|
</Stack>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import React, { useEffect } from "react";
|
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 { useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { StyledButton } from "@/components/StyledButton";
|
import { StyledButton } from "@/components/StyledButton";
|
||||||
import useDetailStore from "@/stores/detailStore";
|
import useDetailStore from "@/stores/detailStore";
|
||||||
|
import { FontAwesome } from "@expo/vector-icons";
|
||||||
|
|
||||||
export default function DetailScreen() {
|
export default function DetailScreen() {
|
||||||
const { q } = useLocalSearchParams<{ q: string }>();
|
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
if (q) {
|
if (q) {
|
||||||
init(q);
|
init(q, source, id);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
abort();
|
abort();
|
||||||
};
|
};
|
||||||
}, [abort, init, q]);
|
}, [abort, init, q, source, id]);
|
||||||
|
|
||||||
const handlePlay = (episodeIndex: number) => {
|
const handlePlay = (episodeIndex: number) => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
@@ -75,6 +87,10 @@ export default function DetailScreen() {
|
|||||||
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
|
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
|
||||||
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
|
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
|
||||||
</View>
|
</View>
|
||||||
|
<Pressable onPress={toggleFavorite} style={styles.favoriteButton}>
|
||||||
|
<FontAwesome name={isFavorited ? "star" : "star-o"} size={24} color={isFavorited ? "#FFD700" : "#ccc"} />
|
||||||
|
<ThemedText style={styles.favoriteButtonText}>{isFavorited ? "已收藏" : "收藏"}</ThemedText>
|
||||||
|
</Pressable>
|
||||||
<ScrollView style={styles.descriptionScrollView}>
|
<ScrollView style={styles.descriptionScrollView}>
|
||||||
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
|
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
@@ -177,6 +193,19 @@ const styles = StyleSheet.create({
|
|||||||
color: "#ccc",
|
color: "#ccc",
|
||||||
lineHeight: 22,
|
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: {
|
bottomContainer: {
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
|
|||||||
124
app/favorites.tsx
Normal file
124
app/favorites.tsx
Normal file
@@ -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 (
|
||||||
|
<ThemedView style={styles.centered}>
|
||||||
|
<ActivityIndicator size="large" />
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.centered}>
|
||||||
|
<ThemedText type="subtitle">{error}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favorites.length === 0) {
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.centered}>
|
||||||
|
<ThemedText type="subtitle">暂无收藏</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = ({ item }: { item: Favorite & { key: string } }) => (
|
||||||
|
<Pressable onPress={() => handlePress(item)} style={styles.itemContainer}>
|
||||||
|
<Image source={{ uri: item.poster }} style={styles.poster} />
|
||||||
|
<View style={styles.infoContainer}>
|
||||||
|
<ThemedText style={styles.title} numberOfLines={1}>
|
||||||
|
{item.title}
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText style={styles.year}>{item.year}</ThemedText>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<View style={styles.headerContainer}>
|
||||||
|
<ThemedText style={styles.headerTitle}>我的收藏</ThemedText>
|
||||||
|
</View>
|
||||||
|
<FlatList
|
||||||
|
data={favorites}
|
||||||
|
renderItem={renderItem}
|
||||||
|
keyExtractor={(item) => item.key}
|
||||||
|
numColumns={3}
|
||||||
|
contentContainerStyle={styles.list}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,7 +5,7 @@ import { ThemedText } from "@/components/ThemedText";
|
|||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import VideoCard from "@/components/VideoCard.tv";
|
import VideoCard from "@/components/VideoCard.tv";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
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 { StyledButton } from "@/components/StyledButton";
|
||||||
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
||||||
import useAuthStore from "@/stores/authStore";
|
import useAuthStore from "@/stores/authStore";
|
||||||
@@ -96,7 +96,7 @@ export default function HomeScreen() {
|
|||||||
year={item.year}
|
year={item.year}
|
||||||
rate={item.rate}
|
rate={item.rate}
|
||||||
progress={item.progress}
|
progress={item.progress}
|
||||||
playTime={item.time}
|
playTime={item.play_time}
|
||||||
episodeIndex={item.episodeIndex}
|
episodeIndex={item.episodeIndex}
|
||||||
sourceName={item.sourceName}
|
sourceName={item.sourceName}
|
||||||
totalEpisodes={item.totalEpisodes}
|
totalEpisodes={item.totalEpisodes}
|
||||||
@@ -124,6 +124,9 @@ export default function HomeScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.rightHeaderButtons}>
|
<View style={styles.rightHeaderButtons}>
|
||||||
|
<StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
|
||||||
|
<Star color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||||
|
</StyledButton>
|
||||||
<StyledButton
|
<StyledButton
|
||||||
style={styles.searchButton}
|
style={styles.searchButton}
|
||||||
onPress={() => router.push({ pathname: "/search" })}
|
onPress={() => router.push({ pathname: "/search" })}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export interface Favorite {
|
|||||||
total_episodes: number;
|
total_episodes: number;
|
||||||
search_title: string;
|
search_title: string;
|
||||||
year: string;
|
year: string;
|
||||||
|
save_time?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayRecord {
|
export interface PlayRecord {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from "zustand";
|
|||||||
import { SearchResult, api } from "@/services/api";
|
import { SearchResult, api } from "@/services/api";
|
||||||
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import { FavoriteManager } from "@/services/storage";
|
||||||
|
|
||||||
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
|
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
|
||||||
|
|
||||||
@@ -13,11 +14,13 @@ interface DetailState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
allSourcesLoaded: boolean;
|
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;
|
setDetail: (detail: SearchResultWithResolution) => void;
|
||||||
abort: () => void;
|
abort: () => void;
|
||||||
|
toggleFavorite: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useDetailStore = create<DetailState>((set, get) => ({
|
const useDetailStore = create<DetailState>((set, get) => ({
|
||||||
@@ -29,8 +32,9 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
|||||||
error: null,
|
error: null,
|
||||||
allSourcesLoaded: false,
|
allSourcesLoaded: false,
|
||||||
controller: null,
|
controller: null,
|
||||||
|
isFavorited: false,
|
||||||
|
|
||||||
init: async (q) => {
|
init: async (q, preferredSource, id) => {
|
||||||
const { controller: oldController } = get();
|
const { controller: oldController } = get();
|
||||||
if (oldController) {
|
if (oldController) {
|
||||||
oldController.abort();
|
oldController.abort();
|
||||||
@@ -50,70 +54,89 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
|||||||
|
|
||||||
const { videoSource } = useSettingsStore.getState();
|
const { videoSource } = useSettingsStore.getState();
|
||||||
|
|
||||||
try {
|
const processAndSetResults = async (results: SearchResult[], merge = false) => {
|
||||||
const processAndSetResults = async (
|
const resultsWithResolution = await Promise.all(
|
||||||
results: SearchResult[]
|
results.map(async (searchResult) => {
|
||||||
) => {
|
let resolution;
|
||||||
const resultsWithResolution = await Promise.all(
|
try {
|
||||||
results.map(async (searchResult) => {
|
if (searchResult.episodes && searchResult.episodes.length > 0) {
|
||||||
let resolution;
|
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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;
|
if (signal.aborted) return;
|
||||||
|
|
||||||
const filteredResults = videoSource.enabledAll
|
set((state) => {
|
||||||
? allResults
|
const existingSources = new Set(state.searchResults.map((r) => r.source));
|
||||||
: allResults.filter((result) => videoSource.sources[result.source]);
|
const newResults = resultsWithResolution.filter((r) => !existingSources.has(r.source));
|
||||||
|
const finalResults = merge ? [...state.searchResults, ...newResults] : resultsWithResolution;
|
||||||
|
|
||||||
if (filteredResults.length > 0) {
|
return {
|
||||||
await processAndSetResults(filteredResults);
|
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 (get().searchResults.length === 0) {
|
||||||
if (!videoSource.enabledAll) {
|
set({ error: "未找到任何播放源" });
|
||||||
set({ error: "请到设置页面启用的播放源" });
|
}
|
||||||
} else {
|
|
||||||
set({ error: "未找到播放源" });
|
if (get().detail) {
|
||||||
}
|
const { source, id } = get().detail!;
|
||||||
|
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||||
|
set({ isFavorited });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as Error).name !== "AbortError") {
|
if ((e as Error).name !== "AbortError") {
|
||||||
@@ -126,16 +149,38 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setDetail: (detail) => {
|
setDetail: async (detail) => {
|
||||||
set({ detail });
|
set({ detail });
|
||||||
|
const { source, id } = detail;
|
||||||
|
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||||
|
set({ isFavorited });
|
||||||
},
|
},
|
||||||
|
|
||||||
abort: () => {
|
abort: () => {
|
||||||
get().controller?.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 const sourcesSelector = (state: DetailState) => state.sources;
|
||||||
export default useDetailStore;
|
export default useDetailStore;
|
||||||
export const episodesSelectorBySource = (source: string) => (state: DetailState) =>
|
export const episodesSelectorBySource = (source: string) => (state: DetailState) =>
|
||||||
state.searchResults.find((r) => r.source === source)?.episodes || [];
|
state.searchResults.find((r) => r.source === source)?.episodes || [];
|
||||||
|
|||||||
32
stores/favoritesStore.ts
Normal file
32
stores/favoritesStore.ts
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFavoritesStore = create<FavoritesState>((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;
|
||||||
Reference in New Issue
Block a user