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

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

View File

@@ -1,4 +1,4 @@
name: Build Android TV APK
name: Build Android APK
on:
workflow_dispatch:
@@ -7,7 +7,7 @@ permissions:
contents: write
jobs:
direct_build:
build_tv:
name: Build Android TV APK
runs-on: ubuntu-latest
steps:
@@ -37,21 +37,94 @@ jobs:
run: yarn prebuild-tv
- name: Build TV APK
run: yarn build-local
run: yarn build-tv
- name: Rename APK file
- name: Upload TV APK
uses: actions/upload-artifact@v3
with:
name: orion-tv-apk
path: android/app/build/outputs/apk/release/app-release.apk
build_mobile:
name: Build Android Mobile APK
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: 18
cache: "yarn"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get version from package.json
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Setup JDK
uses: actions/setup-java@v3
with:
distribution: "zulu"
java-version: "17"
- name: Prebuild Mobile App
run: yarn prebuild-mobile
- name: Build Mobile APK
run: yarn build-mobile
- name: Upload Mobile APK
uses: actions/upload-artifact@v3
with:
name: orion-mobile-apk
path: android/app/build/outputs/apk/release/app-release.apk
release:
name: Create Release
needs: [build_tv, build_mobile]
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v3
- name: Get version from package.json
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: Download TV APK
uses: actions/download-artifact@v3
with:
name: orion-tv-apk
path: artifacts/tv
- name: Download Mobile APK
uses: actions/download-artifact@v3
with:
name: orion-mobile-apk
path: artifacts/mobile
- name: Rename APK files
run: |
mkdir -p artifacts
cp android/app/build/outputs/apk/release/app-release.apk artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
mv artifacts/tv/app-release.apk artifacts/orionTV-tv.${{ steps.package-version.outputs.version }}.apk
mv artifacts/mobile/app-release.apk artifacts/orionTV-mobile.${{ steps.package-version.outputs.version }}.apk
- name: Create Release and Upload APK
- name: Create Release and Upload APKs
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.package-version.outputs.version }}
name: Release v${{ steps.package-version.outputs.version }}
body: Automated release for version v${{ steps.package-version.outputs.version }}.
body: |
Automated release for version v${{ steps.package-version.outputs.version }}.
- orionTV-tv.${{ steps.package-version.outputs.version }}.apk - Android TV版本
- orionTV-mobile.${{ steps.package-version.outputs.version }}.apk - 手机/平板版本
draft: false
prerelease: false
files: artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
files: |
artifacts/orionTV-tv.${{ steps.package-version.outputs.version }}.apk
artifacts/orionTV-mobile.${{ steps.package-version.outputs.version }}.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -9,24 +9,42 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
## Key Commands
### Development Commands
#### TV Development (Apple TV & Android TV)
- `yarn start-tv` - Start Metro bundler in TV mode (EXPO_TV=1)
- `yarn android-tv` - Build and run on Android TV
- `yarn ios-tv` - Build and run on Apple TV
- `yarn android-tv` - Build and run on Android TV
- `yarn prebuild-tv` - Generate native project files for TV (run after dependency changes)
- `yarn build-tv` - Build Android APK for TV release
#### Mobile/Tablet Development (Responsive)
- `yarn start` or `yarn start-mobile` - Start Metro bundler for mobile/tablet
- `yarn android` or `yarn android-mobile` - Build and run on Android mobile/tablet
- `yarn ios` or `yarn ios-mobile` - Build and run on iOS mobile/tablet
- `yarn prebuild` or `yarn prebuild-mobile` - Generate native project files for mobile
- `yarn build` or `yarn build-mobile` - Build Android APK for mobile release
#### General Commands
- `yarn copy-config` - Copy TV-specific Android configurations
- `yarn build-debug` - Build Android APK for debugging
- `yarn lint` - Run linting checks
- `yarn typecheck` - Run TypeScript type checking
- `yarn test` - Run Jest tests with watch mode
- `yarn build-local` - Build Android APK locally (from android/ directory)
- `yarn test-ci` - Run Jest tests for CI with coverage
- `yarn clean` - Clean cache and build artifacts
- `yarn clean-modules` - Reinstall all node modules
## Architecture Overview
### Frontend Structure
- **Expo Router**: File-based routing with screens in `/app` directory
- **State Management**: Zustand stores for global state (`/stores`)
- **TV-Specific Components**: Components optimized for TV remote control interaction
- **Services**: API layer, storage management, remote control server, and update service
### Key Technologies
- React Native TVOS (0.74.x) - TV-optimized React Native with TV-specific events
- Expo SDK 51 - Development platform and tooling
- TypeScript - Type safety throughout with `@/*` path mapping
@@ -34,6 +52,7 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
- Expo AV - Video playback functionality
### State Management (Zustand Stores)
- `homeStore.ts` - Home screen content, categories, Douban API data, and play records
- `playerStore.ts` - Video player state, controls, and episode management
- `settingsStore.ts` - App settings, API configuration, and user preferences
@@ -43,6 +62,7 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
- `favoritesStore.ts` - User favorites management
### TV-Specific Features
- Remote control navigation (`useTVRemoteHandler` hook with HWEvent handling)
- TV-optimized UI components with focus management and `.tv.tsx` extensions
- Remote control server for external control via HTTP bridge (`remoteControlService.ts`)
@@ -50,6 +70,7 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
- TV-specific assets and icons for Apple TV and Android TV
### Service Layer Architecture
- `api.ts` - External API integration (search, video details, Douban data)
- `storage.ts` - AsyncStorage wrapper for local data persistence
- `remoteControlService.ts` - HTTP server for external device control
@@ -58,42 +79,43 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
## Development Workflow
### TV Development Notes
- Always use TV-specific commands (`*-tv` variants) with EXPO_TV=1 environment variable
- Run `yarn prebuild-tv` after adding new dependencies or Expo configuration changes
- Use `yarn copy-config` to apply TV-specific Android configurations
- Test on both Apple TV and Android TV simulators/devices
### Responsive Development Notes
- Use TV commands (`*-tv` variants) with EXPO_TV=1 for TV development
- Use mobile/tablet commands (without EXPO_TV=1) for responsive mobile/tablet development
- Run `yarn prebuild-tv` after adding new dependencies for TV builds
- Run `yarn prebuild-mobile` after adding new dependencies for mobile builds
- Use `yarn copy-config` to apply TV-specific Android configurations (TV builds only)
- Test on both TV devices (Apple TV/Android TV) and mobile devices (phones/tablets)
- TV components require focus management and remote control support
- TV builds use react-native-tvos instead of standard react-native
- Mobile/tablet components use touch-optimized responsive design
- The same codebase supports all platforms through responsive architecture
### State Management Patterns
- Use Zustand stores for global state
- Stores follow a consistent pattern with actions and state
- API calls are centralized in the `/services` directory
- Storage operations use AsyncStorage wrapper in `storage.ts`
### Component Structure
- TV-specific components have `.tv.tsx` extensions
- Common components in `/components` directory
- Custom hooks in `/hooks` directory for reusable logic
- TV remote handling is centralized in `useTVRemoteHandler`
## Testing
- Uses Jest with `jest-expo` preset
- Run tests with `yarn test`
- Component tests in `__tests__` directories
- Snapshot testing for UI components
## Common Issues
### TV Platform Specifics
- TV apps require special focus management
- Remote control events need careful handling
- TV-specific assets and icons required
- Platform-specific build configurations
### Development Environment
- Ensure Xcode is installed for Apple TV development
- Android Studio required for Android TV development
- Metro bundler must run in TV mode (`EXPO_TV=1`)
@@ -110,7 +132,9 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
- `/assets` - Static assets including TV-specific icons and banners
# important-instruction-reminders
Do what has been asked; nothing more, nothing less.
NEVER create files unless they're absolutely necessary for achieving your goal.
ALWAYS prefer editing an existing file to creating a new one.
NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.
NEVER proactively create documentation files (\*.md) or README files. Only create documentation files if explicitly requested by the User.
ALWAYS When plan mode switches to edit, the contents of plan and todo need to be output as a document.

