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

@@ -3,7 +3,7 @@ import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { Platform } from "react-native";
import { Platform, View, StyleSheet } from "react-native";
import Toast from "react-native-toast-message";
import { useSettingsStore } from "@/stores/settingsStore";
@@ -13,6 +13,8 @@ import useAuthStore from "@/stores/authStore";
import { useUpdateStore, initUpdateStore } from "@/stores/updateStore";
import { UpdateModal } from "@/components/UpdateModal";
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
import MobileBottomNavigation from "@/components/MobileBottomNavigation";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -26,6 +28,7 @@ export default function RootLayout() {
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
const { checkForUpdate, lastCheckTime } = useUpdateStore();
const responsiveConfig = useResponsiveLayout();
useEffect(() => {
loadSettings();
@@ -70,21 +73,32 @@ export default function RootLayout() {
return null;
}
const isMobile = responsiveConfig.deviceType === 'mobile';
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="detail" options={{ headerShown: false }} />
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="favorites" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<View style={styles.container}>
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="detail" options={{ headerShown: false }} />
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="favorites" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
{isMobile && <MobileBottomNavigation colorScheme={colorScheme} />}
</View>
<Toast />
<LoginModal />
<UpdateModal />
</ThemeProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});

View File

@@ -1,5 +1,5 @@
import React, { useEffect } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
@@ -7,11 +7,20 @@ import { StyledButton } from "@/components/StyledButton";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { FontAwesome } from "@expo/vector-icons";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
export default function DetailScreen() {
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
const router = useRouter();
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
const {
detail,
searchResults,
@@ -54,80 +63,108 @@ export default function DetailScreen() {
}
if (error) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle" style={styles.text}>
const content = (
<ThemedView style={[commonStyles.safeContainer, commonStyles.center]}>
<ThemedText type="subtitle" style={commonStyles.textMedium}>
{error}
</ThemedText>
</ThemedView>
);
}
if (!detail) {
if (deviceType === 'tv') {
return content;
}
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle"></ThemedText>
</ThemedView>
<ResponsiveNavigation>
<ResponsiveHeader title="详情" showBackButton />
{content}
</ResponsiveNavigation>
);
}
return (
<ThemedView style={styles.container}>
<ScrollView>
<View style={styles.topContainer}>
<Image source={{ uri: detail.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<View style={styles.titleContainer}>
<ThemedText style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={styles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={styles.metaContainer}>
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View>
if (!detail) {
const content = (
<ThemedView style={[commonStyles.safeContainer, commonStyles.center]}>
<ThemedText type="subtitle"></ThemedText>
</ThemedView>
);
<ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView>
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title="详情" showBackButton />
{content}
</ResponsiveNavigation>
);
}
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const renderDetailContent = () => {
if (deviceType === 'mobile') {
// 移动端垂直布局
return (
<ScrollView style={dynamicStyles.scrollContainer}>
{/* 海报和基本信息 */}
<View style={dynamicStyles.mobileTopContainer}>
<Image source={{ uri: detail.poster }} style={dynamicStyles.mobilePoster} />
<View style={dynamicStyles.mobileInfoContainer}>
<View style={dynamicStyles.titleContainer}>
<ThemedText style={dynamicStyles.title} numberOfLines={2}>
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={dynamicStyles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={20}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={dynamicStyles.metaContainer}>
<ThemedText style={dynamicStyles.metaText}>{detail.year}</ThemedText>
<ThemedText style={dynamicStyles.metaText}>{detail.type_name}</ThemedText>
</View>
</View>
</View>
</View>
<View style={styles.bottomContainer}>
<View style={styles.sourcesContainer}>
<View style={styles.sourcesTitleContainer}>
<ThemedText style={styles.sourcesTitle}> {searchResults.length} </ThemedText>
{/* 描述 */}
<View style={dynamicStyles.descriptionContainer}>
<ThemedText style={dynamicStyles.description}>{detail.desc}</ThemedText>
</View>
{/* 播放源 */}
<View style={dynamicStyles.sourcesContainer}>
<View style={dynamicStyles.sourcesTitleContainer}>
<ThemedText style={dynamicStyles.sourcesTitle}> ({searchResults.length})</ThemedText>
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={styles.sourceList}>
<View style={dynamicStyles.sourceList}>
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={styles.sourceButton}
style={dynamicStyles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
<ThemedText style={dynamicStyles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>
<View style={[dynamicStyles.badge, isSelected && dynamicStyles.selectedBadge]}>
<Text style={dynamicStyles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
<View style={[dynamicStyles.badge, { backgroundColor: "#666" }, isSelected && dynamicStyles.selectedBadge]}>
<Text style={dynamicStyles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
@@ -135,144 +172,278 @@ export default function DetailScreen() {
})}
</View>
</View>
<View style={styles.episodesContainer}>
<ThemedText style={styles.episodesTitle}></ThemedText>
<ScrollView contentContainerStyle={styles.episodeList}>
{/* 剧集列表 */}
<View style={dynamicStyles.episodesContainer}>
<ThemedText style={dynamicStyles.episodesTitle}></ThemedText>
<View style={dynamicStyles.episodeList}>
{detail.episodes.map((episode, index) => (
<StyledButton
key={index}
style={styles.episodeButton}
style={dynamicStyles.episodeButton}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
textStyle={dynamicStyles.episodeButtonText}
/>
))}
</ScrollView>
</View>
</View>
</View>
</ScrollView>
</ScrollView>
);
} else {
// 平板和TV端水平布局
return (
<ScrollView style={dynamicStyles.scrollContainer}>
<View style={dynamicStyles.topContainer}>
<Image source={{ uri: detail.poster }} style={dynamicStyles.poster} />
<View style={dynamicStyles.infoContainer}>
<View style={dynamicStyles.titleContainer}>
<ThemedText style={dynamicStyles.title} numberOfLines={1} ellipsizeMode="tail">
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={dynamicStyles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={dynamicStyles.metaContainer}>
<ThemedText style={dynamicStyles.metaText}>{detail.year}</ThemedText>
<ThemedText style={dynamicStyles.metaText}>{detail.type_name}</ThemedText>
</View>
<ScrollView style={dynamicStyles.descriptionScrollView}>
<ThemedText style={dynamicStyles.description}>{detail.desc}</ThemedText>
</ScrollView>
</View>
</View>
<View style={dynamicStyles.bottomContainer}>
<View style={dynamicStyles.sourcesContainer}>
<View style={dynamicStyles.sourcesTitleContainer}>
<ThemedText style={dynamicStyles.sourcesTitle}> {searchResults.length} </ThemedText>
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={dynamicStyles.sourceList}>
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={dynamicStyles.sourceButton}
>
<ThemedText style={dynamicStyles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[dynamicStyles.badge, isSelected && dynamicStyles.selectedBadge]}>
<Text style={dynamicStyles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[dynamicStyles.badge, { backgroundColor: "#666" }, isSelected && dynamicStyles.selectedBadge]}>
<Text style={dynamicStyles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
);
})}
</View>
</View>
<View style={dynamicStyles.episodesContainer}>
<ThemedText style={dynamicStyles.episodesTitle}></ThemedText>
<ScrollView contentContainerStyle={dynamicStyles.episodeList}>
{detail.episodes.map((episode, index) => (
<StyledButton
key={index}
style={dynamicStyles.episodeButton}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={dynamicStyles.episodeButtonText}
/>
))}
</ScrollView>
</View>
</View>
</ScrollView>
);
}
};
const content = (
<ThemedView style={[commonStyles.container, { paddingTop: deviceType === 'tv' ? 40 : 0 }]}>
{renderDetailContent()}
</ThemedView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title={detail?.title || "详情"} showBackButton />
{content}
</ResponsiveNavigation>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
topContainer: {
flexDirection: "row",
padding: 20,
},
text: {
padding: 20,
textAlign: "center",
},
poster: {
width: 200,
height: 300,
borderRadius: 8,
},
infoContainer: {
flex: 1,
marginLeft: 20,
justifyContent: "flex-start",
},
titleContainer: {
flexDirection: "row",
alignItems: "center",
},
title: {
paddingTop: 16,
fontSize: 28,
fontWeight: "bold",
flexShrink: 1,
},
metaContainer: {
flexDirection: "row",
marginBottom: 10,
},
metaText: {
color: "#aaa",
marginRight: 10,
fontSize: 14,
},
descriptionScrollView: {
height: 150, // Constrain height to make it scrollable
},
description: {
fontSize: 14,
color: "#ccc",
lineHeight: 22,
},
favoriteButton: {
padding: 10,
marginLeft: 10,
backgroundColor: "transparent",
},
favoriteButtonText: {
marginLeft: 8,
fontSize: 16,
},
bottomContainer: {
paddingHorizontal: 20,
},
sourcesContainer: {
marginTop: 20,
},
sourcesTitleContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 10,
},
sourcesTitle: {
fontSize: 20,
fontWeight: "bold",
},
sourceList: {
flexDirection: "row",
flexWrap: "wrap",
},
sourceButton: {
margin: 8,
},
sourceButtonText: {
color: "white",
fontSize: 16,
},
badge: {
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: "#fff",
fontSize: 12,
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
selectedbadgeText: {
color: "#333",
},
episodesContainer: {
marginTop: 20,
},
episodesTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 10,
},
episodeList: {
flexDirection: "row",
flexWrap: "wrap",
},
episodeButton: {
margin: 5,
},
episodeButtonText: {
color: "white",
},
});
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isTV = deviceType === 'tv';
const isTablet = deviceType === 'tablet';
const isMobile = deviceType === 'mobile';
return StyleSheet.create({
scrollContainer: {
flex: 1,
},
// 移动端专用样式
mobileTopContainer: {
paddingHorizontal: spacing,
paddingTop: spacing,
paddingBottom: spacing / 2,
},
mobilePoster: {
width: '100%',
height: 280,
borderRadius: 8,
alignSelf: 'center',
marginBottom: spacing,
},
mobileInfoContainer: {
flex: 1,
},
descriptionContainer: {
paddingHorizontal: spacing,
paddingBottom: spacing,
},
// 平板和TV端样式
topContainer: {
flexDirection: "row",
padding: spacing,
},
poster: {
width: isTV ? 200 : 160,
height: isTV ? 300 : 240,
borderRadius: 8,
},
infoContainer: {
flex: 1,
marginLeft: spacing,
justifyContent: "flex-start",
},
descriptionScrollView: {
height: 150,
},
// 通用样式
titleContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing / 2,
},
title: {
paddingTop: 16,
fontSize: isMobile ? 20 : isTablet ? 24 : 28,
fontWeight: "bold",
flexShrink: 1,
color: 'white',
},
favoriteButton: {
padding: 10,
marginLeft: 10,
backgroundColor: "transparent",
},
metaContainer: {
flexDirection: "row",
marginBottom: spacing / 2,
},
metaText: {
color: "#aaa",
marginRight: spacing / 2,
fontSize: isMobile ? 12 : 14,
},
description: {
fontSize: isMobile ? 13 : 14,
color: "#ccc",
lineHeight: isMobile ? 18 : 22,
},
// 播放源和剧集样式
bottomContainer: {
paddingHorizontal: spacing,
},
sourcesContainer: {
marginTop: spacing,
},
sourcesTitleContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: spacing / 2,
},
sourcesTitle: {
fontSize: isMobile ? 16 : isTablet ? 18 : 20,
fontWeight: "bold",
color: 'white',
},
sourceList: {
flexDirection: "row",
flexWrap: "wrap",
},
sourceButton: {
margin: isMobile ? 4 : 8,
minHeight: isMobile ? 36 : 44,
},
sourceButtonText: {
color: "white",
fontSize: isMobile ? 14 : 16,
},
badge: {
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: "#fff",
fontSize: isMobile ? 10 : 12,
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
episodesContainer: {
marginTop: spacing,
paddingBottom: spacing * 2,
},
episodesTitle: {
fontSize: isMobile ? 16 : isTablet ? 18 : 20,
fontWeight: "bold",
marginBottom: spacing / 2,
color: 'white',
},
episodeList: {
flexDirection: "row",
flexWrap: "wrap",
},
episodeButton: {
margin: isMobile ? 3 : 5,
minHeight: isMobile ? 32 : 36,
},
episodeButtonText: {
color: "white",
fontSize: isMobile ? 12 : 14,
},
});
};

View File

@@ -1,16 +1,25 @@
import React, { useEffect } from "react";
import { View, StyleSheet, ActivityIndicator } from "react-native";
import { View, StyleSheet } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import useFavoritesStore from "@/stores/favoritesStore";
import { Favorite } from "@/services/storage";
import VideoCard from "@/components/VideoCard.tv";
import VideoCard from "@/components/VideoCard";
import { api } from "@/services/api";
import CustomScrollView from "@/components/CustomScrollView";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
export default function FavoritesScreen() {
const { favorites, loading, error, fetchFavorites } = useFavoritesStore();
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
useEffect(() => {
fetchFavorites();
}, [fetchFavorites]);
@@ -32,46 +41,67 @@ export default function FavoritesScreen() {
);
};
return (
<ThemedView style={styles.container}>
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
</View>
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const renderFavoritesContent = () => (
<>
{deviceType === 'tv' && (
<View style={dynamicStyles.headerContainer}>
<ThemedText style={dynamicStyles.headerTitle}></ThemedText>
</View>
)}
<CustomScrollView
data={favorites}
renderItem={renderItem}
numColumns={5}
loading={loading}
error={error}
emptyMessage="暂无收藏"
/>
</>
);
const content = (
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{renderFavoritesContent()}
</ThemedView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title="我的收藏" showBackButton />
{content}
</ResponsiveNavigation>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 40,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 10,
},
headerTitle: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 16,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
list: {
padding: 10,
},
});
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
const isTV = deviceType === 'tv';
return StyleSheet.create({
container: {
flex: 1,
paddingTop: isTV ? spacing * 2 : 0,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing * 1.5,
marginBottom: spacing / 2,
},
headerTitle: {
fontSize: isMobile ? 24 : isTablet ? 28 : 32,
fontWeight: "bold",
paddingTop: spacing,
color: 'white',
},
});
};

