mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-21 09:14:44 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa7efb0dfb | ||
|
|
01cf3b9a07 | ||
|
|
37d8580b9c | ||
|
|
79308607b8 |
@@ -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, Platform } from "react-native";
|
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated, StatusBar, Platform, BackHandler, ToastAndroid } 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";
|
||||||
@@ -53,6 +53,41 @@ export default function HomeScreen() {
|
|||||||
}, [refreshPlayRecords])
|
}, [refreshPlayRecords])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 双击返回退出逻辑(只限当前页面)
|
||||||
|
const backPressTimeRef = useRef<number | null>(null);
|
||||||
|
const exitToastShownRef = useRef(false); // 防止重复显示提示
|
||||||
|
|
||||||
|
useFocusEffect(
|
||||||
|
useCallback(() => {
|
||||||
|
const handleBackPress = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 如果还没按过返回键,或距离上次超过2秒
|
||||||
|
if (!backPressTimeRef.current || now - backPressTimeRef.current > 2000) {
|
||||||
|
backPressTimeRef.current = now;
|
||||||
|
ToastAndroid.show("再按一次返回键退出", ToastAndroid.SHORT);
|
||||||
|
return true; // 拦截返回事件,不退出
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两次返回键间隔小于2秒,退出应用
|
||||||
|
BackHandler.exitApp();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 仅限 Android 平台启用此功能
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
const backHandler = BackHandler.addEventListener("hardwareBackPress", handleBackPress);
|
||||||
|
|
||||||
|
// 返回首页时重置状态
|
||||||
|
return () => {
|
||||||
|
backHandler.remove();
|
||||||
|
backPressTimeRef.current = null;
|
||||||
|
exitToastShownRef.current = false;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
// 统一的数据获取逻辑
|
// 统一的数据获取逻辑
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCategory) return;
|
if (!selectedCategory) return;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback } from "react";
|
import React, { useCallback, useRef, useState, useEffect } from "react";
|
||||||
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
|
import { View, StyleSheet, ScrollView, ActivityIndicator, TouchableOpacity, BackHandler } from "react-native";
|
||||||
import { ThemedText } from "@/components/ThemedText";
|
import { ThemedText } from "@/components/ThemedText";
|
||||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||||
@@ -20,7 +20,7 @@ interface CustomScrollViewProps {
|
|||||||
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||||
data,
|
data,
|
||||||
renderItem,
|
renderItem,
|
||||||
numColumns, // 现在可选,如果不提供将使用响应式默认值
|
numColumns,
|
||||||
loading = false,
|
loading = false,
|
||||||
loadingMore = false,
|
loadingMore = false,
|
||||||
error = null,
|
error = null,
|
||||||
@@ -29,9 +29,28 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
|||||||
emptyMessage = "暂无内容",
|
emptyMessage = "暂无内容",
|
||||||
ListFooterComponent,
|
ListFooterComponent,
|
||||||
}) => {
|
}) => {
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
const firstCardRef = useRef<any>(null); // <--- 新增
|
||||||
|
const [showScrollToTop, setShowScrollToTop] = useState(false);
|
||||||
const responsiveConfig = useResponsiveLayout();
|
const responsiveConfig = useResponsiveLayout();
|
||||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||||
|
const { deviceType } = responsiveConfig;
|
||||||
|
|
||||||
|
// 添加返回键处理逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
if (deviceType === 'tv') {
|
||||||
|
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||||
|
if (showScrollToTop) {
|
||||||
|
scrollToTop();
|
||||||
|
return true; // 阻止默认的返回行为
|
||||||
|
}
|
||||||
|
return false; // 允许默认的返回行为
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => backHandler.remove();
|
||||||
|
}
|
||||||
|
}, [showScrollToTop,deviceType]);
|
||||||
|
|
||||||
// 使用响应式列数,如果没有明确指定的话
|
// 使用响应式列数,如果没有明确指定的话
|
||||||
const effectiveColumns = numColumns || responsiveConfig.columns;
|
const effectiveColumns = numColumns || responsiveConfig.columns;
|
||||||
|
|
||||||
@@ -40,6 +59,9 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
|||||||
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
|
||||||
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
|
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
|
||||||
|
|
||||||
|
// 显示/隐藏返回顶部按钮
|
||||||
|
setShowScrollToTop(contentOffset.y > 200);
|
||||||
|
|
||||||
if (isCloseToBottom && !loadingMore && onEndReached) {
|
if (isCloseToBottom && !loadingMore && onEndReached) {
|
||||||
onEndReached();
|
onEndReached();
|
||||||
}
|
}
|
||||||
@@ -47,6 +69,14 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
|||||||
[onEndReached, loadingMore, loadMoreThreshold]
|
[onEndReached, loadingMore, loadMoreThreshold]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrollToTop = () => {
|
||||||
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
// 滚动动画结束后聚焦第一个卡片
|
||||||
|
setTimeout(() => {
|
||||||
|
firstCardRef.current?.focus();
|
||||||
|
}, 500); // 500ms 适配大多数动画时长
|
||||||
|
};
|
||||||
|
|
||||||
const renderFooter = () => {
|
const renderFooter = () => {
|
||||||
if (ListFooterComponent) {
|
if (ListFooterComponent) {
|
||||||
if (React.isValidElement(ListFooterComponent)) {
|
if (React.isValidElement(ListFooterComponent)) {
|
||||||
@@ -111,7 +141,8 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
|||||||
marginBottom: responsiveConfig.spacing,
|
marginBottom: responsiveConfig.spacing,
|
||||||
},
|
},
|
||||||
fullRowContainer: {
|
fullRowContainer: {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-around",
|
||||||
|
marginRight: responsiveConfig.spacing / 2,
|
||||||
},
|
},
|
||||||
partialRowContainer: {
|
partialRowContainer: {
|
||||||
justifyContent: "flex-start",
|
justifyContent: "flex-start",
|
||||||
@@ -123,47 +154,72 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
|||||||
width: responsiveConfig.cardWidth,
|
width: responsiveConfig.cardWidth,
|
||||||
marginRight: responsiveConfig.spacing,
|
marginRight: responsiveConfig.spacing,
|
||||||
},
|
},
|
||||||
|
scrollToTopButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: responsiveConfig.spacing,
|
||||||
|
bottom: responsiveConfig.spacing * 2,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
padding: responsiveConfig.spacing,
|
||||||
|
borderRadius: responsiveConfig.spacing,
|
||||||
|
opacity: showScrollToTop ? 1 : 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<View style={{ flex: 1 }}>
|
||||||
contentContainerStyle={dynamicStyles.listContent}
|
<ScrollView
|
||||||
onScroll={handleScroll}
|
ref={scrollViewRef}
|
||||||
scrollEventThrottle={16}
|
contentContainerStyle={dynamicStyles.listContent}
|
||||||
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
|
onScroll={handleScroll}
|
||||||
>
|
scrollEventThrottle={16}
|
||||||
{data.length > 0 ? (
|
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
|
||||||
<>
|
>
|
||||||
{rows.map((row, rowIndex) => {
|
{data.length > 0 ? (
|
||||||
const isFullRow = row.length === effectiveColumns;
|
<>
|
||||||
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
|
{rows.map((row, rowIndex) => {
|
||||||
|
const isFullRow = row.length === effectiveColumns;
|
||||||
return (
|
const rowStyle = isFullRow ? dynamicStyles.fullRowContainer : dynamicStyles.partialRowContainer;
|
||||||
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
|
|
||||||
{row.map((item, itemIndex) => {
|
|
||||||
const actualIndex = rowIndex * effectiveColumns + itemIndex;
|
|
||||||
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
|
|
||||||
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={actualIndex} style={isFullRow ? dynamicStyles.itemContainer : itemStyle}>
|
|
||||||
{renderItem({ item, index: actualIndex })}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{renderFooter()}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<View style={commonStyles.center}>
|
|
||||||
<ThemedText>{emptyMessage}</ThemedText>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</ScrollView>
|
|
||||||
);
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View key={rowIndex} style={[dynamicStyles.rowContainer, rowStyle]}>
|
||||||
|
{row.map((item, itemIndex) => {
|
||||||
|
const actualIndex = rowIndex * effectiveColumns + itemIndex;
|
||||||
|
const isLastItemInPartialRow = !isFullRow && itemIndex === row.length - 1;
|
||||||
|
const itemStyle = isLastItemInPartialRow ? dynamicStyles.itemContainer : dynamicStyles.itemWithMargin;
|
||||||
|
|
||||||
|
const cardProps = {
|
||||||
|
key: actualIndex,
|
||||||
|
style: isFullRow ? dynamicStyles.itemContainer : itemStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...cardProps}>
|
||||||
|
{renderItem({ item, index: actualIndex })}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{renderFooter()}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<View style={commonStyles.center}>
|
||||||
|
<ThemedText>{emptyMessage}</ThemedText>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
{deviceType!=='tv' && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={dynamicStyles.scrollToTopButton}
|
||||||
|
onPress={scrollToTop}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<ThemedText>⬆️</ThemedText>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default CustomScrollView;
|
export default CustomScrollView;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "OrionTV",
|
"name": "OrionTV",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.3.9",
|
"version": "1.3.10",
|
||||||
"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",
|
||||||
|
|||||||
Reference in New Issue
Block a user