From 62d814117828420a3a29ecce1baea8739a6c76d1 Mon Sep 17 00:00:00 2001 From: James Chen Date: Mon, 1 Sep 2025 14:02:46 +0800 Subject: [PATCH 1/5] feat(ui): replace TouchableOpacity with Pressable in VideoCard component for improved touch handling --- components/VideoCard.tv.tsx | 179 ++++++++++++------------------------ 1 file changed, 61 insertions(+), 118 deletions(-) diff --git a/components/VideoCard.tv.tsx b/components/VideoCard.tv.tsx index 423b097..78411b9 100644 --- a/components/VideoCard.tv.tsx +++ b/components/VideoCard.tv.tsx @@ -150,126 +150,69 @@ const VideoCard = forwardRef( return ( - {Platform.isTV || deviceType !== 'tv' ? ( - - - - {isFocused && ( - - {isContinueWatching && ( - - - 继续观看 - - )} - - )} + [ + styles.pressable, + { + zIndex: pressed ? 999 : 1, // 确保按下时有最高优先级 + }, + ]} + // activeOpacity={1} + delayLongPress={1000} + > + + + {isFocused && ( + + {isContinueWatching && ( + + + 继续观看 + + )} + + )} - {/* 进度条 */} - {isContinueWatching && ( - - - - )} - - {rate && ( - - - {rate} - - )} - {year && ( - - {year} - - )} - {sourceName && ( - - {sourceName} - - )} - - - {title} - {isContinueWatching && ( - - - 第{episodeIndex}集 已观看 {Math.round((progress || 0) * 100)}% - - - )} - - - ) : ( - - - - {isFocused && ( - - {isContinueWatching && ( - - - 继续观看 - - )} - - )} - - {/* 进度条 */} - {isContinueWatching && ( - - - - )} - - {rate && ( - - - {rate} - - )} - {year && ( - - {year} - - )} - {sourceName && ( - - {sourceName} - - )} - - - {title} - {isContinueWatching && ( - - - 第{episodeIndex}集 已观看 {Math.round((progress || 0) * 100)}% - - - )} - - - - )} + {/* 进度条 */} + {isContinueWatching && ( + + + + )} + {rate && ( + + + {rate} + + )} + {year && ( + + {year} + + )} + {sourceName && ( + + {sourceName} + + )} + + + {title} + {isContinueWatching && ( + + + 第{episodeIndex}集 已观看 {Math.round((progress || 0) * 100)}% + + + )} + + ); } From 0d9f552ede06328dabdea9d5dd5f3cd903d4adbf Mon Sep 17 00:00:00 2001 From: James Chen Date: Mon, 1 Sep 2025 14:06:09 +0800 Subject: [PATCH 2/5] feat(ui): enhance settings sections with responsive layout and keyboard handling --- app/settings.tsx | 19 +++++++-- components/settings/APIConfigSection.tsx | 16 ++++++-- components/settings/LiveStreamSection.tsx | 17 ++++++-- components/settings/RemoteInputSection.tsx | 15 +++++-- components/settings/SettingsSection.tsx | 20 ++++++++-- package.json | 1 + yarn.lock | 46 ++++++++++++++++++++-- 7 files changed, 114 insertions(+), 20 deletions(-) diff --git a/app/settings.tsx b/app/settings.tsx index 8a0eace..d511000 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform } from "react-native"; +import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform, ScrollView } from "react-native"; import { useTVEventHandler } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ThemedText } from "@/components/ThemedText"; @@ -20,6 +20,7 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles"; import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation"; import ResponsiveHeader from "@/components/navigation/ResponsiveHeader"; import { DeviceUtils } from "@/utils/DeviceUtils"; +import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view'; export default function SettingsScreen() { const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore(); @@ -164,13 +165,22 @@ export default function SettingsScreen() { [currentFocusIndex, sections.length, deviceType] ); - useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => {}); + useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => { }); // 动态样式 const dynamicStyles = createResponsiveStyles(deviceType, spacing, insets); const renderSettingsContent = () => ( - + // + + {deviceType === "tv" && ( @@ -203,7 +213,8 @@ export default function SettingsScreen() { /> - + + // ); // 根据设备类型决定是否包装在响应式导航中 diff --git a/components/settings/APIConfigSection.tsx b/components/settings/APIConfigSection.tsx index 7868eac..218b806 100644 --- a/components/settings/APIConfigSection.tsx +++ b/components/settings/APIConfigSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useImperativeHandle, forwardRef } from "react"; -import { View, TextInput, StyleSheet, Animated } from "react-native"; +import { View, TextInput, StyleSheet, Animated, Platform } from "react-native"; import { useTVEventHandler } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { SettingsSection } from "./SettingsSection"; @@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useRemoteControlStore } from "@/stores/remoteControlStore"; import { useButtonAnimation } from "@/hooks/useAnimation"; import { Colors } from "@/constants/Colors"; +import { useResponsiveLayout } from "@/hooks/useResponsiveLayout"; interface APIConfigSectionProps { onChanged: () => void; onFocus?: () => void; onBlur?: () => void; + onPress?: () => void; hideDescription?: boolean; } @@ -20,13 +22,14 @@ export interface APIConfigSectionRef { } export const APIConfigSection = forwardRef( - ({ onChanged, onFocus, onBlur, hideDescription = false }, ref) => { + ({ onChanged, onFocus, onBlur, onPress, hideDescription = false }, ref) => { const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore(); const { serverUrl } = useRemoteControlStore(); const [isInputFocused, setIsInputFocused] = useState(false); const [isSectionFocused, setIsSectionFocused] = useState(false); const inputRef = useRef(null); const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01); + const deviceType = useResponsiveLayout().deviceType; const handleUrlChange = (url: string) => { setApiBaseUrl(url); @@ -60,10 +63,17 @@ export const APIConfigSection = forwardRef { + inputRef.current?.focus(); + onPress?.(); + } + useTVEventHandler(handleTVEvent); return ( - + API 地址 diff --git a/components/settings/LiveStreamSection.tsx b/components/settings/LiveStreamSection.tsx index d09e680..bd1a56d 100644 --- a/components/settings/LiveStreamSection.tsx +++ b/components/settings/LiveStreamSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useImperativeHandle, forwardRef } from "react"; -import { View, TextInput, StyleSheet, Animated } from "react-native"; +import { View, TextInput, StyleSheet, Animated, Platform } from "react-native"; import { useTVEventHandler } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { SettingsSection } from "./SettingsSection"; @@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useRemoteControlStore } from "@/stores/remoteControlStore"; import { useButtonAnimation } from "@/hooks/useAnimation"; import { Colors } from "@/constants/Colors"; +import { useResponsiveLayout } from "@/hooks/useResponsiveLayout"; interface LiveStreamSectionProps { onChanged: () => void; onFocus?: () => void; onBlur?: () => void; + onPress?: () => void; } export interface LiveStreamSectionRef { @@ -19,13 +21,14 @@ export interface LiveStreamSectionRef { } export const LiveStreamSection = forwardRef( - ({ onChanged, onFocus, onBlur }, ref) => { + ({ onChanged, onFocus, onBlur, onPress }, ref) => { const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore(); const { serverUrl } = useRemoteControlStore(); const [isInputFocused, setIsInputFocused] = useState(false); const [isSectionFocused, setIsSectionFocused] = useState(false); const inputRef = useRef(null); const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01); + const deviceType = useResponsiveLayout().deviceType; const handleUrlChange = (url: string) => { setM3uUrl(url); @@ -49,6 +52,11 @@ export const LiveStreamSection = forwardRef { + inputRef.current?.focus(); + onPress?.(); + } + const handleTVEvent = React.useCallback( (event: any) => { if (isSectionFocused && event.eventType === "select") { @@ -61,7 +69,9 @@ export const LiveStreamSection = forwardRef + 直播源地址 @@ -81,6 +91,7 @@ export const LiveStreamSection = forwardRef setIsInputFocused(true)} onBlur={() => setIsInputFocused(false)} + // onPress={handlePress} /> diff --git a/components/settings/RemoteInputSection.tsx b/components/settings/RemoteInputSection.tsx index 7a7323a..77218d0 100644 --- a/components/settings/RemoteInputSection.tsx +++ b/components/settings/RemoteInputSection.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from "react"; -import { View, Switch, StyleSheet, Pressable, Animated } from "react-native"; +import { View, Switch, StyleSheet, Pressable, Animated, Platform } from "react-native"; import { useTVEventHandler } from "react-native"; import { ThemedText } from "@/components/ThemedText"; import { SettingsSection } from "./SettingsSection"; @@ -7,18 +7,21 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { useRemoteControlStore } from "@/stores/remoteControlStore"; import { useButtonAnimation } from "@/hooks/useAnimation"; import { Colors } from "@/constants/Colors"; +import { useResponsiveLayout } from "@/hooks/useResponsiveLayout"; interface RemoteInputSectionProps { onChanged: () => void; onFocus?: () => void; onBlur?: () => void; + onPress?: () => void; } -export const RemoteInputSection: React.FC = ({ onChanged, onFocus, onBlur }) => { +export const RemoteInputSection: React.FC = ({ onChanged, onFocus, onBlur, onPress }) => { const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore(); const { isServerRunning, serverUrl, error } = useRemoteControlStore(); const [isFocused, setIsFocused] = React.useState(false); const animationStyle = useButtonAnimation(isFocused, 1.2); + const deviceType = useResponsiveLayout().deviceType; const handleToggle = useCallback( (enabled: boolean) => { @@ -38,6 +41,10 @@ export const RemoteInputSection: React.FC = ({ onChange onBlur?.(); }; + const handlePress = () => { + handleToggle(!remoteInputEnabled); + } + // TV遥控器事件处理 const handleTVEvent = React.useCallback( (event: any) => { @@ -51,7 +58,9 @@ export const RemoteInputSection: React.FC = ({ onChange useTVEventHandler(handleTVEvent); return ( - + 启用远程输入 diff --git a/components/settings/SettingsSection.tsx b/components/settings/SettingsSection.tsx index 2334581..d857f3f 100644 --- a/components/settings/SettingsSection.tsx +++ b/components/settings/SettingsSection.tsx @@ -1,17 +1,20 @@ import React, { useState } from "react"; -import { StyleSheet, Pressable } from "react-native"; +import { StyleSheet, Pressable, Platform } from "react-native"; import { ThemedView } from "@/components/ThemedView"; import { Colors } from "@/constants/Colors"; +import { useResponsiveLayout } from "@/hooks/useResponsiveLayout"; interface SettingsSectionProps { children: React.ReactNode; onFocus?: () => void; onBlur?: () => void; + onPress?: () => void; focusable?: boolean; } -export const SettingsSection: React.FC = ({ children, onFocus, onBlur, focusable = false }) => { +export const SettingsSection: React.FC = ({ children, onFocus, onBlur, onPress, focusable = false }) => { const [isFocused, setIsFocused] = useState(false); + const deviceType = useResponsiveLayout().deviceType; const handleFocus = () => { setIsFocused(true); @@ -23,13 +26,24 @@ export const SettingsSection: React.FC = ({ children, onFo onBlur?.(); }; + const handlePress = () => { + onPress?.(); + } + if (!focusable) { return {children}; } return ( - + {children} diff --git a/package.json b/package.json index d492b18..ad804e4 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "react-native-blob-util": "^0.22.2", "react-native-file-viewer": "^2.1.5", "react-native-gesture-handler": "~2.16.1", + "react-native-keyboard-aware-scroll-view": "^0.9.5", "react-native-media-console": "*", "react-native-qrcode-svg": "^6.3.1", "react-native-reanimated": "~3.10.1", diff --git a/yarn.lock b/yarn.lock index 6573989..d4832c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7775,7 +7775,7 @@ prompts@^2.0.1, prompts@^2.2.1, prompts@^2.3.2, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1: +prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.0, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -7944,6 +7944,19 @@ react-native-helmet-async@2.0.4: react-fast-compare "^3.2.2" shallowequal "^1.1.0" +react-native-iphone-x-helper@^1.0.3: + version "1.3.1" + resolved "https://registry.yarnpkg.com/react-native-iphone-x-helper/-/react-native-iphone-x-helper-1.3.1.tgz#20c603e9a0e765fd6f97396638bdeb0e5a60b010" + integrity sha512-HOf0jzRnq2/aFUcdCJ9w9JGzN3gdEg0zFE4FyYlp4jtidqU03D5X7ZegGKfT1EWteR0gPBGp9ye5T5FvSWi9Yg== + +react-native-keyboard-aware-scroll-view@^0.9.5: + version "0.9.5" + resolved "https://registry.yarnpkg.com/react-native-keyboard-aware-scroll-view/-/react-native-keyboard-aware-scroll-view-0.9.5.tgz#e2e9665d320c188e6b1f22f151b94eb358bf9b71" + integrity sha512-XwfRn+T/qBH9WjTWIBiJD2hPWg0yJvtaEw6RtPCa5/PYHabzBaWxYBOl0usXN/368BL1XktnZPh8C2lmTpOREA== + dependencies: + prop-types "^15.6.2" + react-native-iphone-x-helper "^1.0.3" + react-native-media-console@*: version "2.2.4" resolved "https://registry.yarnpkg.com/react-native-media-console/-/react-native-media-console-2.2.4.tgz#76a232cdcb645cfdb25bacddee514f360eb4947d" @@ -8832,7 +8845,16 @@ string-length@^5.0.1: char-regex "^2.0.0" strip-ansi "^7.0.1" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -8923,7 +8945,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8937,6 +8959,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -9768,7 +9797,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9786,6 +9815,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" From 7af9bf2b4c64d9e802b9222a7aa47d52cb80115b Mon Sep 17 00:00:00 2001 From: James Chen Date: Tue, 2 Sep 2025 09:54:58 +0800 Subject: [PATCH 3/5] feat(ui): add text selection handling in APIConfigSection and LiveStreamSection components, settings remote input can save --- app/settings.tsx | 1 + components/settings/APIConfigSection.tsx | 31 +++++++++++++++++++--- components/settings/LiveStreamSection.tsx | 32 ++++++++++++++++++++--- 3 files changed, 58 insertions(+), 6 deletions(-) diff --git a/app/settings.tsx b/app/settings.tsx index d511000..f860834 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -51,6 +51,7 @@ export default function SettingsScreen() { const realMessage = lastMessage.split("_")[0]; handleRemoteInput(realMessage); clearMessage(); // Clear the message after processing + markAsChanged(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [lastMessage, targetPage]); diff --git a/components/settings/APIConfigSection.tsx b/components/settings/APIConfigSection.tsx index 218b806..14fcaa4 100644 --- a/components/settings/APIConfigSection.tsx +++ b/components/settings/APIConfigSection.tsx @@ -70,10 +70,21 @@ export const APIConfigSection = forwardRef({ + start: 0, + end: 0, + }); + // 当用户手动移动光标或选中文本时,同步到 state(可选) + const onSelectionChange = ({ + nativeEvent: { selection }, + }: any) => { + setSelection(selection); + }; + return ( + {...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }} + > API 地址 @@ -91,7 +102,21 @@ export const APIConfigSection = forwardRef setIsInputFocused(true)} + onFocus={() => { + setIsInputFocused(true); + // 将光标移动到文本末尾 + const end = apiBaseUrl.length; + setSelection({ start: end, end: end }); + // 有时需要延迟一下,让系统先完成 focus 再设置 selection + //(在 Android 上更可靠) + setTimeout(() => { + // 对于受控的 selection 已经生效,这里仅作保险 + inputRef.current?.setNativeProps({ selection: { start: end, end: end } }); + }, 0); + }} + selection={selection} + onSelectionChange={onSelectionChange} // 可选 + onBlur={() => setIsInputFocused(false)} /> diff --git a/components/settings/LiveStreamSection.tsx b/components/settings/LiveStreamSection.tsx index bd1a56d..7d1dd54 100644 --- a/components/settings/LiveStreamSection.tsx +++ b/components/settings/LiveStreamSection.tsx @@ -68,9 +68,21 @@ export const LiveStreamSection = forwardRef({ + start: 0, + end: 0, + }); + // 当用户手动移动光标或选中文本时,同步到 state(可选) + const onSelectionChange = ({ + nativeEvent: { selection }, + }: any) => { + setSelection(selection); + }; + return ( @@ -89,9 +101,23 @@ export const LiveStreamSection = forwardRef setIsInputFocused(true)} + onFocus={() => { + setIsInputFocused(true); + // 将光标移动到文本末尾 + const end = m3uUrl.length; + setSelection({ start: end, end: end }); + // 有时需要延迟一下,让系统先完成 focus 再设置 selection + //(在 Android 上更可靠) + setTimeout(() => { + // 对于受控的 selection 已经生效,这里仅作保险 + inputRef.current?.setNativeProps({ selection: { start: end, end: end } }); + }, 0); + }} + selection={selection} + onSelectionChange={onSelectionChange} // 可选 + onBlur={() => setIsInputFocused(false)} - // onPress={handlePress} + // onPress={handlePress} /> From 2fbbca21e7ab5bf0c5af7a7a6c4a222d2c175c42 Mon Sep 17 00:00:00 2001 From: James Chen Date: Tue, 2 Sep 2025 16:24:20 +0800 Subject: [PATCH 4/5] feat(cache): implement caching mechanism for content data with expiration and size limits, can loadMoreData --- stores/homeStore.ts | 185 ++++++++++++++++++++++++++++++-------------- 1 file changed, 127 insertions(+), 58 deletions(-) diff --git a/stores/homeStore.ts b/stores/homeStore.ts index cb22752..09f2648 100644 --- a/stores/homeStore.ts +++ b/stores/homeStore.ts @@ -55,6 +55,26 @@ const initialCategories: Category[] = [ { title: "豆瓣 Top250", type: "movie", tag: "top250" }, ]; +// 添加缓存项接口 +interface CacheItem { + data: RowItem[]; + timestamp: number; + type: 'movie' | 'tv' | 'record'; + hasMore: boolean; +} + +const CACHE_EXPIRE_TIME = 5 * 60 * 1000; // 5分钟过期 +const MAX_CACHE_SIZE = 10; // 最大缓存容量 +const MAX_ITEMS_PER_CACHE = 40; // 每个缓存最大条目数 + +const getCacheKey = (category: Category) => { + return `${category.type || 'unknown'}-${category.title}-${category.tag || ''}`; +}; + +const isValidCache = (cacheItem: CacheItem) => { + return Date.now() - cacheItem.timestamp < CACHE_EXPIRE_TIME; +}; + interface HomeState { categories: Category[]; selectedCategory: Category; @@ -72,7 +92,7 @@ interface HomeState { } // 内存缓存,应用生命周期内有效 -const dataCache = new Map(); +const dataCache = new Map(); const useHomeStore = create((set, get) => ({ categories: initialCategories, @@ -87,29 +107,30 @@ const useHomeStore = create((set, get) => ({ fetchInitialData: async () => { const { apiBaseUrl } = useSettingsStore.getState(); await useAuthStore.getState().checkLoginStatus(apiBaseUrl); - + const { selectedCategory } = get(); - const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`; - + const cacheKey = getCacheKey(selectedCategory); + // 最近播放不缓存,始终实时获取 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 + if (dataCache.has(cacheKey) && isValidCache(dataCache.get(cacheKey)!)) { + const cachedData = dataCache.get(cacheKey)!; + set({ + loading: false, + contentData: cachedData.data, + pageStart: cachedData.data.length, + hasMore: cachedData.hasMore, + error: null }); return; } - + set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null }); await get().loadMoreData(); }, @@ -151,34 +172,74 @@ const useHomeStore = create((set, get) => ({ set({ contentData: rowItems, hasMore: false }); } else if (selectedCategory.type && selectedCategory.tag) { - const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart); - if (result.list.length === 0) { - set({ hasMore: false }); - } else { - const newItems = result.list.map((item) => ({ - ...item, - id: item.title, - source: "douban", - })) as RowItem[]; - - const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`; - - if (pageStart === 0) { - // 缓存新数据 - dataCache.set(cacheKey, newItems); - set({ - contentData: newItems, - pageStart: result.list.length, - hasMore: true, - }); - } else { - // 增量加载时不缓存,直接追加 - set((state) => ({ - contentData: [...state.contentData, ...newItems], - pageStart: state.pageStart + result.list.length, - hasMore: true, - })); + const result = await api.getDoubanData( + selectedCategory.type, + selectedCategory.tag, + 20, + pageStart + ); + + const newItems = result.list.map((item) => ({ + ...item, + id: item.title, + source: "douban", + })) as RowItem[]; + + const cacheKey = getCacheKey(selectedCategory); + + if (pageStart === 0) { + // 清理过期缓存 + for (const [key, value] of dataCache.entries()) { + if (!isValidCache(value)) { + dataCache.delete(key); + } } + + // 如果缓存太大,删除最旧的项 + if (dataCache.size >= MAX_CACHE_SIZE) { + const oldestKey = Array.from(dataCache.keys())[0]; + dataCache.delete(oldestKey); + } + + // 限制缓存的数据条目数,但不限制显示的数据 + const cacheItems = newItems.slice(0, MAX_ITEMS_PER_CACHE); + + // 存储新缓存 + dataCache.set(cacheKey, { + data: cacheItems, + timestamp: Date.now(), + type: selectedCategory.type, + hasMore: true // 始终为 true,因为我们允许继续加载 + }); + + set({ + contentData: newItems, // 使用完整的新数据 + pageStart: newItems.length, + hasMore: result.list.length === 20, + }); + } else { + // 增量加载时更新缓存 + const existingCache = dataCache.get(cacheKey); + if (existingCache) { + // 只有当缓存数据少于最大限制时才更新缓存 + if (existingCache.data.length < MAX_ITEMS_PER_CACHE) { + const updatedData = [...existingCache.data, ...newItems]; + const limitedCacheData = updatedData.slice(0, MAX_ITEMS_PER_CACHE); + + dataCache.set(cacheKey, { + ...existingCache, + data: limitedCacheData, + hasMore: true // 始终为 true,因为我们允许继续加载 + }); + } + } + + // 更新状态时使用所有数据 + set((state) => ({ + contentData: [...state.contentData, ...newItems], + pageStart: state.pageStart + newItems.length, + hasMore: result.list.length === 20, + })); } } else if (selectedCategory.tags) { // It's a container category, do not load content, but clear current content @@ -188,7 +249,7 @@ const useHomeStore = create((set, get) => ({ } } catch (err: any) { let errorMessage = "加载失败,请重试"; - + if (err.message === "API_URL_NOT_SET") { errorMessage = "请点击右上角设置按钮,配置您的服务器地址"; } else if (err.message === "UNAUTHORIZED") { @@ -204,7 +265,7 @@ const useHomeStore = create((set, get) => ({ } else if (err.message.includes("403")) { errorMessage = "访问被拒绝,请检查权限设置"; } - + set({ error: errorMessage }); } finally { set({ loading: false, loadingMore: false }); @@ -213,27 +274,35 @@ const useHomeStore = create((set, get) => ({ selectCategory: (category: Category) => { const currentCategory = get().selectedCategory; - const cacheKey = `${category.title}-${category.tag || ''}`; - - // 只有当分类或标签真正变化时才处理 + const cacheKey = getCacheKey(category); + 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 + }); + 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 + + const cachedData = dataCache.get(cacheKey); + if (cachedData && isValidCache(cachedData)) { + set({ + contentData: cachedData.data, + pageStart: cachedData.data.length, + hasMore: cachedData.hasMore, + loading: false }); } else { + // 删除过期缓存 + if (cachedData) { + dataCache.delete(cacheKey); + } get().fetchInitialData(); } } @@ -273,10 +342,10 @@ const useHomeStore = create((set, get) => ({ } return {}; }); - + get().fetchInitialData(); }, - + clearError: () => { set({ error: null }); }, From a2fd16ede5030f5835943f17442bcabfcac0cb57 Mon Sep 17 00:00:00 2001 From: James Chen Date: Tue, 2 Sep 2025 18:14:42 +0800 Subject: [PATCH 5/5] fix(homeStore): update hasMore logic to check for non-empty result list --- stores/homeStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stores/homeStore.ts b/stores/homeStore.ts index 09f2648..fd275bc 100644 --- a/stores/homeStore.ts +++ b/stores/homeStore.ts @@ -215,7 +215,7 @@ const useHomeStore = create((set, get) => ({ set({ contentData: newItems, // 使用完整的新数据 pageStart: newItems.length, - hasMore: result.list.length === 20, + hasMore: result.list.length !== 0, }); } else { // 增量加载时更新缓存 @@ -238,7 +238,7 @@ const useHomeStore = create((set, get) => ({ set((state) => ({ contentData: [...state.contentData, ...newItems], pageStart: state.pageStart + newItems.length, - hasMore: result.list.length === 20, + hasMore: result.list.length !== 0, })); } } else if (selectedCategory.tags) {