View File

@@ -1,20 +1,19 @@
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions, Animated } from "react-native";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv";
import VideoCard from "@/components/VideoCard";
import { useFocusEffect, useRouter } from "expo-router";
import { Search, Settings, LogOut, Heart } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore";
import CustomScrollView from "@/components/CustomScrollView";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
// Threshold for triggering load more data (in pixels)
const LOAD_MORE_THRESHOLD = 200;
export default function HomeScreen() {
@@ -23,6 +22,11 @@ export default function HomeScreen() {
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const fadeAnim = useRef(new Animated.Value(0)).current;
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
const {
categories,
selectedCategory,
@@ -47,7 +51,6 @@ export default function HomeScreen() {
if (selectedCategory && !selectedCategory.tags) {
fetchInitialData();
} else if (selectedCategory?.tags && !selectedCategory.tag) {
// Category with tags selected, but no specific tag yet. Select the first one.
const defaultTag = selectedCategory.tags[0];
setSelectedTag(defaultTag);
selectCategory({ ...selectedCategory, tag: defaultTag });
@@ -80,7 +83,6 @@ export default function HomeScreen() {
const handleTagSelect = (tag: string) => {
setSelectedTag(tag);
if (selectedCategory) {
// Create a new category object with the selected tag
const categoryWithTag = { ...selectedCategory, tag: tag };
selectCategory(categoryWithTag);
}
@@ -93,30 +95,28 @@ export default function HomeScreen() {
text={item.title}
onPress={() => handleCategorySelect(item)}
isSelected={isSelected}
style={styles.categoryButton}
textStyle={styles.categoryText}
style={dynamicStyles.categoryButton}
textStyle={dynamicStyles.categoryText}
/>
);
};
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
<View style={styles.itemContainer}>
<VideoCard
id={item.id}
source={item.source}
title={item.title}
poster={item.poster}
year={item.year}
rate={item.rate}
progress={item.progress}
playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}
api={api}
onRecordDeleted={fetchInitialData} // For "Recent Plays"
/>
</View>
<VideoCard
id={item.id}
source={item.source}
title={item.title}
poster={item.poster}
year={item.year}
rate={item.rate}
progress={item.progress}
playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}
api={api}
onRecordDeleted={fetchInitialData}
/>
);
const renderFooter = () => {
@@ -124,67 +124,126 @@ export default function HomeScreen() {
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
};
return (
<ThemedView style={styles.container}>
{/* 顶部导航 */}
<View style={styles.headerContainer}>
// TV端和平板端的顶部导航
const renderHeader = () => {
if (deviceType === 'mobile') {
// 移动端不显示顶部导航使用底部Tab导航
return null;
}
return (
<View style={dynamicStyles.headerContainer}>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={styles.headerTitle}></ThemedText>
<ThemedText style={dynamicStyles.headerTitle}></ThemedText>
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
{({ focused }) => (
<ThemedText style={[styles.headerTitle, { color: focused ? "white" : "grey" }]}></ThemedText>
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}></ThemedText>
)}
</Pressable>
</View>
<View style={styles.rightHeaderButtons}>
<StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<View style={dynamicStyles.rightHeaderButtons}>
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton
style={styles.searchButton}
style={dynamicStyles.iconButton}
onPress={() => router.push({ pathname: "/search" })}
variant="ghost"
>
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
{isLoggedIn && (
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
<StyledButton style={dynamicStyles.iconButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View>
</View>
);
};
// 动态样式
const dynamicStyles = StyleSheet.create({
container: {
flex: 1,
paddingTop: deviceType === 'mobile' ? 0 : 40,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: spacing * 1.5,
marginBottom: spacing,
},
headerTitle: {
fontSize: deviceType === 'mobile' ? 24 : deviceType === 'tablet' ? 28 : 32,
fontWeight: "bold",
paddingTop: 16,
},
rightHeaderButtons: {
flexDirection: "row",
alignItems: "center",
},
iconButton: {
borderRadius: 30,
marginLeft: spacing / 2,
},
categoryContainer: {
paddingBottom: spacing / 2,
},
categoryListContent: {
paddingHorizontal: spacing,
},
categoryButton: {
paddingHorizontal: spacing / 2,
paddingVertical: spacing / 2,
borderRadius: deviceType === 'mobile' ? 6 : 8,
marginHorizontal: spacing / 2,
},
categoryText: {
fontSize: deviceType === 'mobile' ? 14 : 16,
fontWeight: "500",
},
contentContainer: {
flex: 1,
},
});
const content = (
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{/* 顶部导航 */}
{renderHeader()}
{/* 分类选择器 */}
<View style={styles.categoryContainer}>
<View style={dynamicStyles.categoryContainer}>
<FlatList
data={categories}
renderItem={renderCategory}
keyExtractor={(item) => item.title}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent}
contentContainerStyle={dynamicStyles.categoryListContent}
/>
</View>
{/* Sub-category Tags */}
{/* 子分类标签 */}
{selectedCategory && selectedCategory.tags && (
<View style={styles.categoryContainer}>
<View style={dynamicStyles.categoryContainer}>
<FlatList
data={selectedCategory.tags}
renderItem={({ item, index }) => {
const isSelected = selectedTag === item;
return (
<StyledButton
hasTVPreferredFocus={index === 0} // Focus the first tag by default
hasTVPreferredFocus={index === 0}
text={item}
onPress={() => handleTagSelect(item)}
isSelected={isSelected}
style={styles.categoryButton}
textStyle={styles.categoryText}
style={dynamicStyles.categoryButton}
textStyle={dynamicStyles.categoryText}
variant="ghost"
/>
);
@@ -192,28 +251,27 @@ export default function HomeScreen() {
keyExtractor={(item) => item}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent}
contentContainerStyle={dynamicStyles.categoryListContent}
/>
</View>
)}
{/* 内容网格 */}
{loading ? (
<View style={styles.centerContainer}>
<View style={commonStyles.center}>
<ActivityIndicator size="large" />
</View>
) : error ? (
<View style={styles.centerContainer}>
<ThemedText type="subtitle" style={{ padding: 10 }}>
<View style={commonStyles.center}>
<ThemedText type="subtitle" style={{ padding: spacing }}>
{error}
</ThemedText>
</View>
) : (
<Animated.View style={[styles.contentContainer, { opacity: fadeAnim }]}>
<Animated.View style={[dynamicStyles.contentContainer, { opacity: fadeAnim }]}>
<CustomScrollView
data={contentData}
renderItem={renderContentItem}
numColumns={NUM_COLUMNS}
loading={loading}
loadingMore={loadingMore}
error={error}
@@ -226,66 +284,15 @@ export default function HomeScreen() {
)}
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 40,
},
centerContainer: {
flex: 1,
paddingTop: 20,
justifyContent: "center",
alignItems: "center",
},
// Header
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 10,
},
headerTitle: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 16,
},
rightHeaderButtons: {
flexDirection: "row",
alignItems: "center",
},
searchButton: {
borderRadius: 30,
},
// Category Selector
categoryContainer: {
paddingBottom: 6,
},
categoryListContent: {
paddingHorizontal: 16,
},
categoryButton: {
paddingHorizontal: 2,
paddingVertical: 6,
borderRadius: 8,
marginHorizontal: 6,
},
categoryText: {
fontSize: 16,
fontWeight: "500",
},
// Content Grid
listContent: {
paddingHorizontal: 16,
paddingBottom: 20,
},
contentContainer: {
flex: 1,
},
itemContainer: {
margin: 8,
alignItems: "center",
},
});
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
{content}
</ResponsiveNavigation>
);
}

View File

@@ -5,9 +5,20 @@ import { fetchAndParseM3u, getPlayableUrl, Channel } from "@/services/m3u";
import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { useSettingsStore } from "@/stores/settingsStore";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
import { DeviceUtils } from "@/utils/DeviceUtils";
export default function LiveScreen() {
const { m3uUrl } = useSettingsStore();
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
const [channels, setChannels] = useState<Channel[]>([]);
const [groupedChannels, setGroupedChannels] = useState<Record<string, Channel[]>>({});
const [channelGroups, setChannelGroups] = useState<string[]>([]);
@@ -80,30 +91,38 @@ export default function LiveScreen() {
const handleTVEvent = useCallback(
(event: HWEvent) => {
if (deviceType !== 'tv') return;
if (isChannelListVisible) return;
if (event.eventType === "down") setIsChannelListVisible(true);
else if (event.eventType === "left") changeChannel("prev");
else if (event.eventType === "right") changeChannel("next");
},
[changeChannel, isChannelListVisible]
[changeChannel, isChannelListVisible, deviceType]
);
useTVEventHandler(handleTVEvent);
useTVEventHandler(deviceType === 'tv' ? handleTVEvent : () => {});
return (
<ThemedView style={styles.container}>
<LivePlayer streamUrl={selectedChannelUrl} channelTitle={channelTitle} onPlaybackStatusUpdate={() => {}} />
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const renderLiveContent = () => (
<>
<LivePlayer
streamUrl={selectedChannelUrl}
channelTitle={channelTitle}
onPlaybackStatusUpdate={() => {}}
/>
<Modal
animationType="slide"
transparent={true}
visible={isChannelListVisible}
onRequestClose={() => setIsChannelListVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<View style={styles.listContainer}>
<View style={styles.groupColumn}>
<View style={dynamicStyles.modalContainer}>
<View style={dynamicStyles.modalContent}>
<Text style={dynamicStyles.modalTitle}></Text>
<View style={dynamicStyles.listContainer}>
<View style={dynamicStyles.groupColumn}>
<FlatList
data={channelGroups}
keyExtractor={(item, index) => `group-${item}-${index}`}
@@ -112,13 +131,13 @@ export default function LiveScreen() {
text={item}
onPress={() => setSelectedGroup(item)}
isSelected={selectedGroup === item}
style={styles.groupButton}
textStyle={styles.groupButtonText}
style={dynamicStyles.groupButton}
textStyle={dynamicStyles.groupButtonText}
/>
)}
/>
</View>
<View style={styles.channelColumn}>
<View style={dynamicStyles.channelColumn}>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
@@ -131,8 +150,8 @@ export default function LiveScreen() {
onPress={() => handleSelectChannel(item)}
isSelected={channels[currentChannelIndex]?.id === item.id}
hasTVPreferredFocus={channels[currentChannelIndex]?.id === item.id}
style={styles.channelItem}
textStyle={styles.channelItemText}
style={dynamicStyles.channelItem}
textStyle={dynamicStyles.channelItemText}
/>
)}
/>
@@ -142,68 +161,86 @@ export default function LiveScreen() {
</View>
</View>
</Modal>
</>
);
const content = (
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{renderLiveContent()}
</ThemedView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title="直播" showBackButton />
{content}
</ResponsiveNavigation>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: 450,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 15,
},
modalTitle: {
color: "white",
marginBottom: 10,
textAlign: "center",
fontSize: 16,
fontWeight: "bold",
},
listContainer: {
flex: 1,
flexDirection: "row",
},
groupColumn: {
flex: 1,
marginRight: 10,
},
channelColumn: {
flex: 2,
},
groupButton: {
paddingVertical: 8,
paddingHorizontal: 4,
marginVertical: 4,
paddingLeft: 10,
paddingRight: 10,
},
groupButtonText: {
fontSize: 13,
},
channelItem: {
paddingVertical: 6,
paddingHorizontal: 4,
marginVertical: 3,
paddingLeft: 16,
paddingRight: 16,
},
channelItemText: {
fontSize: 12,
},
});
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
flex: 1,
},
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: isMobile ? "center" : "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: isMobile ? '90%' : isTablet ? 400 : 450,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: spacing,
},
modalTitle: {
color: "white",
marginBottom: spacing / 2,
textAlign: "center",
fontSize: isMobile ? 18 : 16,
fontWeight: "bold",
},
listContainer: {
flex: 1,
flexDirection: isMobile ? "column" : "row",
},
groupColumn: {
flex: isMobile ? 0 : 1,
marginRight: isMobile ? 0 : spacing / 2,
marginBottom: isMobile ? spacing : 0,
maxHeight: isMobile ? 120 : undefined,
},
channelColumn: {
flex: isMobile ? 1 : 2,
},
groupButton: {
paddingVertical: isMobile ? minTouchTarget / 4 : 8,
paddingHorizontal: spacing / 2,
marginVertical: isMobile ? 2 : 4,
minHeight: isMobile ? minTouchTarget * 0.7 : undefined,
},
groupButtonText: {
fontSize: isMobile ? 14 : 13,
},
channelItem: {
paddingVertical: isMobile ? minTouchTarget / 5 : 6,
paddingHorizontal: spacing,
marginVertical: isMobile ? 2 : 3,
minHeight: isMobile ? minTouchTarget * 0.8 : undefined,
},
channelItemText: {
fontSize: isMobile ? 14 : 12,
},
});
};