View File

@@ -23,7 +23,9 @@
"newArchEnabled": false
},
"android": {
"newArchEnabled": false
"newArchEnabled": false,
"enableProguardInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true
}
}
],
@@ -34,20 +36,60 @@
},
"name": "OrionTV",
"slug": "OrionTV",
"version": "1.3.0",
"orientation": "default",
"icon": "./assets/images/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#000000"
},
"assetBundlePatterns": [
"**/*"
],
"android": {
"package": "com.oriontv",
"usesCleartextTraffic": true,
"hardwareAcceleration": true,
"networkSecurityConfig": "@xml/network_security_config",
"icon": "./assets/images/icon.png",
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#000000"
},
"permissions": [
"android.permission.INTERNET",
"android.permission.ACCESS_NETWORK_STATE"
"android.permission.ACCESS_NETWORK_STATE",
"android.permission.WAKE_LOCK"
],
"intentFilters": [
{
"action": "VIEW",
"data": [
{
"scheme": "oriontv"
}
],
"category": [
"BROWSABLE",
"DEFAULT"
]
}
]
},
"ios": {
"bundleIdentifier": "com.oriontv"
"bundleIdentifier": "com.oriontv",
"supportsTablet": true,
"requireFullScreen": false,
"config": {
"usesNonExemptEncryption": false
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"scheme": "oriontv",
"extra": {

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,
},
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

50
components/VideoCard.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,308 @@
# OrionTV 手机端和平板端适配方案
## 项目概述
OrionTV 是一个基于 React Native TVOS 的视频流媒体应用,目前专为 Android TV 和 Apple TV 平台设计。本文档详细描述了将应用适配到 Android 手机和平板设备的完整方案。
## 当前状态分析
### TV端特征
- **技术栈**: React Native TVOS 0.74.x + Expo 51
- **导航**: Stack 导航结构,适合遥控器操作
- **布局**: 固定5列网格布局 (`NUM_COLUMNS = 5`)
- **交互**: TV遥控器专用事件处理 (`useTVRemoteHandler`)
- **组件**: TV专用组件 (`VideoCard.tv.tsx`)
- **UI元素**: 大间距、大按钮适合10英尺距离观看
### 现有页面结构
1. **index.tsx** - 首页:分类选择 + 5列视频网格
2. **detail.tsx** - 详情页:横向布局,海报+信息+播放源
3. **search.tsx** - 搜索页:搜索框 + 5列结果网格
4. **play.tsx** - 播放页:全屏视频播放器 + TV遥控器控制
5. **settings.tsx** - 设置页TV遥控器导航 + 远程输入配置
6. **favorites.tsx** - 收藏页:网格布局展示收藏内容
7. **live.tsx** - 直播页:直播流播放
8. **_layout.tsx** - 根布局Stack导航 + 全局状态管理
## 适配目标
### 设备分类
- **手机端** (< 768px): 单手操作,纵向为主,触摸交互
- **平板端** (768px - 1024px): 双手操作,横竖屏,触摸+键盘
- **TV端** (> 1024px): 遥控器操作横屏10英尺距离
### 响应式设计原则
1. **内容优先**: 保持核心功能一致性
2. **渐进增强**: 根据屏幕尺寸增加功能
3. **平台原生感**: 符合各平台交互习惯
4. **性能优化**: 避免不必要的重新渲染
## 技术实施方案
### 阶段1: 响应式基础架构
#### 1.1 创建响应式 Hook
```typescript
// hooks/useResponsiveLayout.ts
export interface ResponsiveConfig {
deviceType: 'mobile' | 'tablet' | 'tv';
columns: number;
cardWidth: number;
cardHeight: number;
spacing: number;
isPortrait: boolean;
}
```
#### 1.2 设备检测逻辑
- 基于 `Dimensions.get('window')` 获取屏幕尺寸
- 监听方向变化 `useDeviceOrientation()`
- 平台检测 `Platform.OS` 和 TV 环境变量
#### 1.3 断点定义
```typescript
const BREAKPOINTS = {
mobile: { min: 0, max: 767 },
tablet: { min: 768, max: 1023 },
tv: { min: 1024, max: Infinity }
};
```
### 阶段2: 多平台组件系统
#### 2.1 VideoCard 组件族
- **VideoCard.mobile.tsx**: 纵向卡片,大触摸目标
- **VideoCard.tablet.tsx**: 中等卡片,平衡布局
- **VideoCard.tv.tsx**: 保持现有实现
#### 2.2 组件选择器
```typescript
// components/VideoCard/index.tsx
export const VideoCard = (props) => {
const { deviceType } = useResponsiveLayout();
switch(deviceType) {
case 'mobile': return <VideoCardMobile {...props} />;
case 'tablet': return <VideoCardTablet {...props} />;
case 'tv': return <VideoCardTV {...props} />;
}
};
```
### 阶段3: 导航系统重构
#### 3.1 手机端导航
- **底部Tab导航**: 首页、搜索、收藏、设置
- **Header导航**: 返回按钮、标题、操作按钮
- **抽屉导航**: 次要功能入口
#### 3.2 平板端导航
- **侧边栏导航**: 持久化主导航
- **Master-Detail**: 列表+详情分屏
- **Tab Bar**: 内容区域二级导航
#### 3.3 TV端导航
- **保持现有**: Stack导航结构
- **遥控器优化**: Focus管理和按键导航
### 阶段4: 页面逐一适配
#### 4.1 首页 (index.tsx)
**手机端改进:**
- 1-2列网格布局
- 分类用横向滚动标签
- 下拉刷新
- 上拉加载更多
**平板端改进:**
- 2-3列网格布局
- 左侧分类侧边栏
- 内容区域可滚动
- 支持横竖屏切换
**TV端保持:**
- 5列网格布局
- 遥控器导航
- 现有交互逻辑
#### 4.2 详情页 (detail.tsx)
**手机端改进:**
- 纵向布局:海报→信息→播放源→剧集
- 海报占屏幕宽度40%
- 播放源横向滚动
- 剧集网格4-5列
**平板端改进:**
- 左右分栏:海报+信息 | 播放源+剧集
- 海报固定尺寸
- 播放源卡片式布局
- 剧集6-8列网格
#### 4.3 搜索页 (search.tsx)
**手机端改进:**
- 优化键盘输入体验
- 搜索历史和推荐
- 2列结果网格
- 筛选和排序功能
**平板端改进:**
- 更大的搜索框
- 3列结果网格
- 侧边栏筛选选项
- 搜索建议下拉
#### 4.4 播放页 (play.tsx)
**手机端改进:**
- 触摸控制替代遥控器
- 手势操作:双击暂停、滑动调节
- 竖屏小窗播放模式
- 亮度和音量调节
**平板端改进:**
- 触摸+手势控制
- 画中画模式支持
- 外接键盘快捷键
- 更大的控制按钮
#### 4.5 设置页 (settings.tsx)
**手机端改进:**
- 分组设置列表
- 原生选择器和开关
- 键盘友好的输入框
- 滚动优化
**平板端改进:**
- 左侧设置分类,右侧设置详情
- 更大的输入区域
- 实时预览效果
- 批量操作支持
### 阶段5: 组件库升级
#### 5.1 触摸优化组件
- **TouchableButton**: 44px最小触摸目标
- **SwipeableCard**: 支持滑动操作
- **PullToRefresh**: 下拉刷新组件
- **InfiniteScroll**: 无限滚动加载
#### 5.2 手势处理
- **PinchToZoom**: 双指缩放
- **SwipeNavigation**: 滑动导航
- **LongPressMenu**: 长按菜单
- **DoubleTapHandler**: 双击处理
#### 5.3 响应式工具
- **ResponsiveText**: 自适应字体大小
- **ResponsiveSpacing**: 自适应间距
- **ConditionalRender**: 条件渲染组件
- **OrientationHandler**: 方向变化处理
### 阶段6: 构建和部署
#### 6.1 构建脚本更新
```json
{
"scripts": {
"android-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
"android-tablet": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android --device tablet",
"android-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android"
}
}
```
#### 6.2 配置文件适配
- **app.json**: 多平台配置
- **metro.config.js**: 条件资源加载
- **package.json**: 平台特定依赖
#### 6.3 资源文件
- 手机端应用图标 (48dp-192dp)
- 平板端应用图标 (优化尺寸)
- 启动屏适配不同分辨率
- 自适应图标 (Adaptive Icons)
## 测试计划
### 测试设备覆盖
- **手机**: Android 5.0-14, 屏幕 4"-7"
- **平板**: Android 平板 7"-12", 横竖屏
- **TV**: 保持现有测试覆盖
### 测试要点
1. **响应式布局**: 不同屏幕尺寸正确显示
2. **交互体验**: 触摸、手势、导航流畅
3. **性能表现**: 启动速度、滚动性能、内存使用
4. **兼容性**: 不同Android版本和设备
### 自动化测试
- Jest单元测试覆盖新组件
- E2E测试核心用户流程
- 视觉回归测试UI一致性
- 性能基准测试
## 风险评估
### 技术风险
- **复杂度增加**: 多平台适配增加维护成本
- **性能影响**: 条件渲染可能影响性能
- **测试覆盖**: 需要覆盖更多设备组合
### 缓解策略
- 渐进式迁移,优先核心功能
- 性能监控和优化
- 自动化测试保证质量
- 代码复用最大化
## 实施时间表
### 第1周: 基础架构
- [ ] 响应式Hook开发
- [ ] 多平台组件框架
- [ ] 基础样式系统
### 第2周: 核心页面适配
- [ ] 首页手机/平板适配
- [ ] 详情页手机/平板适配
- [ ] 搜索页手机/平板适配
### 第3周: 功能完善
- [ ] 播放页适配
- [ ] 设置页适配
- [ ] 导航系统重构
### 第4周: 优化和测试
- [ ] 组件库升级
- [ ] 性能优化
- [ ] 全面测试
## 成功指标
### 用户体验指标
- 应用启动时间 < 3秒
- 页面切换流畅度 > 95%
- 触摸响应延迟 < 100ms
- 用户满意度 > 4.5/5
### 技术指标
- 代码复用率 > 80%
- 自动化测试覆盖率 > 90%
- 应用包大小增长 < 20%
- 内存使用优化 > 15%
---
## 附录
### 参考资料
- [React Native 响应式设计指南]
- [Material Design 自适应布局]
- [TV应用设计最佳实践]
### 相关文档
- 项目架构文档 (CLAUDE.md)
- TV端开发指南
- 组件库使用手册
---
*最后更新: 2025-08-01*
*版本: 1.0*

View File

@@ -0,0 +1,129 @@
import { useState, useEffect } from 'react';
import { Dimensions, Platform } from 'react-native';
export type DeviceType = 'mobile' | 'tablet' | 'tv';
export interface ResponsiveConfig {
deviceType: DeviceType;
columns: number;
cardWidth: number;
cardHeight: number;
spacing: number;
isPortrait: boolean;
screenWidth: number;
screenHeight: number;
}
const BREAKPOINTS = {
mobile: { min: 0, max: 767 },
tablet: { min: 768, max: 1023 },
tv: { min: 1024, max: Infinity }
};
const getDeviceType = (width: number): DeviceType => {
const isTV = process.env.EXPO_TV === '1' || Platform.isTV;
if (isTV) return 'tv';
if (width >= BREAKPOINTS.tv.min) return 'tv';
if (width >= BREAKPOINTS.tablet.min) return 'tablet';
return 'mobile';
};
const getLayoutConfig = (deviceType: DeviceType, width: number, height: number, isPortrait: boolean): ResponsiveConfig => {
const spacing = deviceType === 'mobile' ? 8 : deviceType === 'tablet' ? 12 : 16;
let columns: number;
let cardWidth: number;
let cardHeight: number;
switch (deviceType) {
case 'mobile':
columns = isPortrait ? 2 : 3;
cardWidth = (width - spacing * (columns + 1)) / columns;
cardHeight = cardWidth * 1.5; // 2:3 aspect ratio
break;
case 'tablet':
columns = isPortrait ? 3 : 4;
cardWidth = (width - spacing * (columns + 1)) / columns;
cardHeight = cardWidth * 1.4; // slightly less tall ratio
break;
case 'tv':
default:
columns = 5;
cardWidth = 160; // Fixed width for TV
cardHeight = 240; // Fixed height for TV
break;
}
return {
deviceType,
columns,
cardWidth,
cardHeight,
spacing,
isPortrait,
screenWidth: width,
screenHeight: height,
};
};
export const useResponsiveLayout = (): ResponsiveConfig => {
const [dimensions, setDimensions] = useState(() => {
const { width, height } = Dimensions.get('window');
return { width, height };
});
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions({ width: window.width, height: window.height });
});
return () => subscription?.remove();
}, []);
const { width, height } = dimensions;
const isPortrait = height > width;
const deviceType = getDeviceType(width);
return getLayoutConfig(deviceType, width, height, isPortrait);
};
// Utility hook for responsive values
export const useResponsiveValue = <T>(values: { mobile: T; tablet: T; tv: T }): T => {
const { deviceType } = useResponsiveLayout();
return values[deviceType];
};
// Utility hook for responsive styles
export const useResponsiveStyles = () => {
const config = useResponsiveLayout();
return {
// Common responsive styles
container: {
paddingHorizontal: config.spacing,
},
// Card styles
cardContainer: {
width: config.cardWidth,
height: config.cardHeight,
marginBottom: config.spacing,
},
// Grid styles
gridContainer: {
paddingHorizontal: config.spacing / 2,
},
// Typography
titleFontSize: config.deviceType === 'mobile' ? 18 : config.deviceType === 'tablet' ? 22 : 28,
bodyFontSize: config.deviceType === 'mobile' ? 14 : config.deviceType === 'tablet' ? 16 : 18,
// Spacing
sectionSpacing: config.deviceType === 'mobile' ? 16 : config.deviceType === 'tablet' ? 20 : 24,
itemSpacing: config.spacing,
};
};

