5 Commits

Author SHA1 Message Date
zimplexing
10bfbbbf8e fix: ensure data is refreshed for all categories by calling fetchInitialData unconditionally 2025-07-29 19:15:57 +08:00
zimplexing
187a753735 feat: Enhance UI with fade animations and implement data caching in home store 2025-07-28 10:28:02 +08:00
Xin
8cda0d7a82 Update README.md 2025-07-26 15:06:10 +08:00
zimplexing
b2de622a40 feat: Update disclaimer 2025-07-26 15:05:17 +08:00
Xin
2988dad829 Merge pull request #96 from zimplexing/v1.2.9
fix: UI issue
2025-07-26 14:59:33 +08:00
5 changed files with 139 additions and 46 deletions

View File

@@ -66,14 +66,10 @@ yarn ios-tv
yarn android-tv yarn android-tv
``` ```
## 部署 ## 使用
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 使用。 - 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 使用。
## 其他
- 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本 ## 📜 主要脚本

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useCallback, useRef, useState } from "react"; import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from "react-native"; import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions, Animated } from "react-native";
import { ThemedView } from "@/components/ThemedView"; import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText"; import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api"; import { api } from "@/services/api";
@@ -21,6 +21,7 @@ export default function HomeScreen() {
const router = useRouter(); const router = useRouter();
const colorScheme = "dark"; const colorScheme = "dark";
const [selectedTag, setSelectedTag] = useState<string | null>(null); const [selectedTag, setSelectedTag] = useState<string | null>(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
const { const {
categories, categories,
@@ -59,6 +60,18 @@ export default function HomeScreen() {
} }
}, [fetchInitialData, selectedCategory, selectedCategory.tag]); }, [fetchInitialData, selectedCategory, selectedCategory.tag]);
useEffect(() => {
if (!loading && contentData.length > 0) {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 300,
useNativeDriver: true,
}).start();
} else if (loading) {
fadeAnim.setValue(0);
}
}, [loading, contentData.length, fadeAnim]);
const handleCategorySelect = (category: Category) => { const handleCategorySelect = (category: Category) => {
setSelectedTag(null); setSelectedTag(null);
selectCategory(category); selectCategory(category);
@@ -196,18 +209,20 @@ export default function HomeScreen() {
</ThemedText> </ThemedText>
</View> </View>
) : ( ) : (
<CustomScrollView <Animated.View style={[styles.contentContainer, { opacity: fadeAnim }]}>
data={contentData} <CustomScrollView
renderItem={renderContentItem} data={contentData}
numColumns={NUM_COLUMNS} renderItem={renderContentItem}
loading={loading} numColumns={NUM_COLUMNS}
loadingMore={loadingMore} loading={loading}
error={error} loadingMore={loadingMore}
onEndReached={loadMoreData} error={error}
loadMoreThreshold={LOAD_MORE_THRESHOLD} onEndReached={loadMoreData}
emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"} loadMoreThreshold={LOAD_MORE_THRESHOLD}
ListFooterComponent={renderFooter} emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}
/> ListFooterComponent={renderFooter}
/>
</Animated.View>
)} )}
</ThemedView> </ThemedView>
); );
@@ -266,6 +281,9 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 20, paddingBottom: 20,
}, },
contentContainer: {
flex: 1,
},
itemContainer: { itemContainer: {
margin: 8, margin: 8,
alignItems: "center", alignItems: "center",

View File

@@ -26,7 +26,7 @@ const LoginModal = () => {
useEffect(() => { useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) { if (isLoginModalVisible && !isSettingsPage) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage"; const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
// Use a small delay to ensure the modal is fully rendered // Use a small delay to ensure the modal is fully rendered
const focusTimeout = setTimeout(() => { const focusTimeout = setTimeout(() => {
if (isUsernameVisible) { if (isUsernameVisible) {
@@ -35,7 +35,7 @@ const LoginModal = () => {
passwordInputRef.current?.focus(); passwordInputRef.current?.focus();
} }
}, 100); }, 100);
return () => clearTimeout(focusTimeout); return () => clearTimeout(focusTimeout);
} }
}, [isLoginModalVisible, serverConfig, isSettingsPage]); }, [isLoginModalVisible, serverConfig, isSettingsPage]);
@@ -55,15 +55,19 @@ const LoginModal = () => {
hideLoginModal(); hideLoginModal();
setUsername(""); setUsername("");
setPassword(""); setPassword("");
// Show disclaimer alert after successful login // Show disclaimer alert after successful login
Alert.alert( Alert.alert(
"免责声明", "免责声明",
"本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。", "本应用仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }] [{ text: "确定" }]
); );
} catch { } catch (error) {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" }); Toast.show({
type: "error",
text1: "登录失败",
text2: error instanceof Error ? error.message : "用户名或密码错误",
});
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,6 +1,5 @@
import React, { useState, useCallback, useRef, forwardRef } from "react"; import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert } from "react-native"; import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { Star, Play } from "lucide-react-native"; import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage"; import { PlayRecordManager } from "@/services/storage";
@@ -46,16 +45,15 @@ const VideoCard = forwardRef<View, VideoCardProps>(
) => { ) => {
const router = useRouter(); const router = useRouter();
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const [fadeAnim] = useState(new Animated.Value(0));
const longPressTriggered = useRef(false); const longPressTriggered = useRef(false);
const scale = useSharedValue(1); const scale = useRef(new Animated.Value(1)).current;
const animatedStyle = useAnimatedStyle(() => { const animatedStyle = {
return { transform: [{ scale }],
transform: [{ scale: scale.value }], };
};
});
const handlePress = () => { const handlePress = () => {
if (longPressTriggered.current) { if (longPressTriggered.current) {
@@ -78,15 +76,32 @@ const VideoCard = forwardRef<View, VideoCardProps>(
const handleFocus = useCallback(() => { const handleFocus = useCallback(() => {
setIsFocused(true); setIsFocused(true);
scale.value = withSpring(1.05, { damping: 15, stiffness: 200 }); Animated.spring(scale, {
toValue: 1.05,
damping: 15,
stiffness: 200,
useNativeDriver: true,
}).start();
onFocus?.(); onFocus?.();
}, [scale, onFocus]); }, [scale, onFocus]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
setIsFocused(false); setIsFocused(false);
scale.value = withSpring(1.0); Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
}).start();
}, [scale]); }, [scale]);
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
delay: Math.random() * 200, // 随机延迟创造交错效果
useNativeDriver: true,
}).start();
}, [fadeAnim]);
const handleLongPress = () => { const handleLongPress = () => {
// Only allow long press for items with progress (play records) // Only allow long press for items with progress (play records)
if (progress === undefined) return; if (progress === undefined) return;
@@ -128,7 +143,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1; const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
return ( return (
<Animated.View style={[styles.wrapper, animatedStyle]}> <Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
<TouchableOpacity <TouchableOpacity
onPress={handlePress} onPress={handlePress}
onLongPress={handleLongPress} onLongPress={handleLongPress}
@@ -260,9 +275,9 @@ const styles = StyleSheet.create({
infoContainer: { infoContainer: {
width: CARD_WIDTH, width: CARD_WIDTH,
marginTop: 8, marginTop: 8,
alignItems: "flex-start", // Align items to the start alignItems: "flex-start",
marginBottom: 16, marginBottom: 16,
paddingHorizontal: 4, // Add some padding paddingHorizontal: 4,
}, },
infoRow: { infoRow: {
flexDirection: "row", flexDirection: "row",
@@ -328,4 +343,4 @@ const styles = StyleSheet.create({
color: Colors.dark.primary, color: Colors.dark.primary,
fontSize: 12, fontSize: 12,
}, },
}); });

View File

@@ -70,6 +70,9 @@ interface HomeState {
refreshPlayRecords: () => Promise<void>; refreshPlayRecords: () => Promise<void>;
} }
// 内存缓存,应用生命周期内有效
const dataCache = new Map<string, RowItem[]>();
const useHomeStore = create<HomeState>((set, get) => ({ const useHomeStore = create<HomeState>((set, get) => ({
categories: initialCategories, categories: initialCategories,
selectedCategory: initialCategories[0], selectedCategory: initialCategories[0],
@@ -83,6 +86,29 @@ const useHomeStore = create<HomeState>((set, get) => ({
fetchInitialData: async () => { fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState(); const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl); await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
const { selectedCategory } = get();
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
// 最近播放不缓存,始终实时获取
if (selectedCategory.type === 'record') {
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
return;
}
// 检查缓存
if (dataCache.has(cacheKey)) {
set({
loading: false,
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
error: null
});
return;
}
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null }); set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData(); await get().loadMoreData();
}, },
@@ -133,11 +159,25 @@ const useHomeStore = create<HomeState>((set, get) => ({
id: item.title, id: item.title,
source: "douban", source: "douban",
})) as RowItem[]; })) as RowItem[];
set((state) => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems], const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
pageStart: state.pageStart + result.list.length,
hasMore: true, if (pageStart === 0) {
})); // 缓存新数据
dataCache.set(cacheKey, newItems);
set((state) => ({
contentData: newItems,
pageStart: result.list.length,
hasMore: true,
}));
} else {
// 增量加载时不缓存,直接追加
set((state) => ({
contentData: [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
}
} }
} else if (selectedCategory.tags) { } else if (selectedCategory.tags) {
// It's a container category, do not load content, but clear current content // It's a container category, do not load content, but clear current content
@@ -158,10 +198,29 @@ const useHomeStore = create<HomeState>((set, get) => ({
selectCategory: (category: Category) => { selectCategory: (category: Category) => {
const currentCategory = get().selectedCategory; const currentCategory = get().selectedCategory;
// Only fetch new data if the category or tag actually changes const cacheKey = `${category.title}-${category.tag || ''}`;
// 只有当分类或标签真正变化时才处理
if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) { if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) {
set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null }); set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null });
get().fetchInitialData();
// 最近播放始终实时获取
if (category.type === 'record') {
get().fetchInitialData();
return;
}
// 检查缓存,有则直接使用,无则请求
if (dataCache.has(cacheKey)) {
set({
contentData: dataCache.get(cacheKey)!,
pageStart: dataCache.get(cacheKey)!.length,
hasMore: false,
loading: false
});
} else {
get().fetchInitialData();
}
} }
}, },
@@ -199,6 +258,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
} }
return {}; return {};
}); });
get().fetchInitialData(); get().fetchInitialData();
}, },
})); }));