View File

@@ -14,11 +14,16 @@ import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
export default function PlayScreen() {
const videoRef = useRef<Video>(null);
const router = useRouter();
useKeepAwake();
// 响应式布局配置
const { deviceType } = useResponsiveLayout();
const {
episodeIndex: episodeIndexStr,
position: positionStr,
@@ -79,7 +84,13 @@ export default function PlayScreen() {
};
}, []);
const { onScreenPress } = useTVRemoteHandler();
// TV遥控器处理 - 总是调用hook但根据设备类型决定是否使用结果
const tvRemoteHandler = useTVRemoteHandler();
// 根据设备类型使用不同的交互处理
const onScreenPress = deviceType === 'tv'
? tvRemoteHandler.onScreenPress
: () => setShowControls(!showControls);
useEffect(() => {
const backAction = () => {
@@ -119,12 +130,20 @@ export default function PlayScreen() {
return <VideoLoadingAnimation showProgressBar />;
}
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType);
return (
<ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<ThemedView focusable style={dynamicStyles.container}>
<TouchableOpacity
activeOpacity={1}
style={dynamicStyles.videoContainer}
onPress={onScreenPress}
disabled={deviceType !== 'tv' && showControls} // 移动端和平板端在显示控制条时禁用触摸
>
<Video
ref={videoRef}
style={styles.videoPlayer}
style={dynamicStyles.videoPlayer}
source={{ uri: currentEpisode?.url || "" }}
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
@@ -146,7 +165,7 @@ export default function PlayScreen() {
<SeekingBar />
{isLoading && (
<View style={styles.videoContainer}>
<View style={dynamicStyles.loadingContainer}>
<VideoLoadingAnimation showProgressBar />
</View>
)}
@@ -160,13 +179,31 @@ export default function PlayScreen() {
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "black" },
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
videoContainer: {
...StyleSheet.absoluteFillObject,
},
videoPlayer: {
...StyleSheet.absoluteFillObject,
},
});
const createResponsiveStyles = (deviceType: string) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: "black",
// 移动端和平板端可能需要状态栏处理
...(isMobile || isTablet ? { paddingTop: 0 } : {}),
},
videoContainer: {
...StyleSheet.absoluteFillObject,
// 为触摸设备添加更多的交互区域
...(isMobile || isTablet ? { zIndex: 1 } : {}),
},
videoPlayer: {
...StyleSheet.absoluteFillObject,
},
loadingContainer: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
});
};

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native";
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import VideoCard from "@/components/VideoCard.tv";
import VideoCard from "@/components/VideoCard";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import { api, SearchResult } from "@/services/api";
import { Search, QrCode } from "lucide-react-native";
@@ -13,6 +13,11 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useRouter } from "expo-router";
import { Colors } from "@/constants/Colors";
import CustomScrollView from "@/components/CustomScrollView";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
import { DeviceUtils } from "@/utils/DeviceUtils";
export default function SearchScreen() {
const [keyword, setKeyword] = useState("");
@@ -20,12 +25,16 @@ export default function SearchScreen() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const textInputRef = useRef<TextInput>(null);
const colorScheme = "dark"; // Replace with useColorScheme() if needed
const [isInputFocused, setIsInputFocused] = useState(false);
const { showModal: showRemoteModal, lastMessage } = useRemoteControlStore();
const { remoteInputEnabled } = useSettingsStore();
const router = useRouter();
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
useEffect(() => {
if (lastMessage) {
console.log("Received remote input:", lastMessage);
@@ -93,110 +102,134 @@ export default function SearchScreen() {
/>
);
return (
<ThemedView style={styles.container}>
<View style={styles.searchContainer}>
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
const renderSearchContent = () => (
<>
<View style={dynamicStyles.searchContainer}>
<TouchableOpacity
activeOpacity={1}
style={[
styles.input,
dynamicStyles.inputContainer,
{
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
borderWidth: 2,
},
]}
onPress={() => textInputRef.current?.focus()}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
>
<TextInput
ref={textInputRef}
style={[
styles.input,
{
color: colorScheme === "dark" ? "white" : "black",
},
]}
style={dynamicStyles.input}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
placeholderTextColor="#888"
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={onSearchPress}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
returnKeyType="search"
/>
</TouchableOpacity>
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
</StyledButton>
<StyledButton style={styles.qrButton} onPress={handleQrPress}>
<QrCode size={24} color={colorScheme === "dark" ? "white" : "black"} />
<StyledButton style={dynamicStyles.searchButton} onPress={onSearchPress}>
<Search size={deviceType === 'mobile' ? 20 : 24} color="white" />
</StyledButton>
{deviceType !== 'mobile' && (
<StyledButton style={dynamicStyles.qrButton} onPress={handleQrPress}>
<QrCode size={deviceType === 'tv' ? 24 : 20} color="white" />
</StyledButton>
)}
</View>
{loading ? (
<VideoLoadingAnimation showProgressBar={false} />
) : error ? (
<View style={styles.centerContainer}>
<ThemedText style={styles.errorText}>{error}</ThemedText>
<View style={[commonStyles.center, { flex: 1 }]}>
<ThemedText style={dynamicStyles.errorText}>{error}</ThemedText>
</View>
) : (
<CustomScrollView
data={results}
renderItem={renderItem}
numColumns={5}
loading={loading}
error={error}
emptyMessage="输入关键词开始搜索"
/>
)}
<RemoteControlModal />
</>
);
const content = (
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{renderSearchContent()}
</ThemedView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return content;
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title="搜索" showBackButton />
{content}
</ResponsiveNavigation>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 50,
},
searchContainer: {
flexDirection: "row",
paddingHorizontal: 20,
marginBottom: 20,
alignItems: "center",
},
input: {
flex: 1,
height: 50,
backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline
borderRadius: 8,
paddingHorizontal: 15,
color: "white", // Default for dark mode, overridden inline
fontSize: 18,
marginRight: 10,
borderWidth: 2,
borderColor: "transparent", // Default, overridden for focus
},
searchButton: {
padding: 12,
// backgroundColor is now set dynamically
borderRadius: 8,
},
qrButton: {
padding: 12,
borderRadius: 8,
marginLeft: 10,
},
centerContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
errorText: {
color: "red",
},
listContent: {
paddingHorizontal: 10,
},
});
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
flex: 1,
paddingTop: deviceType === 'tv' ? 50 : 0,
},
searchContainer: {
flexDirection: "row",
paddingHorizontal: spacing,
marginBottom: spacing,
alignItems: "center",
paddingTop: isMobile ? spacing / 2 : 0,
},
inputContainer: {
flex: 1,
height: isMobile ? minTouchTarget : 50,
backgroundColor: "#2c2c2e",
borderRadius: isMobile ? 8 : 8,
marginRight: spacing / 2,
borderWidth: 2,
borderColor: "transparent",
justifyContent: "center",
},
input: {
flex: 1,
paddingHorizontal: spacing,
color: "white",
fontSize: isMobile ? 16 : 18,
},
searchButton: {
width: isMobile ? minTouchTarget : 50,
height: isMobile ? minTouchTarget : 50,
justifyContent: "center",
alignItems: "center",
borderRadius: isMobile ? 8 : 8,
marginRight: deviceType !== 'mobile' ? spacing / 2 : 0,
},
qrButton: {
width: isMobile ? minTouchTarget : 50,
height: isMobile ? minTouchTarget : 50,
justifyContent: "center",
alignItems: "center",
borderRadius: isMobile ? 8 : 8,
},
errorText: {
color: "red",
fontSize: isMobile ? 14 : 16,
textAlign: "center",
},
});
};