View File

@@ -3,10 +3,11 @@ const {getDefaultConfig} = require('expo/metro-config');
const path = require('path');
// Find the project and workspace directories
// eslint-disable-next-line no-undef
const projectRoot = __dirname;
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(projectRoot); // eslint-disable-line no-undef
const config = getDefaultConfig(projectRoot);
// When enabled, the optional code below will allow Metro to resolve
// and bundle source files with TV-specific extensions

View File

@@ -6,18 +6,28 @@
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start --tunnel",
"android": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
"android-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
"android-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android --device",
"ios": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
"ios-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
"web": "expo start --web",
"reset-project": "./scripts/reset-project.js",
"test": "jest --watchAll",
"lint": "expo lint",
"ios-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios --device",
"prebuild": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean",
"prebuild-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean && yarn copy-config",
"prebuild-mobile": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean --platform android && yarn copy-config",
"copy-config": "cp -r xml/* android/app/src/*",
"build-local": "cd android && ./gradlew assembleRelease"
"build": "cd android && ./gradlew assembleRelease",
"build-tv": "EXPO_TV=1 yarn prebuild-tv && cd android && ./gradlew assembleRelease",
"build-mobile": "yarn prebuild-mobile && cd android && ./gradlew assembleRelease",
"build-debug": "cd android && ./gradlew assembleDebug",
"test": "jest --watchAll",
"test-ci": "jest --ci --coverage --no-cache",
"lint": "expo lint",
"typecheck": "tsc --noEmit",
"clean": "expo r -c && yarn cache clean && cd android && ./gradlew clean",
"clean-modules": "rm -rf node_modules && yarn install",
"reset-project": "./scripts/reset-project.js"
},
"jest": {
"preset": "jest-expo"

View File

@@ -1,5 +1,3 @@
import { api } from "./api";
export interface Channel {
id: string;
name: string;

View File

@@ -50,7 +50,7 @@ export const getResolutionFromM3U8 = async (
};
return resolutionString;
} catch (error) {
} catch {
return null;
}
};

