feat: Enhance mobile and tablet support with responsive layout adjustments and new navigation components

This commit is contained in:
zimplexing
2025-08-01 16:36:28 +08:00
parent 942703509e
commit 9e9e4597cc
35 changed files with 4082 additions and 634 deletions

View File

@@ -1,11 +1,13 @@
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
interface CustomScrollViewProps {
data: any[];
renderItem: ({ item, index }: { item: any; index: number }) => React.ReactNode;
numColumns?: number;
numColumns?: number; // 如果不提供,将使用响应式默认值
loading?: boolean;
loadingMore?: boolean;
error?: string | null;
@@ -15,12 +17,10 @@ interface CustomScrollViewProps {
ListFooterComponent?: React.ComponentType<any> | React.ReactElement | null;
}
const { width } = Dimensions.get("window");
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
data,
renderItem,
numColumns = 1,
numColumns, // 现在可选,如果不提供将使用响应式默认值
loading = false,
loadingMore = false,
error = null,
@@ -29,7 +29,11 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
emptyMessage = "暂无内容",
ListFooterComponent,
}) => {
const ITEM_WIDTH = numColumns > 0 ? width / numColumns - 24 : width - 24;
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
// 使用响应式列数,如果没有明确指定的话
const effectiveColumns = numColumns || responsiveConfig.columns;
const handleScroll = useCallback(
({ nativeEvent }: { nativeEvent: any }) => {
@@ -61,7 +65,7 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
if (loading) {
return (
<View style={styles.centerContainer}>
<View style={commonStyles.center}>
<ActivityIndicator size="large" />
</View>
);
@@ -69,8 +73,8 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
if (error) {
return (
<View style={styles.centerContainer}>
<ThemedText type="subtitle" style={{ padding: 10 }}>
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: responsiveConfig.spacing }}>
{error}
</ThemedText>
</View>
@@ -79,22 +83,44 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
if (data.length === 0) {
return (
<View style={styles.centerContainer}>
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
);
}
// 动态样式
const dynamicStyles = StyleSheet.create({
listContent: {
paddingBottom: responsiveConfig.spacing * 2,
},
rowContainer: {
flexDirection: "row",
justifyContent: responsiveConfig.deviceType === 'mobile' ? "space-around" : "flex-start",
flexWrap: "wrap",
marginBottom: responsiveConfig.spacing / 2,
},
itemContainer: {
marginHorizontal: responsiveConfig.spacing / 2,
alignItems: "center",
},
});
return (
<ScrollView contentContainerStyle={styles.listContent} onScroll={handleScroll} scrollEventThrottle={16}>
<ScrollView
contentContainerStyle={[commonStyles.gridContainer, dynamicStyles.listContent]}
onScroll={handleScroll}
scrollEventThrottle={16}
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
>
{data.length > 0 ? (
<>
{/* Render content in a grid layout */}
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
<View key={rowIndex} style={styles.rowContainer}>
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
{renderItem({ item, index: rowIndex * numColumns + index })}
{/* Render content in a responsive grid layout */}
{Array.from({ length: Math.ceil(data.length / effectiveColumns) }).map((_, rowIndex) => (
<View key={rowIndex} style={dynamicStyles.rowContainer}>
{data.slice(rowIndex * effectiveColumns, (rowIndex + 1) * effectiveColumns).map((item, index) => (
<View key={index} style={dynamicStyles.itemContainer}>
{renderItem({ item, index: rowIndex * effectiveColumns + index })}
</View>
))}
</View>
@@ -102,34 +128,13 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
{renderFooter()}
</>
) : (
<View style={styles.centerContainer}>
<View style={commonStyles.center}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
paddingTop: 20,
justifyContent: "center",
alignItems: "center",
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 20,
},
rowContainer: {
flexDirection: "row",
justifyContent: "flex-start",
flexWrap: "wrap",
},
itemContainer: {
margin: 8,
alignItems: "center",
},
});
export default CustomScrollView;

View File

@@ -1,13 +1,7 @@
import React from "react";
import { View, Text, StyleSheet, Modal, FlatList, Pressable } from "react-native";
import React, { useState } from "react";
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
import { StyledButton } from "./StyledButton";
import usePlayerStore from "@/stores/playerStore";
import { useState } from "react";
interface Episode {
title?: string;
url: string;
}
interface EpisodeSelectionModalProps {}

View File

@@ -0,0 +1,149 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity } from 'react-native';
import { useRouter, usePathname } from 'expo-router';
import { Home, Heart, Search, Settings, Tv } from 'lucide-react-native';
import { ThemedText } from '@/components/ThemedText';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
interface NavigationItem {
name: string;
label: string;
icon: any;
route: string;
}
const navigationItems: NavigationItem[] = [
{
name: 'home',
label: '首页',
icon: Home,
route: '/',
},
{
name: 'live',
label: '直播',
icon: Tv,
route: '/live',
},
{
name: 'search',
label: '搜索',
icon: Search,
route: '/search',
},
{
name: 'favorites',
label: '收藏',
icon: Heart,
route: '/favorites',
},
{
name: 'settings',
label: '设置',
icon: Settings,
route: '/settings',
},
];
interface MobileBottomNavigationProps {
colorScheme?: 'light' | 'dark';
}
export const MobileBottomNavigation: React.FC<MobileBottomNavigationProps> = ({
colorScheme = 'dark',
}) => {
const router = useRouter();
const pathname = usePathname();
const responsiveConfig = useResponsiveLayout();
// Only show on mobile devices
if (responsiveConfig.deviceType !== 'mobile') {
return null;
}
const handleNavigation = (route: string) => {
if (route === '/') {
router.push('/');
} else {
router.push(route as any);
}
};
const isActiveRoute = (route: string) => {
if (route === '/') {
return pathname === '/';
}
return pathname.startsWith(route);
};
const activeColor = colorScheme === 'dark' ? '#007AFF' : '#007AFF';
const inactiveColor = colorScheme === 'dark' ? '#8E8E93' : '#8E8E93';
const backgroundColor = colorScheme === 'dark' ? '#1C1C1E' : '#F2F2F7';
const dynamicStyles = StyleSheet.create({
container: {
backgroundColor,
borderTopColor: colorScheme === 'dark' ? '#38383A' : '#C6C6C8',
},
});
return (
<View style={[styles.container, dynamicStyles.container]}>
{navigationItems.map((item) => {
const isActive = isActiveRoute(item.route);
const IconComponent = item.icon;
return (
<TouchableOpacity
key={item.name}
style={styles.tabItem}
onPress={() => handleNavigation(item.route)}
activeOpacity={0.7}
>
<IconComponent
size={24}
color={isActive ? activeColor : inactiveColor}
/>
<ThemedText
style={[
styles.tabLabel,
{ color: isActive ? activeColor : inactiveColor },
]}
>
{item.label}
</ThemedText>
</TouchableOpacity>
);
})}
</View>
);
};
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
height: 84, // 49 + 35 for safe area
paddingBottom: 35, // Safe area padding
paddingTop: 8,
paddingHorizontal: 8,
borderTopWidth: 0.5,
justifyContent: 'space-around',
alignItems: 'flex-start',
},
tabItem: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 4,
minHeight: DeviceUtils.getMinTouchTargetSize(),
},
tabLabel: {
fontSize: 11,
marginTop: 2,
textAlign: 'center',
fontWeight: '500',
},
});
export default MobileBottomNavigation;

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Modal, View, Text, StyleSheet } from "react-native";
import { Modal, View, StyleSheet } from "react-native";
import QRCode from "react-native-qrcode-svg";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { ThemedView } from "./ThemedView";

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { TouchableOpacity, StyleSheet, ViewStyle, TextStyle } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
import { Colors } from '@/constants/Colors';
interface ResponsiveButtonProps {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'ghost';
size?: 'small' | 'medium' | 'large';
disabled?: boolean;
fullWidth?: boolean;
icon?: React.ReactNode;
style?: ViewStyle;
textStyle?: TextStyle;
}
const ResponsiveButton: React.FC<ResponsiveButtonProps> = ({
title,
onPress,
variant = 'primary',
size = 'medium',
disabled = false,
fullWidth = false,
icon,
style,
textStyle,
}) => {
const { deviceType, spacing } = useResponsiveLayout();
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const buttonStyle = [
dynamicStyles.baseButton,
dynamicStyles[variant],
dynamicStyles[size],
fullWidth && dynamicStyles.fullWidth,
disabled && dynamicStyles.disabled,
style,
];
const textStyleCombined = [
dynamicStyles.baseText,
dynamicStyles[`${variant}Text`],
dynamicStyles[`${size}Text`],
disabled && dynamicStyles.disabledText,
textStyle,
];
return (
<TouchableOpacity
style={buttonStyle}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
{icon && <>{icon}</>}
<ThemedText style={textStyleCombined}>{title}</ThemedText>
</TouchableOpacity>
);
};
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
baseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
},
// Variants
primary: {
backgroundColor: Colors.dark.primary,
},
secondary: {
backgroundColor: '#2c2c2e',
borderWidth: 1,
borderColor: '#666',
},
ghost: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#666',
},
// Sizes
small: {
paddingHorizontal: spacing * 0.75,
paddingVertical: spacing * 0.5,
minHeight: isMobile ? minTouchTarget * 0.8 : 36,
},
medium: {
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
},
large: {
paddingHorizontal: spacing * 1.5,
paddingVertical: spacing,
minHeight: isMobile ? minTouchTarget * 1.2 : isTablet ? 56 : 52,
},
fullWidth: {
width: '100%',
},
disabled: {
opacity: 0.5,
},
// Text styles
baseText: {
textAlign: 'center',
fontWeight: '600',
},
primaryText: {
color: 'white',
},
secondaryText: {
color: 'white',
},
ghostText: {
color: '#ccc',
},
// Text sizes
smallText: {
fontSize: isMobile ? 14 : 12,
},
mediumText: {
fontSize: isMobile ? 16 : isTablet ? 16 : 14,
},
largeText: {
fontSize: isMobile ? 18 : isTablet ? 18 : 16,
},
disabledText: {
opacity: 0.7,
},
});
};
export default ResponsiveButton;

