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