mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-15 04:14:42 +08:00
feat: Enhance mobile and tablet support with responsive layout adjustments and new navigation components
This commit is contained in:
91
.github/workflows/build-apk.yml
vendored
91
.github/workflows/build-apk.yml
vendored
@@ -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 }}
|
||||
|
||||
56
CLAUDE.md
56
CLAUDE.md
@@ -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.
|
||||
|
||||
48
app.json
48
app.json
@@ -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": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useFonts } from "expo-font";
|
||||
import { Stack } from "expo-router";
|
||||
import * as SplashScreen from "expo-splash-screen";
|
||||
import { useEffect } from "react";
|
||||
import { Platform } from "react-native";
|
||||
import { Platform, View, StyleSheet } from "react-native";
|
||||
import Toast from "react-native-toast-message";
|
||||
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
@@ -13,6 +13,8 @@ import useAuthStore from "@/stores/authStore";
|
||||
import { useUpdateStore, initUpdateStore } from "@/stores/updateStore";
|
||||
import { UpdateModal } from "@/components/UpdateModal";
|
||||
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
|
||||
import MobileBottomNavigation from "@/components/MobileBottomNavigation";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -26,6 +28,7 @@ export default function RootLayout() {
|
||||
const { startServer, stopServer } = useRemoteControlStore();
|
||||
const { checkLoginStatus } = useAuthStore();
|
||||
const { checkForUpdate, lastCheckTime } = useUpdateStore();
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
@@ -70,21 +73,32 @@ export default function RootLayout() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isMobile = responsiveConfig.deviceType === 'mobile';
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="detail" options={{ headerShown: false }} />
|
||||
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
|
||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="live" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="favorites" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
<View style={styles.container}>
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="detail" options={{ headerShown: false }} />
|
||||
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
|
||||
<Stack.Screen name="search" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="live" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="favorites" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="+not-found" />
|
||||
</Stack>
|
||||
{isMobile && <MobileBottomNavigation colorScheme={colorScheme} />}
|
||||
</View>
|
||||
<Toast />
|
||||
<LoginModal />
|
||||
<UpdateModal />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
523
app/detail.tsx
523
app/detail.tsx
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native";
|
||||
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
@@ -7,11 +7,20 @@ import { StyledButton } from "@/components/StyledButton";
|
||||
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
|
||||
import useDetailStore from "@/stores/detailStore";
|
||||
import { FontAwesome } from "@expo/vector-icons";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
|
||||
export default function DetailScreen() {
|
||||
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
|
||||
const router = useRouter();
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
const {
|
||||
detail,
|
||||
searchResults,
|
||||
@@ -54,80 +63,108 @@ export default function DetailScreen() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ThemedText type="subtitle" style={styles.text}>
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.safeContainer, commonStyles.center]}>
|
||||
<ThemedText type="subtitle" style={commonStyles.textMedium}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.centered}>
|
||||
<ThemedText type="subtitle">未找到详情信息</ThemedText>
|
||||
</ThemedView>
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="详情" showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ScrollView>
|
||||
<View style={styles.topContainer}>
|
||||
<Image source={{ uri: detail.poster }} style={styles.poster} />
|
||||
<View style={styles.infoContainer}>
|
||||
<View style={styles.titleContainer}>
|
||||
<ThemedText style={styles.title} numberOfLines={1} ellipsizeMode="tail">
|
||||
{detail.title}
|
||||
</ThemedText>
|
||||
<StyledButton onPress={toggleFavorite} variant="ghost" style={styles.favoriteButton}>
|
||||
<FontAwesome
|
||||
name={isFavorited ? "heart" : "heart-o"}
|
||||
size={24}
|
||||
color={isFavorited ? "#feff5f" : "#ccc"}
|
||||
/>
|
||||
</StyledButton>
|
||||
</View>
|
||||
<View style={styles.metaContainer}>
|
||||
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
|
||||
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
|
||||
</View>
|
||||
if (!detail) {
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.safeContainer, commonStyles.center]}>
|
||||
<ThemedText type="subtitle">未找到详情信息</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
<ScrollView style={styles.descriptionScrollView}>
|
||||
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
|
||||
</ScrollView>
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="详情" showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const renderDetailContent = () => {
|
||||
if (deviceType === 'mobile') {
|
||||
// 移动端垂直布局
|
||||
return (
|
||||
<ScrollView style={dynamicStyles.scrollContainer}>
|
||||
{/* 海报和基本信息 */}
|
||||
<View style={dynamicStyles.mobileTopContainer}>
|
||||
<Image source={{ uri: detail.poster }} style={dynamicStyles.mobilePoster} />
|
||||
<View style={dynamicStyles.mobileInfoContainer}>
|
||||
<View style={dynamicStyles.titleContainer}>
|
||||
<ThemedText style={dynamicStyles.title} numberOfLines={2}>
|
||||
{detail.title}
|
||||
</ThemedText>
|
||||
<StyledButton onPress={toggleFavorite} variant="ghost" style={dynamicStyles.favoriteButton}>
|
||||
<FontAwesome
|
||||
name={isFavorited ? "heart" : "heart-o"}
|
||||
size={20}
|
||||
color={isFavorited ? "#feff5f" : "#ccc"}
|
||||
/>
|
||||
</StyledButton>
|
||||
</View>
|
||||
<View style={dynamicStyles.metaContainer}>
|
||||
<ThemedText style={dynamicStyles.metaText}>{detail.year}</ThemedText>
|
||||
<ThemedText style={dynamicStyles.metaText}>{detail.type_name}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomContainer}>
|
||||
<View style={styles.sourcesContainer}>
|
||||
<View style={styles.sourcesTitleContainer}>
|
||||
<ThemedText style={styles.sourcesTitle}>选择播放源 共 {searchResults.length} 个</ThemedText>
|
||||
{/* 描述 */}
|
||||
<View style={dynamicStyles.descriptionContainer}>
|
||||
<ThemedText style={dynamicStyles.description}>{detail.desc}</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* 播放源 */}
|
||||
<View style={dynamicStyles.sourcesContainer}>
|
||||
<View style={dynamicStyles.sourcesTitleContainer}>
|
||||
<ThemedText style={dynamicStyles.sourcesTitle}>播放源 ({searchResults.length})</ThemedText>
|
||||
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
|
||||
</View>
|
||||
<View style={styles.sourceList}>
|
||||
<View style={dynamicStyles.sourceList}>
|
||||
{searchResults.map((item, index) => {
|
||||
const isSelected = detail?.source === item.source;
|
||||
return (
|
||||
<StyledButton
|
||||
key={index}
|
||||
onPress={() => setDetail(item)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isSelected={isSelected}
|
||||
style={styles.sourceButton}
|
||||
style={dynamicStyles.sourceButton}
|
||||
>
|
||||
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
|
||||
<ThemedText style={dynamicStyles.sourceButtonText}>{item.source_name}</ThemedText>
|
||||
{item.episodes.length > 1 && (
|
||||
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
|
||||
<Text style={styles.badgeText}>
|
||||
<View style={[dynamicStyles.badge, isSelected && dynamicStyles.selectedBadge]}>
|
||||
<Text style={dynamicStyles.badgeText}>
|
||||
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} 集
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.resolution && (
|
||||
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
|
||||
<Text style={styles.badgeText}>{item.resolution}</Text>
|
||||
<View style={[dynamicStyles.badge, { backgroundColor: "#666" }, isSelected && dynamicStyles.selectedBadge]}>
|
||||
<Text style={dynamicStyles.badgeText}>{item.resolution}</Text>
|
||||
</View>
|
||||
)}
|
||||
</StyledButton>
|
||||
@@ -135,144 +172,278 @@ export default function DetailScreen() {
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.episodesContainer}>
|
||||
<ThemedText style={styles.episodesTitle}>播放列表</ThemedText>
|
||||
<ScrollView contentContainerStyle={styles.episodeList}>
|
||||
|
||||
{/* 剧集列表 */}
|
||||
<View style={dynamicStyles.episodesContainer}>
|
||||
<ThemedText style={dynamicStyles.episodesTitle}>播放列表</ThemedText>
|
||||
<View style={dynamicStyles.episodeList}>
|
||||
{detail.episodes.map((episode, index) => (
|
||||
<StyledButton
|
||||
key={index}
|
||||
style={styles.episodeButton}
|
||||
style={dynamicStyles.episodeButton}
|
||||
onPress={() => handlePlay(index)}
|
||||
text={`第 ${index + 1} 集`}
|
||||
textStyle={styles.episodeButtonText}
|
||||
textStyle={dynamicStyles.episodeButtonText}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
);
|
||||
} else {
|
||||
// 平板和TV端水平布局
|
||||
return (
|
||||
<ScrollView style={dynamicStyles.scrollContainer}>
|
||||
<View style={dynamicStyles.topContainer}>
|
||||
<Image source={{ uri: detail.poster }} style={dynamicStyles.poster} />
|
||||
<View style={dynamicStyles.infoContainer}>
|
||||
<View style={dynamicStyles.titleContainer}>
|
||||
<ThemedText style={dynamicStyles.title} numberOfLines={1} ellipsizeMode="tail">
|
||||
{detail.title}
|
||||
</ThemedText>
|
||||
<StyledButton onPress={toggleFavorite} variant="ghost" style={dynamicStyles.favoriteButton}>
|
||||
<FontAwesome
|
||||
name={isFavorited ? "heart" : "heart-o"}
|
||||
size={24}
|
||||
color={isFavorited ? "#feff5f" : "#ccc"}
|
||||
/>
|
||||
</StyledButton>
|
||||
</View>
|
||||
<View style={dynamicStyles.metaContainer}>
|
||||
<ThemedText style={dynamicStyles.metaText}>{detail.year}</ThemedText>
|
||||
<ThemedText style={dynamicStyles.metaText}>{detail.type_name}</ThemedText>
|
||||
</View>
|
||||
|
||||
<ScrollView style={dynamicStyles.descriptionScrollView}>
|
||||
<ThemedText style={dynamicStyles.description}>{detail.desc}</ThemedText>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={dynamicStyles.bottomContainer}>
|
||||
<View style={dynamicStyles.sourcesContainer}>
|
||||
<View style={dynamicStyles.sourcesTitleContainer}>
|
||||
<ThemedText style={dynamicStyles.sourcesTitle}>选择播放源 共 {searchResults.length} 个</ThemedText>
|
||||
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
|
||||
</View>
|
||||
<View style={dynamicStyles.sourceList}>
|
||||
{searchResults.map((item, index) => {
|
||||
const isSelected = detail?.source === item.source;
|
||||
return (
|
||||
<StyledButton
|
||||
key={index}
|
||||
onPress={() => setDetail(item)}
|
||||
hasTVPreferredFocus={index === 0}
|
||||
isSelected={isSelected}
|
||||
style={dynamicStyles.sourceButton}
|
||||
>
|
||||
<ThemedText style={dynamicStyles.sourceButtonText}>{item.source_name}</ThemedText>
|
||||
{item.episodes.length > 1 && (
|
||||
<View style={[dynamicStyles.badge, isSelected && dynamicStyles.selectedBadge]}>
|
||||
<Text style={dynamicStyles.badgeText}>
|
||||
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} 集
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{item.resolution && (
|
||||
<View style={[dynamicStyles.badge, { backgroundColor: "#666" }, isSelected && dynamicStyles.selectedBadge]}>
|
||||
<Text style={dynamicStyles.badgeText}>{item.resolution}</Text>
|
||||
</View>
|
||||
)}
|
||||
</StyledButton>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
<View style={dynamicStyles.episodesContainer}>
|
||||
<ThemedText style={dynamicStyles.episodesTitle}>播放列表</ThemedText>
|
||||
<ScrollView contentContainerStyle={dynamicStyles.episodeList}>
|
||||
{detail.episodes.map((episode, index) => (
|
||||
<StyledButton
|
||||
key={index}
|
||||
style={dynamicStyles.episodeButton}
|
||||
onPress={() => handlePlay(index)}
|
||||
text={`第 ${index + 1} 集`}
|
||||
textStyle={dynamicStyles.episodeButtonText}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.container, { paddingTop: deviceType === 'tv' ? 40 : 0 }]}>
|
||||
{renderDetailContent()}
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title={detail?.title || "详情"} showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
topContainer: {
|
||||
flexDirection: "row",
|
||||
padding: 20,
|
||||
},
|
||||
text: {
|
||||
padding: 20,
|
||||
textAlign: "center",
|
||||
},
|
||||
poster: {
|
||||
width: 200,
|
||||
height: 300,
|
||||
borderRadius: 8,
|
||||
},
|
||||
infoContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 20,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
title: {
|
||||
paddingTop: 16,
|
||||
fontSize: 28,
|
||||
fontWeight: "bold",
|
||||
flexShrink: 1,
|
||||
},
|
||||
metaContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: 10,
|
||||
},
|
||||
metaText: {
|
||||
color: "#aaa",
|
||||
marginRight: 10,
|
||||
fontSize: 14,
|
||||
},
|
||||
descriptionScrollView: {
|
||||
height: 150, // Constrain height to make it scrollable
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
color: "#ccc",
|
||||
lineHeight: 22,
|
||||
},
|
||||
favoriteButton: {
|
||||
padding: 10,
|
||||
marginLeft: 10,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
favoriteButtonText: {
|
||||
marginLeft: 8,
|
||||
fontSize: 16,
|
||||
},
|
||||
bottomContainer: {
|
||||
paddingHorizontal: 20,
|
||||
},
|
||||
sourcesContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
sourcesTitleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
sourcesTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
sourceList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
sourceButton: {
|
||||
margin: 8,
|
||||
},
|
||||
sourceButtonText: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: "#666",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: "#fff",
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
paddingBottom: 2.5,
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: "#4c4c4c",
|
||||
},
|
||||
selectedbadgeText: {
|
||||
color: "#333",
|
||||
},
|
||||
episodesContainer: {
|
||||
marginTop: 20,
|
||||
},
|
||||
episodesTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 10,
|
||||
},
|
||||
episodeList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
episodeButton: {
|
||||
margin: 5,
|
||||
},
|
||||
episodeButtonText: {
|
||||
color: "white",
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isTV = deviceType === 'tv';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isMobile = deviceType === 'mobile';
|
||||
|
||||
return StyleSheet.create({
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
// 移动端专用样式
|
||||
mobileTopContainer: {
|
||||
paddingHorizontal: spacing,
|
||||
paddingTop: spacing,
|
||||
paddingBottom: spacing / 2,
|
||||
},
|
||||
mobilePoster: {
|
||||
width: '100%',
|
||||
height: 280,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'center',
|
||||
marginBottom: spacing,
|
||||
},
|
||||
mobileInfoContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
descriptionContainer: {
|
||||
paddingHorizontal: spacing,
|
||||
paddingBottom: spacing,
|
||||
},
|
||||
|
||||
// 平板和TV端样式
|
||||
topContainer: {
|
||||
flexDirection: "row",
|
||||
padding: spacing,
|
||||
},
|
||||
poster: {
|
||||
width: isTV ? 200 : 160,
|
||||
height: isTV ? 300 : 240,
|
||||
borderRadius: 8,
|
||||
},
|
||||
infoContainer: {
|
||||
flex: 1,
|
||||
marginLeft: spacing,
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
descriptionScrollView: {
|
||||
height: 150,
|
||||
},
|
||||
|
||||
// 通用样式
|
||||
titleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing / 2,
|
||||
},
|
||||
title: {
|
||||
paddingTop: 16,
|
||||
fontSize: isMobile ? 20 : isTablet ? 24 : 28,
|
||||
fontWeight: "bold",
|
||||
flexShrink: 1,
|
||||
color: 'white',
|
||||
},
|
||||
favoriteButton: {
|
||||
padding: 10,
|
||||
marginLeft: 10,
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
metaContainer: {
|
||||
flexDirection: "row",
|
||||
marginBottom: spacing / 2,
|
||||
},
|
||||
metaText: {
|
||||
color: "#aaa",
|
||||
marginRight: spacing / 2,
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
},
|
||||
description: {
|
||||
fontSize: isMobile ? 13 : 14,
|
||||
color: "#ccc",
|
||||
lineHeight: isMobile ? 18 : 22,
|
||||
},
|
||||
|
||||
// 播放源和剧集样式
|
||||
bottomContainer: {
|
||||
paddingHorizontal: spacing,
|
||||
},
|
||||
sourcesContainer: {
|
||||
marginTop: spacing,
|
||||
},
|
||||
sourcesTitleContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing / 2,
|
||||
},
|
||||
sourcesTitle: {
|
||||
fontSize: isMobile ? 16 : isTablet ? 18 : 20,
|
||||
fontWeight: "bold",
|
||||
color: 'white',
|
||||
},
|
||||
sourceList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
sourceButton: {
|
||||
margin: isMobile ? 4 : 8,
|
||||
minHeight: isMobile ? 36 : 44,
|
||||
},
|
||||
sourceButtonText: {
|
||||
color: "white",
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
},
|
||||
badge: {
|
||||
backgroundColor: "#666",
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 8,
|
||||
},
|
||||
badgeText: {
|
||||
color: "#fff",
|
||||
fontSize: isMobile ? 10 : 12,
|
||||
fontWeight: "bold",
|
||||
paddingBottom: 2.5,
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: "#4c4c4c",
|
||||
},
|
||||
|
||||
episodesContainer: {
|
||||
marginTop: spacing,
|
||||
paddingBottom: spacing * 2,
|
||||
},
|
||||
episodesTitle: {
|
||||
fontSize: isMobile ? 16 : isTablet ? 18 : 20,
|
||||
fontWeight: "bold",
|
||||
marginBottom: spacing / 2,
|
||||
color: 'white',
|
||||
},
|
||||
episodeList: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
episodeButton: {
|
||||
margin: isMobile ? 3 : 5,
|
||||
minHeight: isMobile ? 32 : 36,
|
||||
},
|
||||
episodeButtonText: {
|
||||
color: "white",
|
||||
fontSize: isMobile ? 12 : 14,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { View, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { View, StyleSheet } from "react-native";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import useFavoritesStore from "@/stores/favoritesStore";
|
||||
import { Favorite } from "@/services/storage";
|
||||
import VideoCard from "@/components/VideoCard.tv";
|
||||
import VideoCard from "@/components/VideoCard";
|
||||
import { api } from "@/services/api";
|
||||
import CustomScrollView from "@/components/CustomScrollView";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
|
||||
export default function FavoritesScreen() {
|
||||
const { favorites, loading, error, fetchFavorites } = useFavoritesStore();
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
useEffect(() => {
|
||||
fetchFavorites();
|
||||
}, [fetchFavorites]);
|
||||
@@ -32,46 +41,67 @@ export default function FavoritesScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.headerContainer}>
|
||||
<ThemedText style={styles.headerTitle}>我的收藏</ThemedText>
|
||||
</View>
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const renderFavoritesContent = () => (
|
||||
<>
|
||||
{deviceType === 'tv' && (
|
||||
<View style={dynamicStyles.headerContainer}>
|
||||
<ThemedText style={dynamicStyles.headerTitle}>我的收藏</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
<CustomScrollView
|
||||
data={favorites}
|
||||
renderItem={renderItem}
|
||||
numColumns={5}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyMessage="暂无收藏"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{renderFavoritesContent()}
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="我的收藏" showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 40,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 16,
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
list: {
|
||||
padding: 10,
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: isTV ? spacing * 2 : 0,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing * 1.5,
|
||||
marginBottom: spacing / 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: isMobile ? 24 : isTablet ? 28 : 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: spacing,
|
||||
color: 'white',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
233
app/index.tsx
233
app/index.tsx
@@ -1,20 +1,19 @@
|
||||
import React, { useEffect, useCallback, useRef, useState } from "react";
|
||||
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions, Animated } from "react-native";
|
||||
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { api } from "@/services/api";
|
||||
import VideoCard from "@/components/VideoCard.tv";
|
||||
import VideoCard from "@/components/VideoCard";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { Search, Settings, LogOut, Heart } from "lucide-react-native";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
|
||||
import useAuthStore from "@/stores/authStore";
|
||||
import CustomScrollView from "@/components/CustomScrollView";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
|
||||
const NUM_COLUMNS = 5;
|
||||
const { width } = Dimensions.get("window");
|
||||
|
||||
// Threshold for triggering load more data (in pixels)
|
||||
const LOAD_MORE_THRESHOLD = 200;
|
||||
|
||||
export default function HomeScreen() {
|
||||
@@ -23,6 +22,11 @@ export default function HomeScreen() {
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
const {
|
||||
categories,
|
||||
selectedCategory,
|
||||
@@ -47,7 +51,6 @@ export default function HomeScreen() {
|
||||
if (selectedCategory && !selectedCategory.tags) {
|
||||
fetchInitialData();
|
||||
} else if (selectedCategory?.tags && !selectedCategory.tag) {
|
||||
// Category with tags selected, but no specific tag yet. Select the first one.
|
||||
const defaultTag = selectedCategory.tags[0];
|
||||
setSelectedTag(defaultTag);
|
||||
selectCategory({ ...selectedCategory, tag: defaultTag });
|
||||
@@ -80,7 +83,6 @@ export default function HomeScreen() {
|
||||
const handleTagSelect = (tag: string) => {
|
||||
setSelectedTag(tag);
|
||||
if (selectedCategory) {
|
||||
// Create a new category object with the selected tag
|
||||
const categoryWithTag = { ...selectedCategory, tag: tag };
|
||||
selectCategory(categoryWithTag);
|
||||
}
|
||||
@@ -93,30 +95,28 @@ export default function HomeScreen() {
|
||||
text={item.title}
|
||||
onPress={() => handleCategorySelect(item)}
|
||||
isSelected={isSelected}
|
||||
style={styles.categoryButton}
|
||||
textStyle={styles.categoryText}
|
||||
style={dynamicStyles.categoryButton}
|
||||
textStyle={dynamicStyles.categoryText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
|
||||
<View style={styles.itemContainer}>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
year={item.year}
|
||||
rate={item.rate}
|
||||
progress={item.progress}
|
||||
playTime={item.play_time}
|
||||
episodeIndex={item.episodeIndex}
|
||||
sourceName={item.sourceName}
|
||||
totalEpisodes={item.totalEpisodes}
|
||||
api={api}
|
||||
onRecordDeleted={fetchInitialData} // For "Recent Plays"
|
||||
/>
|
||||
</View>
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
source={item.source}
|
||||
title={item.title}
|
||||
poster={item.poster}
|
||||
year={item.year}
|
||||
rate={item.rate}
|
||||
progress={item.progress}
|
||||
playTime={item.play_time}
|
||||
episodeIndex={item.episodeIndex}
|
||||
sourceName={item.sourceName}
|
||||
totalEpisodes={item.totalEpisodes}
|
||||
api={api}
|
||||
onRecordDeleted={fetchInitialData}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
@@ -124,67 +124,126 @@ export default function HomeScreen() {
|
||||
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
{/* 顶部导航 */}
|
||||
<View style={styles.headerContainer}>
|
||||
// TV端和平板端的顶部导航
|
||||
const renderHeader = () => {
|
||||
if (deviceType === 'mobile') {
|
||||
// 移动端不显示顶部导航,使用底部Tab导航
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={dynamicStyles.headerContainer}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<ThemedText style={styles.headerTitle}>首页</ThemedText>
|
||||
<ThemedText style={dynamicStyles.headerTitle}>首页</ThemedText>
|
||||
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
|
||||
{({ focused }) => (
|
||||
<ThemedText style={[styles.headerTitle, { color: focused ? "white" : "grey" }]}>直播</ThemedText>
|
||||
<ThemedText style={[dynamicStyles.headerTitle, { color: focused ? "white" : "grey" }]}>直播</ThemedText>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.rightHeaderButtons}>
|
||||
<StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
|
||||
<View style={dynamicStyles.rightHeaderButtons}>
|
||||
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/favorites")} variant="ghost">
|
||||
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
<StyledButton
|
||||
style={styles.searchButton}
|
||||
style={dynamicStyles.iconButton}
|
||||
onPress={() => router.push({ pathname: "/search" })}
|
||||
variant="ghost"
|
||||
>
|
||||
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
|
||||
<StyledButton style={dynamicStyles.iconButton} onPress={() => router.push("/settings")} variant="ghost">
|
||||
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
{isLoggedIn && (
|
||||
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
|
||||
<StyledButton style={dynamicStyles.iconButton} onPress={logout} variant="ghost">
|
||||
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
|
||||
</StyledButton>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: deviceType === 'mobile' ? 0 : 40,
|
||||
},
|
||||
headerContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing * 1.5,
|
||||
marginBottom: spacing,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: deviceType === 'mobile' ? 24 : deviceType === 'tablet' ? 28 : 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 16,
|
||||
},
|
||||
rightHeaderButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
iconButton: {
|
||||
borderRadius: 30,
|
||||
marginLeft: spacing / 2,
|
||||
},
|
||||
categoryContainer: {
|
||||
paddingBottom: spacing / 2,
|
||||
},
|
||||
categoryListContent: {
|
||||
paddingHorizontal: spacing,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: spacing / 2,
|
||||
paddingVertical: spacing / 2,
|
||||
borderRadius: deviceType === 'mobile' ? 6 : 8,
|
||||
marginHorizontal: spacing / 2,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: deviceType === 'mobile' ? 14 : 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{/* 顶部导航 */}
|
||||
{renderHeader()}
|
||||
|
||||
{/* 分类选择器 */}
|
||||
<View style={styles.categoryContainer}>
|
||||
<View style={dynamicStyles.categoryContainer}>
|
||||
<FlatList
|
||||
data={categories}
|
||||
renderItem={renderCategory}
|
||||
keyExtractor={(item) => item.title}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoryListContent}
|
||||
contentContainerStyle={dynamicStyles.categoryListContent}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Sub-category Tags */}
|
||||
{/* 子分类标签 */}
|
||||
{selectedCategory && selectedCategory.tags && (
|
||||
<View style={styles.categoryContainer}>
|
||||
<View style={dynamicStyles.categoryContainer}>
|
||||
<FlatList
|
||||
data={selectedCategory.tags}
|
||||
renderItem={({ item, index }) => {
|
||||
const isSelected = selectedTag === item;
|
||||
return (
|
||||
<StyledButton
|
||||
hasTVPreferredFocus={index === 0} // Focus the first tag by default
|
||||
hasTVPreferredFocus={index === 0}
|
||||
text={item}
|
||||
onPress={() => handleTagSelect(item)}
|
||||
isSelected={isSelected}
|
||||
style={styles.categoryButton}
|
||||
textStyle={styles.categoryText}
|
||||
style={dynamicStyles.categoryButton}
|
||||
textStyle={dynamicStyles.categoryText}
|
||||
variant="ghost"
|
||||
/>
|
||||
);
|
||||
@@ -192,28 +251,27 @@ export default function HomeScreen() {
|
||||
keyExtractor={(item) => item}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.categoryListContent}
|
||||
contentContainerStyle={dynamicStyles.categoryListContent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 内容网格 */}
|
||||
{loading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<View style={commonStyles.center}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
) : error ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText type="subtitle" style={{ padding: 10 }}>
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText type="subtitle" style={{ padding: spacing }}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<Animated.View style={[styles.contentContainer, { opacity: fadeAnim }]}>
|
||||
<Animated.View style={[dynamicStyles.contentContainer, { opacity: fadeAnim }]}>
|
||||
<CustomScrollView
|
||||
data={contentData}
|
||||
renderItem={renderContentItem}
|
||||
numColumns={NUM_COLUMNS}
|
||||
loading={loading}
|
||||
loadingMore={loadingMore}
|
||||
error={error}
|
||||
@@ -226,66 +284,15 @@ export default function HomeScreen() {
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 40,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Header
|
||||
headerContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 24,
|
||||
marginBottom: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 16,
|
||||
},
|
||||
rightHeaderButtons: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
},
|
||||
searchButton: {
|
||||
borderRadius: 30,
|
||||
},
|
||||
// Category Selector
|
||||
categoryContainer: {
|
||||
paddingBottom: 6,
|
||||
},
|
||||
categoryListContent: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
categoryButton: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
categoryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: "500",
|
||||
},
|
||||
// Content Grid
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
itemContainer: {
|
||||
margin: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
189
app/live.tsx
189
app/live.tsx
@@ -5,9 +5,20 @@ import { fetchAndParseM3u, getPlayableUrl, Channel } from "@/services/m3u";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { StyledButton } from "@/components/StyledButton";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
export default function LiveScreen() {
|
||||
const { m3uUrl } = useSettingsStore();
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
const [groupedChannels, setGroupedChannels] = useState<Record<string, Channel[]>>({});
|
||||
const [channelGroups, setChannelGroups] = useState<string[]>([]);
|
||||
@@ -80,30 +91,38 @@ export default function LiveScreen() {
|
||||
|
||||
const handleTVEvent = useCallback(
|
||||
(event: HWEvent) => {
|
||||
if (deviceType !== 'tv') return;
|
||||
if (isChannelListVisible) return;
|
||||
if (event.eventType === "down") setIsChannelListVisible(true);
|
||||
else if (event.eventType === "left") changeChannel("prev");
|
||||
else if (event.eventType === "right") changeChannel("next");
|
||||
},
|
||||
[changeChannel, isChannelListVisible]
|
||||
[changeChannel, isChannelListVisible, deviceType]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
useTVEventHandler(deviceType === 'tv' ? handleTVEvent : () => {});
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<LivePlayer streamUrl={selectedChannelUrl} channelTitle={channelTitle} onPlaybackStatusUpdate={() => {}} />
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const renderLiveContent = () => (
|
||||
<>
|
||||
<LivePlayer
|
||||
streamUrl={selectedChannelUrl}
|
||||
channelTitle={channelTitle}
|
||||
onPlaybackStatusUpdate={() => {}}
|
||||
/>
|
||||
<Modal
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
visible={isChannelListVisible}
|
||||
onRequestClose={() => setIsChannelListVisible(false)}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>选择频道</Text>
|
||||
<View style={styles.listContainer}>
|
||||
<View style={styles.groupColumn}>
|
||||
<View style={dynamicStyles.modalContainer}>
|
||||
<View style={dynamicStyles.modalContent}>
|
||||
<Text style={dynamicStyles.modalTitle}>选择频道</Text>
|
||||
<View style={dynamicStyles.listContainer}>
|
||||
<View style={dynamicStyles.groupColumn}>
|
||||
<FlatList
|
||||
data={channelGroups}
|
||||
keyExtractor={(item, index) => `group-${item}-${index}`}
|
||||
@@ -112,13 +131,13 @@ export default function LiveScreen() {
|
||||
text={item}
|
||||
onPress={() => setSelectedGroup(item)}
|
||||
isSelected={selectedGroup === item}
|
||||
style={styles.groupButton}
|
||||
textStyle={styles.groupButtonText}
|
||||
style={dynamicStyles.groupButton}
|
||||
textStyle={dynamicStyles.groupButtonText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.channelColumn}>
|
||||
<View style={dynamicStyles.channelColumn}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="large" />
|
||||
) : (
|
||||
@@ -131,8 +150,8 @@ export default function LiveScreen() {
|
||||
onPress={() => handleSelectChannel(item)}
|
||||
isSelected={channels[currentChannelIndex]?.id === item.id}
|
||||
hasTVPreferredFocus={channels[currentChannelIndex]?.id === item.id}
|
||||
style={styles.channelItem}
|
||||
textStyle={styles.channelItemText}
|
||||
style={dynamicStyles.channelItem}
|
||||
textStyle={dynamicStyles.channelItemText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -142,68 +161,86 @@ export default function LiveScreen() {
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{renderLiveContent()}
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="直播" showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
modalContent: {
|
||||
width: 450,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
padding: 15,
|
||||
},
|
||||
modalTitle: {
|
||||
color: "white",
|
||||
marginBottom: 10,
|
||||
textAlign: "center",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
},
|
||||
groupColumn: {
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
channelColumn: {
|
||||
flex: 2,
|
||||
},
|
||||
groupButton: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 4,
|
||||
marginVertical: 4,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
},
|
||||
groupButtonText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
channelItem: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 4,
|
||||
marginVertical: 3,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
channelItemText: {
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: isMobile ? "center" : "flex-end",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
modalContent: {
|
||||
width: isMobile ? '90%' : isTablet ? 400 : 450,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
padding: spacing,
|
||||
},
|
||||
modalTitle: {
|
||||
color: "white",
|
||||
marginBottom: spacing / 2,
|
||||
textAlign: "center",
|
||||
fontSize: isMobile ? 18 : 16,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
listContainer: {
|
||||
flex: 1,
|
||||
flexDirection: isMobile ? "column" : "row",
|
||||
},
|
||||
groupColumn: {
|
||||
flex: isMobile ? 0 : 1,
|
||||
marginRight: isMobile ? 0 : spacing / 2,
|
||||
marginBottom: isMobile ? spacing : 0,
|
||||
maxHeight: isMobile ? 120 : undefined,
|
||||
},
|
||||
channelColumn: {
|
||||
flex: isMobile ? 1 : 2,
|
||||
},
|
||||
groupButton: {
|
||||
paddingVertical: isMobile ? minTouchTarget / 4 : 8,
|
||||
paddingHorizontal: spacing / 2,
|
||||
marginVertical: isMobile ? 2 : 4,
|
||||
minHeight: isMobile ? minTouchTarget * 0.7 : undefined,
|
||||
},
|
||||
groupButtonText: {
|
||||
fontSize: isMobile ? 14 : 13,
|
||||
},
|
||||
channelItem: {
|
||||
paddingVertical: isMobile ? minTouchTarget / 5 : 6,
|
||||
paddingHorizontal: spacing,
|
||||
marginVertical: isMobile ? 2 : 3,
|
||||
minHeight: isMobile ? minTouchTarget * 0.8 : undefined,
|
||||
},
|
||||
channelItemText: {
|
||||
fontSize: isMobile ? 14 : 12,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
67
app/play.tsx
67
app/play.tsx
@@ -14,11 +14,16 @@ import useDetailStore from "@/stores/detailStore";
|
||||
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
||||
import Toast from "react-native-toast-message";
|
||||
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
|
||||
export default function PlayScreen() {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const router = useRouter();
|
||||
useKeepAwake();
|
||||
|
||||
// 响应式布局配置
|
||||
const { deviceType } = useResponsiveLayout();
|
||||
|
||||
const {
|
||||
episodeIndex: episodeIndexStr,
|
||||
position: positionStr,
|
||||
@@ -79,7 +84,13 @@ export default function PlayScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { onScreenPress } = useTVRemoteHandler();
|
||||
// TV遥控器处理 - 总是调用hook,但根据设备类型决定是否使用结果
|
||||
const tvRemoteHandler = useTVRemoteHandler();
|
||||
|
||||
// 根据设备类型使用不同的交互处理
|
||||
const onScreenPress = deviceType === 'tv'
|
||||
? tvRemoteHandler.onScreenPress
|
||||
: () => setShowControls(!showControls);
|
||||
|
||||
useEffect(() => {
|
||||
const backAction = () => {
|
||||
@@ -119,12 +130,20 @@ export default function PlayScreen() {
|
||||
return <VideoLoadingAnimation showProgressBar />;
|
||||
}
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType);
|
||||
|
||||
return (
|
||||
<ThemedView focusable style={styles.container}>
|
||||
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
|
||||
<ThemedView focusable style={dynamicStyles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={dynamicStyles.videoContainer}
|
||||
onPress={onScreenPress}
|
||||
disabled={deviceType !== 'tv' && showControls} // 移动端和平板端在显示控制条时禁用触摸
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={styles.videoPlayer}
|
||||
style={dynamicStyles.videoPlayer}
|
||||
source={{ uri: currentEpisode?.url || "" }}
|
||||
posterSource={{ uri: detail?.poster ?? "" }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
@@ -146,7 +165,7 @@ export default function PlayScreen() {
|
||||
<SeekingBar />
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.videoContainer}>
|
||||
<View style={dynamicStyles.loadingContainer}>
|
||||
<VideoLoadingAnimation showProgressBar />
|
||||
</View>
|
||||
)}
|
||||
@@ -160,13 +179,31 @@ export default function PlayScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: "black" },
|
||||
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
|
||||
videoContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
videoPlayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "black",
|
||||
// 移动端和平板端可能需要状态栏处理
|
||||
...(isMobile || isTablet ? { paddingTop: 0 } : {}),
|
||||
},
|
||||
videoContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
// 为触摸设备添加更多的交互区域
|
||||
...(isMobile || isTablet ? { zIndex: 1 } : {}),
|
||||
},
|
||||
videoPlayer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
175
app/search.tsx
175
app/search.tsx
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native";
|
||||
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity } from "react-native";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import VideoCard from "@/components/VideoCard.tv";
|
||||
import VideoCard from "@/components/VideoCard";
|
||||
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
|
||||
import { api, SearchResult } from "@/services/api";
|
||||
import { Search, QrCode } from "lucide-react-native";
|
||||
@@ -13,6 +13,11 @@ import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import CustomScrollView from "@/components/CustomScrollView";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -20,12 +25,16 @@ export default function SearchScreen() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textInputRef = useRef<TextInput>(null);
|
||||
const colorScheme = "dark"; // Replace with useColorScheme() if needed
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const { showModal: showRemoteModal, lastMessage } = useRemoteControlStore();
|
||||
const { remoteInputEnabled } = useSettingsStore();
|
||||
const router = useRouter();
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage) {
|
||||
console.log("Received remote input:", lastMessage);
|
||||
@@ -93,110 +102,134 @@ export default function SearchScreen() {
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.searchContainer}>
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const renderSearchContent = () => (
|
||||
<>
|
||||
<View style={dynamicStyles.searchContainer}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={[
|
||||
styles.input,
|
||||
dynamicStyles.inputContainer,
|
||||
{
|
||||
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
|
||||
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
|
||||
borderWidth: 2,
|
||||
},
|
||||
]}
|
||||
onPress={() => textInputRef.current?.focus()}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
>
|
||||
<TextInput
|
||||
ref={textInputRef}
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
color: colorScheme === "dark" ? "white" : "black",
|
||||
},
|
||||
]}
|
||||
style={dynamicStyles.input}
|
||||
placeholder="搜索电影、剧集..."
|
||||
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
|
||||
placeholderTextColor="#888"
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
onSubmitEditing={onSearchPress}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
|
||||
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
|
||||
</StyledButton>
|
||||
<StyledButton style={styles.qrButton} onPress={handleQrPress}>
|
||||
<QrCode size={24} color={colorScheme === "dark" ? "white" : "black"} />
|
||||
<StyledButton style={dynamicStyles.searchButton} onPress={onSearchPress}>
|
||||
<Search size={deviceType === 'mobile' ? 20 : 24} color="white" />
|
||||
</StyledButton>
|
||||
{deviceType !== 'mobile' && (
|
||||
<StyledButton style={dynamicStyles.qrButton} onPress={handleQrPress}>
|
||||
<QrCode size={deviceType === 'tv' ? 24 : 20} color="white" />
|
||||
</StyledButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{loading ? (
|
||||
<VideoLoadingAnimation showProgressBar={false} />
|
||||
) : error ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText style={styles.errorText}>{error}</ThemedText>
|
||||
<View style={[commonStyles.center, { flex: 1 }]}>
|
||||
<ThemedText style={dynamicStyles.errorText}>{error}</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
<CustomScrollView
|
||||
data={results}
|
||||
renderItem={renderItem}
|
||||
numColumns={5}
|
||||
loading={loading}
|
||||
error={error}
|
||||
emptyMessage="输入关键词开始搜索"
|
||||
/>
|
||||
)}
|
||||
<RemoteControlModal />
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{renderSearchContent()}
|
||||
</ThemedView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="搜索" showBackButton />
|
||||
{content}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: 50,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: 20,
|
||||
marginBottom: 20,
|
||||
alignItems: "center",
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
height: 50,
|
||||
backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 15,
|
||||
color: "white", // Default for dark mode, overridden inline
|
||||
fontSize: 18,
|
||||
marginRight: 10,
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent", // Default, overridden for focus
|
||||
},
|
||||
searchButton: {
|
||||
padding: 12,
|
||||
// backgroundColor is now set dynamically
|
||||
borderRadius: 8,
|
||||
},
|
||||
qrButton: {
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
marginLeft: 10,
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
errorText: {
|
||||
color: "red",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingTop: deviceType === 'tv' ? 50 : 0,
|
||||
},
|
||||
searchContainer: {
|
||||
flexDirection: "row",
|
||||
paddingHorizontal: spacing,
|
||||
marginBottom: spacing,
|
||||
alignItems: "center",
|
||||
paddingTop: isMobile ? spacing / 2 : 0,
|
||||
},
|
||||
inputContainer: {
|
||||
flex: 1,
|
||||
height: isMobile ? minTouchTarget : 50,
|
||||
backgroundColor: "#2c2c2e",
|
||||
borderRadius: isMobile ? 8 : 8,
|
||||
marginRight: spacing / 2,
|
||||
borderWidth: 2,
|
||||
borderColor: "transparent",
|
||||
justifyContent: "center",
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing,
|
||||
color: "white",
|
||||
fontSize: isMobile ? 16 : 18,
|
||||
},
|
||||
searchButton: {
|
||||
width: isMobile ? minTouchTarget : 50,
|
||||
height: isMobile ? minTouchTarget : 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: isMobile ? 8 : 8,
|
||||
marginRight: deviceType !== 'mobile' ? spacing / 2 : 0,
|
||||
},
|
||||
qrButton: {
|
||||
width: isMobile ? minTouchTarget : 50,
|
||||
height: isMobile ? minTouchTarget : 50,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: isMobile ? 8 : 8,
|
||||
},
|
||||
errorText: {
|
||||
color: "red",
|
||||
fontSize: isMobile ? 14 : 16,
|
||||
textAlign: "center",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
147
app/settings.tsx
147
app/settings.tsx
@@ -14,12 +14,22 @@ import { RemoteInputSection } from "@/components/settings/RemoteInputSection";
|
||||
import { UpdateSection } from "@/components/settings/UpdateSection";
|
||||
// import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
|
||||
const { lastMessage } = useRemoteControlStore();
|
||||
const backgroundColor = useThemeColor({}, "background");
|
||||
|
||||
// 响应式布局配置
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
const { deviceType, spacing } = responsiveConfig;
|
||||
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
|
||||
@@ -131,9 +141,11 @@ export default function SettingsScreen() {
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
// TV遥控器事件处理
|
||||
// TV遥控器事件处理 - 仅在TV设备上启用
|
||||
const handleTVEvent = React.useCallback(
|
||||
(event: any) => {
|
||||
if (deviceType !== 'tv') return;
|
||||
|
||||
if (event.eventType === "down") {
|
||||
const nextIndex = Math.min(currentFocusIndex + 1, sections.length);
|
||||
setCurrentFocusIndex(nextIndex);
|
||||
@@ -145,72 +157,111 @@ export default function SettingsScreen() {
|
||||
setCurrentFocusIndex(prevIndex);
|
||||
}
|
||||
},
|
||||
[currentFocusIndex, sections.length]
|
||||
[currentFocusIndex, sections.length, deviceType]
|
||||
);
|
||||
|
||||
useTVEventHandler(handleTVEvent);
|
||||
useTVEventHandler(deviceType === 'tv' ? handleTVEvent : () => {});
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
|
||||
<ThemedView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText style={styles.title}>设置</ThemedText>
|
||||
</View>
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
<View style={styles.scrollView}>
|
||||
const renderSettingsContent = () => (
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1, backgroundColor }}
|
||||
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||
>
|
||||
<ThemedView style={[commonStyles.container, dynamicStyles.container]}>
|
||||
{deviceType === 'tv' && (
|
||||
<View style={dynamicStyles.header}>
|
||||
<ThemedText style={dynamicStyles.title}>设置</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={dynamicStyles.scrollView}>
|
||||
<FlatList
|
||||
data={sections}
|
||||
renderItem={({ item }) => item.component}
|
||||
keyExtractor={(item) => item.key}
|
||||
renderItem={({ item }) => {
|
||||
if (item) {
|
||||
return item.component;
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
keyExtractor={(item) => item ? item.key : 'default'}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={dynamicStyles.listContent}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={dynamicStyles.footer}>
|
||||
<StyledButton
|
||||
text={isLoading ? "保存中..." : "保存设置"}
|
||||
onPress={handleSave}
|
||||
variant="primary"
|
||||
disabled={!hasChanges || isLoading}
|
||||
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
|
||||
style={[
|
||||
dynamicStyles.saveButton,
|
||||
(!hasChanges || isLoading) && dynamicStyles.disabledButton
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
|
||||
// 根据设备类型决定是否包装在响应式导航中
|
||||
if (deviceType === 'tv') {
|
||||
return renderSettingsContent();
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveNavigation>
|
||||
<ResponsiveHeader title="设置" showBackButton />
|
||||
{renderSettingsContent()}
|
||||
</ResponsiveNavigation>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: 10,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: 24,
|
||||
},
|
||||
backButton: {
|
||||
minWidth: 100,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 12,
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
saveButton: {
|
||||
minHeight: 50,
|
||||
width: 120,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: spacing,
|
||||
paddingTop: isTV ? spacing * 2 : 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing,
|
||||
},
|
||||
title: {
|
||||
fontSize: isMobile ? 24 : isTablet ? 28 : 32,
|
||||
fontWeight: "bold",
|
||||
paddingTop: spacing,
|
||||
color: 'white',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: spacing,
|
||||
},
|
||||
footer: {
|
||||
paddingTop: spacing,
|
||||
alignItems: isMobile ? "center" : "flex-end",
|
||||
},
|
||||
saveButton: {
|
||||
minHeight: isMobile ? minTouchTarget : isTablet ? 50 : 50,
|
||||
width: isMobile ? '100%' : isTablet ? 140 : 120,
|
||||
maxWidth: isMobile ? 280 : undefined,
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
|
||||
import { View, StyleSheet, ScrollView, ActivityIndicator } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
|
||||
interface CustomScrollViewProps {
|
||||
data: any[];
|
||||
renderItem: ({ item, index }: { item: any; index: number }) => React.ReactNode;
|
||||
numColumns?: number;
|
||||
numColumns?: number; // 如果不提供,将使用响应式默认值
|
||||
loading?: boolean;
|
||||
loadingMore?: boolean;
|
||||
error?: string | null;
|
||||
@@ -15,12 +17,10 @@ interface CustomScrollViewProps {
|
||||
ListFooterComponent?: React.ComponentType<any> | React.ReactElement | null;
|
||||
}
|
||||
|
||||
const { width } = Dimensions.get("window");
|
||||
|
||||
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
data,
|
||||
renderItem,
|
||||
numColumns = 1,
|
||||
numColumns, // 现在可选,如果不提供将使用响应式默认值
|
||||
loading = false,
|
||||
loadingMore = false,
|
||||
error = null,
|
||||
@@ -29,7 +29,11 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
emptyMessage = "暂无内容",
|
||||
ListFooterComponent,
|
||||
}) => {
|
||||
const ITEM_WIDTH = numColumns > 0 ? width / numColumns - 24 : width - 24;
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
const commonStyles = getCommonResponsiveStyles(responsiveConfig);
|
||||
|
||||
// 使用响应式列数,如果没有明确指定的话
|
||||
const effectiveColumns = numColumns || responsiveConfig.columns;
|
||||
|
||||
const handleScroll = useCallback(
|
||||
({ nativeEvent }: { nativeEvent: any }) => {
|
||||
@@ -61,7 +65,7 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<View style={commonStyles.center}>
|
||||
<ActivityIndicator size="large" />
|
||||
</View>
|
||||
);
|
||||
@@ -69,8 +73,8 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<ThemedText type="subtitle" style={{ padding: 10 }}>
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText type="subtitle" style={{ padding: responsiveConfig.spacing }}>
|
||||
{error}
|
||||
</ThemedText>
|
||||
</View>
|
||||
@@ -79,22 +83,44 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText>{emptyMessage}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: responsiveConfig.spacing * 2,
|
||||
},
|
||||
rowContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: responsiveConfig.deviceType === 'mobile' ? "space-around" : "flex-start",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: responsiveConfig.spacing / 2,
|
||||
},
|
||||
itemContainer: {
|
||||
marginHorizontal: responsiveConfig.spacing / 2,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.listContent} onScroll={handleScroll} scrollEventThrottle={16}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[commonStyles.gridContainer, dynamicStyles.listContent]}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={responsiveConfig.deviceType !== 'tv'}
|
||||
>
|
||||
{data.length > 0 ? (
|
||||
<>
|
||||
{/* Render content in a grid layout */}
|
||||
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.rowContainer}>
|
||||
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
|
||||
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
|
||||
{renderItem({ item, index: rowIndex * numColumns + index })}
|
||||
{/* Render content in a responsive grid layout */}
|
||||
{Array.from({ length: Math.ceil(data.length / effectiveColumns) }).map((_, rowIndex) => (
|
||||
<View key={rowIndex} style={dynamicStyles.rowContainer}>
|
||||
{data.slice(rowIndex * effectiveColumns, (rowIndex + 1) * effectiveColumns).map((item, index) => (
|
||||
<View key={index} style={dynamicStyles.itemContainer}>
|
||||
{renderItem({ item, index: rowIndex * effectiveColumns + index })}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -102,34 +128,13 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
{renderFooter()}
|
||||
</>
|
||||
) : (
|
||||
<View style={styles.centerContainer}>
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText>{emptyMessage}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 20,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
rowContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
itemContainer: {
|
||||
margin: 8,
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
export default CustomScrollView;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Modal, FlatList, Pressable } from "react-native";
|
||||
import React, { useState } from "react";
|
||||
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
import usePlayerStore from "@/stores/playerStore";
|
||||
import { useState } from "react";
|
||||
|
||||
interface Episode {
|
||||
title?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface EpisodeSelectionModalProps {}
|
||||
|
||||
|
||||
149
components/MobileBottomNavigation.tsx
Normal file
149
components/MobileBottomNavigation.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
import { Home, Heart, Search, Settings, Tv } from 'lucide-react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
label: string;
|
||||
icon: any;
|
||||
route: string;
|
||||
}
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
name: 'home',
|
||||
label: '首页',
|
||||
icon: Home,
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
name: 'live',
|
||||
label: '直播',
|
||||
icon: Tv,
|
||||
route: '/live',
|
||||
},
|
||||
{
|
||||
name: 'search',
|
||||
label: '搜索',
|
||||
icon: Search,
|
||||
route: '/search',
|
||||
},
|
||||
{
|
||||
name: 'favorites',
|
||||
label: '收藏',
|
||||
icon: Heart,
|
||||
route: '/favorites',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: '设置',
|
||||
icon: Settings,
|
||||
route: '/settings',
|
||||
},
|
||||
];
|
||||
|
||||
interface MobileBottomNavigationProps {
|
||||
colorScheme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
export const MobileBottomNavigation: React.FC<MobileBottomNavigationProps> = ({
|
||||
colorScheme = 'dark',
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
|
||||
// Only show on mobile devices
|
||||
if (responsiveConfig.deviceType !== 'mobile') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleNavigation = (route: string) => {
|
||||
if (route === '/') {
|
||||
router.push('/');
|
||||
} else {
|
||||
router.push(route as any);
|
||||
}
|
||||
};
|
||||
|
||||
const isActiveRoute = (route: string) => {
|
||||
if (route === '/') {
|
||||
return pathname === '/';
|
||||
}
|
||||
return pathname.startsWith(route);
|
||||
};
|
||||
|
||||
const activeColor = colorScheme === 'dark' ? '#007AFF' : '#007AFF';
|
||||
const inactiveColor = colorScheme === 'dark' ? '#8E8E93' : '#8E8E93';
|
||||
const backgroundColor = colorScheme === 'dark' ? '#1C1C1E' : '#F2F2F7';
|
||||
|
||||
const dynamicStyles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor,
|
||||
borderTopColor: colorScheme === 'dark' ? '#38383A' : '#C6C6C8',
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, dynamicStyles.container]}>
|
||||
{navigationItems.map((item) => {
|
||||
const isActive = isActiveRoute(item.route);
|
||||
const IconComponent = item.icon;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.name}
|
||||
style={styles.tabItem}
|
||||
onPress={() => handleNavigation(item.route)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
size={24}
|
||||
color={isActive ? activeColor : inactiveColor}
|
||||
/>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{ color: isActive ? activeColor : inactiveColor },
|
||||
]}
|
||||
>
|
||||
{item.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
height: 84, // 49 + 35 for safe area
|
||||
paddingBottom: 35, // Safe area padding
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 8,
|
||||
borderTopWidth: 0.5,
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 4,
|
||||
minHeight: DeviceUtils.getMinTouchTargetSize(),
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 11,
|
||||
marginTop: 2,
|
||||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default MobileBottomNavigation;
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Modal, View, Text, StyleSheet } from "react-native";
|
||||
import { Modal, View, StyleSheet } from "react-native";
|
||||
import QRCode from "react-native-qrcode-svg";
|
||||
import { useRemoteControlStore } from "@/stores/remoteControlStore";
|
||||
import { ThemedView } from "./ThemedView";
|
||||
|
||||
152
components/ResponsiveButton.tsx
Normal file
152
components/ResponsiveButton.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity, StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
|
||||
interface ResponsiveButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'secondary' | 'ghost';
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
style?: ViewStyle;
|
||||
textStyle?: TextStyle;
|
||||
}
|
||||
|
||||
const ResponsiveButton: React.FC<ResponsiveButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
disabled = false,
|
||||
fullWidth = false,
|
||||
icon,
|
||||
style,
|
||||
textStyle,
|
||||
}) => {
|
||||
const { deviceType, spacing } = useResponsiveLayout();
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const buttonStyle = [
|
||||
dynamicStyles.baseButton,
|
||||
dynamicStyles[variant],
|
||||
dynamicStyles[size],
|
||||
fullWidth && dynamicStyles.fullWidth,
|
||||
disabled && dynamicStyles.disabled,
|
||||
style,
|
||||
];
|
||||
|
||||
const textStyleCombined = [
|
||||
dynamicStyles.baseText,
|
||||
dynamicStyles[`${variant}Text`],
|
||||
dynamicStyles[`${size}Text`],
|
||||
disabled && dynamicStyles.disabledText,
|
||||
textStyle,
|
||||
];
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={buttonStyle}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{icon && <>{icon}</>}
|
||||
<ThemedText style={textStyleCombined}>{title}</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
baseButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 0.75,
|
||||
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
|
||||
},
|
||||
|
||||
// Variants
|
||||
primary: {
|
||||
backgroundColor: Colors.dark.primary,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: '#2c2c2e',
|
||||
borderWidth: 1,
|
||||
borderColor: '#666',
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#666',
|
||||
},
|
||||
|
||||
// Sizes
|
||||
small: {
|
||||
paddingHorizontal: spacing * 0.75,
|
||||
paddingVertical: spacing * 0.5,
|
||||
minHeight: isMobile ? minTouchTarget * 0.8 : 36,
|
||||
},
|
||||
medium: {
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 0.75,
|
||||
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
|
||||
},
|
||||
large: {
|
||||
paddingHorizontal: spacing * 1.5,
|
||||
paddingVertical: spacing,
|
||||
minHeight: isMobile ? minTouchTarget * 1.2 : isTablet ? 56 : 52,
|
||||
},
|
||||
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
|
||||
// Text styles
|
||||
baseText: {
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryText: {
|
||||
color: 'white',
|
||||
},
|
||||
secondaryText: {
|
||||
color: 'white',
|
||||
},
|
||||
ghostText: {
|
||||
color: '#ccc',
|
||||
},
|
||||
|
||||
// Text sizes
|
||||
smallText: {
|
||||
fontSize: isMobile ? 14 : 12,
|
||||
},
|
||||
mediumText: {
|
||||
fontSize: isMobile ? 16 : isTablet ? 16 : 14,
|
||||
},
|
||||
largeText: {
|
||||
fontSize: isMobile ? 18 : isTablet ? 18 : 16,
|
||||
},
|
||||
|
||||
disabledText: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ResponsiveButton;
|
||||
97
components/ResponsiveCard.tsx
Normal file
97
components/ResponsiveCard.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, TouchableOpacity } from 'react-native';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
|
||||
interface ResponsiveCardProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
variant?: 'default' | 'elevated' | 'outlined';
|
||||
padding?: 'small' | 'medium' | 'large';
|
||||
style?: ViewStyle;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ResponsiveCard: React.FC<ResponsiveCardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
variant = 'default',
|
||||
padding = 'medium',
|
||||
style,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const { deviceType, spacing } = useResponsiveLayout();
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
const cardStyle = [
|
||||
dynamicStyles.baseCard,
|
||||
dynamicStyles[variant],
|
||||
dynamicStyles[padding],
|
||||
disabled && dynamicStyles.disabled,
|
||||
style,
|
||||
];
|
||||
|
||||
if (onPress && !disabled) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={cardStyle}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={cardStyle}>{children}</View>;
|
||||
};
|
||||
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
|
||||
return StyleSheet.create({
|
||||
baseCard: {
|
||||
backgroundColor: '#1c1c1e',
|
||||
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
|
||||
marginBottom: spacing,
|
||||
},
|
||||
|
||||
// Variants
|
||||
default: {
|
||||
backgroundColor: '#1c1c1e',
|
||||
},
|
||||
elevated: {
|
||||
backgroundColor: '#1c1c1e',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: isMobile ? 2 : isTablet ? 4 : 6,
|
||||
},
|
||||
shadowOpacity: isMobile ? 0.1 : isTablet ? 0.15 : 0.2,
|
||||
shadowRadius: isMobile ? 4 : isTablet ? 6 : 8,
|
||||
elevation: isMobile ? 3 : isTablet ? 5 : 8,
|
||||
},
|
||||
outlined: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: '#333',
|
||||
},
|
||||
|
||||
// Padding variants
|
||||
small: {
|
||||
padding: spacing * 0.75,
|
||||
},
|
||||
medium: {
|
||||
padding: spacing,
|
||||
},
|
||||
large: {
|
||||
padding: spacing * 1.5,
|
||||
},
|
||||
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ResponsiveCard;
|
||||
131
components/ResponsiveTextInput.tsx
Normal file
131
components/ResponsiveTextInput.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { forwardRef } from 'react';
|
||||
import { TextInput, View, StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
|
||||
interface ResponsiveTextInputProps {
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
label?: string;
|
||||
error?: string;
|
||||
secureTextEntry?: boolean;
|
||||
keyboardType?: 'default' | 'numeric' | 'email-address' | 'phone-pad';
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
editable?: boolean;
|
||||
style?: ViewStyle;
|
||||
inputStyle?: TextStyle;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
}
|
||||
|
||||
const ResponsiveTextInput = forwardRef<TextInput, ResponsiveTextInputProps>(
|
||||
(
|
||||
{
|
||||
placeholder,
|
||||
value,
|
||||
onChangeText,
|
||||
label,
|
||||
error,
|
||||
secureTextEntry = false,
|
||||
keyboardType = 'default',
|
||||
multiline = false,
|
||||
numberOfLines = 1,
|
||||
editable = true,
|
||||
style,
|
||||
inputStyle,
|
||||
onFocus,
|
||||
onBlur,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { deviceType, spacing } = useResponsiveLayout();
|
||||
const dynamicStyles = createResponsiveStyles(deviceType, spacing);
|
||||
|
||||
return (
|
||||
<View style={[dynamicStyles.container, style]}>
|
||||
{label && (
|
||||
<ThemedText style={dynamicStyles.label}>{label}</ThemedText>
|
||||
)}
|
||||
|
||||
<View style={[
|
||||
dynamicStyles.inputContainer,
|
||||
error ? dynamicStyles.errorContainer : undefined,
|
||||
!editable ? dynamicStyles.disabledContainer : undefined,
|
||||
]}>
|
||||
<TextInput
|
||||
ref={ref}
|
||||
style={[dynamicStyles.input, inputStyle]}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#888"
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
secureTextEntry={secureTextEntry}
|
||||
keyboardType={keyboardType}
|
||||
multiline={multiline}
|
||||
numberOfLines={multiline ? numberOfLines : 1}
|
||||
editable={editable}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<ThemedText style={dynamicStyles.errorText}>{error}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ResponsiveTextInput.displayName = 'ResponsiveTextInput';
|
||||
|
||||
const createResponsiveStyles = (deviceType: string, spacing: number) => {
|
||||
const isMobile = deviceType === 'mobile';
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: spacing,
|
||||
},
|
||||
label: {
|
||||
fontSize: isMobile ? 16 : 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing * 0.5,
|
||||
color: 'white',
|
||||
},
|
||||
inputContainer: {
|
||||
backgroundColor: '#2c2c2e',
|
||||
borderRadius: isMobile ? 8 : isTablet ? 10 : 12,
|
||||
borderWidth: 2,
|
||||
borderColor: 'transparent',
|
||||
minHeight: isMobile ? minTouchTarget : isTablet ? 48 : 44,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 0.75,
|
||||
fontSize: isMobile ? 16 : isTablet ? 16 : 14,
|
||||
color: 'white',
|
||||
textAlignVertical: 'top', // For multiline inputs
|
||||
},
|
||||
errorContainer: {
|
||||
borderColor: '#ff4444',
|
||||
},
|
||||
disabledContainer: {
|
||||
backgroundColor: '#1a1a1c',
|
||||
opacity: 0.6,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: isMobile ? 14 : 12,
|
||||
color: '#ff4444',
|
||||
marginTop: spacing * 0.25,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ResponsiveTextInput;
|
||||
373
components/ResponsiveVideoCard.tsx
Normal file
373
components/ResponsiveVideoCard.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
|
||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Star, Play } from "lucide-react-native";
|
||||
import { PlayRecordManager } from "@/services/storage";
|
||||
import { API } from "@/services/api";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
year?: string;
|
||||
rate?: string;
|
||||
sourceName?: string;
|
||||
progress?: number; // 播放进度,0-1之间的小数
|
||||
playTime?: number; // 播放时间 in ms
|
||||
episodeIndex?: number; // 剧集索引
|
||||
totalEpisodes?: number; // 总集数
|
||||
onFocus?: () => void;
|
||||
onRecordDeleted?: () => void; // 添加回调属性
|
||||
api: API;
|
||||
}
|
||||
|
||||
const ResponsiveVideoCard = forwardRef<View, VideoCardProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
source,
|
||||
title,
|
||||
poster,
|
||||
year,
|
||||
rate,
|
||||
sourceName,
|
||||
progress,
|
||||
episodeIndex,
|
||||
onFocus,
|
||||
onRecordDeleted,
|
||||
api,
|
||||
playTime = 0,
|
||||
}: VideoCardProps,
|
||||
ref
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
const responsiveConfig = useResponsiveLayout();
|
||||
|
||||
const longPressTriggered = useRef(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const animatedStyle = {
|
||||
transform: [{ scale }],
|
||||
};
|
||||
|
||||
const handlePress = () => {
|
||||
if (longPressTriggered.current) {
|
||||
longPressTriggered.current = false;
|
||||
return;
|
||||
}
|
||||
// 如果有播放进度,直接转到播放页面
|
||||
if (progress !== undefined && episodeIndex !== undefined) {
|
||||
router.push({
|
||||
pathname: "/play",
|
||||
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: "/detail",
|
||||
params: { source, q: title },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
// Only apply focus scaling for TV devices
|
||||
if (responsiveConfig.deviceType === 'tv') {
|
||||
setIsFocused(true);
|
||||
Animated.spring(scale, {
|
||||
toValue: 1.05,
|
||||
damping: 15,
|
||||
stiffness: 200,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
onFocus?.();
|
||||
}, [scale, onFocus, responsiveConfig.deviceType]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (responsiveConfig.deviceType === 'tv') {
|
||||
setIsFocused(false);
|
||||
Animated.spring(scale, {
|
||||
toValue: 1.0,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}
|
||||
}, [scale, responsiveConfig.deviceType]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: DeviceUtils.getAnimationDuration(400),
|
||||
delay: Math.random() * 200, // 随机延迟创建交错效果
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
|
||||
const handleLongPress = () => {
|
||||
// Only allow long press for items with progress (play records)
|
||||
if (progress === undefined) return;
|
||||
|
||||
longPressTriggered.current = true;
|
||||
|
||||
// Show confirmation dialog to delete play record
|
||||
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
|
||||
{
|
||||
text: "取消",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "删除",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
// Delete from local storage
|
||||
await PlayRecordManager.remove(source, id);
|
||||
|
||||
// Call the onRecordDeleted callback
|
||||
if (onRecordDeleted) {
|
||||
onRecordDeleted();
|
||||
}
|
||||
// 如果没有回调函数,则使用导航刷新作为备选方案
|
||||
else if (router.canGoBack()) {
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
// 是否是继续观看的视频
|
||||
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
|
||||
|
||||
// Dynamic styles based on device type
|
||||
const cardWidth = responsiveConfig.cardWidth;
|
||||
const cardHeight = responsiveConfig.cardHeight;
|
||||
|
||||
const dynamicStyles = StyleSheet.create({
|
||||
wrapper: {
|
||||
marginHorizontal: responsiveConfig.spacing / 2,
|
||||
},
|
||||
card: {
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: responsiveConfig.deviceType === 'mobile' ? 8 : responsiveConfig.deviceType === 'tablet' ? 10 : 8,
|
||||
backgroundColor: "#222",
|
||||
overflow: "hidden",
|
||||
},
|
||||
infoContainer: {
|
||||
width: cardWidth,
|
||||
marginTop: responsiveConfig.spacing / 2,
|
||||
alignItems: "flex-start",
|
||||
marginBottom: responsiveConfig.spacing,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
borderColor: Colors.dark.primary,
|
||||
borderWidth: responsiveConfig.deviceType === 'tv' ? 2 : 0,
|
||||
borderRadius: responsiveConfig.deviceType === 'mobile' ? 8 : responsiveConfig.deviceType === 'tablet' ? 10 : 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
continueWatchingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: Colors.dark.primary,
|
||||
paddingHorizontal: responsiveConfig.deviceType === 'mobile' ? 8 : 10,
|
||||
paddingVertical: responsiveConfig.deviceType === 'mobile' ? 4 : 5,
|
||||
borderRadius: 5,
|
||||
},
|
||||
continueWatchingText: {
|
||||
color: "white",
|
||||
marginLeft: 5,
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Animated.View style={[dynamicStyles.wrapper, animatedStyle, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={styles.pressable}
|
||||
activeOpacity={responsiveConfig.deviceType === 'tv' ? 1 : 0.8}
|
||||
delayLongPress={responsiveConfig.deviceType === 'mobile' ? 500 : 1000}
|
||||
>
|
||||
<View style={dynamicStyles.card}>
|
||||
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
|
||||
{(isFocused && responsiveConfig.deviceType === 'tv') && (
|
||||
<View style={dynamicStyles.overlay}>
|
||||
{isContinueWatching && (
|
||||
<View style={dynamicStyles.continueWatchingBadge}>
|
||||
<Play size={responsiveConfig.deviceType === 'tv' ? 16 : 12} color="#ffffff" fill="#ffffff" />
|
||||
<ThemedText style={dynamicStyles.continueWatchingText}>继续观看</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
{isContinueWatching && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rate && (
|
||||
<View style={[styles.ratingContainer, {
|
||||
top: responsiveConfig.spacing / 2,
|
||||
right: responsiveConfig.spacing / 2
|
||||
}]}>
|
||||
<Star size={responsiveConfig.deviceType === 'mobile' ? 10 : 12} color="#FFD700" fill="#FFD700" />
|
||||
<ThemedText style={[styles.ratingText, {
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
|
||||
}]}>{rate}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
{year && (
|
||||
<View style={[styles.yearBadge, {
|
||||
top: responsiveConfig.spacing / 2,
|
||||
right: responsiveConfig.spacing / 2
|
||||
}]}>
|
||||
<Text style={[styles.badgeText, {
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
|
||||
}]}>{year}</Text>
|
||||
</View>
|
||||
)}
|
||||
{sourceName && (
|
||||
<View style={[styles.sourceNameBadge, {
|
||||
top: responsiveConfig.spacing / 2,
|
||||
left: responsiveConfig.spacing / 2
|
||||
}]}>
|
||||
<Text style={[styles.badgeText, {
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
|
||||
}]}>{sourceName}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={dynamicStyles.infoContainer}>
|
||||
<ThemedText
|
||||
numberOfLines={responsiveConfig.deviceType === 'mobile' ? 2 : 1}
|
||||
style={{
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 14 : 16,
|
||||
lineHeight: responsiveConfig.deviceType === 'mobile' ? 18 : 20,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</ThemedText>
|
||||
{isContinueWatching && (
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={[styles.continueLabel, {
|
||||
fontSize: responsiveConfig.deviceType === 'mobile' ? 10 : 12
|
||||
}]}>
|
||||
第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ResponsiveVideoCard.displayName = "ResponsiveVideoCard";
|
||||
|
||||
export default ResponsiveVideoCard;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pressable: {
|
||||
alignItems: "center",
|
||||
},
|
||||
poster: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
},
|
||||
buttonRow: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
flexDirection: "row",
|
||||
gap: 8,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 4,
|
||||
},
|
||||
favButton: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
},
|
||||
ratingContainer: {
|
||||
position: "absolute",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
ratingText: {
|
||||
color: "#FFD700",
|
||||
fontWeight: "bold",
|
||||
marginLeft: 4,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
},
|
||||
title: {
|
||||
color: "white",
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
},
|
||||
yearBadge: {
|
||||
position: "absolute",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
sourceNameBadge: {
|
||||
position: "absolute",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
badgeText: {
|
||||
color: "white",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
progressContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
},
|
||||
progressBar: {
|
||||
height: 4,
|
||||
backgroundColor: Colors.dark.primary,
|
||||
},
|
||||
continueLabel: {
|
||||
color: Colors.dark.primary,
|
||||
},
|
||||
});
|
||||
286
components/VideoCard.mobile.tsx
Normal file
286
components/VideoCard.mobile.tsx
Normal file
@@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect, useRef, forwardRef } from "react";
|
||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Star, Play } from "lucide-react-native";
|
||||
import { PlayRecordManager } from "@/services/storage";
|
||||
import { API } from "@/services/api";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
interface VideoCardMobileProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
year?: string;
|
||||
rate?: string;
|
||||
sourceName?: string;
|
||||
progress?: number;
|
||||
playTime?: number;
|
||||
episodeIndex?: number;
|
||||
totalEpisodes?: number;
|
||||
onFocus?: () => void;
|
||||
onRecordDeleted?: () => void;
|
||||
api: API;
|
||||
}
|
||||
|
||||
const VideoCardMobile = forwardRef<View, VideoCardMobileProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
source,
|
||||
title,
|
||||
poster,
|
||||
year,
|
||||
rate,
|
||||
sourceName,
|
||||
progress,
|
||||
episodeIndex,
|
||||
onFocus,
|
||||
onRecordDeleted,
|
||||
api,
|
||||
playTime = 0,
|
||||
}: VideoCardMobileProps,
|
||||
ref
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const { cardWidth, cardHeight, spacing } = useResponsiveLayout();
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
|
||||
const longPressTriggered = useRef(false);
|
||||
|
||||
const handlePress = () => {
|
||||
if (longPressTriggered.current) {
|
||||
longPressTriggered.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress !== undefined && episodeIndex !== undefined) {
|
||||
router.push({
|
||||
pathname: "/play",
|
||||
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: "/detail",
|
||||
params: { source, q: title },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: DeviceUtils.getAnimationDuration(300),
|
||||
delay: Math.random() * 100,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
|
||||
const handleLongPress = () => {
|
||||
if (progress === undefined) return;
|
||||
|
||||
longPressTriggered.current = true;
|
||||
|
||||
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
|
||||
{
|
||||
text: "取消",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "删除",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
|
||||
|
||||
const styles = createMobileStyles(cardWidth, cardHeight, spacing);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.wrapper, { opacity: fadeAnim }]} ref={ref}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onLongPress={handleLongPress}
|
||||
style={styles.pressable}
|
||||
activeOpacity={0.8}
|
||||
delayLongPress={800}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
|
||||
|
||||
{/* 进度条 */}
|
||||
{isContinueWatching && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 继续观看标识 */}
|
||||
{isContinueWatching && (
|
||||
<View style={styles.continueWatchingBadge}>
|
||||
<Play size={12} color="#ffffff" fill="#ffffff" />
|
||||
<Text style={styles.continueWatchingText}>继续</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{rate && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<Star size={10} color="#FFD700" fill="#FFD700" />
|
||||
<Text style={styles.ratingText}>{rate}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 年份 */}
|
||||
{year && (
|
||||
<View style={styles.yearBadge}>
|
||||
<Text style={styles.badgeText}>{year}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 来源 */}
|
||||
{sourceName && (
|
||||
<View style={styles.sourceNameBadge}>
|
||||
<Text style={styles.badgeText}>{sourceName}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.infoContainer}>
|
||||
<ThemedText numberOfLines={2} style={styles.title}>{title}</ThemedText>
|
||||
{isContinueWatching && (
|
||||
<ThemedText style={styles.continueLabel} numberOfLines={1}>
|
||||
第{episodeIndex! + 1}集 {Math.round((progress || 0) * 100)}%
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoCardMobile.displayName = "VideoCardMobile";
|
||||
|
||||
const createMobileStyles = (cardWidth: number, cardHeight: number, spacing: number) => {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
width: cardWidth,
|
||||
marginHorizontal: spacing / 2,
|
||||
marginBottom: spacing,
|
||||
},
|
||||
pressable: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
card: {
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#222",
|
||||
overflow: "hidden",
|
||||
},
|
||||
poster: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
progressContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 3,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.6)",
|
||||
},
|
||||
progressBar: {
|
||||
height: 3,
|
||||
backgroundColor: Colors.dark.primary,
|
||||
},
|
||||
continueWatchingBadge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: Colors.dark.primary,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
continueWatchingText: {
|
||||
color: "white",
|
||||
marginLeft: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
ratingContainer: {
|
||||
position: "absolute",
|
||||
top: 6,
|
||||
right: 6,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
ratingText: {
|
||||
color: "#FFD700",
|
||||
fontSize: 10,
|
||||
fontWeight: "bold",
|
||||
marginLeft: 2,
|
||||
},
|
||||
yearBadge: {
|
||||
position: "absolute",
|
||||
bottom: 24,
|
||||
right: 6,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
sourceNameBadge: {
|
||||
position: "absolute",
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
},
|
||||
badgeText: {
|
||||
color: "white",
|
||||
fontSize: 9,
|
||||
fontWeight: "500",
|
||||
},
|
||||
infoContainer: {
|
||||
width: cardWidth,
|
||||
marginTop: 6,
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13,
|
||||
lineHeight: 16,
|
||||
marginBottom: 2,
|
||||
},
|
||||
continueLabel: {
|
||||
color: Colors.dark.primary,
|
||||
fontSize: 11,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default VideoCardMobile;
|
||||
334
components/VideoCard.tablet.tsx
Normal file
334
components/VideoCard.tablet.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, forwardRef } from "react";
|
||||
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert, Animated } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { Star, Play } from "lucide-react-native";
|
||||
import { PlayRecordManager } from "@/services/storage";
|
||||
import { API } from "@/services/api";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
|
||||
interface VideoCardTabletProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
year?: string;
|
||||
rate?: string;
|
||||
sourceName?: string;
|
||||
progress?: number;
|
||||
playTime?: number;
|
||||
episodeIndex?: number;
|
||||
totalEpisodes?: number;
|
||||
onFocus?: () => void;
|
||||
onRecordDeleted?: () => void;
|
||||
api: API;
|
||||
}
|
||||
|
||||
const VideoCardTablet = forwardRef<View, VideoCardTabletProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
source,
|
||||
title,
|
||||
poster,
|
||||
year,
|
||||
rate,
|
||||
sourceName,
|
||||
progress,
|
||||
episodeIndex,
|
||||
onFocus,
|
||||
onRecordDeleted,
|
||||
api,
|
||||
playTime = 0,
|
||||
}: VideoCardTabletProps,
|
||||
ref
|
||||
) => {
|
||||
const router = useRouter();
|
||||
const { cardWidth, cardHeight, spacing } = useResponsiveLayout();
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
const longPressTriggered = useRef(false);
|
||||
const scale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
const handlePress = () => {
|
||||
if (longPressTriggered.current) {
|
||||
longPressTriggered.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress !== undefined && episodeIndex !== undefined) {
|
||||
router.push({
|
||||
pathname: "/play",
|
||||
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: "/detail",
|
||||
params: { source, q: title },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressIn = useCallback(() => {
|
||||
setIsPressed(true);
|
||||
Animated.spring(scale, {
|
||||
toValue: 0.96,
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [scale]);
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
setIsPressed(false);
|
||||
Animated.spring(scale, {
|
||||
toValue: 1.0,
|
||||
damping: 15,
|
||||
stiffness: 300,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [scale]);
|
||||
|
||||
useEffect(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: DeviceUtils.getAnimationDuration(400),
|
||||
delay: Math.random() * 150,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
}, [fadeAnim]);
|
||||
|
||||
const handleLongPress = () => {
|
||||
if (progress === undefined) return;
|
||||
|
||||
longPressTriggered.current = true;
|
||||
|
||||
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
|
||||
{
|
||||
text: "取消",
|
||||
style: "cancel",
|
||||
},
|
||||
{
|
||||
text: "删除",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
try {
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
|
||||
|
||||
const animatedStyle = {
|
||||
transform: [{ scale }],
|
||||
};
|
||||
|
||||
const styles = createTabletStyles(cardWidth, cardHeight, spacing);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.wrapper, animatedStyle, { opacity: fadeAnim }]} ref={ref}>
|
||||
<TouchableOpacity
|
||||
onPress={handlePress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onLongPress={handleLongPress}
|
||||
style={styles.pressable}
|
||||
activeOpacity={1}
|
||||
delayLongPress={900}
|
||||
>
|
||||
<View style={[styles.card, isPressed && styles.cardPressed]}>
|
||||
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
|
||||
|
||||
{/* 悬停效果遮罩 */}
|
||||
{isPressed && (
|
||||
<View style={styles.pressOverlay}>
|
||||
{isContinueWatching && (
|
||||
<View style={styles.continueWatchingBadge}>
|
||||
<Play size={16} color="#ffffff" fill="#ffffff" />
|
||||
<Text style={styles.continueWatchingText}>继续观看</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 进度条 */}
|
||||
{isContinueWatching && (
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 评分 */}
|
||||
{rate && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<Star size={12} color="#FFD700" fill="#FFD700" />
|
||||
<Text style={styles.ratingText}>{rate}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 年份 */}
|
||||
{year && (
|
||||
<View style={styles.yearBadge}>
|
||||
<Text style={styles.badgeText}>{year}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 来源 */}
|
||||
{sourceName && (
|
||||
<View style={styles.sourceNameBadge}>
|
||||
<Text style={styles.badgeText}>{sourceName}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.infoContainer}>
|
||||
<ThemedText numberOfLines={2} style={styles.title}>{title}</ThemedText>
|
||||
{isContinueWatching && (
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={styles.continueLabel} numberOfLines={1}>
|
||||
第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
VideoCardTablet.displayName = "VideoCardTablet";
|
||||
|
||||
const createTabletStyles = (cardWidth: number, cardHeight: number, spacing: number) => {
|
||||
return StyleSheet.create({
|
||||
wrapper: {
|
||||
width: cardWidth,
|
||||
marginHorizontal: spacing / 2,
|
||||
marginBottom: spacing,
|
||||
},
|
||||
pressable: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
card: {
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
borderRadius: 10,
|
||||
backgroundColor: "#222",
|
||||
overflow: "hidden",
|
||||
},
|
||||
cardPressed: {
|
||||
borderColor: Colors.dark.primary,
|
||||
borderWidth: 2,
|
||||
},
|
||||
poster: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
resizeMode: 'cover',
|
||||
},
|
||||
pressOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: "rgba(0,0,0,0.4)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 10,
|
||||
},
|
||||
progressContainer: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 4,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.8)",
|
||||
},
|
||||
progressBar: {
|
||||
height: 4,
|
||||
backgroundColor: Colors.dark.primary,
|
||||
},
|
||||
continueWatchingBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: Colors.dark.primary,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 6,
|
||||
},
|
||||
continueWatchingText: {
|
||||
color: "white",
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
ratingContainer: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
ratingText: {
|
||||
color: "#FFD700",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
marginLeft: 3,
|
||||
},
|
||||
yearBadge: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
sourceNameBadge: {
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
left: 8,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
},
|
||||
badgeText: {
|
||||
color: "white",
|
||||
fontSize: 11,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
infoContainer: {
|
||||
width: cardWidth,
|
||||
marginTop: 8,
|
||||
alignItems: "flex-start",
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
marginTop: 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: 15,
|
||||
lineHeight: 18,
|
||||
},
|
||||
continueLabel: {
|
||||
color: Colors.dark.primary,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default VideoCardTablet;
|
||||
50
components/VideoCard.tsx
Normal file
50
components/VideoCard.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { TouchableOpacity } from 'react-native';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { API } from '@/services/api';
|
||||
|
||||
// 导入不同平台的VideoCard组件
|
||||
import VideoCardMobile from './VideoCard.mobile';
|
||||
import VideoCardTablet from './VideoCard.tablet';
|
||||
import VideoCardTV from './VideoCard.tv';
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
source: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
year?: string;
|
||||
rate?: string;
|
||||
sourceName?: string;
|
||||
progress?: number;
|
||||
playTime?: number;
|
||||
episodeIndex?: number;
|
||||
totalEpisodes?: number;
|
||||
onFocus?: () => void;
|
||||
onRecordDeleted?: () => void;
|
||||
api: API;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式VideoCard组件
|
||||
* 根据设备类型自动选择合适的VideoCard实现
|
||||
*/
|
||||
const VideoCard = React.forwardRef<any, VideoCardProps>((props, ref) => {
|
||||
const { deviceType } = useResponsiveLayout();
|
||||
|
||||
switch (deviceType) {
|
||||
case 'mobile':
|
||||
return <VideoCardMobile {...props} ref={ref} />;
|
||||
|
||||
case 'tablet':
|
||||
return <VideoCardTablet {...props} ref={ref} />;
|
||||
|
||||
case 'tv':
|
||||
default:
|
||||
return <VideoCardTV {...props} ref={ref} />;
|
||||
}
|
||||
});
|
||||
|
||||
VideoCard.displayName = 'VideoCard';
|
||||
|
||||
export default VideoCard;
|
||||
121
components/navigation/MobileBottomTabNavigator.tsx
Normal file
121
components/navigation/MobileBottomTabNavigator.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
import { Home, Search, Heart, Settings, Tv } from 'lucide-react-native';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
|
||||
interface TabItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<any>;
|
||||
route: string;
|
||||
}
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ key: 'home', label: '首页', icon: Home, route: '/' },
|
||||
{ key: 'search', label: '搜索', icon: Search, route: '/search' },
|
||||
{ key: 'live', label: '直播', icon: Tv, route: '/live' },
|
||||
{ key: 'favorites', label: '收藏', icon: Heart, route: '/favorites' },
|
||||
{ key: 'settings', label: '设置', icon: Settings, route: '/settings' },
|
||||
];
|
||||
|
||||
const MobileBottomTabNavigator: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { spacing } = useResponsiveLayout();
|
||||
|
||||
const handleTabPress = (route: string) => {
|
||||
if (route === '/') {
|
||||
router.push('/');
|
||||
} else {
|
||||
router.push(route as any);
|
||||
}
|
||||
};
|
||||
|
||||
const isTabActive = (route: string) => {
|
||||
if (route === '/' && pathname === '/') return true;
|
||||
if (route !== '/' && pathname === route) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const dynamicStyles = createStyles(spacing);
|
||||
|
||||
return (
|
||||
<View style={dynamicStyles.container}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = isTabActive(tab.route);
|
||||
const IconComponent = tab.icon;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
style={[dynamicStyles.tab, isActive && dynamicStyles.activeTab]}
|
||||
onPress={() => handleTabPress(tab.route)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
size={20}
|
||||
color={isActive ? Colors.dark.primary : '#888'}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
<Text style={[
|
||||
dynamicStyles.tabLabel,
|
||||
isActive && dynamicStyles.activeTabLabel
|
||||
]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (spacing: number) => {
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#1c1c1e',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#333',
|
||||
paddingTop: spacing / 2,
|
||||
paddingBottom: Platform.OS === 'ios' ? spacing * 2 : spacing,
|
||||
paddingHorizontal: spacing,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: -2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 10,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: minTouchTarget,
|
||||
paddingVertical: spacing / 2,
|
||||
borderRadius: 8,
|
||||
},
|
||||
activeTab: {
|
||||
backgroundColor: 'rgba(64, 156, 255, 0.1)',
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
marginTop: 2,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabLabel: {
|
||||
color: Colors.dark.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default MobileBottomTabNavigator;
|
||||
134
components/navigation/ResponsiveHeader.tsx
Normal file
134
components/navigation/ResponsiveHeader.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, StatusBar, Platform } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
|
||||
interface ResponsiveHeaderProps {
|
||||
title?: string;
|
||||
showBackButton?: boolean;
|
||||
rightComponent?: React.ReactNode;
|
||||
onBackPress?: () => void;
|
||||
}
|
||||
|
||||
const ResponsiveHeader: React.FC<ResponsiveHeaderProps> = ({
|
||||
title,
|
||||
showBackButton = false,
|
||||
rightComponent,
|
||||
onBackPress,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { deviceType, spacing } = useResponsiveLayout();
|
||||
|
||||
// TV端不显示Header,使用现有的页面内导航
|
||||
if (deviceType === 'tv') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleBackPress = () => {
|
||||
if (onBackPress) {
|
||||
onBackPress();
|
||||
} else if (router.canGoBack()) {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const dynamicStyles = createStyles(spacing, deviceType);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Platform.OS === 'android' && <StatusBar backgroundColor="#1c1c1e" barStyle="light-content" />}
|
||||
<View style={dynamicStyles.container}>
|
||||
<View style={dynamicStyles.content}>
|
||||
{/* 左侧区域 */}
|
||||
<View style={dynamicStyles.leftSection}>
|
||||
{showBackButton && (
|
||||
<TouchableOpacity
|
||||
onPress={handleBackPress}
|
||||
style={dynamicStyles.backButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ArrowLeft size={20} color="#fff" strokeWidth={2} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 中间标题区域 */}
|
||||
<View style={dynamicStyles.centerSection}>
|
||||
{title && (
|
||||
<ThemedText style={dynamicStyles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 右侧区域 */}
|
||||
<View style={dynamicStyles.rightSection}>
|
||||
{rightComponent}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (spacing: number, deviceType: string) => {
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
const statusBarHeight = Platform.OS === 'ios' ? 44 : StatusBar.currentHeight || 24;
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: '#1c1c1e',
|
||||
paddingTop: statusBarHeight,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3,
|
||||
elevation: 5,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 0.75,
|
||||
minHeight: minTouchTarget + spacing,
|
||||
},
|
||||
leftSection: {
|
||||
width: minTouchTarget + spacing,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
centerSection: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rightSection: {
|
||||
width: minTouchTarget + spacing,
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
backButton: {
|
||||
width: minTouchTarget,
|
||||
height: minTouchTarget,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: minTouchTarget / 2,
|
||||
},
|
||||
title: {
|
||||
fontSize: DeviceUtils.getOptimalFontSize(deviceType === 'mobile' ? 18 : 20),
|
||||
fontWeight: '600',
|
||||
color: '#fff',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default ResponsiveHeader;
|
||||
48
components/navigation/ResponsiveNavigation.tsx
Normal file
48
components/navigation/ResponsiveNavigation.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import MobileBottomTabNavigator from './MobileBottomTabNavigator';
|
||||
import TabletSidebarNavigator from './TabletSidebarNavigator';
|
||||
|
||||
interface ResponsiveNavigationProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ResponsiveNavigation: React.FC<ResponsiveNavigationProps> = ({ children }) => {
|
||||
const { deviceType } = useResponsiveLayout();
|
||||
|
||||
switch (deviceType) {
|
||||
case 'mobile':
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
{children}
|
||||
</View>
|
||||
<MobileBottomTabNavigator />
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'tablet':
|
||||
return (
|
||||
<TabletSidebarNavigator>
|
||||
{children}
|
||||
</TabletSidebarNavigator>
|
||||
);
|
||||
|
||||
case 'tv':
|
||||
default:
|
||||
// TV端保持原有的Stack导航,不需要额外的导航容器
|
||||
return <>{children}</>;
|
||||
}
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default ResponsiveNavigation;
|
||||
240
components/navigation/TabletSidebarNavigator.tsx
Normal file
240
components/navigation/TabletSidebarNavigator.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, ScrollView } from 'react-native';
|
||||
import { useRouter, usePathname } from 'expo-router';
|
||||
import { Home, Search, Heart, Settings, Tv, Menu, X } from 'lucide-react-native';
|
||||
import { Colors } from '@/constants/Colors';
|
||||
import { useResponsiveLayout } from '@/hooks/useResponsiveLayout';
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
interface SidebarItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<any>;
|
||||
route: string;
|
||||
section?: string;
|
||||
}
|
||||
|
||||
const sidebarItems: SidebarItem[] = [
|
||||
{ key: 'home', label: '首页', icon: Home, route: '/', section: 'main' },
|
||||
{ key: 'search', label: '搜索', icon: Search, route: '/search', section: 'main' },
|
||||
{ key: 'live', label: '直播', icon: Tv, route: '/live', section: 'main' },
|
||||
{ key: 'favorites', label: '收藏', icon: Heart, route: '/favorites', section: 'user' },
|
||||
{ key: 'settings', label: '设置', icon: Settings, route: '/settings', section: 'user' },
|
||||
];
|
||||
|
||||
interface TabletSidebarNavigatorProps {
|
||||
children: React.ReactNode;
|
||||
collapsed?: boolean;
|
||||
onToggleCollapse?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
const TabletSidebarNavigator: React.FC<TabletSidebarNavigatorProps> = ({
|
||||
children,
|
||||
collapsed: controlledCollapsed,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { spacing, isPortrait } = useResponsiveLayout();
|
||||
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false);
|
||||
|
||||
// 使用外部控制的collapsed状态,如果没有则使用内部状态
|
||||
const collapsed = controlledCollapsed !== undefined ? controlledCollapsed : internalCollapsed;
|
||||
|
||||
const handleToggleCollapse = () => {
|
||||
if (onToggleCollapse) {
|
||||
onToggleCollapse(!collapsed);
|
||||
} else {
|
||||
setInternalCollapsed(!collapsed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemPress = (route: string) => {
|
||||
if (route === '/') {
|
||||
router.push('/');
|
||||
} else {
|
||||
router.push(route as any);
|
||||
}
|
||||
|
||||
// 在竖屏模式下,导航后自动折叠侧边栏
|
||||
if (isPortrait && !controlledCollapsed) {
|
||||
setInternalCollapsed(true);
|
||||
}
|
||||
};
|
||||
|
||||
const isItemActive = (route: string) => {
|
||||
if (route === '/' && pathname === '/') return true;
|
||||
if (route !== '/' && pathname === route) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const sidebarWidth = collapsed ? 60 : 200;
|
||||
const dynamicStyles = createStyles(spacing, sidebarWidth, isPortrait);
|
||||
|
||||
const renderSidebarItems = () => {
|
||||
const sections = ['main', 'user'];
|
||||
|
||||
return sections.map((section) => {
|
||||
const sectionItems = sidebarItems.filter(item => item.section === section);
|
||||
|
||||
return (
|
||||
<View key={section} style={dynamicStyles.section}>
|
||||
{!collapsed && (
|
||||
<ThemedText style={dynamicStyles.sectionTitle}>
|
||||
{section === 'main' ? '主要功能' : '用户'}
|
||||
</ThemedText>
|
||||
)}
|
||||
{sectionItems.map((item) => {
|
||||
const isActive = isItemActive(item.route);
|
||||
const IconComponent = item.icon;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={item.key}
|
||||
style={[dynamicStyles.sidebarItem, isActive && dynamicStyles.activeSidebarItem]}
|
||||
onPress={() => handleItemPress(item.route)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
size={20}
|
||||
color={isActive ? Colors.dark.primary : '#ccc'}
|
||||
strokeWidth={isActive ? 2.5 : 2}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<Text style={[
|
||||
dynamicStyles.sidebarItemLabel,
|
||||
isActive && dynamicStyles.activeSidebarItemLabel
|
||||
]}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={dynamicStyles.container}>
|
||||
{/* 侧边栏 */}
|
||||
<View style={[dynamicStyles.sidebar, collapsed && dynamicStyles.collapsedSidebar]}>
|
||||
{/* 侧边栏头部 */}
|
||||
<View style={dynamicStyles.sidebarHeader}>
|
||||
<TouchableOpacity
|
||||
onPress={handleToggleCollapse}
|
||||
style={dynamicStyles.toggleButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{collapsed ? (
|
||||
<Menu size={20} color="#ccc" />
|
||||
) : (
|
||||
<X size={20} color="#ccc" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{!collapsed && (
|
||||
<ThemedText style={dynamicStyles.appTitle}>OrionTV</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 侧边栏内容 */}
|
||||
<ScrollView style={dynamicStyles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||
{renderSidebarItems()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<View style={dynamicStyles.content}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const createStyles = (spacing: number, sidebarWidth: number, isPortrait: boolean) => {
|
||||
const minTouchTarget = DeviceUtils.getMinTouchTargetSize();
|
||||
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
sidebar: {
|
||||
width: sidebarWidth,
|
||||
backgroundColor: '#1c1c1e',
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: '#333',
|
||||
zIndex: isPortrait ? 1000 : 1, // 在竖屏时提高层级
|
||||
},
|
||||
collapsedSidebar: {
|
||||
width: 60,
|
||||
},
|
||||
sidebarHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 1.5,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#333',
|
||||
},
|
||||
toggleButton: {
|
||||
width: minTouchTarget,
|
||||
height: minTouchTarget,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
},
|
||||
appTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginLeft: spacing,
|
||||
color: Colors.dark.primary,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
paddingTop: spacing,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing * 1.5,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing / 2,
|
||||
marginHorizontal: spacing,
|
||||
},
|
||||
sidebarItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing,
|
||||
paddingVertical: spacing * 0.75,
|
||||
marginHorizontal: spacing / 2,
|
||||
borderRadius: 8,
|
||||
minHeight: minTouchTarget,
|
||||
},
|
||||
activeSidebarItem: {
|
||||
backgroundColor: 'rgba(64, 156, 255, 0.15)',
|
||||
},
|
||||
sidebarItemLabel: {
|
||||
fontSize: 14,
|
||||
color: '#ccc',
|
||||
marginLeft: spacing,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeSidebarItemLabel: {
|
||||
color: Colors.dark.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default TabletSidebarNavigator;
|
||||
@@ -54,20 +54,24 @@ export function UpdateSection() {
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<StyledButton
|
||||
title={checking ? "检查中..." : "检查更新"}
|
||||
onPress={handleCheckUpdate}
|
||||
disabled={checking || downloading}
|
||||
style={styles.button}
|
||||
>
|
||||
{checking && <ActivityIndicator color="#fff" size="small" />}
|
||||
{checking ? (
|
||||
<ActivityIndicator color="#fff" size="small" />
|
||||
) : (
|
||||
<ThemedText style={styles.buttonText}>检查更新</ThemedText>
|
||||
)}
|
||||
</StyledButton>
|
||||
|
||||
{updateAvailable && !downloading && (
|
||||
<StyledButton
|
||||
title="立即更新"
|
||||
onPress={() => setShowUpdateModal(true)}
|
||||
style={[styles.button, styles.updateButton]}
|
||||
/>
|
||||
>
|
||||
<ThemedText style={styles.buttonText}>立即更新</ThemedText>
|
||||
</StyledButton>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -124,6 +128,11 @@ const styles = StyleSheet.create({
|
||||
updateButton: {
|
||||
backgroundColor: "#00bb5e",
|
||||
},
|
||||
buttonText: {
|
||||
color: "#ffffff",
|
||||
fontSize: Platform.isTV ? 16 : 14,
|
||||
fontWeight: "500",
|
||||
},
|
||||
hint: {
|
||||
fontSize: Platform.isTV ? 14 : 12,
|
||||
color: "#666",
|
||||
|
||||
308
docs/MOBILE_TABLET_ADAPTATION.md
Normal file
308
docs/MOBILE_TABLET_ADAPTATION.md
Normal 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*
|
||||
129
hooks/useResponsiveLayout.ts
Normal file
129
hooks/useResponsiveLayout.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
20
package.json
20
package.json
@@ -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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { api } from "./api";
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -50,7 +50,7 @@ export const getResolutionFromM3U8 = async (
|
||||
};
|
||||
|
||||
return resolutionString;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
138
utils/DeviceUtils.ts
Normal file
138
utils/DeviceUtils.ts
Normal 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
222
utils/ResponsiveStyles.ts
Normal 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);
|
||||
};
|
||||
Reference in New Issue
Block a user