mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-03-17 03:07:29 +08:00
feat: Enhance mobile and tablet support with responsive layout adjustments and new navigation components
This commit is contained in:
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
523
app/detail.tsx
523
app/detail.tsx
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
233
app/index.tsx
233
app/index.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
189
app/live.tsx
189
app/live.tsx
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
67
app/play.tsx
67
app/play.tsx
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
175
app/search.tsx
175
app/search.tsx
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
147
app/settings.tsx
147
app/settings.tsx
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user