mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-03-01 07:44:44 +08:00
feat: Enhance mobile and tablet support with responsive layout adjustments and new navigation components
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
149
components/MobileBottomNavigation.tsx
Normal file
149
components/MobileBottomNavigation.tsx
Normal 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;
|
||||
@@ -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";
|
||||
|
||||
152
components/ResponsiveButton.tsx
Normal file
152
components/ResponsiveButton.tsx
Normal 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;
|
||||
97
components/ResponsiveCard.tsx
Normal file
97
components/ResponsiveCard.tsx
Normal 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;
|
||||
131
components/ResponsiveTextInput.tsx
Normal file
131
components/ResponsiveTextInput.tsx
Normal 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;
|
||||
373
components/ResponsiveVideoCard.tsx
Normal file
373
components/ResponsiveVideoCard.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
286
components/VideoCard.mobile.tsx
Normal file
286
components/VideoCard.mobile.tsx
Normal 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;
|
||||
334
components/VideoCard.tablet.tsx
Normal file
334
components/VideoCard.tablet.tsx
Normal 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
50
components/VideoCard.tsx
Normal 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;
|
||||
121
components/navigation/MobileBottomTabNavigator.tsx
Normal file
121
components/navigation/MobileBottomTabNavigator.tsx
Normal 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;
|
||||
134
components/navigation/ResponsiveHeader.tsx
Normal file
134
components/navigation/ResponsiveHeader.tsx
Normal 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;
|
||||
48
components/navigation/ResponsiveNavigation.tsx
Normal file
48
components/navigation/ResponsiveNavigation.tsx
Normal 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;
|
||||
240
components/navigation/TabletSidebarNavigator.tsx
Normal file
240
components/navigation/TabletSidebarNavigator.tsx
Normal 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;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user