138
utils/DeviceUtils.ts Normal file
View File

@@ -0,0 +1,138 @@
import { Platform, Dimensions } from 'react-native';
import { DeviceType } from '@/hooks/useResponsiveLayout';
export const DeviceUtils = {
/**
* 检测当前设备类型
*/
getDeviceType(): DeviceType {
const isTV = process.env.EXPO_TV === '1' || Platform.isTV;
if (isTV) return 'tv';
const { width } = Dimensions.get('window');
if (width >= 1024) return 'tv';
if (width >= 768) return 'tablet';
return 'mobile';
},
/**
* 检测是否为TV环境
*/
isTV(): boolean {
return this.getDeviceType() === 'tv';
},
/**
* 检测是否为移动设备
*/
isMobile(): boolean {
return this.getDeviceType() === 'mobile';
},
/**
* 检测是否为平板设备
*/
isTablet(): boolean {
return this.getDeviceType() === 'tablet';
},
/**
* 检测是否支持触摸交互
*/
supportsTouchInteraction(): boolean {
return !this.isTV();
},
/**
* 检测是否支持遥控器交互
*/
supportsRemoteControlInteraction(): boolean {
return this.isTV();
},
/**
* 获取最小触摸目标尺寸
*/
getMinTouchTargetSize(): number {
const deviceType = this.getDeviceType();
switch (deviceType) {
case 'mobile':
return 44; // iOS HIG minimum
case 'tablet':
return 48; // Material Design minimum
case 'tv':
return 60; // TV optimized
default:
return 44;
}
},
/**
* 获取适合的文字大小
*/
getOptimalFontSize(baseSize: number): number {
const deviceType = this.getDeviceType();
const scaleFactor = {
mobile: 1.0,
tablet: 1.1,
tv: 1.25,
}[deviceType];
return Math.round(baseSize * scaleFactor);
},
/**
* 获取适合的间距
*/
getOptimalSpacing(baseSpacing: number): number {
const deviceType = this.getDeviceType();
const scaleFactor = {
mobile: 0.8,
tablet: 1.0,
tv: 1.5,
}[deviceType];
return Math.round(baseSpacing * scaleFactor);
},
/**
* 检测设备是否处于横屏模式
*/
isLandscape(): boolean {
const { width, height } = Dimensions.get('window');
return width > height;
},
/**
* 检测设备是否处于竖屏模式
*/
isPortrait(): boolean {
return !this.isLandscape();
},
/**
* 获取安全的网格列数
*/
getSafeColumnCount(preferredColumns: number): number {
const { width } = Dimensions.get('window');
const minCardWidth = this.isMobile() ? 120 : this.isTablet() ? 140 : 160;
const maxColumns = Math.floor(width / minCardWidth);
return Math.min(preferredColumns, maxColumns);
},
/**
* 获取设备特定的动画持续时间
*/
getAnimationDuration(baseDuration: number): number {
const deviceType = this.getDeviceType();
// TV端动画稍慢更符合10英尺体验
const scaleFactor = {
mobile: 1.0,
tablet: 1.0,
tv: 1.2,
}[deviceType];
return Math.round(baseDuration * scaleFactor);
},
};