View File

@@ -14,12 +14,22 @@ import { RemoteInputSection } from "@/components/settings/RemoteInputSection";
import { UpdateSection } from "@/components/settings/UpdateSection";
// import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
import Toast from "react-native-toast-message";
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
import { DeviceUtils } from "@/utils/DeviceUtils";
export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage } = useRemoteControlStore();
const backgroundColor = useThemeColor({}, "background");
// 响应式布局配置
const responsiveConfig = useResponsiveLayout();
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
const { deviceType, spacing } = responsiveConfig;
const [hasChanges, setHasChanges] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
@@ -131,9 +141,11 @@ export default function SettingsScreen() {
},
].filter(Boolean);
// TV遥控器事件处理
// TV遥控器事件处理 - 仅在TV设备上启用
const handleTVEvent = React.useCallback(
(event: any) => {
if (deviceType !== 'tv') return;
if (event.eventType === "down") {
const nextIndex = Math.min(currentFocusIndex + 1, sections.length);
setCurrentFocusIndex(nextIndex);
@@ -145,72 +157,111 @@ export default function SettingsScreen() {
setCurrentFocusIndex(prevIndex);
}
},
[currentFocusIndex, sections.length]
[currentFocusIndex, sections.length, deviceType]
);
useTVEventHandler(handleTVEvent);
useTVEventHandler(deviceType === 'tv' ? handleTVEvent : () => {});
return (
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<ThemedView style={styles.container}>
<View style={styles.header}>
<ThemedText style={styles.title}></ThemedText>
</View>
// 动态样式
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
<View style={styles.scrollView}>
const renderSettingsContent = () => (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor }}
behavior={Platform.OS === "ios" ? "padding" : "height"}
>
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
{deviceType === 'tv' && (
<View style={dynamicStyles.header}>
<ThemedText style={dynamicStyles.title}></ThemedText>
</View>
)}
<View style={dynamicStyles.scrollView}>
<FlatList
data={sections}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
renderItem={({ item }) => {
if (item) {
return item.component;
}
return null;
}}
keyExtractor={(item) => item ? item.key : 'default'}
showsVerticalScrollIndicator={false}
contentContainerStyle={dynamicStyles.listContent}
/>
</View>
<View style={styles.footer}>
<View style={dynamicStyles.footer}>
<StyledButton
text={isLoading ? "保存中..." : "保存设置"}
onPress={handleSave}
variant="primary"
disabled={!hasChanges || isLoading}
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
style={[
dynamicStyles.saveButton,
(!hasChanges || isLoading) && dynamicStyles.disabledButton
]}
/>
</View>
</ThemedView>
</KeyboardAvoidingView>
);
// 根据设备类型决定是否包装在响应式导航中
if (deviceType === 'tv') {
return renderSettingsContent();
}
return (
<ResponsiveNavigation>
<ResponsiveHeader title="设置" showBackButton />
{renderSettingsContent()}
</ResponsiveNavigation>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 12,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
},
title: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 24,
},
backButton: {
minWidth: 100,
},
scrollView: {
flex: 1,
},
footer: {
paddingTop: 12,
alignItems: "flex-end",
},
saveButton: {
minHeight: 50,
width: 120,
},
disabledButton: {
opacity: 0.5,
},
});
const createResponsiveStyles = (deviceType: string, spacing: number) => {
const isMobile = deviceType === 'mobile';
const isTablet = deviceType === 'tablet';
const isTV = deviceType === 'tv';
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
container: {
flex: 1,
padding: spacing,
paddingTop: isTV ? spacing * 2 : 0,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: spacing,
},
title: {
fontSize: isMobile ? 24 : isTablet ? 28 : 32,
fontWeight: "bold",
paddingTop: spacing,
color: 'white',
},
scrollView: {
flex: 1,
},
listContent: {
paddingBottom: spacing,
},
footer: {
paddingTop: spacing,
alignItems: isMobile ? "center" : "flex-end",
},
saveButton: {
minHeight: isMobile ? minTouchTarget : isTablet ? 50 : 50,
width: isMobile ? '100%' : isTablet ? 140 : 120,
maxWidth: isMobile ? 280 : undefined,
},
disabledButton: {
opacity: 0.5,
},
});
};