mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-16 13:14:46 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11cbcf08c1 | ||
|
|
a13d528cbe | ||
|
|
a2fd16ede5 | ||
|
|
2fbbca21e7 | ||
|
|
25e7db084a | ||
|
|
d14fc941c1 | ||
|
|
7af9bf2b4c | ||
|
|
0d9f552ede | ||
|
|
62d8141178 | ||
|
|
023fa591ec | ||
|
|
b401d535ce | ||
|
|
23647f7329 | ||
|
|
67275988bd | ||
|
|
f7ae93bd3d | ||
|
|
f124f7e1e2 | ||
|
|
9bcdeaa44d | ||
|
|
4c93736c5e |
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**Version**
|
||||||
|
- OrionTV:
|
||||||
|
- LunaTV or MoonTV:
|
||||||
@@ -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, Animated, StatusBar } from "react-native";
|
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
@@ -15,6 +15,7 @@ import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
|||||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||||
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
|
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
|
||||||
|
import { Colors } from "@/constants/Colors";
|
||||||
|
|
||||||
const LOAD_MORE_THRESHOLD = 200;
|
const LOAD_MORE_THRESHOLD = 200;
|
||||||
|
|
||||||
@@ -166,7 +167,7 @@ export default function HomeScreen() {
|
|||||||
<View style={dynamicStyles.headerContainer}>
|
<View style={dynamicStyles.headerContainer}>
|
||||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||||
<ThemedText style={dynamicStyles.headerTitle}>首页</ThemedText>
|
<ThemedText style={dynamicStyles.headerTitle}>首页</ThemedText>
|
||||||
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
|
<Pressable android_ripple={Platform.isTV || deviceType !== 'tv'? { color: 'transparent' } : { color: Colors.dark.link }} style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
|
||||||
{({ focused }) => (
|
{({ focused }) => (
|
||||||
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}>直播</ThemedText>
|
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}>直播</ThemedText>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef } from "react";
|
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 { useTVEventHandler } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
@@ -20,6 +20,7 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
|||||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||||
|
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
||||||
@@ -50,6 +51,7 @@ export default function SettingsScreen() {
|
|||||||
const realMessage = lastMessage.split("_")[0];
|
const realMessage = lastMessage.split("_")[0];
|
||||||
handleRemoteInput(realMessage);
|
handleRemoteInput(realMessage);
|
||||||
clearMessage(); // Clear the message after processing
|
clearMessage(); // Clear the message after processing
|
||||||
|
markAsChanged();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [lastMessage, targetPage]);
|
}, [lastMessage, targetPage]);
|
||||||
@@ -164,13 +166,22 @@ export default function SettingsScreen() {
|
|||||||
[currentFocusIndex, sections.length, deviceType]
|
[currentFocusIndex, sections.length, deviceType]
|
||||||
);
|
);
|
||||||
|
|
||||||
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => {});
|
useTVEventHandler(deviceType === "tv" ? handleTVEvent : () => { });
|
||||||
|
|
||||||
// 动态样式
|
// 动态样式
|
||||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing, insets);
|
const dynamicStyles = createResponsiveStyles(deviceType, spacing, insets);
|
||||||
|
|
||||||
const renderSettingsContent = () => (
|
const renderSettingsContent = () => (
|
||||||
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
// <KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
enableOnAndroid={true}
|
||||||
|
extraScrollHeight={20}
|
||||||
|
keyboardOpeningTime={0}
|
||||||
|
keyboardShouldPersistTaps="always"
|
||||||
|
scrollEnabled={true}
|
||||||
|
style={{ flex: 1, backgroundColor }}
|
||||||
|
>
|
||||||
|
|
||||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||||
{deviceType === "tv" && (
|
{deviceType === "tv" && (
|
||||||
<View style={dynamicStyles.header}>
|
<View style={dynamicStyles.header}>
|
||||||
@@ -203,7 +214,8 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAwareScrollView>
|
||||||
|
// </KeyboardAvoidingView>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据设备类型决定是否包装在响应式导航中
|
// 根据设备类型决定是否包装在响应式导航中
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View } from "react-native";
|
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View, Platform } from "react-native";
|
||||||
import { ThemedText } from "./ThemedText";
|
import { ThemedText } from "./ThemedText";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
interface StyledButtonProps extends PressableProps {
|
interface StyledButtonProps extends PressableProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -19,6 +20,7 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
|
|||||||
const colors = Colors[colorScheme];
|
const colors = Colors[colorScheme];
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
const animationStyle = useButtonAnimation(isFocused);
|
const animationStyle = useButtonAnimation(isFocused);
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const variantStyles = {
|
const variantStyles = {
|
||||||
default: StyleSheet.create({
|
default: StyleSheet.create({
|
||||||
@@ -108,6 +110,7 @@ export const StyledButton = forwardRef<View, StyledButtonProps>(
|
|||||||
return (
|
return (
|
||||||
<Animated.View style={[animationStyle, style]}>
|
<Animated.View style={[animationStyle, style]}>
|
||||||
<Pressable
|
<Pressable
|
||||||
|
android_ripple={Platform.isTV || deviceType !== 'tv'? { color: 'transparent' } : { color: Colors.dark.link }}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => setIsFocused(false)}
|
onBlur={() => setIsFocused(false)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
|
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
|
||||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
|
import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert, Animated, Platform } from "react-native";
|
||||||
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";
|
||||||
@@ -7,6 +7,7 @@ import { API } from "@/services/api";
|
|||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
import Logger from '@/utils/Logger';
|
import Logger from '@/utils/Logger';
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
const logger = Logger.withTag('VideoCardTV');
|
const logger = Logger.withTag('VideoCardTV');
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
|||||||
|
|
||||||
const scale = useRef(new Animated.Value(1)).current;
|
const scale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const animatedStyle = {
|
const animatedStyle = {
|
||||||
transform: [{ scale }],
|
transform: [{ scale }],
|
||||||
};
|
};
|
||||||
@@ -147,13 +150,19 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
|
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
|
||||||
<TouchableOpacity
|
<Pressable
|
||||||
|
android_ripple={Platform.isTV || deviceType !== 'tv' ? { color: 'transparent' } : { color: Colors.dark.link }}
|
||||||
onPress={handlePress}
|
onPress={handlePress}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
style={styles.pressable}
|
style={({ pressed }) => [
|
||||||
activeOpacity={1}
|
styles.pressable,
|
||||||
|
{
|
||||||
|
zIndex: pressed ? 999 : 1, // 确保按下时有最高优先级
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
// activeOpacity={1}
|
||||||
delayLongPress={1000}
|
delayLongPress={1000}
|
||||||
>
|
>
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
@@ -203,7 +212,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,9 +230,14 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 8,
|
marginHorizontal: 8,
|
||||||
},
|
},
|
||||||
pressable: {
|
pressable: {
|
||||||
|
width: CARD_WIDTH + 20,
|
||||||
|
height: CARD_HEIGHT + 60,
|
||||||
|
justifyContent: 'center',
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
overflow: "visible",
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
|
marginTop: 10,
|
||||||
width: CARD_WIDTH,
|
width: CARD_WIDTH,
|
||||||
height: CARD_HEIGHT,
|
height: CARD_HEIGHT,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
|
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 { useTVEventHandler } from "react-native";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { SettingsSection } from "./SettingsSection";
|
import { SettingsSection } from "./SettingsSection";
|
||||||
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
|||||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
interface APIConfigSectionProps {
|
interface APIConfigSectionProps {
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
hideDescription?: boolean;
|
hideDescription?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -20,13 +22,14 @@ export interface APIConfigSectionRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSectionProps>(
|
export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSectionProps>(
|
||||||
({ onChanged, onFocus, onBlur, hideDescription = false }, ref) => {
|
({ onChanged, onFocus, onBlur, onPress, hideDescription = false }, ref) => {
|
||||||
const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore();
|
const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore();
|
||||||
const { serverUrl } = useRemoteControlStore();
|
const { serverUrl } = useRemoteControlStore();
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const handleUrlChange = (url: string) => {
|
const handleUrlChange = (url: string) => {
|
||||||
setApiBaseUrl(url);
|
setApiBaseUrl(url);
|
||||||
@@ -60,10 +63,28 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
|
|||||||
[isSectionFocused]
|
[isSectionFocused]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
|
||||||
useTVEventHandler(handleTVEvent);
|
useTVEventHandler(handleTVEvent);
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<{ start: number; end: number }>({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
});
|
||||||
|
// 当用户手动移动光标或选中文本时,同步到 state(可选)
|
||||||
|
const onSelectionChange = ({
|
||||||
|
nativeEvent: { selection },
|
||||||
|
}: any) => {
|
||||||
|
setSelection(selection);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||||
|
{...Platform.isTV || deviceType !== 'tv' ? undefined : { onPress: handlePress }}
|
||||||
|
>
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<ThemedText style={styles.sectionTitle}>API 地址</ThemedText>
|
<ThemedText style={styles.sectionTitle}>API 地址</ThemedText>
|
||||||
@@ -81,7 +102,21 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
|
|||||||
placeholderTextColor="#888"
|
placeholderTextColor="#888"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
onFocus={() => 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)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
|
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 { useTVEventHandler } from "react-native";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { SettingsSection } from "./SettingsSection";
|
import { SettingsSection } from "./SettingsSection";
|
||||||
@@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
|||||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
interface LiveStreamSectionProps {
|
interface LiveStreamSectionProps {
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LiveStreamSectionRef {
|
export interface LiveStreamSectionRef {
|
||||||
@@ -19,13 +21,14 @@ export interface LiveStreamSectionRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSectionProps>(
|
export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSectionProps>(
|
||||||
({ onChanged, onFocus, onBlur }, ref) => {
|
({ onChanged, onFocus, onBlur, onPress }, ref) => {
|
||||||
const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore();
|
const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore();
|
||||||
const { serverUrl } = useRemoteControlStore();
|
const { serverUrl } = useRemoteControlStore();
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
const [isSectionFocused, setIsSectionFocused] = useState(false);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const handleUrlChange = (url: string) => {
|
const handleUrlChange = (url: string) => {
|
||||||
setM3uUrl(url);
|
setM3uUrl(url);
|
||||||
@@ -49,6 +52,11 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
|||||||
onBlur?.();
|
onBlur?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
|
||||||
const handleTVEvent = React.useCallback(
|
const handleTVEvent = React.useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (isSectionFocused && event.eventType === "select") {
|
if (isSectionFocused && event.eventType === "select") {
|
||||||
@@ -60,8 +68,22 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
|||||||
|
|
||||||
useTVEventHandler(handleTVEvent);
|
useTVEventHandler(handleTVEvent);
|
||||||
|
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState<{ start: number; end: number }>({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
});
|
||||||
|
// 当用户手动移动光标或选中文本时,同步到 state(可选)
|
||||||
|
const onSelectionChange = ({
|
||||||
|
nativeEvent: { selection },
|
||||||
|
}: any) => {
|
||||||
|
setSelection(selection);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||||
|
onPress={Platform.isTV || deviceType !== 'tv' ? undefined : handlePress}
|
||||||
|
>
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<ThemedText style={styles.sectionTitle}>直播源地址</ThemedText>
|
<ThemedText style={styles.sectionTitle}>直播源地址</ThemedText>
|
||||||
@@ -79,8 +101,23 @@ export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSect
|
|||||||
placeholderTextColor="#888"
|
placeholderTextColor="#888"
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
onFocus={() => 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)}
|
onBlur={() => setIsInputFocused(false)}
|
||||||
|
// onPress={handlePress}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from "react";
|
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 { useTVEventHandler } from "react-native";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { SettingsSection } from "./SettingsSection";
|
import { SettingsSection } from "./SettingsSection";
|
||||||
@@ -7,18 +7,21 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
|||||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||||
import { useButtonAnimation } from "@/hooks/useAnimation";
|
import { useButtonAnimation } from "@/hooks/useAnimation";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
interface RemoteInputSectionProps {
|
interface RemoteInputSectionProps {
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur }) => {
|
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur, onPress }) => {
|
||||||
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
|
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
|
||||||
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
|
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
|
||||||
const [isFocused, setIsFocused] = React.useState(false);
|
const [isFocused, setIsFocused] = React.useState(false);
|
||||||
const animationStyle = useButtonAnimation(isFocused, 1.2);
|
const animationStyle = useButtonAnimation(isFocused, 1.2);
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
(enabled: boolean) => {
|
(enabled: boolean) => {
|
||||||
@@ -38,6 +41,10 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
|||||||
onBlur?.();
|
onBlur?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
handleToggle(!remoteInputEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
// TV遥控器事件处理
|
// TV遥控器事件处理
|
||||||
const handleTVEvent = React.useCallback(
|
const handleTVEvent = React.useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
@@ -51,7 +58,9 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
|
|||||||
useTVEventHandler(handleTVEvent);
|
useTVEventHandler(handleTVEvent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}
|
||||||
|
{...Platform.isTV||deviceType !=='tv'? undefined :{onPress:handlePress}}
|
||||||
|
>
|
||||||
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
|
||||||
<View style={styles.settingInfo}>
|
<View style={styles.settingInfo}>
|
||||||
<ThemedText style={styles.settingName}>启用远程输入</ThemedText>
|
<ThemedText style={styles.settingName}>启用远程输入</ThemedText>
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { StyleSheet, Pressable } from "react-native";
|
import { StyleSheet, Pressable, Platform } from "react-native";
|
||||||
import { ThemedView } from "@/components/ThemedView";
|
import { ThemedView } from "@/components/ThemedView";
|
||||||
import { Colors } from "@/constants/Colors";
|
import { Colors } from "@/constants/Colors";
|
||||||
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
|
|
||||||
interface SettingsSectionProps {
|
interface SettingsSectionProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
|
onPress?: () => void;
|
||||||
focusable?: boolean;
|
focusable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
|
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, onPress, focusable = false }) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const deviceType = useResponsiveLayout().deviceType;
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
setIsFocused(true);
|
setIsFocused(true);
|
||||||
@@ -23,13 +26,24 @@ export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFo
|
|||||||
onBlur?.();
|
onBlur?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
onPress?.();
|
||||||
|
}
|
||||||
|
|
||||||
if (!focusable) {
|
if (!focusable) {
|
||||||
return <ThemedView style={styles.section}>{children}</ThemedView>;
|
return <ThemedView style={styles.section}>{children}</ThemedView>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
|
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
|
||||||
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
|
<Pressable
|
||||||
|
android_ripple={Platform.isTV||deviceType !=='tv'? {color:'transparent'}:{color:Colors.dark.link}}
|
||||||
|
style={styles.sectionPressable}
|
||||||
|
// {...(Platform.isTV ? {onFocus: handleFocus, onBlur: handleBlur} : {onPress: onPress})}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "OrionTV",
|
"name": "OrionTV",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.3.7",
|
"version": "1.3.9",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||||
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
|
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
"react-native-blob-util": "^0.22.2",
|
"react-native-blob-util": "^0.22.2",
|
||||||
"react-native-file-viewer": "^2.1.5",
|
"react-native-file-viewer": "^2.1.5",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
|
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||||
"react-native-media-console": "*",
|
"react-native-media-console": "*",
|
||||||
"react-native-qrcode-svg": "^6.3.1",
|
"react-native-qrcode-svg": "^6.3.1",
|
||||||
"react-native-reanimated": "~3.10.1",
|
"react-native-reanimated": "~3.10.1",
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ const initialCategories: Category[] = [
|
|||||||
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
|
{ 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 {
|
interface HomeState {
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
selectedCategory: Category;
|
selectedCategory: Category;
|
||||||
@@ -72,7 +92,7 @@ interface HomeState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 内存缓存,应用生命周期内有效
|
// 内存缓存,应用生命周期内有效
|
||||||
const dataCache = new Map<string, RowItem[]>();
|
const dataCache = new Map<string, CacheItem>();
|
||||||
|
|
||||||
const useHomeStore = create<HomeState>((set, get) => ({
|
const useHomeStore = create<HomeState>((set, get) => ({
|
||||||
categories: initialCategories,
|
categories: initialCategories,
|
||||||
@@ -87,29 +107,30 @@ 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 { selectedCategory } = get();
|
||||||
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
|
const cacheKey = getCacheKey(selectedCategory);
|
||||||
|
|
||||||
// 最近播放不缓存,始终实时获取
|
// 最近播放不缓存,始终实时获取
|
||||||
if (selectedCategory.type === 'record') {
|
if (selectedCategory.type === 'record') {
|
||||||
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();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
if (dataCache.has(cacheKey)) {
|
if (dataCache.has(cacheKey) && isValidCache(dataCache.get(cacheKey)!)) {
|
||||||
set({
|
const cachedData = dataCache.get(cacheKey)!;
|
||||||
loading: false,
|
set({
|
||||||
contentData: dataCache.get(cacheKey)!,
|
loading: false,
|
||||||
pageStart: dataCache.get(cacheKey)!.length,
|
contentData: cachedData.data,
|
||||||
hasMore: false,
|
pageStart: cachedData.data.length,
|
||||||
error: null
|
hasMore: cachedData.hasMore,
|
||||||
|
error: null
|
||||||
});
|
});
|
||||||
return;
|
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();
|
||||||
},
|
},
|
||||||
@@ -151,34 +172,74 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
|
|
||||||
set({ contentData: rowItems, hasMore: false });
|
set({ contentData: rowItems, hasMore: false });
|
||||||
} else if (selectedCategory.type && selectedCategory.tag) {
|
} else if (selectedCategory.type && selectedCategory.tag) {
|
||||||
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
|
const result = await api.getDoubanData(
|
||||||
if (result.list.length === 0) {
|
selectedCategory.type,
|
||||||
set({ hasMore: false });
|
selectedCategory.tag,
|
||||||
} else {
|
20,
|
||||||
const newItems = result.list.map((item) => ({
|
pageStart
|
||||||
...item,
|
);
|
||||||
id: item.title,
|
|
||||||
source: "douban",
|
const newItems = result.list.map((item) => ({
|
||||||
})) as RowItem[];
|
...item,
|
||||||
|
id: item.title,
|
||||||
const cacheKey = `${selectedCategory.title}-${selectedCategory.tag || ''}`;
|
source: "douban",
|
||||||
|
})) as RowItem[];
|
||||||
if (pageStart === 0) {
|
|
||||||
// 缓存新数据
|
const cacheKey = getCacheKey(selectedCategory);
|
||||||
dataCache.set(cacheKey, newItems);
|
|
||||||
set({
|
if (pageStart === 0) {
|
||||||
contentData: newItems,
|
// 清理过期缓存
|
||||||
pageStart: result.list.length,
|
for (const [key, value] of dataCache.entries()) {
|
||||||
hasMore: true,
|
if (!isValidCache(value)) {
|
||||||
});
|
dataCache.delete(key);
|
||||||
} else {
|
}
|
||||||
// 增量加载时不缓存,直接追加
|
|
||||||
set((state) => ({
|
|
||||||
contentData: [...state.contentData, ...newItems],
|
|
||||||
pageStart: state.pageStart + result.list.length,
|
|
||||||
hasMore: true,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果缓存太大,删除最旧的项
|
||||||
|
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 !== 0,
|
||||||
|
});
|
||||||
|
} 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 !== 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
} 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
|
||||||
@@ -188,7 +249,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
let errorMessage = "加载失败,请重试";
|
let errorMessage = "加载失败,请重试";
|
||||||
|
|
||||||
if (err.message === "API_URL_NOT_SET") {
|
if (err.message === "API_URL_NOT_SET") {
|
||||||
errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
|
errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
|
||||||
} else if (err.message === "UNAUTHORIZED") {
|
} else if (err.message === "UNAUTHORIZED") {
|
||||||
@@ -204,7 +265,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
} else if (err.message.includes("403")) {
|
} else if (err.message.includes("403")) {
|
||||||
errorMessage = "访问被拒绝,请检查权限设置";
|
errorMessage = "访问被拒绝,请检查权限设置";
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ error: errorMessage });
|
set({ error: errorMessage });
|
||||||
} finally {
|
} finally {
|
||||||
set({ loading: false, loadingMore: false });
|
set({ loading: false, loadingMore: false });
|
||||||
@@ -213,27 +274,35 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
|
|
||||||
selectCategory: (category: Category) => {
|
selectCategory: (category: Category) => {
|
||||||
const currentCategory = get().selectedCategory;
|
const currentCategory = get().selectedCategory;
|
||||||
const cacheKey = `${category.title}-${category.tag || ''}`;
|
const cacheKey = getCacheKey(category);
|
||||||
|
|
||||||
// 只有当分类或标签真正变化时才处理
|
|
||||||
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
|
||||||
|
});
|
||||||
|
|
||||||
if (category.type === 'record') {
|
if (category.type === 'record') {
|
||||||
get().fetchInitialData();
|
get().fetchInitialData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查缓存,有则直接使用,无则请求
|
const cachedData = dataCache.get(cacheKey);
|
||||||
if (dataCache.has(cacheKey)) {
|
if (cachedData && isValidCache(cachedData)) {
|
||||||
set({
|
set({
|
||||||
contentData: dataCache.get(cacheKey)!,
|
contentData: cachedData.data,
|
||||||
pageStart: dataCache.get(cacheKey)!.length,
|
pageStart: cachedData.data.length,
|
||||||
hasMore: false,
|
hasMore: cachedData.hasMore,
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// 删除过期缓存
|
||||||
|
if (cachedData) {
|
||||||
|
dataCache.delete(cacheKey);
|
||||||
|
}
|
||||||
get().fetchInitialData();
|
get().fetchInitialData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,10 +342,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
});
|
});
|
||||||
|
|
||||||
get().fetchInitialData();
|
get().fetchInitialData();
|
||||||
},
|
},
|
||||||
|
|
||||||
clearError: () => {
|
clearError: () => {
|
||||||
set({ error: null });
|
set({ error: null });
|
||||||
},
|
},
|
||||||
|
|||||||
46
yarn.lock
46
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"
|
kleur "^3.0.3"
|
||||||
sisteransi "^1.0.5"
|
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"
|
version "15.8.1"
|
||||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||||
@@ -7944,6 +7944,19 @@ react-native-helmet-async@2.0.4:
|
|||||||
react-fast-compare "^3.2.2"
|
react-fast-compare "^3.2.2"
|
||||||
shallowequal "^1.1.0"
|
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@*:
|
react-native-media-console@*:
|
||||||
version "2.2.4"
|
version "2.2.4"
|
||||||
resolved "https://registry.yarnpkg.com/react-native-media-console/-/react-native-media-console-2.2.4.tgz#76a232cdcb645cfdb25bacddee514f360eb4947d"
|
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"
|
char-regex "^2.0.0"
|
||||||
strip-ansi "^7.0.1"
|
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"
|
version "4.2.3"
|
||||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
@@ -8923,7 +8945,7 @@ string_decoder@~1.1.1:
|
|||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.1.0"
|
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"
|
version "6.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
@@ -8937,6 +8959,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex "^4.1.0"
|
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:
|
strip-ansi@^7.0.1:
|
||||||
version "7.1.0"
|
version "7.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
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"
|
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
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"
|
version "7.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
@@ -9786,6 +9815,15 @@ wrap-ansi@^6.2.0:
|
|||||||
string-width "^4.1.0"
|
string-width "^4.1.0"
|
||||||
strip-ansi "^6.0.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:
|
wrap-ansi@^8.1.0:
|
||||||
version "8.1.0"
|
version "8.1.0"
|
||||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||||
|
|||||||
Reference in New Issue
Block a user