222
utils/ResponsiveStyles.ts Normal file
View File

@@ -0,0 +1,222 @@
import { StyleSheet } from 'react-native';
import { useResponsiveLayout, ResponsiveConfig } from '@/hooks/useResponsiveLayout';
import { DeviceUtils } from '@/utils/DeviceUtils';
// 响应式样式创建器类型
export type ResponsiveStyleCreator<T> = (config: ResponsiveConfig) => T;
/**
* 创建响应式样式的高阶函数
*/
export const createResponsiveStyles = <T extends Record<string, any>>(
styleCreator: ResponsiveStyleCreator<T>
) => {
return (config: ResponsiveConfig): T => {
return StyleSheet.create(styleCreator(config)) as T;
};
};
/**
* 响应式样式 Hook
*/
export const useResponsiveStyles = <T extends Record<string, any>>(
styleCreator: ResponsiveStyleCreator<T>
): T => {
const config = useResponsiveLayout();
return createResponsiveStyles(styleCreator)(config);
};
/**
* 通用响应式样式
*/
export const getCommonResponsiveStyles = (config: ResponsiveConfig) => {
const { deviceType, spacing } = config;
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
return StyleSheet.create({
// 容器样式
container: {
flex: 1,
paddingHorizontal: spacing,
},
safeContainer: {
flex: 1,
paddingHorizontal: spacing,
paddingTop: deviceType === 'mobile' ? 20 : deviceType === 'tablet' ? 30 : 40,
},
// 标题样式
pageTitle: {
fontSize: DeviceUtils.getOptimalFontSize(deviceType === 'mobile' ? 24 : deviceType === 'tablet' ? 28 : 32),
fontWeight: 'bold',
marginBottom: spacing,
color: 'white',
},
sectionTitle: {
fontSize: DeviceUtils.getOptimalFontSize(deviceType === 'mobile' ? 18 : deviceType === 'tablet' ? 20 : 22),
fontWeight: '600',
marginBottom: spacing / 2,
color: 'white',
},
// 按钮样式
primaryButton: {
minHeight: minTouchTarget,
paddingHorizontal: spacing * 1.5,
paddingVertical: spacing,
borderRadius: deviceType === 'mobile' ? 8 : deviceType === 'tablet' ? 10 : 12,
justifyContent: 'center',
alignItems: 'center',
},
secondaryButton: {
minHeight: minTouchTarget,
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
borderRadius: deviceType === 'mobile' ? 6 : deviceType === 'tablet' ? 8 : 10,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: '#ccc',
},
// 输入框样式
textInput: {
minHeight: minTouchTarget,
paddingHorizontal: spacing,
paddingVertical: spacing * 0.75,
borderRadius: deviceType === 'mobile' ? 8 : deviceType === 'tablet' ? 10 : 12,
fontSize: DeviceUtils.getOptimalFontSize(16),
backgroundColor: '#2c2c2e',
color: 'white',
borderWidth: 2,
borderColor: 'transparent',
},
// 卡片样式
card: {
backgroundColor: '#1c1c1e',
borderRadius: deviceType === 'mobile' ? 8 : deviceType === 'tablet' ? 10 : 12,
padding: spacing,
marginBottom: spacing,
},
// 网格样式
gridContainer: {
paddingHorizontal: spacing / 2,
},
gridRow: {
flexDirection: 'row',
justifyContent: 'flex-start',
flexWrap: 'wrap',
},
gridItem: {
margin: spacing / 2,
alignItems: 'center',
},
// 间距工具类
marginTopSmall: { marginTop: spacing / 2 },
marginTopMedium: { marginTop: spacing },
marginTopLarge: { marginTop: spacing * 1.5 },
marginBottomSmall: { marginBottom: spacing / 2 },
marginBottomMedium: { marginBottom: spacing },
marginBottomLarge: { marginBottom: spacing * 1.5 },
paddingSmall: { padding: spacing / 2 },
paddingMedium: { padding: spacing },
paddingLarge: { padding: spacing * 1.5 },
// 布局工具类
row: {
flexDirection: 'row',
alignItems: 'center',
},
rowBetween: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
column: {
flexDirection: 'column',
},
center: {
justifyContent: 'center',
alignItems: 'center',
},
centerHorizontal: {
alignItems: 'center',
},
centerVertical: {
justifyContent: 'center',
},
// 文本样式
textSmall: {
fontSize: DeviceUtils.getOptimalFontSize(12),
color: '#ccc',
},
textMedium: {
fontSize: DeviceUtils.getOptimalFontSize(14),
color: 'white',
},
textLarge: {
fontSize: DeviceUtils.getOptimalFontSize(16),
color: 'white',
},
// 阴影样式
shadow: deviceType !== 'tv' ? {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
} : {}, // TV端不需要阴影
});
};
/**
* 响应式文本大小
*/
export const getResponsiveTextSize = (baseSize: number, deviceType: string) => {
const scaleFactors = {
mobile: 1.0,
tablet: 1.1,
tv: 1.25,
};
const scaleFactor = scaleFactors[deviceType as keyof typeof scaleFactors] || 1.0;
return Math.round(baseSize * scaleFactor);
};
/**
* 响应式间距
*/
export const getResponsiveSpacing = (baseSpacing: number, deviceType: string) => {
const scaleFactors = {
mobile: 0.8,
tablet: 1.0,
tv: 1.5,
};
const scaleFactor = scaleFactors[deviceType as keyof typeof scaleFactors] || 1.0;
return Math.round(baseSpacing * scaleFactor);
};