View File

@@ -0,0 +1,97 @@
import React from 'react';
import { View, StyleSheet, ViewStyle, TouchableOpacity } from 'react-native';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
interface ResponsiveCardProps {
children: React.ReactNode;
onPress?: () => void;
variant?: 'default' | 'elevated' | 'outlined';
padding?: 'small' | 'medium' | 'large';
style?: ViewStyle;
disabled?: boolean;
}
const ResponsiveCard: React.FC<ResponsiveCardProps> = ({
children,
onPress,
variant = 'default',
padding = 'medium',
style,
disabled = false,
}) => {
const { deviceType, spacing } = useResponsiveLayout();
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const cardStyle = [
dynamicStyles.baseCard,
dynamicStyles[variant],
dynamicStyles[padding],
disabled && dynamicStyles.disabled,
style,
];
if (onPress && !disabled) {
return (
<TouchableOpacity
style={cardStyle}
onPress={onPress}
activeOpacity={0.8}
>
{children}
</TouchableOpacity>
);
}
return <View style={cardStyle}>{children}</View>;
};
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
return StyleSheet.create({
baseCard: {
backgroundColor: '#1c1c1e',
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
marginBottom: spacing,
},
// Variants
default: {
backgroundColor: '#1c1c1e',
},
elevated: {
backgroundColor: '#1c1c1e',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: isMobile ? 2 : isTablet ? 4 : 6,
},
shadowOpacity: isMobile ? 0.1 : isTablet ? 0.15 : 0.2,
shadowRadius: isMobile ? 4 : isTablet ? 6 : 8,
elevation: isMobile ? 3 : isTablet ? 5 : 8,
},
outlined: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: '#333',
},
// Padding variants
small: {
padding: spacing * 0.75,
},
medium: {
padding: spacing,
},
large: {
padding: spacing * 1.5,
},
disabled: {
opacity: 0.5,
},
});
};
export default ResponsiveCard;

