From 9e9e4597cc5a4dc3cf5fb678632590b27a37bc50 Mon Sep 17 00:00:00 2001 From: zimplexing Date: Fri, 1 Aug 2025 16:36:28 +0800 Subject: [PATCH] feat: Enhance mobile and tablet support with responsive layout adjustments and new navigation components --- .github/workflows/build-apk.yml | 91 ++- CLAUDE.md | 56 +- app.json | 48 +- app/_layout.tsx | 36 +- app/detail.tsx | 523 ++++++++++++------ app/favorites.tsx | 98 ++-- app/index.tsx | 233 ++++---- app/live.tsx | 189 ++++--- app/play.tsx | 67 ++- app/search.tsx | 175 +++--- app/settings.tsx | 147 +++-- components/CustomScrollView.tsx | 85 +-- components/EpisodeSelectionModal.tsx | 10 +- components/MobileBottomNavigation.tsx | 149 +++++ components/RemoteControlModal.tsx | 2 +- components/ResponsiveButton.tsx | 152 +++++ components/ResponsiveCard.tsx | 97 ++++ components/ResponsiveTextInput.tsx | 131 +++++ components/ResponsiveVideoCard.tsx | 373 +++++++++++++ components/VideoCard.mobile.tsx | 286 ++++++++++ components/VideoCard.tablet.tsx | 334 +++++++++++ components/VideoCard.tsx | 50 ++ .../navigation/MobileBottomTabNavigator.tsx | 121 ++++ components/navigation/ResponsiveHeader.tsx | 134 +++++ .../navigation/ResponsiveNavigation.tsx | 48 ++ .../navigation/TabletSidebarNavigator.tsx | 240 ++++++++ components/settings/UpdateSection.tsx | 17 +- docs/MOBILE_TABLET_ADAPTATION.md | 308 +++++++++++ hooks/useResponsiveLayout.ts | 129 +++++ metro.config.js | 3 +- package.json | 20 +- services/m3u.ts | 2 - services/m3u8.ts | 2 +- utils/DeviceUtils.ts | 138 +++++ utils/ResponsiveStyles.ts | 222 ++++++++ 35 files changed, 4082 insertions(+), 634 deletions(-) create mode 100644 components/MobileBottomNavigation.tsx create mode 100644 components/ResponsiveButton.tsx create mode 100644 components/ResponsiveCard.tsx create mode 100644 components/ResponsiveTextInput.tsx create mode 100644 components/ResponsiveVideoCard.tsx create mode 100644 components/VideoCard.mobile.tsx create mode 100644 components/VideoCard.tablet.tsx create mode 100644 components/VideoCard.tsx create mode 100644 components/navigation/MobileBottomTabNavigator.tsx create mode 100644 components/navigation/ResponsiveHeader.tsx create mode 100644 components/navigation/ResponsiveNavigation.tsx create mode 100644 components/navigation/TabletSidebarNavigator.tsx create mode 100644 docs/MOBILE_TABLET_ADAPTATION.md create mode 100644 hooks/useResponsiveLayout.ts create mode 100644 utils/DeviceUtils.ts create mode 100644 utils/ResponsiveStyles.ts diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml index 097fb7c..aaa1c0b 100644 --- a/.github/workflows/build-apk.yml +++ b/.github/workflows/build-apk.yml @@ -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 }} diff --git a/CLAUDE.md b/CLAUDE.md index 1eddd9a..3d12a7d 100644 --- a/CLAUDE.md +++ b/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. \ No newline at end of file +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. diff --git a/app.json b/app.json index 22f35a1..7212785 100644 --- a/app.json +++ b/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": { diff --git a/app/_layout.tsx b/app/_layout.tsx index 01f281e..03f756e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -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 ( - - - - {Platform.OS !== "web" && } - - - - - - + + + + + {Platform.OS !== "web" && } + + + + + + + {isMobile && } + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/app/detail.tsx b/app/detail.tsx index f5e0ffd..bcf7a8e 100644 --- a/app/detail.tsx +++ b/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 ( - - + const content = ( + + {error} ); - } - if (!detail) { + if (deviceType === 'tv') { + return content; + } + return ( - - 未找到详情信息 - + + + {content} + ); } - return ( - - - - - - - - {detail.title} - - - - - - - {detail.year} - {detail.type_name} - + if (!detail) { + const content = ( + + 未找到详情信息 + + ); - - {detail.desc} - + if (deviceType === 'tv') { + return content; + } + + return ( + + + {content} + + ); + } + + // 动态样式 + const dynamicStyles = createResponsiveStyles(deviceType, spacing); + + const renderDetailContent = () => { + if (deviceType === 'mobile') { + // 移动端垂直布局 + return ( + + {/* 海报和基本信息 */} + + + + + + {detail.title} + + + + + + + {detail.year} + {detail.type_name} + + - - - - - 选择播放源 共 {searchResults.length} 个 + {/* 描述 */} + + {detail.desc} + + + {/* 播放源 */} + + + 播放源 ({searchResults.length}) {!allSourcesLoaded && } - + {searchResults.map((item, index) => { const isSelected = detail?.source === item.source; return ( setDetail(item)} - hasTVPreferredFocus={index === 0} isSelected={isSelected} - style={styles.sourceButton} + style={dynamicStyles.sourceButton} > - {item.source_name} + {item.source_name} {item.episodes.length > 1 && ( - - + + {item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} 集 )} {item.resolution && ( - - {item.resolution} + + {item.resolution} )} @@ -135,144 +172,278 @@ export default function DetailScreen() { })} - - 播放列表 - + + {/* 剧集列表 */} + + 播放列表 + {detail.episodes.map((episode, index) => ( handlePlay(index)} text={`第 ${index + 1} 集`} - textStyle={styles.episodeButtonText} + textStyle={dynamicStyles.episodeButtonText} /> ))} - + - - + + ); + } else { + // 平板和TV端水平布局 + return ( + + + + + + + {detail.title} + + + + + + + {detail.year} + {detail.type_name} + + + + {detail.desc} + + + + + + + + 选择播放源 共 {searchResults.length} 个 + {!allSourcesLoaded && } + + + {searchResults.map((item, index) => { + const isSelected = detail?.source === item.source; + return ( + setDetail(item)} + hasTVPreferredFocus={index === 0} + isSelected={isSelected} + style={dynamicStyles.sourceButton} + > + {item.source_name} + {item.episodes.length > 1 && ( + + + {item.episodes.length > 99 ? "99+" : `${item.episodes.length}`} 集 + + + )} + {item.resolution && ( + + {item.resolution} + + )} + + ); + })} + + + + 播放列表 + + {detail.episodes.map((episode, index) => ( + handlePlay(index)} + text={`第 ${index + 1} 集`} + textStyle={dynamicStyles.episodeButtonText} + /> + ))} + + + + + ); + } + }; + + const content = ( + + {renderDetailContent()} ); + + // 根据设备类型决定是否包装在响应式导航中 + if (deviceType === 'tv') { + return content; + } + + return ( + + + {content} + + ); } -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, + }, + }); +}; diff --git a/app/favorites.tsx b/app/favorites.tsx index a8fc3b5..61ec9db 100644 --- a/app/favorites.tsx +++ b/app/favorites.tsx @@ -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 ( - - - 我的收藏 - + // 动态样式 + const dynamicStyles = createResponsiveStyles(deviceType, spacing); + + const renderFavoritesContent = () => ( + <> + {deviceType === 'tv' && ( + + 我的收藏 + + )} + + ); + + const content = ( + + {renderFavoritesContent()} ); + + // 根据设备类型决定是否包装在响应式导航中 + if (deviceType === 'tv') { + return content; + } + + return ( + + + {content} + + ); } -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', + }, + }); +}; diff --git a/app/index.tsx b/app/index.tsx index a62e59a..0ba5409 100644 --- a/app/index.tsx +++ b/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(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 }) => ( - - - + ); const renderFooter = () => { @@ -124,67 +124,126 @@ export default function HomeScreen() { return ; }; - return ( - - {/* 顶部导航 */} - + // TV端和平板端的顶部导航 + const renderHeader = () => { + if (deviceType === 'mobile') { + // 移动端不显示顶部导航,使用底部Tab导航 + return null; + } + + return ( + - 首页 + 首页 router.push("/live")}> {({ focused }) => ( - 直播 + 直播 )} - - router.push("/favorites")} variant="ghost"> + + router.push("/favorites")} variant="ghost"> router.push({ pathname: "/search" })} variant="ghost" > - router.push("/settings")} variant="ghost"> + router.push("/settings")} variant="ghost"> {isLoggedIn && ( - + )} + ); + }; + + // 动态样式 + 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 = ( + + {/* 顶部导航 */} + {renderHeader()} {/* 分类选择器 */} - + item.title} horizontal showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.categoryListContent} + contentContainerStyle={dynamicStyles.categoryListContent} /> - {/* Sub-category Tags */} + {/* 子分类标签 */} {selectedCategory && selectedCategory.tags && ( - + { const isSelected = selectedTag === item; return ( 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} /> )} {/* 内容网格 */} {loading ? ( - + ) : error ? ( - - + + {error} ) : ( - + ); -} -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 ( + + {content} + + ); +} \ No newline at end of file diff --git a/app/live.tsx b/app/live.tsx index 06193e1..64229a4 100644 --- a/app/live.tsx +++ b/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([]); const [groupedChannels, setGroupedChannels] = useState>({}); const [channelGroups, setChannelGroups] = useState([]); @@ -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 ( - - {}} /> + // 动态样式 + const dynamicStyles = createResponsiveStyles(deviceType, spacing); + + const renderLiveContent = () => ( + <> + {}} + /> setIsChannelListVisible(false)} > - - - 选择频道 - - + + + 选择频道 + + `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} /> )} /> - + {isLoading ? ( ) : ( @@ -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() { + + ); + + const content = ( + + {renderLiveContent()} ); + + // 根据设备类型决定是否包装在响应式导航中 + if (deviceType === 'tv') { + return content; + } + + return ( + + + {content} + + ); } -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, + }, + }); +}; diff --git a/app/play.tsx b/app/play.tsx index 9340663..497fa7b 100644 --- a/app/play.tsx +++ b/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