View File

@@ -0,0 +1,131 @@
import React, { forwardRef } from 'react';
import { TextInput, View, StyleSheet, ViewStyle, TextStyle } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
interface ResponsiveTextInputProps {
placeholder?: string;
value: string;
onChangeText: (text: string) => void;
label?: string;
error?: string;
secureTextEntry?: boolean;
keyboardType?: 'default' | 'numeric' | 'email-address' | 'phone-pad';
multiline?: boolean;
numberOfLines?: number;
editable?: boolean;
style?: ViewStyle;
inputStyle?: TextStyle;
onFocus?: () => void;
onBlur?: () => void;
}
const ResponsiveTextInput = forwardRef<TextInput, ResponsiveTextInputProps>(
(
{
placeholder,
value,
onChangeText,
label,
error,
secureTextEntry = false,
keyboardType = 'default',
multiline = false,
numberOfLines = 1,
editable = true,
style,
inputStyle,
onFocus,
onBlur,
},
ref
) => {
const { deviceType, spacing } = useResponsiveLayout();
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
return (
<View style={[dynamicStyles.container, style]}>
{label && (
<ThemedText style={dynamicStyles.label}>{label}</ThemedText>
)}
<View style={[
dynamicStyles.inputContainer,
error ? dynamicStyles.errorContainer : undefined,
!editable ? dynamicStyles.disabledContainer : undefined,
]}>
<TextInput
ref={ref}
style={[dynamicStyles.input, inputStyle]}
placeholder={placeholder}
placeholderTextColor="#888"
value={value}
onChangeText={onChangeText}
secureTextEntry={secureTextEntry}
keyboardType={keyboardType}
multiline={multiline}
numberOfLines={multiline ? numberOfLines : 1}
editable={editable}
onFocus={onFocus}
onBlur={onBlur}
/>
</View>
{error && (
<ThemedText style={dynamicStyles.errorText}>{error}</ThemedText>
)}
</View>
);
}
);
ResponsiveTextInput.displayName = 'ResponsiveTextInput';
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
marginBottom: spacing,
},
label: {
fontSize: isMobile ? 16 : 14,
fontWeight: '600',
marginBottom: spacing * 0.5,
color: 'white',
},
inputContainer: {
backgroundColor: '#2c2c2e',
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
borderWidth: 2,
borderColor: 'transparent',
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
justifyContent: 'center',
},
input: {
flex: 1,
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
fontSize: isMobile ? 16 : isTablet ? 16 : 14,
color: 'white',
textAlignVertical: 'top', // For multiline inputs
},
errorContainer: {
borderColor: '#ff4444',
},
disabledContainer: {
backgroundColor: '#1a1a1c',
opacity: 0.6,
},
errorText: {
fontSize: isMobile ? 14 : 12,
color: '#ff4444',
marginTop: spacing * 0.25,
},
});
};
export default ResponsiveTextInput;

View File

@@ -0,0 +1,373 @@
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
import { useRouter } from "expo-router";
import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage";
import { API } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { DeviceUtils } from "@/utils/DeviceUtils";
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
sourceName?: string;
progress?: number; // 播放进度0-1之间的小数
playTime?: number; // 播放时间 in ms
episodeIndex?: number; // 剧集索引
totalEpisodes?: number; // 总集数
onFocus?: () => void;
onRecordDeleted?: () => void; // 添加回调属性
api: API;
}
const ResponsiveVideoCard = forwardRef<View, VideoCardProps>(
(
{
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardProps,
ref
) => {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const [fadeAnim] = useState(new Animated.Value(0));
const responsiveConfig = useResponsiveLayout();
const longPressTriggered = useRef(false);
const scale = useRef(new Animated.Value(1)).current;
const animatedStyle = {
transform: [{ scale }],
};
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
const handleFocus = useCallback(() => {
// Only apply focus scaling for TV devices
if (responsiveConfig.deviceType === 'tv') {
setIsFocused(true);
Animated.spring(scale, {
toValue: 1.05,
damping: 15,
stiffness: 200,
useNativeDriver: true,
}).start();
}
onFocus?.();
}, [scale, onFocus, responsiveConfig.deviceType]);
const handleBlur = useCallback(() => {
if (responsiveConfig.deviceType === 'tv') {
setIsFocused(false);
Animated.spring(scale, {
toValue: 1.0,
useNativeDriver: true,
}).start();
}
}, [scale, responsiveConfig.deviceType]);
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: DeviceUtils.getAnimationDuration(400),
delay: Math.random() * 200, // 随机延迟创建交错效果
useNativeDriver: true,
}).start();
}, [fadeAnim]);
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
longPressTriggered.current = true;
// Show confirmation dialog to delete play record
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{
text: "取消",
style: "cancel",
},
{
text: "删除",
style: "destructive",
onPress: async () => {
try {
// Delete from local storage
await PlayRecordManager.remove(source, id);
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace("/");
}
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
// 是否是继续观看的视频
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
// Dynamic styles based on device type
const cardWidth = responsiveConfig.cardWidth;
const cardHeight = responsiveConfig.cardHeight;
const dynamicStyles = StyleSheet.create({
wrapper: {
marginHorizontal: responsiveConfig.spacing / 2,
},
card: {
width: cardWidth,
height: cardHeight,
borderRadius: responsiveConfig.deviceType === 'mobile' ? 8 : responsiveConfig.deviceType === 'tablet' ? 10 : 8,
backgroundColor: "#222",
overflow: "hidden",
},
infoContainer: {
width: cardWidth,
marginTop: responsiveConfig.spacing / 2,
alignItems: "flex-start",
marginBottom: responsiveConfig.spacing,
paddingHorizontal: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.3)",
borderColor: Colors.dark.primary,
borderWidth: responsiveConfig.deviceType === 'tv' ? 2 : 0,
borderRadius: responsiveConfig.deviceType === 'mobile' ? 8 : responsiveConfig.deviceType === 'tablet' ? 10 : 8,
justifyContent: "center",
alignItems: "center",
},
continueWatchingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: Colors.dark.primary,
paddingHorizontal: responsiveConfig.deviceType === 'mobile' ? 8 : 10,
paddingVertical: responsiveConfig.deviceType === 'mobile' ? 4 : 5,
borderRadius: 5,
},
continueWatchingText: {
color: "white",
marginLeft: 5,
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12,
fontWeight: "bold",
},
});
return (
<Animated.View style={[dynamicStyles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={responsiveConfig.deviceType === 'tv' ? 1 : 0.8}
delayLongPress={responsiveConfig.deviceType === 'mobile' ? 500 : 1000}
>
<View style={dynamicStyles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{(isFocused && responsiveConfig.deviceType === 'tv') && (
<View style={dynamicStyles.overlay}>
{isContinueWatching && (
<View style={dynamicStyles.continueWatchingBadge}>
<Play size={responsiveConfig.deviceType === 'tv' ? 16 : 12} color="#ffffff" fill="#ffffff" />
<ThemedText style={dynamicStyles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{rate && (
<View style={[styles.ratingContainer, {
top: responsiveConfig.spacing / 2,
right: responsiveConfig.spacing / 2
}]}>
<Star size={responsiveConfig.deviceType === 'mobile' ? 10 : 12} color="#FFD700" fill="#FFD700" />
<ThemedText style={[styles.ratingText, {
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
}]}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={[styles.yearBadge, {
top: responsiveConfig.spacing / 2,
right: responsiveConfig.spacing / 2
}]}>
<Text style={[styles.badgeText, {
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
}]}>{year}</Text>
</View>
)}
{sourceName && (
<View style={[styles.sourceNameBadge, {
top: responsiveConfig.spacing / 2,
left: responsiveConfig.spacing / 2
}]}>
<Text style={[styles.badgeText, {
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
}]}>{sourceName}</Text>
</View>
)}
</View>
<View style={dynamicStyles.infoContainer}>
<ThemedText
numberOfLines={responsiveConfig.deviceType === 'mobile' ? 2 : 1}
style={{
fontSize: responsiveConfig.deviceType === 'mobile' ? 14 : 16,
lineHeight: responsiveConfig.deviceType === 'mobile' ? 18 : 20,
}}
>
{title}
</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={[styles.continueLabel, {
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
}]}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
);
ResponsiveVideoCard.displayName = "ResponsiveVideoCard";
export default ResponsiveVideoCard;
const styles = StyleSheet.create({
pressable: {
alignItems: "center",
},
poster: {
width: "100%",
height: "100%",
},
buttonRow: {
position: "absolute",
top: 8,
left: 8,
flexDirection: "row",
gap: 8,
},
iconButton: {
padding: 4,
},
favButton: {
position: "absolute",
top: 8,
left: 8,
},
ratingContainer: {
position: "absolute",
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
ratingText: {
color: "#FFD700",
fontWeight: "bold",
marginLeft: 4,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
title: {
color: "white",
fontSize: 16,
fontWeight: "bold",
textAlign: "center",
},
yearBadge: {
position: "absolute",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
sourceNameBadge: {
position: "absolute",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
badgeText: {
color: "white",
fontWeight: "bold",
},
progressContainer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 4,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
progressBar: {
height: 4,
backgroundColor: Colors.dark.primary,
},
continueLabel: {
color: Colors.dark.primary,
},
});

View File

@@ -0,0 +1,286 @@
import React, { useState, useEffect, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
import { useRouter } from "expo-router";
import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage";
import { API } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { DeviceUtils } from "@/utils/DeviceUtils";
interface VideoCardMobileProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
sourceName?: string;
progress?: number;
playTime?: number;
episodeIndex?: number;
totalEpisodes?: number;
onFocus?: () => void;
onRecordDeleted?: () => void;
api: API;
}
const VideoCardMobile = forwardRef<View, VideoCardMobileProps>(
(
{
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardMobileProps,
ref
) => {
const router = useRouter();
const { cardWidth, cardHeight, spacing } = useResponsiveLayout();
const [fadeAnim] = useState(new Animated.Value(0));
const longPressTriggered = useRef(false);
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: DeviceUtils.getAnimationDuration(300),
delay: Math.random() * 100,
useNativeDriver: true,
}).start();
}, [fadeAnim]);
const handleLongPress = () => {
if (progress === undefined) return;
longPressTriggered.current = true;
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{
text: "取消",
style: "cancel",
},
{
text: "删除",
style: "destructive",
onPress: async () => {
try {
await PlayRecordManager.remove(source, id);
onRecordDeleted?.();
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
const styles = createMobileStyles(cardWidth, cardHeight, spacing);
return (
<Animated.View style={[styles.wrapper, { opacity: fadeAnim }]} ref={ref}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
style={styles.pressable}
activeOpacity={0.8}
delayLongPress={800}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{/* 继续观看标识 */}
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={12} color="#ffffff" fill="#ffffff" />
<Text style={styles.continueWatchingText}></Text>
</View>
)}
{/* 评分 */}
{rate && (
<View style={styles.ratingContainer}>
<Star size={10} color="#FFD700" fill="#FFD700" />
<Text style={styles.ratingText}>{rate}</Text>
</View>
)}
{/* 年份 */}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{/* 来源 */}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={2} style={styles.title}>{title}</ThemedText>
{isContinueWatching && (
<ThemedText style={styles.continueLabel} numberOfLines={1}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
);
VideoCardMobile.displayName = "VideoCardMobile";
const createMobileStyles = (cardWidth: number, cardHeight: number, spacing: number) => {
return StyleSheet.create({
wrapper: {
width: cardWidth,
marginHorizontal: spacing / 2,
marginBottom: spacing,
},
pressable: {
alignItems: 'flex-start',
},
card: {
width: cardWidth,
height: cardHeight,
borderRadius: 8,
backgroundColor: "#222",
overflow: "hidden",
},
poster: {
width: "100%",
height: "100%",
resizeMode: 'cover',
},
progressContainer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
progressBar: {
height: 3,
backgroundColor: Colors.dark.primary,
},
continueWatchingBadge: {
position: 'absolute',
top: 6,
left: 6,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Colors.dark.primary,
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
},
continueWatchingText: {
color: "white",
marginLeft: 3,
fontSize: 10,
fontWeight: "bold",
},
ratingContainer: {
position: "absolute",
top: 6,
right: 6,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
ratingText: {
color: "#FFD700",
fontSize: 10,
fontWeight: "bold",
marginLeft: 2,
},
yearBadge: {
position: "absolute",
bottom: 24,
right: 6,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
sourceNameBadge: {
position: "absolute",
bottom: 6,
left: 6,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
},
badgeText: {
color: "white",
fontSize: 9,
fontWeight: "500",
},
infoContainer: {
width: cardWidth,
marginTop: 6,
paddingHorizontal: 2,
},
title: {
fontSize: 13,
lineHeight: 16,
marginBottom: 2,
},
continueLabel: {
color: Colors.dark.primary,
fontSize: 11,
},
});
};
export default VideoCardMobile;

View File

@@ -0,0 +1,334 @@
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
import { useRouter } from "expo-router";
import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage";
import { API } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { DeviceUtils } from "@/utils/DeviceUtils";
interface VideoCardTabletProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
sourceName?: string;
progress?: number;
playTime?: number;
episodeIndex?: number;
totalEpisodes?: number;
onFocus?: () => void;
onRecordDeleted?: () => void;
api: API;
}
const VideoCardTablet = forwardRef<View, VideoCardTabletProps>(
(
{
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardTabletProps,
ref
) => {
const router = useRouter();
const { cardWidth, cardHeight, spacing } = useResponsiveLayout();
const [fadeAnim] = useState(new Animated.Value(0));
const [isPressed, setIsPressed] = useState(false);
const longPressTriggered = useRef(false);
const scale = useRef(new Animated.Value(1)).current;
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
const handlePressIn = useCallback(() => {
setIsPressed(true);
Animated.spring(scale, {
toValue: 0.96,
damping: 15,
stiffness: 300,
useNativeDriver: true,
}).start();
}, [scale]);
const handlePressOut = useCallback(() => {
setIsPressed(false);
Animated.spring(scale, {
toValue: 1.0,
damping: 15,
stiffness: 300,
useNativeDriver: true,
}).start();
}, [scale]);
useEffect(() => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: DeviceUtils.getAnimationDuration(400),
delay: Math.random() * 150,
useNativeDriver: true,
}).start();
}, [fadeAnim]);
const handleLongPress = () => {
if (progress === undefined) return;
longPressTriggered.current = true;
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{
text: "取消",
style: "cancel",
},
{
text: "删除",
style: "destructive",
onPress: async () => {
try {
await PlayRecordManager.remove(source, id);
onRecordDeleted?.();
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
const animatedStyle = {
transform: [{ scale }],
};
const styles = createTabletStyles(cardWidth, cardHeight, spacing);
return (
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]} ref={ref}>
<TouchableOpacity
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onLongPress={handleLongPress}
style={styles.pressable}
activeOpacity={1}
delayLongPress={900}
>
<View style={[styles.card, isPressed && styles.cardPressed]}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{/* 悬停效果遮罩 */}
{isPressed && (
<View style={styles.pressOverlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<Text style={styles.continueWatchingText}></Text>
</View>
)}
</View>
)}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{/* 评分 */}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<Text style={styles.ratingText}>{rate}</Text>
</View>
)}
{/* 年份 */}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{/* 来源 */}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={2} style={styles.title}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel} numberOfLines={1}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
);
VideoCardTablet.displayName = "VideoCardTablet";
const createTabletStyles = (cardWidth: number, cardHeight: number, spacing: number) => {
return StyleSheet.create({
wrapper: {
width: cardWidth,
marginHorizontal: spacing / 2,
marginBottom: spacing,
},
pressable: {
alignItems: 'center',
},
card: {
width: cardWidth,
height: cardHeight,
borderRadius: 10,
backgroundColor: "#222",
overflow: "hidden",
},
cardPressed: {
borderColor: Colors.dark.primary,
borderWidth: 2,
},
poster: {
width: "100%",
height: "100%",
resizeMode: 'cover',
},
pressOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0,0,0,0.4)",
justifyContent: "center",
alignItems: "center",
borderRadius: 10,
},
progressContainer: {
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 4,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
progressBar: {
height: 4,
backgroundColor: Colors.dark.primary,
},
continueWatchingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: Colors.dark.primary,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 6,
},
continueWatchingText: {
color: "white",
marginLeft: 6,
fontSize: 14,
fontWeight: "bold",
},
ratingContainer: {
position: "absolute",
top: 8,
right: 8,
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
ratingText: {
color: "#FFD700",
fontSize: 11,
fontWeight: "bold",
marginLeft: 3,
},
yearBadge: {
position: "absolute",
top: 8,
right: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
sourceNameBadge: {
position: "absolute",
top: 8,
left: 8,
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
badgeText: {
color: "white",
fontSize: 11,
fontWeight: "bold",
},
infoContainer: {
width: cardWidth,
marginTop: 8,
alignItems: "flex-start",
paddingHorizontal: 4,
},
infoRow: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
marginTop: 2,
},
title: {
fontSize: 15,
lineHeight: 18,
},
continueLabel: {
color: Colors.dark.primary,
fontSize: 12,
},
});
};
export default VideoCardTablet;

50
components/VideoCard.tsx Normal file
View File

@@ -0,0 +1,50 @@
import React from 'react';
import { TouchableOpacity } from 'react-native';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { API } from '@/services/api';
// 导入不同平台的VideoCard组件
import VideoCardMobile from './VideoCard.mobile';
import VideoCardTablet from './VideoCard.tablet';
import VideoCardTV from './VideoCard.tv';
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
poster: string;
year?: string;
rate?: string;
sourceName?: string;
progress?: number;
playTime?: number;
episodeIndex?: number;
totalEpisodes?: number;
onFocus?: () => void;
onRecordDeleted?: () => void;
api: API;
}
/**
* 响应式VideoCard组件
* 根据设备类型自动选择合适的VideoCard实现
*/
const VideoCard = React.forwardRef<any, VideoCardProps>((props, ref) => {
const { deviceType } = useResponsiveLayout();
switch (deviceType) {
case 'mobile':
return <VideoCardMobile {...props} ref={ref} />;
case 'tablet':
return <VideoCardTablet {...props} ref={ref} />;
case 'tv':
default:
return <VideoCardTV {...props} ref={ref} />;
}
});
VideoCard.displayName = 'VideoCard';
export default VideoCard;

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
import { useRouter, usePathname } from 'expo-router';
import { Home, Search, Heart, Settings, Tv } from 'lucide-react-native';
import { Colors } from '@/constants/Colors';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
interface TabItem {
key: string;
label: string;
icon: React.ComponentType<any>;
route: string;
}
const tabs: TabItem[] = [
{ key: 'home', label: '首页', icon: Home, route: '/' },
{ key: 'search', label: '搜索', icon: Search, route: '/search' },
{ key: 'live', label: '直播', icon: Tv, route: '/live' },
{ key: 'favorites', label: '收藏', icon: Heart, route: '/favorites' },
{ key: 'settings', label: '设置', icon: Settings, route: '/settings' },
];
const MobileBottomTabNavigator: React.FC = () => {
const router = useRouter();
const pathname = usePathname();
const { spacing } = useResponsiveLayout();
const handleTabPress = (route: string) => {
if (route === '/') {
router.push('/');
} else {
router.push(route as any);
}
};
const isTabActive = (route: string) => {
if (route === '/' && pathname === '/') return true;
if (route !== '/' && pathname === route) return true;
return false;
};
const dynamicStyles = createStyles(spacing);
return (
<View style={dynamicStyles.container}>
{tabs.map((tab) => {
const isActive = isTabActive(tab.route);
const IconComponent = tab.icon;
return (
<TouchableOpacity
key={tab.key}
style={[dynamicStyles.tab, isActive && dynamicStyles.activeTab]}
onPress={() => handleTabPress(tab.route)}
activeOpacity={0.7}
>
<IconComponent
size={20}
color={isActive ? Colors.dark.primary : '#888'}
strokeWidth={isActive ? 2.5 : 2}
/>
<Text style={[
dynamicStyles.tabLabel,
isActive && dynamicStyles.activeTabLabel
]}>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</View>
);
};
const createStyles = (spacing: number) => {
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
flexDirection: 'row',
backgroundColor: '#1c1c1e',
borderTopWidth: 1,
borderTopColor: '#333',
paddingTop: spacing / 2,
paddingBottom: Platform.OS === 'ios' ? spacing * 2 : spacing,
paddingHorizontal: spacing,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: -2,
},
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 10,
},
tab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
minHeight: minTouchTarget,
paddingVertical: spacing / 2,
borderRadius: 8,
},
activeTab: {
backgroundColor: 'rgba(64, 156, 255, 0.1)',
},
tabLabel: {
fontSize: 11,
color: '#888',
marginTop: 2,
fontWeight: '500',
},
activeTabLabel: {
color: Colors.dark.primary,
fontWeight: '600',
},
});
};
export default MobileBottomTabNavigator;

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { View, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { ArrowLeft } from 'lucide-react-native';
import { ThemedText } from '@/components/ThemedText';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
interface ResponsiveHeaderProps {
title?: string;
showBackButton?: boolean;
rightComponent?: React.ReactNode;
onBackPress?: () => void;
}
const ResponsiveHeader: React.FC<ResponsiveHeaderProps> = ({
title,
showBackButton = false,
rightComponent,
onBackPress,
}) => {
const router = useRouter();
const { deviceType, spacing } = useResponsiveLayout();
// TV端不显示Header使用现有的页面内导航
if (deviceType === 'tv') {
return null;
}
const handleBackPress = () => {
if (onBackPress) {
onBackPress();
} else if (router.canGoBack()) {
router.back();
}
};
const dynamicStyles = createStyles(spacing, deviceType);
return (
<>
{Platform.OS === 'android' && <StatusBar backgroundColor="#1c1c1e" barStyle="light-content" />}
<View style={dynamicStyles.container}>
<View style={dynamicStyles.content}>
{/* 左侧区域 */}
<View style={dynamicStyles.leftSection}>
{showBackButton && (
<TouchableOpacity
onPress={handleBackPress}
style={dynamicStyles.backButton}
activeOpacity={0.7}
>
<ArrowLeft size={20} color="#fff" strokeWidth={2} />
</TouchableOpacity>
)}
</View>
{/* 中间标题区域 */}
<View style={dynamicStyles.centerSection}>
{title && (
<ThemedText style={dynamicStyles.title} numberOfLines={1}>
{title}
</ThemedText>
)}
</View>
{/* 右侧区域 */}
<View style={dynamicStyles.rightSection}>
{rightComponent}
</View>
</View>
</View>
</>
);
};
const createStyles = (spacing: number, deviceType: string) => {
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
const statusBarHeight = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 24;
return StyleSheet.create({
container: {
backgroundColor: '#1c1c1e',
paddingTop: statusBarHeight,
borderBottomWidth: 1,
borderBottomColor: '#333',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.1,
shadowRadius: 3,
elevation: 5,
},
content: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
minHeight: minTouchTarget + spacing,
},
leftSection: {
width: minTouchTarget + spacing,
justifyContent: 'flex-start',
alignItems: 'flex-start',
},
centerSection: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
rightSection: {
width: minTouchTarget + spacing,
justifyContent: 'flex-end',
alignItems: 'flex-end',
flexDirection: 'row',
},
backButton: {
width: minTouchTarget,
height: minTouchTarget,
justifyContent: 'center',
alignItems: 'center',
borderRadius: minTouchTarget / 2,
},
title: {
fontSize: DeviceUtils.getOptimalFontSize(deviceType === 'mobile' ? 18 : 20),
fontWeight: '600',
color: '#fff',
},
});
};
export default ResponsiveHeader;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import MobileBottomTabNavigator from './MobileBottomTabNavigator';
import TabletSidebarNavigator from './TabletSidebarNavigator';
interface ResponsiveNavigationProps {
children: React.ReactNode;
}
const ResponsiveNavigation: React.FC<ResponsiveNavigationProps> = ({ children }) => {
const { deviceType } = useResponsiveLayout();
switch (deviceType) {
case 'mobile':
return (
<View style={styles.container}>
<View style={styles.content}>
{children}
</View>
<MobileBottomTabNavigator />
</View>
);
case 'tablet':
return (
<TabletSidebarNavigator>
{children}
</TabletSidebarNavigator>
);
case 'tv':
default:
// TV端保持原有的Stack导航不需要额外的导航容器
return <>{children}</>;
}
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
flex: 1,
},
});
export default ResponsiveNavigation;

View File

@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { View, StyleSheet, TouchableOpacity, Text, ScrollView } from 'react-native';
import { useRouter, usePathname } from 'expo-router';
import { Home, Search, Heart, Settings, Tv, Menu, X } from 'lucide-react-native';
import { Colors } from '@/constants/Colors';
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
import { ThemedText } from '@/components/ThemedText';
interface SidebarItem {
key: string;
label: string;
icon: React.ComponentType<any>;
route: string;
section?: string;
}
const sidebarItems: SidebarItem[] = [
{ key: 'home', label: '首页', icon: Home, route: '/', section: 'main' },
{ key: 'search', label: '搜索', icon: Search, route: '/search', section: 'main' },
{ key: 'live', label: '直播', icon: Tv, route: '/live', section: 'main' },
{ key: 'favorites', label: '收藏', icon: Heart, route: '/favorites', section: 'user' },
{ key: 'settings', label: '设置', icon: Settings, route: '/settings', section: 'user' },
];
interface TabletSidebarNavigatorProps {
children: React.ReactNode;
collapsed?: boolean;
onToggleCollapse?: (collapsed: boolean) => void;
}
const TabletSidebarNavigator: React.FC<TabletSidebarNavigatorProps> = ({
children,
collapsed: controlledCollapsed,
onToggleCollapse,
}) => {
const router = useRouter();
const pathname = usePathname();
const { spacing, isPortrait } = useResponsiveLayout();
const [internalCollapsed, setInternalCollapsed] = useState(false);
// 使用外部控制的collapsed状态如果没有则使用内部状态
const collapsed = controlledCollapsed !== undefined ? controlledCollapsed : internalCollapsed;
const handleToggleCollapse = () => {
if (onToggleCollapse) {
onToggleCollapse(!collapsed);
} else {
setInternalCollapsed(!collapsed);
}
};
const handleItemPress = (route: string) => {
if (route === '/') {
router.push('/');
} else {
router.push(route as any);
}
// 在竖屏模式下,导航后自动折叠侧边栏
if (isPortrait && !controlledCollapsed) {
setInternalCollapsed(true);
}
};
const isItemActive = (route: string) => {
if (route === '/' && pathname === '/') return true;
if (route !== '/' && pathname === route) return true;
return false;
};
const sidebarWidth = collapsed ? 60 : 200;
const dynamicStyles = createStyles(spacing, sidebarWidth, isPortrait);
const renderSidebarItems = () => {
const sections = ['main', 'user'];
return sections.map((section) => {
const sectionItems = sidebarItems.filter(item => item.section === section);
return (
<View key={section} style={dynamicStyles.section}>
{!collapsed && (
<ThemedText style={dynamicStyles.sectionTitle}>
{section === 'main' ? '主要功能' : '用户'}
</ThemedText>
)}
{sectionItems.map((item) => {
const isActive = isItemActive(item.route);
const IconComponent = item.icon;
return (
<TouchableOpacity
key={item.key}
style={[dynamicStyles.sidebarItem, isActive && dynamicStyles.activeSidebarItem]}
onPress={() => handleItemPress(item.route)}
activeOpacity={0.7}
>
<IconComponent
size={20}
color={isActive ? Colors.dark.primary : '#ccc'}
strokeWidth={isActive ? 2.5 : 2}
/>
{!collapsed && (
<Text style={[
dynamicStyles.sidebarItemLabel,
isActive && dynamicStyles.activeSidebarItemLabel
]}>
{item.label}
</Text>
)}
</TouchableOpacity>
);
})}
</View>
);
});
};
return (
<View style={dynamicStyles.container}>
{/* 侧边栏 */}
<View style={[dynamicStyles.sidebar, collapsed && dynamicStyles.collapsedSidebar]}>
{/* 侧边栏头部 */}
<View style={dynamicStyles.sidebarHeader}>
<TouchableOpacity
onPress={handleToggleCollapse}
style={dynamicStyles.toggleButton}
activeOpacity={0.7}
>
{collapsed ? (
<Menu size={20} color="#ccc" />
) : (
<X size={20} color="#ccc" />
)}
</TouchableOpacity>
{!collapsed && (
<ThemedText style={dynamicStyles.appTitle}>OrionTV</ThemedText>
)}
</View>
{/* 侧边栏内容 */}
<ScrollView style={dynamicStyles.sidebarContent} showsVerticalScrollIndicator={false}>
{renderSidebarItems()}
</ScrollView>
</View>
{/* 主内容区域 */}
<View style={dynamicStyles.content}>
{children}
</View>
</View>
);
};
const createStyles = (spacing: number, sidebarWidth: number, isPortrait: boolean) => {
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
},
sidebar: {
width: sidebarWidth,
backgroundColor: '#1c1c1e',
borderRightWidth: 1,
borderRightColor: '#333',
zIndex: isPortrait ? 1000 : 1, // 在竖屏时提高层级
},
collapsedSidebar: {
width: 60,
},
sidebarHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing,
paddingVertical: spacing * 1.5,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
toggleButton: {
width: minTouchTarget,
height: minTouchTarget,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
},
appTitle: {
fontSize: 18,
fontWeight: 'bold',
marginLeft: spacing,
color: Colors.dark.primary,
},
sidebarContent: {
flex: 1,
paddingTop: spacing,
},
section: {
marginBottom: spacing * 1.5,
},
sectionTitle: {
fontSize: 12,
color: '#888',
fontWeight: '600',
textTransform: 'uppercase',
marginBottom: spacing / 2,
marginHorizontal: spacing,
},
sidebarItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
marginHorizontal: spacing / 2,
borderRadius: 8,
minHeight: minTouchTarget,
},
activeSidebarItem: {
backgroundColor: 'rgba(64, 156, 255, 0.15)',
},
sidebarItemLabel: {
fontSize: 14,
color: '#ccc',
marginLeft: spacing,
fontWeight: '500',
},
activeSidebarItemLabel: {
color: Colors.dark.primary,
fontWeight: '600',
},
content: {
flex: 1,
backgroundColor: '#000',
},
});
};
export default TabletSidebarNavigator;

View File

@@ -54,20 +54,24 @@ export function UpdateSection() {
<View style={styles.buttonContainer}>
<StyledButton
title={checking ? "检查中..." : "检查更新"}
onPress={handleCheckUpdate}
disabled={checking || downloading}
style={styles.button}
>
{checking && <ActivityIndicator color="#fff" size="small" />}
{checking ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<ThemedText style={styles.buttonText}></ThemedText>
)}
</StyledButton>
{updateAvailable && !downloading && (
<StyledButton
title="立即更新"
onPress={() => setShowUpdateModal(true)}
style={[styles.button, styles.updateButton]}
/>
>
<ThemedText style={styles.buttonText}></ThemedText>
</StyledButton>
)}
</View>
@@ -124,6 +128,11 @@ const styles = StyleSheet.create({
updateButton: {
backgroundColor: "#00bb5e",
},
buttonText: {
color: "#ffffff",
fontSize: Platform.isTV ? 16 : 14,
fontWeight: "500",
},
hint: {
fontSize: Platform.isTV ? 14 : 12,
color: "#666",