mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-10 17:44:44 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de3c135e4 | ||
|
|
9665ee3ba3 | ||
|
|
a53dde92eb | ||
|
|
e57466c8c1 | ||
|
|
836285dbd5 | ||
|
|
172815f926 | ||
|
|
e83f9d68fc | ||
|
|
04e0d0ac17 | ||
|
|
3e6bcb4920 | ||
|
|
6db0c5d888 | ||
|
|
cfb3982611 | ||
|
|
7f0085361b | ||
|
|
3e3796ab5c | ||
|
|
9fcdf4b5aa | ||
|
|
db7372d732 | ||
|
|
e4ecd1339e | ||
|
|
9b7833b430 | ||
|
|
1ef5a6b445 | ||
|
|
09c3931117 | ||
|
|
10a806a657 | ||
|
|
cb3f694cdc | ||
|
|
1cf3733ee2 | ||
|
|
108c20cd26 | ||
|
|
250c42e1ff | ||
|
|
68a1bc2081 | ||
|
|
d8e47dee7b | ||
|
|
5bf0d05820 |
@@ -11,7 +11,8 @@
|
||||
"Bash(yarn add:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Bash(yarn test-ci:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
91
.github/workflows/build-apk.yml
vendored
91
.github/workflows/build-apk.yml
vendored
@@ -7,7 +7,7 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build_tv:
|
||||
direct_build:
|
||||
name: Build Android TV APK
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -34,97 +34,24 @@ jobs:
|
||||
java-version: "17"
|
||||
|
||||
- name: Prebuild TV App
|
||||
run: yarn prebuild-tv
|
||||
run: yarn prebuild
|
||||
|
||||
- name: Build TV APK
|
||||
run: yarn build-tv
|
||||
run: yarn build
|
||||
|
||||
- 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
|
||||
- name: Rename APK file
|
||||
run: |
|
||||
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
|
||||
mkdir -p artifacts
|
||||
cp android/app/build/outputs/apk/release/app-release.apk artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
|
||||
|
||||
- name: Create Release and Upload APKs
|
||||
- name: Create Release and Upload APK
|
||||
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 }}.
|
||||
- orionTV-tv.${{ steps.package-version.outputs.version }}.apk - Android TV版本
|
||||
- orionTV-mobile.${{ steps.package-version.outputs.version }}.apk - 手机/平板版本
|
||||
body: Automated release for version v${{ steps.package-version.outputs.version }}.
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: |
|
||||
artifacts/orionTV-tv.${{ steps.package-version.outputs.version }}.apk
|
||||
artifacts/orionTV-mobile.${{ steps.package-version.outputs.version }}.apk
|
||||
files: artifacts/orionTV.${{ steps.package-version.outputs.version }}.apk
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -24,4 +24,5 @@ web/**
|
||||
.bmad-core
|
||||
.kilocodemodes
|
||||
.roomodes
|
||||
yarn-errors.log
|
||||
yarn-errors.log
|
||||
coverage/
|
||||
172
CLAUDE.md
172
CLAUDE.md
@@ -11,115 +11,125 @@ OrionTV is a React Native TVOS application for streaming video content, built wi
|
||||
### 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 prebuild-tv` - Generate native project files for TV (run after dependency changes)
|
||||
- `yarn build-tv` - Build Android APK for TV release
|
||||
- `yarn start` - Start Metro bundler in TV mode (EXPO_TV=1)
|
||||
- `yarn android` - Build and run on Android TV
|
||||
- `yarn ios` - Build and run on Apple TV
|
||||
- `yarn prebuild` - Generate native project files for TV (run after dependency changes)
|
||||
- `yarn build` - 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
|
||||
#### Testing Commands
|
||||
- `yarn test` - Run Jest tests with watch mode
|
||||
- `yarn test-ci` - Run Jest tests for CI with coverage
|
||||
- `yarn test utils` - Run tests for specific directory/file pattern
|
||||
- `yarn lint` - Run ESLint checks
|
||||
- `yarn typecheck` - Run TypeScript type checking
|
||||
|
||||
#### Build and Deployment
|
||||
- `yarn copy-config` - Copy TV-specific Android configurations
|
||||
- `yarn build-debug` - Build Android APK for debugging
|
||||
- `yarn clean` - Clean cache and build artifacts
|
||||
- `yarn clean-modules` - Reinstall all node modules
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Frontend Structure
|
||||
### Multi-Platform Responsive Design
|
||||
|
||||
- **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
|
||||
OrionTV implements a sophisticated responsive architecture supporting multiple device types:
|
||||
- **Device Detection**: Width-based breakpoints (mobile <768px, tablet 768-1023px, TV ≥1024px)
|
||||
- **Component Variants**: Platform-specific files with `.tv.tsx`, `.mobile.tsx`, `.tablet.tsx` extensions
|
||||
- **Responsive Utilities**: `DeviceUtils` and `ResponsiveStyles` for adaptive layouts and scaling
|
||||
- **Adaptive Navigation**: Different interaction patterns per device type (touch vs remote control)
|
||||
|
||||
### Key Technologies
|
||||
### State Management Architecture (Zustand)
|
||||
|
||||
- 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
|
||||
- Zustand - Lightweight state management
|
||||
- Expo AV - Video playback functionality
|
||||
Domain-specific stores with consistent patterns:
|
||||
- **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
|
||||
- **remoteControlStore.ts** - Remote control server functionality and HTTP bridge
|
||||
- **authStore.ts** - User authentication state
|
||||
- **updateStore.ts** - Automatic update checking and version management
|
||||
- **favoritesStore.ts** - User favorites management
|
||||
|
||||
### State Management (Zustand Stores)
|
||||
### Service Layer Pattern
|
||||
|
||||
- `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
|
||||
- `remoteControlStore.ts` - Remote control server functionality and HTTP bridge
|
||||
- `authStore.ts` - User authentication state
|
||||
- `updateStore.ts` - Automatic update checking and version management
|
||||
- `favoritesStore.ts` - User favorites management
|
||||
Clean separation of concerns across service modules:
|
||||
- **api.ts** - External API integration with error handling and caching
|
||||
- **storage.ts** - AsyncStorage wrapper with typed interfaces
|
||||
- **remoteControlService.ts** - TCP-based HTTP server for external device control
|
||||
- **updateService.ts** - Automatic version checking and APK download management
|
||||
- **tcpHttpServer.ts** - Low-level TCP server implementation
|
||||
|
||||
### TV-Specific Features
|
||||
### TV Remote Control System
|
||||
|
||||
- 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`)
|
||||
- Gesture handling for TV remote interactions (select, left/right seeking, long press)
|
||||
- TV-specific assets and icons for Apple TV and Android TV
|
||||
Sophisticated TV interaction handling:
|
||||
- **useTVRemoteHandler** - Centralized hook for TV remote event processing
|
||||
- **Hardware Events** - HWEvent handling for TV-specific controls (play/pause, seek, menu)
|
||||
- **Focus Management** - TV-specific focus states and navigation flows
|
||||
- **Gesture Support** - Long press, directional seeking, auto-hide controls
|
||||
|
||||
### Service Layer Architecture
|
||||
## Key Technologies
|
||||
|
||||
- `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
|
||||
- `updateService.ts` - Automatic version checking and APK download
|
||||
- `tcpHttpServer.ts` - TCP-based HTTP server implementation
|
||||
- **React Native TVOS (0.74.x)** - TV-optimized React Native with TV-specific event handling
|
||||
- **Expo SDK 51** - Development platform providing native capabilities and build tooling
|
||||
- **TypeScript** - Complete type safety with `@/*` path mapping configuration
|
||||
- **Zustand** - Lightweight state management for global application state
|
||||
- **Expo Router** - File-based routing system with typed routes
|
||||
- **Expo AV** - Video playback with TV-optimized controls
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Responsive Development Notes
|
||||
### TV-First Development Pattern
|
||||
|
||||
- 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)
|
||||
This project uses a TV-first approach with responsive adaptations:
|
||||
- **Primary Target**: Apple TV and Android TV with remote control interaction
|
||||
- **Secondary Targets**: Mobile and tablet with touch-optimized responsive design
|
||||
- **Build Environment**: `EXPO_TV=1` environment variable enables TV-specific features
|
||||
- **Component Strategy**: Shared components with platform-specific variants using file extensions
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Unit Tests**: Comprehensive test coverage for utilities (`utils/__tests__/`)
|
||||
- **Jest Configuration**: Expo preset with Babel transpilation
|
||||
- **Test Patterns**: Mock-based testing for React Native modules and external dependencies
|
||||
- **Coverage Reporting**: CI-compatible coverage reports with detailed metrics
|
||||
|
||||
### Important Development Notes
|
||||
|
||||
- Run `yarn prebuild` after adding new dependencies for native builds
|
||||
- Use `yarn copy-config` to apply TV-specific Android configurations
|
||||
- TV components require focus management and remote control support
|
||||
- Mobile/tablet components use touch-optimized responsive design
|
||||
- The same codebase supports all platforms through responsive architecture
|
||||
- Test on both TV devices (Apple TV/Android TV) and responsive mobile/tablet layouts
|
||||
- All API calls are centralized in `/services` directory with error handling
|
||||
- Storage operations use AsyncStorage wrapper in `storage.ts` with typed interfaces
|
||||
|
||||
### State Management Patterns
|
||||
### Component Development 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`
|
||||
- **Platform Variants**: Use `.tv.tsx`, `.mobile.tsx`, `.tablet.tsx` for platform-specific implementations
|
||||
- **Responsive Utilities**: Leverage `DeviceUtils.getDeviceType()` for responsive logic
|
||||
- **TV Remote Handling**: Use `useTVRemoteHandler` hook for TV-specific interactions
|
||||
- **Focus Management**: TV components must handle focus states for remote navigation
|
||||
- **Shared Logic**: Place common logic in `/hooks` directory for reusability
|
||||
|
||||
### Component Structure
|
||||
## Common Development Tasks
|
||||
|
||||
- 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`
|
||||
### Adding New Components
|
||||
1. Create base component in `/components` directory
|
||||
2. Add platform-specific variants (`.tv.tsx`) if needed
|
||||
3. Import and use responsive utilities from `@/utils/DeviceUtils`
|
||||
4. Test across device types for proper responsive behavior
|
||||
|
||||
## Common Issues
|
||||
### Working with State
|
||||
1. Identify appropriate Zustand store in `/stores` directory
|
||||
2. Follow existing patterns for actions and state structure
|
||||
3. Use TypeScript interfaces for type safety
|
||||
4. Consider cross-store dependencies and data flow
|
||||
|
||||
### 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`)
|
||||
- External API servers configured in settings for video content
|
||||
### API Integration
|
||||
1. Add new endpoints to `/services/api.ts`
|
||||
2. Implement proper error handling and loading states
|
||||
3. Use caching strategies for frequently accessed data
|
||||
4. Update relevant Zustand stores with API responses
|
||||
|
||||
## File Structure Notes
|
||||
|
||||
|
||||
15
app.json
15
app.json
@@ -36,15 +36,9 @@
|
||||
},
|
||||
"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": [
|
||||
"**/*"
|
||||
],
|
||||
@@ -54,10 +48,6 @@
|
||||
"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",
|
||||
@@ -93,11 +83,6 @@
|
||||
"usesNonExemptEncryption": false
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"scheme": "oriontv",
|
||||
"extra": {
|
||||
"router": {
|
||||
|
||||
@@ -15,6 +15,9 @@ import { useUpdateStore, initUpdateStore } from "@/stores/updateStore";
|
||||
import { UpdateModal } from "@/components/UpdateModal";
|
||||
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RootLayout');
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
SplashScreen.preventAutoHideAsync();
|
||||
@@ -48,7 +51,7 @@ export default function RootLayout() {
|
||||
if (loaded || error) {
|
||||
SplashScreen.hideAsync();
|
||||
if (error) {
|
||||
console.warn(`Error in loading fonts: ${error}`);
|
||||
logger.warn(`Error in loading fonts: ${error}`);
|
||||
}
|
||||
}
|
||||
}, [loaded, error]);
|
||||
|
||||
@@ -10,11 +10,11 @@ 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 { useSettingsStore } from "@/stores/settingsStore";
|
||||
import CustomScrollView from "@/components/CustomScrollView";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import { useApiConfig, getApiConfigErrorMessage } from "@/hooks/useApiConfig";
|
||||
|
||||
const LOAD_MORE_THRESHOLD = 200;
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function HomeScreen() {
|
||||
clearError,
|
||||
} = useHomeStore();
|
||||
const { isLoggedIn, logout } = useAuthStore();
|
||||
const { apiBaseUrl } = useSettingsStore();
|
||||
const apiConfigStatus = useApiConfig();
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -52,34 +52,44 @@ export default function HomeScreen() {
|
||||
}, [refreshPlayRecords])
|
||||
);
|
||||
|
||||
// 统一的数据获取逻辑
|
||||
useEffect(() => {
|
||||
// 只有在 apiBaseUrl 存在时才调用 fetchInitialData,避免时序问题
|
||||
if (selectedCategory && !selectedCategory.tags && apiBaseUrl) {
|
||||
fetchInitialData();
|
||||
} else if (selectedCategory?.tags && !selectedCategory.tag) {
|
||||
if (!selectedCategory) return;
|
||||
|
||||
// 如果是容器分类且没有选择标签,设置默认标签
|
||||
if (selectedCategory.tags && !selectedCategory.tag) {
|
||||
const defaultTag = selectedCategory.tags[0];
|
||||
setSelectedTag(defaultTag);
|
||||
selectCategory({ ...selectedCategory, tag: defaultTag });
|
||||
return;
|
||||
}
|
||||
}, [selectedCategory, fetchInitialData, selectCategory, apiBaseUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
// 只有在 apiBaseUrl 存在时才调用 fetchInitialData,避免时序问题
|
||||
if (selectedCategory && selectedCategory.tag && apiBaseUrl) {
|
||||
fetchInitialData();
|
||||
// 只有在API配置完成且分类有效时才获取数据
|
||||
if (apiConfigStatus.isConfigured && !apiConfigStatus.needsConfiguration) {
|
||||
// 对于有标签的分类,需要确保有标签才获取数据
|
||||
if (selectedCategory.tags && selectedCategory.tag) {
|
||||
fetchInitialData();
|
||||
}
|
||||
// 对于无标签的分类,直接获取数据
|
||||
else if (!selectedCategory.tags) {
|
||||
fetchInitialData();
|
||||
}
|
||||
}
|
||||
}, [fetchInitialData, selectedCategory, selectedCategory.tag, apiBaseUrl]);
|
||||
}, [
|
||||
selectedCategory,
|
||||
selectedCategory?.tag,
|
||||
apiConfigStatus.isConfigured,
|
||||
apiConfigStatus.needsConfiguration,
|
||||
fetchInitialData,
|
||||
selectCategory,
|
||||
]);
|
||||
|
||||
// 检查是否需要显示API配置提示
|
||||
const shouldShowApiConfig = !apiBaseUrl && selectedCategory && !selectedCategory.tags;
|
||||
|
||||
// 清除错误状态,当API未配置时
|
||||
// 清除错误状态的逻辑
|
||||
useEffect(() => {
|
||||
if (shouldShowApiConfig && error) {
|
||||
// 如果需要显示API配置提示,清除之前的错误状态
|
||||
if (apiConfigStatus.needsConfiguration && error) {
|
||||
clearError();
|
||||
}
|
||||
}, [shouldShowApiConfig, error, clearError]);
|
||||
}, [apiConfigStatus.needsConfiguration, error, clearError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && contentData.length > 0) {
|
||||
@@ -119,7 +129,7 @@ export default function HomeScreen() {
|
||||
);
|
||||
};
|
||||
|
||||
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
|
||||
const renderContentItem = ({ item }: { item: RowItem; index: number }) => (
|
||||
<VideoCard
|
||||
id={item.id}
|
||||
source={item.source}
|
||||
@@ -142,6 +152,9 @@ export default function HomeScreen() {
|
||||
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
|
||||
};
|
||||
|
||||
// 检查是否需要显示API配置提示
|
||||
const shouldShowApiConfig = apiConfigStatus.needsConfiguration && selectedCategory && !selectedCategory.tags;
|
||||
|
||||
// TV端和平板端的顶部导航
|
||||
const renderHeader = () => {
|
||||
if (deviceType === "mobile") {
|
||||
@@ -280,8 +293,21 @@ export default function HomeScreen() {
|
||||
{/* 内容网格 */}
|
||||
{shouldShowApiConfig ? (
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: 'center' }}>
|
||||
请点击右上角设置按钮,配置您的服务器地址
|
||||
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
|
||||
{getApiConfigErrorMessage(apiConfigStatus)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : apiConfigStatus.isValidating ? (
|
||||
<View style={commonStyles.center}>
|
||||
<ActivityIndicator size="large" />
|
||||
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
|
||||
正在验证服务器配置...
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : apiConfigStatus.error && !apiConfigStatus.isValid ? (
|
||||
<View style={commonStyles.center}>
|
||||
<ThemedText type="subtitle" style={{ padding: spacing, textAlign: "center" }}>
|
||||
{apiConfigStatus.error}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : loading ? (
|
||||
|
||||
175
app/play.tsx
175
app/play.tsx
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, useCallback, memo, useMemo } from "react";
|
||||
import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { Video, ResizeMode } from "expo-av";
|
||||
import { Video } from "expo-av";
|
||||
import { useKeepAwake } from "expo-keep-awake";
|
||||
import { ThemedView } from "@/components/ThemedView";
|
||||
import { PlayerControls } from "@/components/PlayerControls";
|
||||
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
|
||||
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
|
||||
import { SpeedSelectionModal } from "@/components/SpeedSelectionModal";
|
||||
import { SeekingBar } from "@/components/SeekingBar";
|
||||
// import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
|
||||
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
|
||||
@@ -15,6 +16,58 @@ import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
|
||||
import Toast from "react-native-toast-message";
|
||||
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { useVideoHandlers } from "@/hooks/useVideoHandlers";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('PlayScreen');
|
||||
|
||||
// 优化的加载动画组件
|
||||
const LoadingContainer = memo(
|
||||
({ style, currentEpisode }: { style: any; currentEpisode: { url: string; title: string } | undefined }) => {
|
||||
logger.info(
|
||||
`[PERF] Video component NOT rendered - waiting for valid URL. currentEpisode: ${!!currentEpisode}, url: ${
|
||||
currentEpisode?.url ? "exists" : "missing"
|
||||
}`
|
||||
);
|
||||
return (
|
||||
<View style={style}>
|
||||
<VideoLoadingAnimation showProgressBar />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
LoadingContainer.displayName = "LoadingContainer";
|
||||
|
||||
// 移到组件外部避免重复创建
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlayScreen() {
|
||||
const videoRef = useRef<Video>(null);
|
||||
@@ -50,6 +103,7 @@ export default function PlayScreen() {
|
||||
// showNextEpisodeOverlay,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
setVideoRef,
|
||||
handlePlaybackStatusUpdate,
|
||||
setShowControls,
|
||||
@@ -59,17 +113,54 @@ export default function PlayScreen() {
|
||||
} = usePlayerStore();
|
||||
const currentEpisode = usePlayerStore(selectCurrentEpisode);
|
||||
|
||||
// 使用Video事件处理hook
|
||||
const { videoProps } = useVideoHandlers({
|
||||
videoRef,
|
||||
currentEpisode,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
deviceType,
|
||||
detail: detail || undefined,
|
||||
});
|
||||
|
||||
// TV遥控器处理 - 总是调用hook,但根据设备类型决定是否使用结果
|
||||
const tvRemoteHandler = useTVRemoteHandler();
|
||||
|
||||
// 优化的动态样式 - 使用useMemo避免重复计算
|
||||
const dynamicStyles = useMemo(() => createResponsiveStyles(deviceType), [deviceType]);
|
||||
|
||||
useEffect(() => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayScreen useEffect START - source: ${source}, id: ${id}, title: ${title}`);
|
||||
|
||||
setVideoRef(videoRef);
|
||||
if (source && id && title) {
|
||||
logger.info(`[PERF] Calling loadVideo with episodeIndex: ${episodeIndex}, position: ${position}`);
|
||||
loadVideo({ source, id, episodeIndex, position, title });
|
||||
} else {
|
||||
logger.info(`[PERF] Missing required params - source: ${!!source}, id: ${!!id}, title: ${!!title}`);
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayScreen useEffect END - took ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
|
||||
return () => {
|
||||
logger.info(`[PERF] PlayScreen unmounting - calling reset()`);
|
||||
reset(); // Reset state when component unmounts
|
||||
};
|
||||
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
|
||||
|
||||
// 优化的屏幕点击处理
|
||||
const onScreenPress = useCallback(() => {
|
||||
if (deviceType === "tv") {
|
||||
tvRemoteHandler.onScreenPress();
|
||||
} else {
|
||||
setShowControls(!showControls);
|
||||
}
|
||||
}, [deviceType, tvRemoteHandler, setShowControls, showControls]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === "background" || nextAppState === "inactive") {
|
||||
@@ -84,14 +175,6 @@ export default function PlayScreen() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// TV遥控器处理 - 总是调用hook,但根据设备类型决定是否使用结果
|
||||
const tvRemoteHandler = useTVRemoteHandler();
|
||||
|
||||
// 根据设备类型使用不同的交互处理
|
||||
const onScreenPress = deviceType === 'tv'
|
||||
? tvRemoteHandler.onScreenPress
|
||||
: () => setShowControls(!showControls);
|
||||
|
||||
useEffect(() => {
|
||||
const backAction = () => {
|
||||
if (showControls) {
|
||||
@@ -130,41 +213,29 @@ export default function PlayScreen() {
|
||||
return <VideoLoadingAnimation showProgressBar />;
|
||||
}
|
||||
|
||||
// 动态样式
|
||||
const dynamicStyles = createResponsiveStyles(deviceType);
|
||||
|
||||
return (
|
||||
<ThemedView focusable style={dynamicStyles.container}>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={dynamicStyles.videoContainer}
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={dynamicStyles.videoContainer}
|
||||
onPress={onScreenPress}
|
||||
disabled={deviceType !== 'tv' && showControls} // 移动端和平板端在显示控制条时禁用触摸
|
||||
disabled={deviceType !== "tv" && showControls} // 移动端和平板端在显示控制条时禁用触摸
|
||||
>
|
||||
<Video
|
||||
ref={videoRef}
|
||||
style={dynamicStyles.videoPlayer}
|
||||
source={{ uri: currentEpisode?.url || "" }}
|
||||
posterSource={{ uri: detail?.poster ?? "" }}
|
||||
resizeMode={ResizeMode.CONTAIN}
|
||||
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
|
||||
onLoad={() => {
|
||||
const jumpPosition = initialPosition || introEndTime || 0;
|
||||
if (jumpPosition > 0) {
|
||||
videoRef.current?.setPositionAsync(jumpPosition);
|
||||
}
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
}}
|
||||
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
|
||||
useNativeControls={deviceType !== 'tv'}
|
||||
shouldPlay
|
||||
/>
|
||||
{/* 条件渲染Video组件:只有在有有效URL时才渲染 */}
|
||||
{currentEpisode?.url ? (
|
||||
<Video ref={videoRef} style={dynamicStyles.videoPlayer} {...videoProps} />
|
||||
) : (
|
||||
<LoadingContainer style={dynamicStyles.loadingContainer} currentEpisode={currentEpisode} />
|
||||
)}
|
||||
|
||||
{showControls && deviceType === 'tv' && <PlayerControls showControls={showControls} setShowControls={setShowControls} />}
|
||||
{showControls && deviceType === "tv" && (
|
||||
<PlayerControls showControls={showControls} setShowControls={setShowControls} />
|
||||
)}
|
||||
|
||||
<SeekingBar />
|
||||
|
||||
{isLoading && (
|
||||
{/* 只在Video组件存在且正在加载时显示加载动画覆盖层 */}
|
||||
{currentEpisode?.url && isLoading && (
|
||||
<View style={dynamicStyles.loadingContainer}>
|
||||
<VideoLoadingAnimation showProgressBar />
|
||||
</View>
|
||||
@@ -175,35 +246,7 @@ export default function PlayScreen() {
|
||||
|
||||
<EpisodeSelectionModal />
|
||||
<SourceSelectionModal />
|
||||
<SpeedSelectionModal />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ import { getCommonResponsiveStyles } from "@/utils/ResponsiveStyles";
|
||||
import ResponsiveNavigation from "@/components/navigation/ResponsiveNavigation";
|
||||
import ResponsiveHeader from "@/components/navigation/ResponsiveHeader";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('SearchScreen');
|
||||
|
||||
export default function SearchScreen() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -37,7 +40,7 @@ export default function SearchScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
if (lastMessage && targetPage === 'search') {
|
||||
console.log("Received remote input:", lastMessage);
|
||||
logger.debug("Received remote input:", lastMessage);
|
||||
const realMessage = lastMessage.split("_")[0];
|
||||
setKeyword(realMessage);
|
||||
handleSearch(realMessage);
|
||||
@@ -72,7 +75,7 @@ export default function SearchScreen() {
|
||||
}
|
||||
} catch (err) {
|
||||
setError("搜索失败,请稍后重试。");
|
||||
console.info("Search failed:", err);
|
||||
logger.info("Search failed:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
15
babel.config.js
Normal file
15
babel.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
const plugins = [];
|
||||
|
||||
// 在生产环境移除console调用以优化性能
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
plugins.push('transform-remove-console');
|
||||
}
|
||||
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import useAuthStore from "@/stores/authStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import useHomeStore from "@/stores/homeStore";
|
||||
import { api } from "@/services/api";
|
||||
import { LoginCredentialsManager } from "@/services/storage";
|
||||
import { ThemedView } from "./ThemedView";
|
||||
import { ThemedText } from "./ThemedText";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
@@ -22,6 +23,20 @@ const LoginModal = () => {
|
||||
const pathname = usePathname();
|
||||
const isSettingsPage = pathname.includes("settings");
|
||||
|
||||
// Load saved credentials when modal opens
|
||||
useEffect(() => {
|
||||
if (isLoginModalVisible && !isSettingsPage) {
|
||||
const loadCredentials = async () => {
|
||||
const savedCredentials = await LoginCredentialsManager.get();
|
||||
if (savedCredentials) {
|
||||
setUsername(savedCredentials.username);
|
||||
setPassword(savedCredentials.password);
|
||||
}
|
||||
};
|
||||
loadCredentials();
|
||||
}
|
||||
}, [isLoginModalVisible, isSettingsPage]);
|
||||
|
||||
// Focus management with better TV remote handling
|
||||
useEffect(() => {
|
||||
if (isLoginModalVisible && !isSettingsPage) {
|
||||
@@ -51,10 +66,12 @@ const LoginModal = () => {
|
||||
await api.login(isLocalStorage ? undefined : username, password);
|
||||
await checkLoginStatus(apiBaseUrl);
|
||||
await refreshPlayRecords();
|
||||
|
||||
// Save credentials on successful login
|
||||
await LoginCredentialsManager.save({ username, password });
|
||||
|
||||
Toast.show({ type: "success", text1: "登录成功" });
|
||||
hideLoginModal();
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
|
||||
// Show disclaimer alert after successful login
|
||||
Alert.alert(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Pressable } from "react-native";
|
||||
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot } from "lucide-react-native";
|
||||
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot, Gauge } from "lucide-react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { MediaButton } from "@/components/MediaButton";
|
||||
|
||||
@@ -21,10 +21,12 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
|
||||
isSeeking,
|
||||
seekPosition,
|
||||
progressPosition,
|
||||
playbackRate,
|
||||
togglePlayPause,
|
||||
playEpisode,
|
||||
setShowEpisodeModal,
|
||||
setShowSourceModal,
|
||||
setShowSpeedModal,
|
||||
setIntroEndTime,
|
||||
setOutroStartTime,
|
||||
introEndTime,
|
||||
@@ -109,6 +111,10 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
|
||||
<List color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton onPress={() => setShowSpeedModal(true)} timeLabel={playbackRate !== 1.0 ? `${playbackRate}x` : undefined}>
|
||||
<Gauge color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
<MediaButton onPress={() => setShowSourceModal(true)}>
|
||||
<Tv color="white" size={24} />
|
||||
</MediaButton>
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('ResponsiveVideoCard');
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -138,7 +141,7 @@ const ResponsiveVideoCard = forwardRef<View, VideoCardProps>(
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,13 +3,16 @@ import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
import useDetailStore from "@/stores/detailStore";
|
||||
import usePlayerStore from "@/stores/playerStore";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('SourceSelectionModal');
|
||||
|
||||
export const SourceSelectionModal: React.FC = () => {
|
||||
const { showSourceModal, setShowSourceModal, loadVideo, currentEpisodeIndex, status } = usePlayerStore();
|
||||
const { searchResults, detail, setDetail } = useDetailStore();
|
||||
|
||||
const onSelectSource = (index: number) => {
|
||||
console.log("onSelectSource", index, searchResults[index].source, detail?.source);
|
||||
logger.debug("onSelectSource", index, searchResults[index].source, detail?.source);
|
||||
if (searchResults[index].source !== detail?.source) {
|
||||
const newDetail = searchResults[index];
|
||||
setDetail(newDetail);
|
||||
|
||||
93
components/SpeedSelectionModal.tsx
Normal file
93
components/SpeedSelectionModal.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
|
||||
import { StyledButton } from "./StyledButton";
|
||||
import usePlayerStore from "@/stores/playerStore";
|
||||
|
||||
interface SpeedOption {
|
||||
rate: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SPEED_OPTIONS: SpeedOption[] = [
|
||||
{ rate: 0.5, label: "0.5x" },
|
||||
{ rate: 0.75, label: "0.75x" },
|
||||
{ rate: 1.0, label: "1x" },
|
||||
{ rate: 1.25, label: "1.25x" },
|
||||
{ rate: 1.5, label: "1.5x" },
|
||||
{ rate: 1.75, label: "1.75x" },
|
||||
{ rate: 2.0, label: "2x" },
|
||||
];
|
||||
|
||||
export const SpeedSelectionModal: React.FC = () => {
|
||||
const { showSpeedModal, setShowSpeedModal, playbackRate, setPlaybackRate } = usePlayerStore();
|
||||
|
||||
const onSelectSpeed = (rate: number) => {
|
||||
setPlaybackRate(rate);
|
||||
setShowSpeedModal(false);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setShowSpeedModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={showSpeedModal} transparent={true} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.modalContainer}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text style={styles.modalTitle}>播放速度</Text>
|
||||
<FlatList
|
||||
data={SPEED_OPTIONS}
|
||||
numColumns={3}
|
||||
contentContainerStyle={styles.speedList}
|
||||
keyExtractor={(item) => `speed-${item.rate}`}
|
||||
renderItem={({ item }) => (
|
||||
<StyledButton
|
||||
text={item.label}
|
||||
onPress={() => onSelectSpeed(item.rate)}
|
||||
isSelected={playbackRate === item.rate}
|
||||
hasTVPreferredFocus={playbackRate === item.rate}
|
||||
style={styles.speedItem}
|
||||
textStyle={styles.speedItemText}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
modalContent: {
|
||||
width: 500,
|
||||
height: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.85)",
|
||||
padding: 20,
|
||||
},
|
||||
modalTitle: {
|
||||
color: "white",
|
||||
marginBottom: 12,
|
||||
textAlign: "center",
|
||||
fontSize: 18,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
speedList: {
|
||||
justifyContent: "flex-start",
|
||||
},
|
||||
speedItem: {
|
||||
paddingVertical: 10,
|
||||
margin: 4,
|
||||
marginLeft: 10,
|
||||
marginRight: 8,
|
||||
width: "30%",
|
||||
},
|
||||
speedItemText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('VideoCardMobile');
|
||||
|
||||
interface VideoCardMobileProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -97,7 +100,7 @@ const VideoCardMobile = forwardRef<View, VideoCardMobileProps>(
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,6 +8,9 @@ import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import { useResponsiveLayout } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "@/utils/DeviceUtils";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('VideoCardTablet');
|
||||
|
||||
interface VideoCardTabletProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -119,7 +122,7 @@ const VideoCardTablet = forwardRef<View, VideoCardTabletProps>(
|
||||
await PlayRecordManager.remove(source, id);
|
||||
onRecordDeleted?.();
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,6 +6,9 @@ import { PlayRecordManager } from "@/services/storage";
|
||||
import { API } from "@/services/api";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
import { Colors } from "@/constants/Colors";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('VideoCardTV');
|
||||
|
||||
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
|
||||
id: string;
|
||||
@@ -131,7 +134,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
router.replace("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to delete play record:", error);
|
||||
logger.info("Failed to delete play record:", error);
|
||||
Alert.alert("错误", "删除观看记录失败,请重试");
|
||||
}
|
||||
},
|
||||
@@ -195,7 +198,7 @@ const VideoCard = forwardRef<View, VideoCardProps>(
|
||||
{isContinueWatching && (
|
||||
<View style={styles.infoRow}>
|
||||
<ThemedText style={styles.continueLabel}>
|
||||
第{episodeIndex! + 1}集 已观看 {Math.round((progress || 0) * 100)}%
|
||||
第{episodeIndex}集 已观看 {Math.round((progress || 0) * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
@@ -343,4 +346,4 @@ const styles = StyleSheet.create({
|
||||
color: Colors.dark.primary,
|
||||
fontSize: 12,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,19 @@ import { View, StyleSheet, Platform, ActivityIndicator } from "react-native";
|
||||
import { ThemedText } from "../ThemedText";
|
||||
import { StyledButton } from "../StyledButton";
|
||||
import { useUpdateStore } from "@/stores/updateStore";
|
||||
import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
|
||||
// import { UPDATE_CONFIG } from "@/constants/UpdateConfig";
|
||||
|
||||
export function UpdateSection() {
|
||||
const { currentVersion, remoteVersion, updateAvailable, downloading, downloadProgress, checkForUpdate } =
|
||||
useUpdateStore();
|
||||
const {
|
||||
currentVersion,
|
||||
remoteVersion,
|
||||
updateAvailable,
|
||||
downloading,
|
||||
downloadProgress,
|
||||
checkForUpdate,
|
||||
isLatestVersion,
|
||||
error
|
||||
} = useUpdateStore();
|
||||
|
||||
const [checking, setChecking] = React.useState(false);
|
||||
|
||||
@@ -36,6 +44,20 @@ export function UpdateSection() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isLatestVersion && remoteVersion && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText style={styles.label}>状态</ThemedText>
|
||||
<ThemedText style={[styles.value, styles.latestVersion]}>已是最新版本</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText style={styles.label}>检查结果</ThemedText>
|
||||
<ThemedText style={[styles.value, styles.errorText]}>{error}</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{downloading && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText style={styles.label}>下载进度</ThemedText>
|
||||
@@ -96,6 +118,14 @@ const styles = StyleSheet.create({
|
||||
color: "#00bb5e",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
latestVersion: {
|
||||
color: "#00bb5e",
|
||||
fontWeight: "500",
|
||||
},
|
||||
errorText: {
|
||||
color: "#ff6b6b",
|
||||
fontWeight: "500",
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 12,
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
import { DeviceUtils } from '@/utils/DeviceUtils';
|
||||
|
||||
export const UPDATE_CONFIG = {
|
||||
// 自动检查更新
|
||||
AUTO_CHECK: true,
|
||||
|
||||
|
||||
// 检查更新间隔(毫秒)
|
||||
CHECK_INTERVAL: 12 * 60 * 60 * 1000, // 12小时
|
||||
|
||||
|
||||
// GitHub相关URL
|
||||
GITHUB_RAW_URL: 'https://ghfast.top/https://raw.githubusercontent.com/zimplexing/OrionTV/refs/heads/master/package.json',
|
||||
|
||||
GITHUB_RAW_URL:
|
||||
`https://ghfast.top/https://raw.githubusercontent.com/orion-lib/OrionTV/refs/heads/master/package.json?t=${Date.now()}`,
|
||||
|
||||
// 获取平台特定的下载URL
|
||||
getDownloadUrl(version: string): string {
|
||||
const isTV = DeviceUtils.isTV();
|
||||
const platform = isTV ? 'tv' : 'mobile';
|
||||
return `https://ghfast.top/https://github.com/zimplexing/OrionTV/releases/download/v${version}/orionTV-${platform}.${version}.apk`;
|
||||
return `https://ghfast.top/https://github.com/orion-lib/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
|
||||
},
|
||||
|
||||
|
||||
// 是否显示更新日志
|
||||
SHOW_RELEASE_NOTES: true,
|
||||
|
||||
|
||||
// 是否允许跳过版本
|
||||
ALLOW_SKIP_VERSION: true,
|
||||
|
||||
|
||||
// 下载超时时间(毫秒)
|
||||
DOWNLOAD_TIMEOUT: 10 * 60 * 1000, // 10分钟
|
||||
|
||||
|
||||
// 是否在WIFI下自动下载
|
||||
AUTO_DOWNLOAD_ON_WIFI: false,
|
||||
|
||||
|
||||
// 更新通知设置
|
||||
NOTIFICATION: {
|
||||
ENABLED: true,
|
||||
TITLE: 'OrionTV 更新',
|
||||
DOWNLOADING_TEXT: '正在下载新版本...',
|
||||
DOWNLOAD_COMPLETE_TEXT: '下载完成,点击安装',
|
||||
TITLE: "OrionTV 更新",
|
||||
DOWNLOADING_TEXT: "正在下载新版本...",
|
||||
DOWNLOAD_COMPLETE_TEXT: "下载完成,点击安装",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
139
hooks/useApiConfig.ts
Normal file
139
hooks/useApiConfig.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
export interface ApiConfigStatus {
|
||||
isConfigured: boolean;
|
||||
isValidating: boolean;
|
||||
isValid: boolean | null;
|
||||
error: string | null;
|
||||
needsConfiguration: boolean;
|
||||
}
|
||||
|
||||
export const useApiConfig = () => {
|
||||
const { apiBaseUrl, serverConfig, isLoadingServerConfig } = useSettingsStore();
|
||||
const [validationState, setValidationState] = useState<{
|
||||
isValidating: boolean;
|
||||
isValid: boolean | null;
|
||||
error: string | null;
|
||||
}>({
|
||||
isValidating: false,
|
||||
isValid: null,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const isConfigured = Boolean(apiBaseUrl && apiBaseUrl.trim());
|
||||
const needsConfiguration = !isConfigured;
|
||||
|
||||
// Validate API configuration when it changes
|
||||
useEffect(() => {
|
||||
if (!isConfigured) {
|
||||
setValidationState({
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
error: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validateConfig = async () => {
|
||||
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||
|
||||
try {
|
||||
await api.getServerConfig();
|
||||
setValidationState({
|
||||
isValidating: false,
|
||||
isValid: true,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
let errorMessage = '服务器连接失败';
|
||||
|
||||
if (error instanceof Error) {
|
||||
switch (error.message) {
|
||||
case 'API_URL_NOT_SET':
|
||||
errorMessage = 'API地址未设置';
|
||||
break;
|
||||
case 'UNAUTHORIZED':
|
||||
errorMessage = '服务器认证失败';
|
||||
break;
|
||||
default:
|
||||
if (error.message.includes('Network')) {
|
||||
errorMessage = '网络连接失败,请检查网络或服务器地址';
|
||||
} else if (error.message.includes('timeout')) {
|
||||
errorMessage = '连接超时,请检查服务器地址';
|
||||
} else if (error.message.includes('404')) {
|
||||
errorMessage = '服务器地址无效,请检查API路径';
|
||||
} else if (error.message.includes('500')) {
|
||||
errorMessage = '服务器内部错误';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setValidationState({
|
||||
isValidating: false,
|
||||
isValid: false,
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Only validate if not already loading server config
|
||||
if (!isLoadingServerConfig) {
|
||||
validateConfig();
|
||||
}
|
||||
}, [apiBaseUrl, isConfigured, isLoadingServerConfig]);
|
||||
|
||||
// Reset validation when server config loading state changes
|
||||
useEffect(() => {
|
||||
if (isLoadingServerConfig) {
|
||||
setValidationState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||
}
|
||||
}, [isLoadingServerConfig]);
|
||||
|
||||
// Update validation state based on server config
|
||||
useEffect(() => {
|
||||
if (!isLoadingServerConfig && isConfigured) {
|
||||
if (serverConfig) {
|
||||
setValidationState(prev => ({ ...prev, isValid: true, error: null }));
|
||||
} else {
|
||||
setValidationState(prev => ({
|
||||
...prev,
|
||||
isValid: false,
|
||||
error: prev.error || '无法获取服务器配置'
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [serverConfig, isLoadingServerConfig, isConfigured]);
|
||||
|
||||
const status: ApiConfigStatus = {
|
||||
isConfigured,
|
||||
isValidating: validationState.isValidating || isLoadingServerConfig,
|
||||
isValid: validationState.isValid,
|
||||
error: validationState.error,
|
||||
needsConfiguration,
|
||||
};
|
||||
|
||||
return status;
|
||||
};
|
||||
|
||||
export const getApiConfigErrorMessage = (status: ApiConfigStatus): string => {
|
||||
if (status.needsConfiguration) {
|
||||
return '请点击右上角设置按钮,配置您的服务器地址';
|
||||
}
|
||||
|
||||
if (status.error) {
|
||||
return status.error;
|
||||
}
|
||||
|
||||
if (status.isValidating) {
|
||||
return '正在验证服务器配置...';
|
||||
}
|
||||
|
||||
if (status.isValid === false) {
|
||||
return '服务器配置验证失败,请检查设置';
|
||||
}
|
||||
|
||||
return '加载失败,请重试';
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Dimensions, Platform } from 'react-native';
|
||||
import { useState, useEffect } from "react";
|
||||
import { Dimensions, Platform } from "react-native";
|
||||
|
||||
export type DeviceType = 'mobile' | 'tablet' | 'tv';
|
||||
export type DeviceType = "mobile" | "tablet" | "tv";
|
||||
|
||||
export interface ResponsiveConfig {
|
||||
deviceType: DeviceType;
|
||||
@@ -17,40 +17,44 @@ export interface ResponsiveConfig {
|
||||
const BREAKPOINTS = {
|
||||
mobile: { min: 0, max: 767 },
|
||||
tablet: { min: 768, max: 1023 },
|
||||
tv: { min: 1024, max: Infinity }
|
||||
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';
|
||||
if (Platform.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;
|
||||
|
||||
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':
|
||||
case "mobile":
|
||||
columns = isPortrait ? 3 : 4;
|
||||
// 使用flex布局,卡片可以更大一些来填充空间
|
||||
cardWidth = (width - spacing) / columns * 0.85; // 增大到85%
|
||||
cardWidth = ((width - spacing) / columns) * 0.85; // 增大到85%
|
||||
cardHeight = cardWidth * 1.2; // 5:6 aspect ratio (reduced from 2:3)
|
||||
break;
|
||||
|
||||
case 'tablet':
|
||||
|
||||
case "tablet":
|
||||
columns = isPortrait ? 3 : 4;
|
||||
cardWidth = (width - spacing) / columns * 0.85; // 增大到85%
|
||||
cardWidth = ((width - spacing) / columns) * 0.85; // 增大到85%
|
||||
cardHeight = cardWidth * 1.4; // slightly less tall ratio
|
||||
break;
|
||||
|
||||
case 'tv':
|
||||
|
||||
case "tv":
|
||||
default:
|
||||
columns = 5;
|
||||
cardWidth = 160; // Fixed width for TV
|
||||
@@ -72,12 +76,12 @@ const getLayoutConfig = (deviceType: DeviceType, width: number, height: number,
|
||||
|
||||
export const useResponsiveLayout = (): ResponsiveConfig => {
|
||||
const [dimensions, setDimensions] = useState(() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const { width, height } = Dimensions.get("window");
|
||||
return { width, height };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
const subscription = Dimensions.addEventListener("change", ({ window }) => {
|
||||
setDimensions({ width: window.width, height: window.height });
|
||||
});
|
||||
|
||||
@@ -87,7 +91,7 @@ export const useResponsiveLayout = (): ResponsiveConfig => {
|
||||
const { width, height } = dimensions;
|
||||
const isPortrait = height > width;
|
||||
const deviceType = getDeviceType(width);
|
||||
|
||||
|
||||
return getLayoutConfig(deviceType, width, height, isPortrait);
|
||||
};
|
||||
|
||||
@@ -100,31 +104,31 @@ export const useResponsiveValue = <T>(values: { mobile: T; tablet: T; tv: T }):
|
||||
// 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,
|
||||
|
||||
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,
|
||||
sectionSpacing: config.deviceType === "mobile" ? 16 : config.deviceType === "tablet" ? 20 : 24,
|
||||
itemSpacing: config.spacing,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
131
hooks/useVideoHandlers.ts
Normal file
131
hooks/useVideoHandlers.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useCallback, RefObject, useMemo } from 'react';
|
||||
import { Video, ResizeMode } from 'expo-av';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import usePlayerStore from '@/stores/playerStore';
|
||||
|
||||
interface UseVideoHandlersProps {
|
||||
videoRef: RefObject<Video>;
|
||||
currentEpisode: { url: string; title: string } | undefined;
|
||||
initialPosition: number;
|
||||
introEndTime?: number;
|
||||
playbackRate: number;
|
||||
handlePlaybackStatusUpdate: (status: any) => void;
|
||||
deviceType: string;
|
||||
detail?: { poster?: string };
|
||||
}
|
||||
|
||||
export const useVideoHandlers = ({
|
||||
videoRef,
|
||||
currentEpisode,
|
||||
initialPosition,
|
||||
introEndTime,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
deviceType,
|
||||
detail,
|
||||
}: UseVideoHandlersProps) => {
|
||||
|
||||
const onLoad = useCallback(async () => {
|
||||
console.info(`[PERF] Video onLoad - video ready to play`);
|
||||
|
||||
try {
|
||||
// 1. 先设置位置(如果需要)
|
||||
const jumpPosition = initialPosition || introEndTime || 0;
|
||||
if (jumpPosition > 0) {
|
||||
console.info(`[PERF] Setting initial position to ${jumpPosition}ms`);
|
||||
await videoRef.current?.setPositionAsync(jumpPosition);
|
||||
}
|
||||
|
||||
// 2. 显式调用播放以确保自动播放
|
||||
console.info(`[AUTOPLAY] Attempting to start playback after onLoad`);
|
||||
await videoRef.current?.playAsync();
|
||||
console.info(`[AUTOPLAY] Auto-play successful after onLoad`);
|
||||
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
console.info(`[PERF] Video loading complete - isLoading set to false`);
|
||||
} catch (error) {
|
||||
console.warn(`[AUTOPLAY] Failed to auto-play after onLoad:`, error);
|
||||
// 即使自动播放失败,也要设置加载完成状态
|
||||
usePlayerStore.setState({ isLoading: false });
|
||||
// 不显示错误提示,因为自动播放失败是常见且预期的情况
|
||||
}
|
||||
}, [videoRef, initialPosition, introEndTime]);
|
||||
|
||||
const onLoadStart = useCallback(() => {
|
||||
if (!currentEpisode?.url) return;
|
||||
|
||||
console.info(`[PERF] Video onLoadStart - starting to load video: ${currentEpisode.url.substring(0, 100)}...`);
|
||||
usePlayerStore.setState({ isLoading: true });
|
||||
}, [currentEpisode?.url]);
|
||||
|
||||
const onError = useCallback((error: any) => {
|
||||
if (!currentEpisode?.url) return;
|
||||
|
||||
console.error(`[ERROR] Video playback error:`, error);
|
||||
|
||||
// 检测SSL证书错误和其他网络错误
|
||||
const errorString = (error as any)?.error?.toString() || error?.toString() || '';
|
||||
const isSSLError = errorString.includes('SSLHandshakeException') ||
|
||||
errorString.includes('CertPathValidatorException') ||
|
||||
errorString.includes('Trust anchor for certification path not found');
|
||||
const isNetworkError = errorString.includes('HttpDataSourceException') ||
|
||||
errorString.includes('IOException') ||
|
||||
errorString.includes('SocketTimeoutException');
|
||||
|
||||
if (isSSLError) {
|
||||
console.error(`[SSL_ERROR] SSL certificate validation failed for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "SSL证书错误,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('ssl', currentEpisode.url);
|
||||
} else if (isNetworkError) {
|
||||
console.error(`[NETWORK_ERROR] Network connection failed for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "网络连接失败,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('network', currentEpisode.url);
|
||||
} else {
|
||||
console.error(`[VIDEO_ERROR] Other video error for URL: ${currentEpisode.url}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "视频播放失败,正在尝试其他播放源...",
|
||||
text2: "请稍候"
|
||||
});
|
||||
usePlayerStore.getState().handleVideoError('other', currentEpisode.url);
|
||||
}
|
||||
}, [currentEpisode?.url]);
|
||||
|
||||
// 优化的Video组件props
|
||||
const videoProps = useMemo(() => ({
|
||||
source: { uri: currentEpisode?.url || '' },
|
||||
posterSource: { uri: detail?.poster ?? "" },
|
||||
resizeMode: ResizeMode.CONTAIN,
|
||||
rate: playbackRate,
|
||||
onPlaybackStatusUpdate: handlePlaybackStatusUpdate,
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
useNativeControls: deviceType !== 'tv',
|
||||
shouldPlay: true,
|
||||
}), [
|
||||
currentEpisode?.url,
|
||||
detail?.poster,
|
||||
playbackRate,
|
||||
handlePlaybackStatusUpdate,
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
deviceType,
|
||||
]);
|
||||
|
||||
return {
|
||||
onLoad,
|
||||
onLoadStart,
|
||||
onError,
|
||||
videoProps,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const {getDefaultConfig} = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const path = require("path");
|
||||
|
||||
// Find the project and workspace directories
|
||||
// eslint-disable-next-line no-undef
|
||||
@@ -16,24 +16,24 @@ const config = getDefaultConfig(projectRoot);
|
||||
// Metro will still resolve source files with standard extensions
|
||||
// as usual if TV-specific files are not found for a module.
|
||||
//
|
||||
if (process.env?.EXPO_TV === '1') {
|
||||
const originalSourceExts = config.resolver.sourceExts;
|
||||
const tvSourceExts = [
|
||||
...originalSourceExts.map((e) => `tv.${e}`),
|
||||
...originalSourceExts,
|
||||
];
|
||||
config.resolver.sourceExts = tvSourceExts;
|
||||
}
|
||||
// if (process.env?.EXPO_TV === '1') {
|
||||
// const originalSourceExts = config.resolver.sourceExts;
|
||||
// const tvSourceExts = [
|
||||
// ...originalSourceExts.map((e) => `tv.${e}`),
|
||||
// ...originalSourceExts,
|
||||
// ];
|
||||
// config.resolver.sourceExts = tvSourceExts;
|
||||
// }
|
||||
|
||||
// This can be replaced with `find-yarn-workspace-root`
|
||||
const monorepoRoot = path.resolve(projectRoot, '../..');
|
||||
const monorepoRoot = path.resolve(projectRoot, "../..");
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [monorepoRoot];
|
||||
// 2. Let Metro know where to resolve packages and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(monorepoRoot, 'node_modules'),
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(monorepoRoot, "node_modules"),
|
||||
];
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
|
||||
25
package.json
25
package.json
@@ -2,24 +2,14 @@
|
||||
"name": "OrionTV",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.7",
|
||||
"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",
|
||||
"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",
|
||||
"start": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"android": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:android",
|
||||
"ios": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo run:ios",
|
||||
"prebuild": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo prebuild --clean && yarn copy-config",
|
||||
"copy-config": "cp -r xml/* android/app/src/*",
|
||||
"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": "NODE_ENV=production EXPO_TV=1 yarn prebuild && cd android && ./gradlew assembleRelease",
|
||||
"build-debug": "cd android && ./gradlew assembleDebug",
|
||||
"test": "jest --watchAll",
|
||||
"test-ci": "jest --ci --coverage --no-cache",
|
||||
@@ -77,6 +67,7 @@
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~18.2.45",
|
||||
"@types/react-test-renderer": "^18.0.7",
|
||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-expo": "~7.1.2",
|
||||
"jest": "^29.2.1",
|
||||
@@ -92,4 +83,4 @@
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
// region: --- Interface Definitions ---
|
||||
export interface DoubanItem {
|
||||
title: string;
|
||||
@@ -204,7 +203,8 @@ export class API {
|
||||
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
|
||||
const url = `/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
|
||||
const response = await this._fetch(url, { signal });
|
||||
return response.json();
|
||||
const { results } = await response.json();
|
||||
return { results: results.filter((item: any) => item.title === query )};
|
||||
}
|
||||
|
||||
async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('M3U');
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -59,7 +63,7 @@ export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
|
||||
const m3uText = await response.text();
|
||||
return parseM3U(m3uText);
|
||||
} catch (error) {
|
||||
console.info("Error fetching or parsing M3U:", error);
|
||||
logger.info("Error fetching or parsing M3U:", error);
|
||||
return []; // Return empty array on error
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('M3U8');
|
||||
|
||||
interface CacheEntry {
|
||||
resolution: string | null;
|
||||
timestamp: number;
|
||||
@@ -10,21 +14,33 @@ export const getResolutionFromM3U8 = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection START - url: ${url.substring(0, 100)}...`);
|
||||
|
||||
// 1. Check cache first
|
||||
const cachedEntry = resolutionCache[url];
|
||||
if (cachedEntry && Date.now() - cachedEntry.timestamp < CACHE_DURATION) {
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection CACHED - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${cachedEntry.resolution}`);
|
||||
return cachedEntry.resolution;
|
||||
}
|
||||
|
||||
if (!url.toLowerCase().endsWith(".m3u8")) {
|
||||
logger.info(`[PERF] M3U8 resolution detection SKIPPED - not M3U8 file`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fetchStart = performance.now();
|
||||
const response = await fetch(url, { signal });
|
||||
const fetchEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 fetch took ${(fetchEnd - fetchStart).toFixed(2)}ms, status: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parseStart = performance.now();
|
||||
const playlist = await response.text();
|
||||
const lines = playlist.split("\n");
|
||||
let highestResolution = 0;
|
||||
@@ -42,6 +58,9 @@ export const getResolutionFromM3U8 = async (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 parsing took ${(parseEnd - parseStart).toFixed(2)}ms, lines: ${lines.length}`);
|
||||
|
||||
// 2. Store result in cache
|
||||
resolutionCache[url] = {
|
||||
@@ -49,8 +68,13 @@ export const getResolutionFromM3U8 = async (
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection COMPLETE - took ${(perfEnd - perfStart).toFixed(2)}ms, resolution: ${resolutionString}`);
|
||||
|
||||
return resolutionString;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution detection ERROR - took ${(perfEnd - perfStart).toFixed(2)}ms, error: ${error}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import TCPHttpServer from "./tcpHttpServer";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RemoteControl');
|
||||
|
||||
const getRemotePageHTML = () => {
|
||||
return `
|
||||
@@ -25,7 +28,7 @@ const getRemotePageHTML = () => {
|
||||
</div>
|
||||
<script>
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
fetch('/handshake', { method: 'POST' }).catch(console.info);
|
||||
fetch('/handshake', { method: 'POST' }).catch(err => logger.info('Handshake failed:', err));
|
||||
});
|
||||
function send() {
|
||||
const input = document.getElementById("text");
|
||||
@@ -36,7 +39,7 @@ const getRemotePageHTML = () => {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value })
|
||||
})
|
||||
.catch(err => console.info(err));
|
||||
.catch(err => logger.info('Message send failed:', err));
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
@@ -58,7 +61,7 @@ class RemoteControlService {
|
||||
|
||||
private setupRequestHandler() {
|
||||
this.httpServer.setRequestHandler((request) => {
|
||||
console.log("[RemoteControl] Received request:", request.method, request.url);
|
||||
logger.debug("[RemoteControl] Received request:", request.method, request.url);
|
||||
|
||||
try {
|
||||
if (request.method === "GET" && request.url === "/") {
|
||||
@@ -80,7 +83,7 @@ class RemoteControlService {
|
||||
body: JSON.stringify({ status: "ok" }),
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.info("[RemoteControl] Failed to parse message body:", parseError);
|
||||
logger.info("[RemoteControl] Failed to parse message body:", parseError);
|
||||
return {
|
||||
statusCode: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -102,7 +105,7 @@ class RemoteControlService {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("[RemoteControl] Request handler error:", error);
|
||||
logger.info("[RemoteControl] Request handler error:", error);
|
||||
return {
|
||||
statusCode: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -118,20 +121,20 @@ class RemoteControlService {
|
||||
}
|
||||
|
||||
public async startServer(): Promise<string> {
|
||||
console.log("[RemoteControl] Attempting to start server...");
|
||||
logger.debug("[RemoteControl] Attempting to start server...");
|
||||
|
||||
try {
|
||||
const url = await this.httpServer.start();
|
||||
console.log(`[RemoteControl] Server started successfully at: ${url}`);
|
||||
logger.debug(`[RemoteControl] Server started successfully at: ${url}`);
|
||||
return url;
|
||||
} catch (error) {
|
||||
console.info("[RemoteControl] Failed to start server:", error);
|
||||
logger.info("[RemoteControl] Failed to start server:", error);
|
||||
throw new Error(error instanceof Error ? error.message : "Failed to start server");
|
||||
}
|
||||
}
|
||||
|
||||
public stopServer() {
|
||||
console.log("[RemoteControl] Stopping server...");
|
||||
logger.debug("[RemoteControl] Stopping server...");
|
||||
this.httpServer.stop();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { api, PlayRecord as ApiPlayRecord, Favorite as ApiFavorite } from "./api";
|
||||
import { storageConfig } from "./storageConfig";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('Storage');
|
||||
|
||||
// --- Storage Keys ---
|
||||
const STORAGE_KEYS = {
|
||||
@@ -9,6 +12,7 @@ const STORAGE_KEYS = {
|
||||
FAVORITES: "mytv_favorites",
|
||||
PLAY_RECORDS: "mytv_play_records",
|
||||
SEARCH_HISTORY: "mytv_search_history",
|
||||
LOGIN_CREDENTIALS: "mytv_login_credentials",
|
||||
} as const;
|
||||
|
||||
// --- Type Definitions (aligned with api.ts) ---
|
||||
@@ -22,6 +26,7 @@ export type Favorite = ApiFavorite;
|
||||
export interface PlayerSettings {
|
||||
introEndTime?: number;
|
||||
outroStartTime?: number;
|
||||
playbackRate?: number;
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
@@ -36,6 +41,11 @@ export interface AppSettings {
|
||||
m3uUrl: string;
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// --- Helper ---
|
||||
const generateKey = (source: string, id: string) => `${source}+${id}`;
|
||||
|
||||
@@ -46,24 +56,32 @@ export class PlayerSettingsManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_SETTINGS);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all player settings:", error);
|
||||
logger.info("Failed to get all player settings:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
static async get(source: string, id: string): Promise<PlayerSettings | null> {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get START - source: ${source}, id: ${id}`);
|
||||
|
||||
const allSettings = await this.getAll();
|
||||
return allSettings[generateKey(source, id)] || null;
|
||||
const result = allSettings[generateKey(source, id)] || null;
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get END - took ${(perfEnd - perfStart).toFixed(2)}ms, found: ${!!result}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async save(source: string, id: string, settings: PlayerSettings): Promise<void> {
|
||||
const allSettings = await this.getAll();
|
||||
const key = generateKey(source, id);
|
||||
// Only save if there are actual values to save
|
||||
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined) {
|
||||
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined || settings.playbackRate !== undefined) {
|
||||
allSettings[key] = { ...allSettings[key], ...settings };
|
||||
} else {
|
||||
// If both are undefined, remove the key
|
||||
// If all are undefined, remove the key
|
||||
delete allSettings[key];
|
||||
}
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
|
||||
@@ -92,7 +110,7 @@ export class FavoriteManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
|
||||
return data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all local favorites:", error);
|
||||
logger.info("Failed to get all local favorites:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -158,17 +176,27 @@ export class PlayRecordManager {
|
||||
}
|
||||
|
||||
static async getAll(): Promise<Record<string, PlayRecord>> {
|
||||
const perfStart = performance.now();
|
||||
const storageType = this.getStorageType();
|
||||
logger.info(`[PERF] PlayRecordManager.getAll START - storageType: ${storageType}`);
|
||||
|
||||
let apiRecords: Record<string, PlayRecord> = {};
|
||||
if (this.getStorageType() === "localstorage") {
|
||||
if (storageType === "localstorage") {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
|
||||
apiRecords = data ? JSON.parse(data) : {};
|
||||
} catch (error) {
|
||||
console.info("Failed to get all local play records:", error);
|
||||
logger.info("Failed to get all local play records:", error);
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
const apiStart = performance.now();
|
||||
logger.info(`[PERF] API getPlayRecords START`);
|
||||
|
||||
apiRecords = await api.getPlayRecords();
|
||||
|
||||
const apiEnd = performance.now();
|
||||
logger.info(`[PERF] API getPlayRecords END - took ${(apiEnd - apiStart).toFixed(2)}ms, records: ${Object.keys(apiRecords).length}`);
|
||||
}
|
||||
|
||||
const localSettings = await PlayerSettingsManager.getAll();
|
||||
@@ -179,6 +207,10 @@ export class PlayRecordManager {
|
||||
...localSettings[key],
|
||||
};
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.getAll END - took ${(perfEnd - perfStart).toFixed(2)}ms, total records: ${Object.keys(mergedRecords).length}`);
|
||||
|
||||
return mergedRecords;
|
||||
}
|
||||
|
||||
@@ -200,9 +232,18 @@ export class PlayRecordManager {
|
||||
}
|
||||
|
||||
static async get(source: string, id: string): Promise<PlayRecord | null> {
|
||||
const perfStart = performance.now();
|
||||
const key = generateKey(source, id);
|
||||
const storageType = this.getStorageType();
|
||||
logger.info(`[PERF] PlayRecordManager.get START - source: ${source}, id: ${id}, storageType: ${storageType}`);
|
||||
|
||||
const records = await this.getAll();
|
||||
return records[key] || null;
|
||||
const result = records[key] || null;
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.get END - took ${(perfEnd - perfStart).toFixed(2)}ms, found: ${!!result}`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async remove(source: string, id: string): Promise<void> {
|
||||
@@ -241,7 +282,7 @@ export class SearchHistoryManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (error) {
|
||||
console.info("Failed to get local search history:", error);
|
||||
logger.info("Failed to get local search history:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -286,7 +327,7 @@ export class SettingsManager {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
return data ? { ...defaultSettings, ...JSON.parse(data) } : defaultSettings;
|
||||
} catch (error) {
|
||||
console.info("Failed to get settings:", error);
|
||||
logger.info("Failed to get settings:", error);
|
||||
return defaultSettings;
|
||||
}
|
||||
}
|
||||
@@ -301,3 +342,32 @@ export class SettingsManager {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.SETTINGS);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LoginCredentialsManager (Uses AsyncStorage) ---
|
||||
export class LoginCredentialsManager {
|
||||
static async get(): Promise<LoginCredentials | null> {
|
||||
try {
|
||||
const data = await AsyncStorage.getItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
|
||||
return data ? JSON.parse(data) : null;
|
||||
} catch (error) {
|
||||
logger.info("Failed to get login credentials:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async save(credentials: LoginCredentials): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEYS.LOGIN_CREDENTIALS, JSON.stringify(credentials));
|
||||
} catch (error) {
|
||||
logger.error("Failed to save login credentials:", error);
|
||||
}
|
||||
}
|
||||
|
||||
static async clear(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
|
||||
} catch (error) {
|
||||
logger.error("Failed to clear login credentials:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import TcpSocket from 'react-native-tcp-socket';
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('TCPHttpServer');
|
||||
|
||||
const PORT = 12346;
|
||||
|
||||
@@ -59,7 +62,7 @@ class TCPHttpServer {
|
||||
|
||||
return { method, url, headers, body };
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Error parsing HTTP request:', error);
|
||||
logger.info('[TCPHttpServer] Error parsing HTTP request:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -108,14 +111,14 @@ class TCPHttpServer {
|
||||
}
|
||||
|
||||
if (this.isRunning) {
|
||||
console.log('[TCPHttpServer] Server is already running.');
|
||||
logger.debug('[TCPHttpServer] Server is already running.');
|
||||
return `http://${ipAddress}:${PORT}`;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
|
||||
console.log('[TCPHttpServer] Client connected');
|
||||
logger.debug('[TCPHttpServer] Client connected');
|
||||
|
||||
let requestData = '';
|
||||
|
||||
@@ -140,7 +143,7 @@ class TCPHttpServer {
|
||||
socket.write(errorResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Error handling request:', error);
|
||||
logger.info('[TCPHttpServer] Error handling request:', error);
|
||||
const errorResponse = this.formatHttpResponse({
|
||||
statusCode: 500,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
@@ -155,28 +158,28 @@ class TCPHttpServer {
|
||||
});
|
||||
|
||||
socket.on('error', (error: Error) => {
|
||||
console.info('[TCPHttpServer] Socket error:', error);
|
||||
logger.info('[TCPHttpServer] Socket error:', error);
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
console.log('[TCPHttpServer] Client disconnected');
|
||||
logger.debug('[TCPHttpServer] Client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
this.server.listen({ port: PORT, host: '0.0.0.0' }, () => {
|
||||
console.log(`[TCPHttpServer] Server listening on ${ipAddress}:${PORT}`);
|
||||
logger.debug(`[TCPHttpServer] Server listening on ${ipAddress}:${PORT}`);
|
||||
this.isRunning = true;
|
||||
resolve(`http://${ipAddress}:${PORT}`);
|
||||
});
|
||||
|
||||
this.server.on('error', (error: Error) => {
|
||||
console.info('[TCPHttpServer] Server error:', error);
|
||||
logger.info('[TCPHttpServer] Server error:', error);
|
||||
this.isRunning = false;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.info('[TCPHttpServer] Failed to start server:', error);
|
||||
logger.info('[TCPHttpServer] Failed to start server:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
@@ -187,7 +190,7 @@ class TCPHttpServer {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
this.isRunning = false;
|
||||
console.log('[TCPHttpServer] Server stopped');
|
||||
logger.debug('[TCPHttpServer] Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import FileViewer from "react-native-file-viewer";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { version as currentVersion } from "../package.json";
|
||||
import { UPDATE_CONFIG } from "../constants/UpdateConfig";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('UpdateService');
|
||||
|
||||
interface VersionInfo {
|
||||
version: string;
|
||||
@@ -47,7 +50,7 @@ class UpdateService {
|
||||
};
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error);
|
||||
logger.info(`Error checking version (attempt ${retries}/${maxRetries}):`, error);
|
||||
|
||||
if (retries === maxRetries) {
|
||||
Toast.show({ type: "error", text1: "检查更新失败", text2: "无法获取版本信息,请检查网络连接" });
|
||||
@@ -86,14 +89,14 @@ class UpdateService {
|
||||
for (const file of filesToDelete) {
|
||||
try {
|
||||
await ReactNativeBlobUtil.fs.unlink(`${dirs.DocumentDir}/${file}`);
|
||||
console.log(`Cleaned old APK file: ${file}`);
|
||||
logger.debug(`Cleaned old APK file: ${file}`);
|
||||
} catch (deleteError) {
|
||||
console.warn(`Failed to delete old APK file ${file}:`, deleteError);
|
||||
logger.warn(`Failed to delete old APK file ${file}:`, deleteError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean old APK files:', error);
|
||||
logger.warn('Failed to clean old APK files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +141,11 @@ class UpdateService {
|
||||
}
|
||||
|
||||
const res = await task;
|
||||
console.log(`APK downloaded successfully: ${filePath}`);
|
||||
logger.debug(`APK downloaded successfully: ${filePath}`);
|
||||
return res.path();
|
||||
} catch (error) {
|
||||
retries++;
|
||||
console.info(`Error downloading APK (attempt ${retries}/${maxRetries}):`, error);
|
||||
logger.info(`Error downloading APK (attempt ${retries}/${maxRetries}):`, error);
|
||||
|
||||
if (retries === maxRetries) {
|
||||
Toast.show({ type: "error", text1: "下载失败", text2: "APK下载失败,请检查网络连接" });
|
||||
@@ -173,7 +176,7 @@ class UpdateService {
|
||||
displayName: "OrionTV Update",
|
||||
});
|
||||
} catch (error) {
|
||||
console.info("Error installing APK:", error);
|
||||
logger.info("Error installing APK:", error);
|
||||
|
||||
// 提供更详细的错误信息
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -3,6 +3,9 @@ import Cookies from "@react-native-cookies/cookies";
|
||||
import { api } from "@/services/api";
|
||||
import { useSettingsStore } from "./settingsStore";
|
||||
import Toast from "react-native-toast-message";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('AuthStore');
|
||||
|
||||
interface AuthState {
|
||||
isLoggedIn: boolean;
|
||||
@@ -69,7 +72,7 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to check login status:", error);
|
||||
logger.error("Failed to check login status:", error);
|
||||
if (error instanceof Error && error.message === "UNAUTHORIZED") {
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
} else {
|
||||
@@ -82,7 +85,7 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
await Cookies.clearAll();
|
||||
set({ isLoggedIn: false, isLoginModalVisible: true });
|
||||
} catch (error) {
|
||||
console.info("Failed to logout:", error);
|
||||
logger.error("Failed to logout:", error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,9 @@ import { SearchResult, api } from "@/services/api";
|
||||
import { getResolutionFromM3U8 } from "@/services/m3u8";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { FavoriteManager } from "@/services/storage";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('DetailStore');
|
||||
|
||||
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
|
||||
|
||||
@@ -16,11 +19,14 @@ interface DetailState {
|
||||
allSourcesLoaded: boolean;
|
||||
controller: AbortController | null;
|
||||
isFavorited: boolean;
|
||||
failedSources: Set<string>; // 记录失败的source列表
|
||||
|
||||
init: (q: string, preferredSource?: string, id?: string) => Promise<void>;
|
||||
setDetail: (detail: SearchResultWithResolution) => void;
|
||||
setDetail: (detail: SearchResultWithResolution) => Promise<void>;
|
||||
abort: () => void;
|
||||
toggleFavorite: () => Promise<void>;
|
||||
markSourceAsFailed: (source: string, reason: string) => void;
|
||||
getNextAvailableSource: (currentSource: string, episodeIndex: number) => SearchResultWithResolution | null;
|
||||
}
|
||||
|
||||
const useDetailStore = create<DetailState>((set, get) => ({
|
||||
@@ -33,8 +39,12 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
allSourcesLoaded: false,
|
||||
controller: null,
|
||||
isFavorited: false,
|
||||
failedSources: new Set(),
|
||||
|
||||
init: async (q, preferredSource, id) => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init START - q: ${q}, preferredSource: ${preferredSource}, id: ${id}`);
|
||||
|
||||
const { controller: oldController } = get();
|
||||
if (oldController) {
|
||||
oldController.abort();
|
||||
@@ -55,21 +65,30 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
const { videoSource } = useSettingsStore.getState();
|
||||
|
||||
const processAndSetResults = async (results: SearchResult[], merge = false) => {
|
||||
const resolutionStart = performance.now();
|
||||
logger.info(`[PERF] Resolution detection START - processing ${results.length} sources`);
|
||||
|
||||
const resultsWithResolution = await Promise.all(
|
||||
results.map(async (searchResult) => {
|
||||
let resolution;
|
||||
const m3u8Start = performance.now();
|
||||
try {
|
||||
if (searchResult.episodes && searchResult.episodes.length > 0) {
|
||||
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
|
||||
}
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
console.info(`Failed to get resolution for ${searchResult.source_name}`, e);
|
||||
logger.info(`Failed to get resolution for ${searchResult.source_name}`, e);
|
||||
}
|
||||
}
|
||||
const m3u8End = performance.now();
|
||||
logger.info(`[PERF] M3U8 resolution for ${searchResult.source_name}: ${(m3u8End - m3u8Start).toFixed(2)}ms (${resolution || 'failed'})`);
|
||||
return { ...searchResult, resolution };
|
||||
})
|
||||
);
|
||||
|
||||
const resolutionEnd = performance.now();
|
||||
logger.info(`[PERF] Resolution detection COMPLETE - took ${(resolutionEnd - resolutionStart).toFixed(2)}ms`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
@@ -93,59 +112,205 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
try {
|
||||
// Optimization for favorite navigation
|
||||
if (preferredSource && id) {
|
||||
const { results: preferredResult } = await api.searchVideo(q, preferredSource, signal);
|
||||
const searchPreferredStart = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (preferred) START - source: ${preferredSource}, query: "${q}"`);
|
||||
|
||||
let preferredResult: SearchResult[] = [];
|
||||
let preferredSearchError: any = null;
|
||||
|
||||
try {
|
||||
const response = await api.searchVideo(q, preferredSource, signal);
|
||||
preferredResult = response.results;
|
||||
} catch (error) {
|
||||
preferredSearchError = error;
|
||||
logger.error(`[ERROR] API searchVideo (preferred) FAILED - source: ${preferredSource}, error:`, error);
|
||||
}
|
||||
|
||||
const searchPreferredEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (preferred) END - took ${(searchPreferredEnd - searchPreferredStart).toFixed(2)}ms, results: ${preferredResult.length}, error: ${!!preferredSearchError}`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
// 检查preferred source结果
|
||||
if (preferredResult.length > 0) {
|
||||
logger.info(`[SUCCESS] Preferred source "${preferredSource}" found ${preferredResult.length} results for "${q}"`);
|
||||
await processAndSetResults(preferredResult, false);
|
||||
set({ loading: false });
|
||||
} else {
|
||||
// 降级策略:preferred source失败时立即尝试所有源
|
||||
if (preferredSearchError) {
|
||||
logger.warn(`[FALLBACK] Preferred source "${preferredSource}" failed with error, trying all sources immediately`);
|
||||
} else {
|
||||
logger.warn(`[FALLBACK] Preferred source "${preferredSource}" returned 0 results for "${q}", trying all sources immediately`);
|
||||
}
|
||||
|
||||
// 立即尝试所有源,不再依赖后台搜索
|
||||
const fallbackStart = performance.now();
|
||||
logger.info(`[PERF] FALLBACK search (all sources) START - query: "${q}"`);
|
||||
|
||||
try {
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
const fallbackEnd = performance.now();
|
||||
logger.info(`[PERF] FALLBACK search END - took ${(fallbackEnd - fallbackStart).toFixed(2)}ms, total results: ${allResults.length}`);
|
||||
|
||||
const filteredResults = allResults.filter(item => item.title === q);
|
||||
logger.info(`[FALLBACK] Filtered results: ${filteredResults.length} matches for "${q}"`);
|
||||
|
||||
if (filteredResults.length > 0) {
|
||||
logger.info(`[SUCCESS] FALLBACK search found results, proceeding with ${filteredResults[0].source_name}`);
|
||||
await processAndSetResults(filteredResults, false);
|
||||
set({ loading: false });
|
||||
} else {
|
||||
logger.error(`[ERROR] FALLBACK search found no matching results for "${q}"`);
|
||||
set({
|
||||
error: `未找到 "${q}" 的播放源,请检查标题或稍后重试`,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
logger.error(`[ERROR] FALLBACK search FAILED:`, fallbackError);
|
||||
set({
|
||||
error: `搜索失败:${fallbackError instanceof Error ? fallbackError.message : '网络错误,请稍后重试'}`,
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 后台搜索(如果preferred source成功的话)
|
||||
if (preferredResult.length > 0) {
|
||||
const searchAllStart = performance.now();
|
||||
logger.info(`[PERF] API searchVideos (background) START`);
|
||||
|
||||
try {
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
|
||||
const searchAllEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideos (background) END - took ${(searchAllEnd - searchAllStart).toFixed(2)}ms, results: ${allResults.length}`);
|
||||
|
||||
if (signal.aborted) return;
|
||||
await processAndSetResults(allResults.filter(item => item.title === q), true);
|
||||
} catch (backgroundError) {
|
||||
logger.warn(`[WARN] Background search failed, but preferred source already succeeded:`, backgroundError);
|
||||
}
|
||||
}
|
||||
// Then load all others in background
|
||||
const { results: allResults } = await api.searchVideos(q);
|
||||
if (signal.aborted) return;
|
||||
await processAndSetResults(allResults, true);
|
||||
} else {
|
||||
// Standard navigation: fetch resources, then fetch details one by one
|
||||
const allResources = await api.getResources(signal);
|
||||
const enabledResources = videoSource.enabledAll
|
||||
? allResources
|
||||
: allResources.filter((r) => videoSource.sources[r.key]);
|
||||
const resourcesStart = performance.now();
|
||||
logger.info(`[PERF] API getResources START - query: "${q}"`);
|
||||
|
||||
try {
|
||||
const allResources = await api.getResources(signal);
|
||||
|
||||
const resourcesEnd = performance.now();
|
||||
logger.info(`[PERF] API getResources END - took ${(resourcesEnd - resourcesStart).toFixed(2)}ms, resources: ${allResources.length}`);
|
||||
|
||||
const enabledResources = videoSource.enabledAll
|
||||
? allResources
|
||||
: allResources.filter((r) => videoSource.sources[r.key]);
|
||||
|
||||
let firstResultFound = false;
|
||||
const searchPromises = enabledResources.map(async (resource) => {
|
||||
try {
|
||||
const { results } = await api.searchVideo(q, resource.key, signal);
|
||||
if (results.length > 0) {
|
||||
await processAndSetResults(results, true);
|
||||
if (!firstResultFound) {
|
||||
set({ loading: false }); // Stop loading indicator on first result
|
||||
firstResultFound = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.info(`Failed to fetch from ${resource.name}:`, error);
|
||||
logger.info(`[PERF] Enabled resources: ${enabledResources.length}/${allResources.length}`);
|
||||
|
||||
if (enabledResources.length === 0) {
|
||||
logger.error(`[ERROR] No enabled resources available for search`);
|
||||
set({
|
||||
error: "没有可用的视频源,请检查设置或联系管理员",
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
let firstResultFound = false;
|
||||
let totalResults = 0;
|
||||
const searchPromises = enabledResources.map(async (resource) => {
|
||||
try {
|
||||
const searchStart = performance.now();
|
||||
const { results } = await api.searchVideo(q, resource.key, signal);
|
||||
const searchEnd = performance.now();
|
||||
logger.info(`[PERF] API searchVideo (${resource.name}) took ${(searchEnd - searchStart).toFixed(2)}ms, results: ${results.length}`);
|
||||
|
||||
if (results.length > 0) {
|
||||
totalResults += results.length;
|
||||
logger.info(`[SUCCESS] Source "${resource.name}" found ${results.length} results for "${q}"`);
|
||||
await processAndSetResults(results, true);
|
||||
if (!firstResultFound) {
|
||||
set({ loading: false }); // Stop loading indicator on first result
|
||||
firstResultFound = true;
|
||||
logger.info(`[SUCCESS] First result found from "${resource.name}", stopping loading indicator`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[WARN] Source "${resource.name}" returned 0 results for "${q}"`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[ERROR] Failed to fetch from ${resource.name}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
// 检查是否找到任何结果
|
||||
if (totalResults === 0) {
|
||||
logger.error(`[ERROR] All sources returned 0 results for "${q}"`);
|
||||
set({
|
||||
error: `未找到 "${q}" 的播放源,请尝试其他关键词或稍后重试`,
|
||||
loading: false
|
||||
});
|
||||
} else {
|
||||
logger.info(`[SUCCESS] Standard search completed, total results: ${totalResults}`);
|
||||
}
|
||||
} catch (resourceError) {
|
||||
logger.error(`[ERROR] Failed to get resources:`, resourceError);
|
||||
set({
|
||||
error: `获取视频源失败:${resourceError instanceof Error ? resourceError.message : '网络错误,请稍后重试'}`,
|
||||
loading: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (get().searchResults.length === 0) {
|
||||
set({ error: "未找到任何播放源" });
|
||||
const favoriteCheckStart = performance.now();
|
||||
const finalState = get();
|
||||
|
||||
// 最终检查:如果所有搜索都完成但仍然没有结果
|
||||
if (finalState.searchResults.length === 0 && !finalState.error) {
|
||||
logger.error(`[ERROR] All search attempts completed but no results found for "${q}"`);
|
||||
set({ error: `未找到 "${q}" 的播放源,请检查标题拼写或稍后重试` });
|
||||
} else if (finalState.searchResults.length > 0) {
|
||||
logger.info(`[SUCCESS] DetailStore.init completed successfully with ${finalState.searchResults.length} sources`);
|
||||
}
|
||||
|
||||
if (get().detail) {
|
||||
const { source, id } = get().detail!;
|
||||
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||
set({ isFavorited });
|
||||
if (finalState.detail) {
|
||||
const { source, id } = finalState.detail;
|
||||
logger.info(`[INFO] Checking favorite status for source: ${source}, id: ${id}`);
|
||||
try {
|
||||
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
|
||||
set({ isFavorited });
|
||||
logger.info(`[INFO] Favorite status: ${isFavorited}`);
|
||||
} catch (favoriteError) {
|
||||
logger.warn(`[WARN] Failed to check favorite status:`, favoriteError);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`[WARN] No detail found after all search attempts for "${q}"`);
|
||||
}
|
||||
|
||||
const favoriteCheckEnd = performance.now();
|
||||
logger.info(`[PERF] Favorite check took ${(favoriteCheckEnd - favoriteCheckStart).toFixed(2)}ms`);
|
||||
|
||||
} catch (e) {
|
||||
if ((e as Error).name !== "AbortError") {
|
||||
set({ error: e instanceof Error ? e.message : "获取数据失败" });
|
||||
logger.error(`[ERROR] DetailStore.init caught unexpected error:`, e);
|
||||
const errorMessage = e instanceof Error ? e.message : "获取数据失败";
|
||||
set({ error: `搜索失败:${errorMessage}` });
|
||||
} else {
|
||||
logger.info(`[INFO] DetailStore.init aborted by user`);
|
||||
}
|
||||
} finally {
|
||||
if (!signal.aborted) {
|
||||
set({ loading: false, allSourcesLoaded: true });
|
||||
logger.info(`[INFO] DetailStore.init cleanup completed`);
|
||||
}
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -178,6 +343,64 @@ const useDetailStore = create<DetailState>((set, get) => ({
|
||||
const newIsFavorited = await FavoriteManager.toggle(source, id.toString(), favoriteItem);
|
||||
set({ isFavorited: newIsFavorited });
|
||||
},
|
||||
|
||||
markSourceAsFailed: (source: string, reason: string) => {
|
||||
const { failedSources } = get();
|
||||
const newFailedSources = new Set(failedSources);
|
||||
newFailedSources.add(source);
|
||||
|
||||
logger.warn(`[SOURCE_FAILED] Marking source "${source}" as failed due to: ${reason}`);
|
||||
logger.info(`[SOURCE_FAILED] Total failed sources: ${newFailedSources.size}`);
|
||||
|
||||
set({ failedSources: newFailedSources });
|
||||
},
|
||||
|
||||
getNextAvailableSource: (currentSource: string, episodeIndex: number) => {
|
||||
const { searchResults, failedSources } = get();
|
||||
|
||||
logger.info(`[SOURCE_SELECTION] Looking for alternative to "${currentSource}" for episode ${episodeIndex + 1}`);
|
||||
logger.info(`[SOURCE_SELECTION] Failed sources: [${Array.from(failedSources).join(', ')}]`);
|
||||
|
||||
// 过滤掉当前source和已失败的sources
|
||||
const availableSources = searchResults.filter(result =>
|
||||
result.source !== currentSource &&
|
||||
!failedSources.has(result.source) &&
|
||||
result.episodes &&
|
||||
result.episodes.length > episodeIndex
|
||||
);
|
||||
|
||||
logger.info(`[SOURCE_SELECTION] Available sources: ${availableSources.length}`);
|
||||
availableSources.forEach(source => {
|
||||
logger.info(`[SOURCE_SELECTION] - ${source.source} (${source.source_name}): ${source.episodes?.length || 0} episodes`);
|
||||
});
|
||||
|
||||
if (availableSources.length === 0) {
|
||||
logger.error(`[SOURCE_SELECTION] No available sources for episode ${episodeIndex + 1}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 优先选择有高分辨率的source
|
||||
const sortedSources = availableSources.sort((a, b) => {
|
||||
const aResolution = a.resolution || '';
|
||||
const bResolution = b.resolution || '';
|
||||
|
||||
// 优先级: 1080p > 720p > 其他 > 无分辨率
|
||||
const resolutionPriority = (res: string) => {
|
||||
if (res.includes('1080')) return 4;
|
||||
if (res.includes('720')) return 3;
|
||||
if (res.includes('480')) return 2;
|
||||
if (res.includes('360')) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
return resolutionPriority(bResolution) - resolutionPriority(aResolution);
|
||||
});
|
||||
|
||||
const selectedSource = sortedSources[0];
|
||||
logger.info(`[SOURCE_SELECTION] Selected fallback source: ${selectedSource.source} (${selectedSource.source_name}) with resolution: ${selectedSource.resolution || 'unknown'}`);
|
||||
|
||||
return selectedSource;
|
||||
},
|
||||
}));
|
||||
|
||||
export const sourcesSelector = (state: DetailState) => state.sources;
|
||||
|
||||
@@ -166,11 +166,11 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
if (pageStart === 0) {
|
||||
// 缓存新数据
|
||||
dataCache.set(cacheKey, newItems);
|
||||
set((state) => ({
|
||||
set({
|
||||
contentData: newItems,
|
||||
pageStart: result.list.length,
|
||||
hasMore: true,
|
||||
}));
|
||||
});
|
||||
} else {
|
||||
// 增量加载时不缓存,直接追加
|
||||
set((state) => ({
|
||||
@@ -187,11 +187,25 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
set({ hasMore: false });
|
||||
}
|
||||
} catch (err: any) {
|
||||
let errorMessage = "加载失败,请重试";
|
||||
|
||||
if (err.message === "API_URL_NOT_SET") {
|
||||
set({ error: "请点击右上角设置按钮,配置您的服务器地址" });
|
||||
} else {
|
||||
set({ error: "加载失败,请重试" });
|
||||
errorMessage = "请点击右上角设置按钮,配置您的服务器地址";
|
||||
} else if (err.message === "UNAUTHORIZED") {
|
||||
errorMessage = "认证失败,请重新登录";
|
||||
} else if (err.message.includes("Network")) {
|
||||
errorMessage = "网络连接失败,请检查网络连接";
|
||||
} else if (err.message.includes("timeout")) {
|
||||
errorMessage = "请求超时,请检查网络或服务器状态";
|
||||
} else if (err.message.includes("404")) {
|
||||
errorMessage = "服务器API路径不正确,请检查服务器配置";
|
||||
} else if (err.message.includes("500")) {
|
||||
errorMessage = "服务器内部错误,请联系管理员";
|
||||
} else if (err.message.includes("403")) {
|
||||
errorMessage = "访问被拒绝,请检查权限设置";
|
||||
}
|
||||
|
||||
set({ error: errorMessage });
|
||||
} finally {
|
||||
set({ loading: false, loadingMore: false });
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@ import { create } from "zustand";
|
||||
import Toast from "react-native-toast-message";
|
||||
import { AVPlaybackStatus, Video } from "expo-av";
|
||||
import { RefObject } from "react";
|
||||
import { PlayRecord, PlayRecordManager } from "@/services/storage";
|
||||
import { PlayRecord, PlayRecordManager, PlayerSettingsManager } from "@/services/storage";
|
||||
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('PlayerStore');
|
||||
|
||||
interface Episode {
|
||||
url: string;
|
||||
@@ -19,11 +22,13 @@ interface PlayerState {
|
||||
showControls: boolean;
|
||||
showEpisodeModal: boolean;
|
||||
showSourceModal: boolean;
|
||||
showSpeedModal: boolean;
|
||||
showNextEpisodeOverlay: boolean;
|
||||
isSeeking: boolean;
|
||||
seekPosition: number;
|
||||
progressPosition: number;
|
||||
initialPosition: number;
|
||||
playbackRate: number;
|
||||
introEndTime?: number;
|
||||
outroStartTime?: number;
|
||||
setVideoRef: (ref: RefObject<Video>) => void;
|
||||
@@ -42,7 +47,9 @@ interface PlayerState {
|
||||
setShowControls: (show: boolean) => void;
|
||||
setShowEpisodeModal: (show: boolean) => void;
|
||||
setShowSourceModal: (show: boolean) => void;
|
||||
setShowSpeedModal: (show: boolean) => void;
|
||||
setShowNextEpisodeOverlay: (show: boolean) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setIntroEndTime: () => void;
|
||||
setOutroStartTime: () => void;
|
||||
reset: () => void;
|
||||
@@ -50,6 +57,7 @@ interface PlayerState {
|
||||
_isRecordSaveThrottled: boolean;
|
||||
// Internal helper
|
||||
_savePlayRecord: (updates?: Partial<PlayRecord>, options?: { immediate?: boolean }) => void;
|
||||
handleVideoError: (errorType: 'ssl' | 'network' | 'other', failedUrl: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
@@ -61,11 +69,13 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
showControls: false,
|
||||
showEpisodeModal: false,
|
||||
showSourceModal: false,
|
||||
showSpeedModal: false,
|
||||
showNextEpisodeOverlay: false,
|
||||
isSeeking: false,
|
||||
seekPosition: 0,
|
||||
progressPosition: 0,
|
||||
initialPosition: 0,
|
||||
playbackRate: 1.0,
|
||||
introEndTime: undefined,
|
||||
outroStartTime: undefined,
|
||||
_seekTimeout: undefined,
|
||||
@@ -74,40 +84,156 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setVideoRef: (ref) => set({ videoRef: ref }),
|
||||
|
||||
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
|
||||
const perfStart = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo START - source: ${source}, id: ${id}, title: ${title}`);
|
||||
|
||||
let detail = useDetailStore.getState().detail;
|
||||
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 如果有detail,使用detail的source获取episodes;否则使用传入的source
|
||||
if (detail && detail.source) {
|
||||
logger.info(`[INFO] Using existing detail source "${detail.source}" to get episodes`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
} else {
|
||||
logger.info(`[INFO] No existing detail, using provided source "${source}" to get episodes`);
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
}
|
||||
|
||||
set({
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
if (!detail || !episodes || episodes.length === 0 || detail.title !== title) {
|
||||
const needsDetailInit = !detail || !episodes || episodes.length === 0 || detail.title !== title;
|
||||
logger.info(`[PERF] Detail check - needsInit: ${needsDetailInit}, hasDetail: ${!!detail}, episodesCount: ${episodes?.length || 0}`);
|
||||
|
||||
if (needsDetailInit) {
|
||||
const detailInitStart = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init START - ${title}`);
|
||||
|
||||
await useDetailStore.getState().init(title, source, id);
|
||||
|
||||
const detailInitEnd = performance.now();
|
||||
logger.info(`[PERF] DetailStore.init END - took ${(detailInitEnd - detailInitStart).toFixed(2)}ms`);
|
||||
|
||||
detail = useDetailStore.getState().detail;
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
|
||||
if (!detail) {
|
||||
console.info("Detail not found after initialization");
|
||||
logger.error(`[ERROR] Detail not found after initialization for "${title}" (source: ${source}, id: ${id})`);
|
||||
|
||||
// 检查DetailStore的错误状态
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
if (detailStoreState.error) {
|
||||
logger.error(`[ERROR] DetailStore error: ${detailStoreState.error}`);
|
||||
set({
|
||||
isLoading: false,
|
||||
// 可以选择在这里设置一个错误状态,但playerStore可能没有error字段
|
||||
});
|
||||
} else {
|
||||
logger.error(`[ERROR] DetailStore init completed but no detail found and no error reported`);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用DetailStore找到的实际source来获取episodes,而不是原始的preferredSource
|
||||
logger.info(`[INFO] Using actual source "${detail.source}" instead of preferred source "${source}"`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.error(`[ERROR] No episodes found for "${title}" from source "${detail.source}" (${detail.source_name})`);
|
||||
|
||||
// 尝试从searchResults中直接获取episodes
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
logger.info(`[INFO] Available sources in searchResults: ${detailStoreState.searchResults.map(r => `${r.source}(${r.episodes?.length || 0} episodes)`).join(', ')}`);
|
||||
|
||||
// 如果当前source没有episodes,尝试使用第一个有episodes的source
|
||||
const sourceWithEpisodes = detailStoreState.searchResults.find(r => r.episodes && r.episodes.length > 0);
|
||||
if (sourceWithEpisodes) {
|
||||
logger.info(`[FALLBACK] Using alternative source "${sourceWithEpisodes.source}" with ${sourceWithEpisodes.episodes.length} episodes`);
|
||||
episodes = sourceWithEpisodes.episodes;
|
||||
// 更新detail为有episodes的source
|
||||
detail = sourceWithEpisodes;
|
||||
} else {
|
||||
logger.error(`[ERROR] No source with episodes found in searchResults`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[SUCCESS] Detail and episodes loaded - source: ${detail.source_name}, episodes: ${episodes.length}`);
|
||||
} else {
|
||||
logger.info(`[PERF] Skipping DetailStore.init - using cached data`);
|
||||
|
||||
// 即使是缓存的数据,也要确保使用正确的source获取episodes
|
||||
if (detail && detail.source && detail.source !== source) {
|
||||
logger.info(`[INFO] Cached detail source "${detail.source}" differs from provided source "${source}", updating episodes`);
|
||||
episodes = episodesSelectorBySource(detail.source)(useDetailStore.getState());
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.warn(`[WARN] Cached detail source "${detail.source}" has no episodes, trying provided source "${source}"`);
|
||||
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最终验证:确保我们有有效的detail和episodes数据
|
||||
if (!detail) {
|
||||
logger.error(`[ERROR] Final check failed: detail is null`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!episodes || episodes.length === 0) {
|
||||
logger.error(`[ERROR] Final check failed: no episodes available for source "${detail.source}" (${detail.source_name})`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[SUCCESS] Final validation passed - detail: ${detail.source_name}, episodes: ${episodes.length}`);
|
||||
|
||||
try {
|
||||
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
|
||||
const storageStart = performance.now();
|
||||
logger.info(`[PERF] Storage operations START`);
|
||||
|
||||
const playRecord = await PlayRecordManager.get(detail!.source, detail!.id.toString());
|
||||
const storagePlayRecordEnd = performance.now();
|
||||
logger.info(`[PERF] PlayRecordManager.get took ${(storagePlayRecordEnd - storageStart).toFixed(2)}ms`);
|
||||
|
||||
const playerSettings = await PlayerSettingsManager.get(detail!.source, detail!.id.toString());
|
||||
const storageEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerSettingsManager.get took ${(storageEnd - storagePlayRecordEnd).toFixed(2)}ms`);
|
||||
logger.info(`[PERF] Total storage operations took ${(storageEnd - storageStart).toFixed(2)}ms`);
|
||||
|
||||
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
|
||||
const savedPlaybackRate = playerSettings?.playbackRate || 1.0;
|
||||
|
||||
const episodesMappingStart = performance.now();
|
||||
const mappedEpisodes = episodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
}));
|
||||
const episodesMappingEnd = performance.now();
|
||||
logger.info(`[PERF] Episodes mapping (${episodes.length} episodes) took ${(episodesMappingEnd - episodesMappingStart).toFixed(2)}ms`);
|
||||
|
||||
set({
|
||||
isLoading: false,
|
||||
currentEpisodeIndex: episodeIndex,
|
||||
initialPosition: position || initialPositionFromRecord,
|
||||
episodes: episodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
})),
|
||||
introEndTime: playRecord?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime,
|
||||
playbackRate: savedPlaybackRate,
|
||||
episodes: mappedEpisodes,
|
||||
introEndTime: playRecord?.introEndTime || playerSettings?.introEndTime,
|
||||
outroStartTime: playRecord?.outroStartTime || playerSettings?.outroStartTime,
|
||||
});
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo COMPLETE - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
|
||||
} catch (error) {
|
||||
console.info("Failed to load play record", error);
|
||||
logger.debug("Failed to load play record", error);
|
||||
set({ isLoading: false });
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[PERF] PlayerStore.loadVideo ERROR - total time: ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -124,7 +250,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
try {
|
||||
await videoRef?.current?.replayAsync();
|
||||
} catch (error) {
|
||||
console.info("Failed to replay video:", error);
|
||||
logger.debug("Failed to replay video:", error);
|
||||
Toast.show({ type: "error", text1: "播放失败" });
|
||||
}
|
||||
}
|
||||
@@ -140,7 +266,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
await videoRef?.current?.playAsync();
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Failed to toggle play/pause:", error);
|
||||
logger.debug("Failed to toggle play/pause:", error);
|
||||
Toast.show({ type: "error", text1: "操作失败" });
|
||||
}
|
||||
}
|
||||
@@ -154,7 +280,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
try {
|
||||
await videoRef?.current?.setPositionAsync(newPosition);
|
||||
} catch (error) {
|
||||
console.info("Failed to seek video:", error);
|
||||
logger.debug("Failed to seek video:", error);
|
||||
Toast.show({ type: "error", text1: "快进/快退失败" });
|
||||
}
|
||||
|
||||
@@ -260,7 +386,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
handlePlaybackStatusUpdate: (newStatus) => {
|
||||
if (!newStatus.isLoaded) {
|
||||
if (newStatus.error) {
|
||||
console.info(`Playback Error: ${newStatus.error}`);
|
||||
logger.debug(`Playback Error: ${newStatus.error}`);
|
||||
}
|
||||
set({ status: newStatus });
|
||||
return;
|
||||
@@ -305,8 +431,26 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
setShowControls: (show) => set({ showControls: show }),
|
||||
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
|
||||
setShowSourceModal: (show) => set({ showSourceModal: show }),
|
||||
setShowSpeedModal: (show) => set({ showSpeedModal: show }),
|
||||
setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }),
|
||||
|
||||
setPlaybackRate: async (rate) => {
|
||||
const { videoRef } = get();
|
||||
const detail = useDetailStore.getState().detail;
|
||||
|
||||
try {
|
||||
await videoRef?.current?.setRateAsync(rate, true);
|
||||
set({ playbackRate: rate });
|
||||
|
||||
// Save the playback rate preference
|
||||
if (detail) {
|
||||
await PlayerSettingsManager.save(detail.source, detail.id.toString(), { playbackRate: rate });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("Failed to set playback rate:", error);
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
episodes: [],
|
||||
@@ -316,18 +460,113 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
showControls: false,
|
||||
showEpisodeModal: false,
|
||||
showSourceModal: false,
|
||||
showSpeedModal: false,
|
||||
showNextEpisodeOverlay: false,
|
||||
initialPosition: 0,
|
||||
playbackRate: 1.0,
|
||||
introEndTime: undefined,
|
||||
outroStartTime: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
handleVideoError: async (errorType: 'ssl' | 'network' | 'other', failedUrl: string) => {
|
||||
const perfStart = performance.now();
|
||||
logger.error(`[VIDEO_ERROR] Handling ${errorType} error for URL: ${failedUrl}`);
|
||||
|
||||
const detailStoreState = useDetailStore.getState();
|
||||
const { detail } = detailStoreState;
|
||||
const { currentEpisodeIndex } = get();
|
||||
|
||||
if (!detail) {
|
||||
logger.error(`[VIDEO_ERROR] Cannot fallback - no detail available`);
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// 标记当前source为失败
|
||||
const currentSource = detail.source;
|
||||
const errorReason = `${errorType} error: ${failedUrl.substring(0, 100)}...`;
|
||||
useDetailStore.getState().markSourceAsFailed(currentSource, errorReason);
|
||||
|
||||
// 获取下一个可用的source
|
||||
const fallbackSource = useDetailStore.getState().getNextAvailableSource(currentSource, currentEpisodeIndex);
|
||||
|
||||
if (!fallbackSource) {
|
||||
logger.error(`[VIDEO_ERROR] No fallback sources available for episode ${currentEpisodeIndex + 1}`);
|
||||
Toast.show({
|
||||
type: "error",
|
||||
text1: "播放失败",
|
||||
text2: "所有播放源都不可用,请稍后重试"
|
||||
});
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[VIDEO_ERROR] Switching to fallback source: ${fallbackSource.source} (${fallbackSource.source_name})`);
|
||||
|
||||
try {
|
||||
// 更新DetailStore的当前detail为fallback source
|
||||
await useDetailStore.getState().setDetail(fallbackSource);
|
||||
|
||||
// 重新加载当前集数的episodes
|
||||
const newEpisodes = fallbackSource.episodes || [];
|
||||
if (newEpisodes.length > currentEpisodeIndex) {
|
||||
const mappedEpisodes = newEpisodes.map((ep, index) => ({
|
||||
url: ep,
|
||||
title: `第 ${index + 1} 集`,
|
||||
}));
|
||||
|
||||
set({
|
||||
episodes: mappedEpisodes,
|
||||
isLoading: false, // 让Video组件重新渲染
|
||||
});
|
||||
|
||||
const perfEnd = performance.now();
|
||||
logger.info(`[VIDEO_ERROR] Successfully switched to fallback source in ${(perfEnd - perfStart).toFixed(2)}ms`);
|
||||
logger.info(`[VIDEO_ERROR] New episode URL: ${newEpisodes[currentEpisodeIndex].substring(0, 100)}...`);
|
||||
|
||||
Toast.show({
|
||||
type: "success",
|
||||
text1: "已切换播放源",
|
||||
text2: `正在使用 ${fallbackSource.source_name}`
|
||||
});
|
||||
} else {
|
||||
logger.error(`[VIDEO_ERROR] Fallback source doesn't have episode ${currentEpisodeIndex + 1}`);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[VIDEO_ERROR] Failed to switch to fallback source:`, error);
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export default usePlayerStore;
|
||||
|
||||
export const selectCurrentEpisode = (state: PlayerState) => {
|
||||
if (state.episodes.length > state.currentEpisodeIndex) {
|
||||
return state.episodes[state.currentEpisodeIndex];
|
||||
// 增强数据安全性检查
|
||||
if (
|
||||
state.episodes &&
|
||||
Array.isArray(state.episodes) &&
|
||||
state.episodes.length > 0 &&
|
||||
state.currentEpisodeIndex >= 0 &&
|
||||
state.currentEpisodeIndex < state.episodes.length
|
||||
) {
|
||||
const episode = state.episodes[state.currentEpisodeIndex];
|
||||
// 确保episode有有效的URL
|
||||
if (episode && episode.url && episode.url.trim() !== "") {
|
||||
return episode;
|
||||
} else {
|
||||
// 仅在调试模式下打印
|
||||
if (__DEV__) {
|
||||
logger.debug(`[PERF] selectCurrentEpisode - episode found but invalid URL: ${episode?.url}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 仅在调试模式下打印
|
||||
if (__DEV__) {
|
||||
logger.debug(`[PERF] selectCurrentEpisode - no valid episode: episodes.length=${state.episodes?.length}, currentIndex=${state.currentEpisodeIndex}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { remoteControlService } from '@/services/remoteControlService';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('RemoteControlStore');
|
||||
|
||||
interface RemoteControlState {
|
||||
isServerRunning: boolean;
|
||||
@@ -30,23 +33,23 @@ export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
|
||||
}
|
||||
remoteControlService.init({
|
||||
onMessage: (message: string) => {
|
||||
console.log('[RemoteControlStore] Received message:', message);
|
||||
logger.debug('Received message:', message);
|
||||
const currentState = get();
|
||||
// Use the current targetPage from the store
|
||||
set({ lastMessage: message, targetPage: currentState.targetPage });
|
||||
},
|
||||
onHandshake: () => {
|
||||
console.log('[RemoteControlStore] Handshake successful');
|
||||
logger.debug('Handshake successful');
|
||||
set({ isModalVisible: false })
|
||||
},
|
||||
});
|
||||
try {
|
||||
const url = await remoteControlService.startServer();
|
||||
console.log(`[RemoteControlStore] Server started, URL: ${url}`);
|
||||
logger.info('Server started, URL:', url);
|
||||
set({ isServerRunning: true, serverUrl: url, error: null });
|
||||
} catch {
|
||||
const errorMessage = '启动失败,请强制退应用后重试。';
|
||||
console.info('[RemoteControlStore] Failed to start server:', errorMessage);
|
||||
logger.error('Failed to start server:', errorMessage);
|
||||
set({ error: errorMessage });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ import { create } from "zustand";
|
||||
import { SettingsManager } from "@/services/storage";
|
||||
import { api, ServerConfig } from "@/services/api";
|
||||
import { storageConfig } from "@/services/storageConfig";
|
||||
import Logger from "@/utils/Logger";
|
||||
|
||||
const logger = Logger.withTag('SettingsStore');
|
||||
|
||||
interface SettingsState {
|
||||
apiBaseUrl: string;
|
||||
@@ -65,7 +68,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
}
|
||||
} catch (error) {
|
||||
set({ serverConfig: null });
|
||||
console.info("Failed to fetch server config:", error);
|
||||
logger.error("Failed to fetch server config:", error);
|
||||
} finally {
|
||||
set({ isLoadingServerConfig: false });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { create } from 'zustand';
|
||||
import updateService from '../services/updateService';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import Toast from 'react-native-toast-message';
|
||||
import Logger from '@/utils/Logger';
|
||||
|
||||
const logger = Logger.withTag('UpdateStore');
|
||||
|
||||
interface UpdateState {
|
||||
// 状态
|
||||
@@ -15,6 +19,7 @@ interface UpdateState {
|
||||
lastCheckTime: number;
|
||||
skipVersion: string | null;
|
||||
showUpdateModal: boolean;
|
||||
isLatestVersion: boolean; // 新增:是否已是最新版本
|
||||
|
||||
// 操作
|
||||
checkForUpdate: (silent?: boolean) => Promise<void>;
|
||||
@@ -43,11 +48,12 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
lastCheckTime: 0,
|
||||
skipVersion: null,
|
||||
showUpdateModal: false,
|
||||
isLatestVersion: false, // 新增:初始为false
|
||||
|
||||
// 检查更新
|
||||
checkForUpdate: async (silent = false) => {
|
||||
try {
|
||||
set({ error: null });
|
||||
set({ error: null, isLatestVersion: false });
|
||||
|
||||
// 获取跳过的版本
|
||||
const skipVersion = await AsyncStorage.getItem(STORAGE_KEYS.SKIP_VERSION);
|
||||
@@ -58,6 +64,9 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
// 如果有更新且不是要跳过的版本
|
||||
const shouldShowUpdate = isUpdateAvailable && versionInfo.version !== skipVersion;
|
||||
|
||||
// 检查是否已经是最新版本
|
||||
const isLatest = !isUpdateAvailable;
|
||||
|
||||
set({
|
||||
remoteVersion: versionInfo.version,
|
||||
downloadUrl: versionInfo.downloadUrl,
|
||||
@@ -65,8 +74,19 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
lastCheckTime: Date.now(),
|
||||
skipVersion,
|
||||
showUpdateModal: shouldShowUpdate && !silent,
|
||||
isLatestVersion: isLatest,
|
||||
});
|
||||
|
||||
// 如果是手动检查且已是最新版本,显示提示
|
||||
if (!silent && isLatest) {
|
||||
Toast.show({
|
||||
type: 'success',
|
||||
text1: '已是最新版本',
|
||||
text2: `当前版本 v${updateService.getCurrentVersion()} 已是最新版本`,
|
||||
visibilityTime: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// 保存最后检查时间
|
||||
await AsyncStorage.setItem(
|
||||
STORAGE_KEYS.LAST_CHECK_TIME,
|
||||
@@ -76,7 +96,8 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
// console.info('检查更新失败:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : '检查更新失败',
|
||||
updateAvailable: false
|
||||
updateAvailable: false,
|
||||
isLatestVersion: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -133,7 +154,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
// 安装开始后,关闭弹窗
|
||||
set({ showUpdateModal: false });
|
||||
} catch (error) {
|
||||
console.info('安装失败:', error);
|
||||
logger.error('安装失败:', error);
|
||||
set({
|
||||
error: error instanceof Error ? error.message : '安装失败',
|
||||
});
|
||||
@@ -166,6 +187,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
|
||||
downloadedPath: null,
|
||||
error: null,
|
||||
showUpdateModal: false,
|
||||
isLatestVersion: false, // 重置时也要重置这个状态
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -181,6 +203,6 @@ export const initUpdateStore = async () => {
|
||||
skipVersion: skipVersion || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.info('初始化更新存储失败:', error);
|
||||
logger.error('初始化更新存储失败:', error);
|
||||
}
|
||||
};
|
||||
@@ -1,39 +1,39 @@
|
||||
import { Platform, Dimensions } from 'react-native';
|
||||
import { DeviceType } from '@/hooks/useResponsiveLayout';
|
||||
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';
|
||||
// if (Platform.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';
|
||||
return this.getDeviceType() === "tv";
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测是否为移动设备
|
||||
*/
|
||||
isMobile(): boolean {
|
||||
return this.getDeviceType() === 'mobile';
|
||||
return this.getDeviceType() === "mobile";
|
||||
},
|
||||
|
||||
/**
|
||||
* 检测是否为平板设备
|
||||
*/
|
||||
isTablet(): boolean {
|
||||
return this.getDeviceType() === 'tablet';
|
||||
return this.getDeviceType() === "tablet";
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -56,11 +56,11 @@ export const DeviceUtils = {
|
||||
getMinTouchTargetSize(): number {
|
||||
const deviceType = this.getDeviceType();
|
||||
switch (deviceType) {
|
||||
case 'mobile':
|
||||
case "mobile":
|
||||
return 44; // iOS HIG minimum
|
||||
case 'tablet':
|
||||
case "tablet":
|
||||
return 48; // Material Design minimum
|
||||
case 'tv':
|
||||
case "tv":
|
||||
return 60; // TV optimized
|
||||
default:
|
||||
return 44;
|
||||
@@ -77,7 +77,7 @@ export const DeviceUtils = {
|
||||
tablet: 1.1,
|
||||
tv: 1.25,
|
||||
}[deviceType];
|
||||
|
||||
|
||||
return Math.round(baseSize * scaleFactor);
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@ export const DeviceUtils = {
|
||||
tablet: 1.0,
|
||||
tv: 1.5,
|
||||
}[deviceType];
|
||||
|
||||
|
||||
return Math.round(baseSpacing * scaleFactor);
|
||||
},
|
||||
|
||||
@@ -99,7 +99,7 @@ export const DeviceUtils = {
|
||||
* 检测设备是否处于横屏模式
|
||||
*/
|
||||
isLandscape(): boolean {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const { width, height } = Dimensions.get("window");
|
||||
return width > height;
|
||||
},
|
||||
|
||||
@@ -114,10 +114,10 @@ export const DeviceUtils = {
|
||||
* 获取安全的网格列数
|
||||
*/
|
||||
getSafeColumnCount(preferredColumns: number): number {
|
||||
const { width } = Dimensions.get('window');
|
||||
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);
|
||||
},
|
||||
|
||||
@@ -132,7 +132,7 @@ export const DeviceUtils = {
|
||||
tablet: 1.0,
|
||||
tv: 1.2,
|
||||
}[deviceType];
|
||||
|
||||
|
||||
return Math.round(baseDuration * scaleFactor);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
149
utils/Logger.ts
Normal file
149
utils/Logger.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 统一日志管理器
|
||||
* 在开发环境输出完整日志,生产环境移除所有日志代码
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
}
|
||||
|
||||
interface LoggerOptions {
|
||||
tag?: string;
|
||||
level?: LogLevel;
|
||||
}
|
||||
|
||||
class LoggerClass {
|
||||
private minLevel: LogLevel = LogLevel.DEBUG;
|
||||
|
||||
/**
|
||||
* 设置最小日志级别
|
||||
*/
|
||||
setMinLevel(level: LogLevel): void {
|
||||
if (__DEV__) {
|
||||
this.minLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志输出
|
||||
*/
|
||||
private formatMessage(level: string, tag: string | undefined, message: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
const timestamp = new Date().toISOString().substr(11, 12);
|
||||
const prefix = tag ? `[${timestamp}][${level}][${tag}]` : `[${timestamp}][${level}]`;
|
||||
|
||||
switch (level) {
|
||||
case 'DEBUG':
|
||||
console.log(prefix, message, ...args);
|
||||
break;
|
||||
case 'INFO':
|
||||
console.info(prefix, message, ...args);
|
||||
break;
|
||||
case 'WARN':
|
||||
console.warn(prefix, message, ...args);
|
||||
break;
|
||||
case 'ERROR':
|
||||
console.error(prefix, message, ...args);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试级别日志
|
||||
*/
|
||||
debug(message: any, ...args: any[]): void;
|
||||
debug(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
debug(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.DEBUG) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('DEBUG', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('DEBUG', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息级别日志
|
||||
*/
|
||||
info(message: any, ...args: any[]): void;
|
||||
info(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
info(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.INFO) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('INFO', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('INFO', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告级别日志
|
||||
*/
|
||||
warn(message: any, ...args: any[]): void;
|
||||
warn(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
warn(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.WARN) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('WARN', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('WARN', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误级别日志
|
||||
*/
|
||||
error(message: any, ...args: any[]): void;
|
||||
error(options: LoggerOptions, message: any, ...args: any[]): void;
|
||||
error(optionsOrMessage: LoggerOptions | any, message?: any, ...args: any[]): void {
|
||||
if (!__DEV__) return;
|
||||
|
||||
if (this.minLevel > LogLevel.ERROR) return;
|
||||
|
||||
if (typeof optionsOrMessage === 'object' && optionsOrMessage.tag !== undefined) {
|
||||
const options = optionsOrMessage as LoggerOptions;
|
||||
this.formatMessage('ERROR', options.tag, message, ...args);
|
||||
} else {
|
||||
this.formatMessage('ERROR', undefined, optionsOrMessage, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带标签的日志实例
|
||||
*/
|
||||
withTag(tag: string): LoggerClass {
|
||||
const taggedLogger = new LoggerClass();
|
||||
taggedLogger.minLevel = this.minLevel;
|
||||
|
||||
const originalDebug = taggedLogger.debug.bind(taggedLogger);
|
||||
const originalInfo = taggedLogger.info.bind(taggedLogger);
|
||||
const originalWarn = taggedLogger.warn.bind(taggedLogger);
|
||||
const originalError = taggedLogger.error.bind(taggedLogger);
|
||||
|
||||
taggedLogger.debug = (message: any, ...args: any[]) => originalDebug({ tag }, message, ...args);
|
||||
taggedLogger.info = (message: any, ...args: any[]) => originalInfo({ tag }, message, ...args);
|
||||
taggedLogger.warn = (message: any, ...args: any[]) => originalWarn({ tag }, message, ...args);
|
||||
taggedLogger.error = (message: any, ...args: any[]) => originalError({ tag }, message, ...args);
|
||||
|
||||
return taggedLogger;
|
||||
}
|
||||
}
|
||||
|
||||
export const Logger = new LoggerClass();
|
||||
export default Logger;
|
||||
213
utils/__tests__/DeviceUtils.test.ts
Normal file
213
utils/__tests__/DeviceUtils.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Dimensions } from "react-native";
|
||||
import { DeviceUtils } from "../DeviceUtils";
|
||||
|
||||
jest.mock("react-native", () => ({
|
||||
Dimensions: {
|
||||
get: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedDimensions = Dimensions as jest.Mocked<typeof Dimensions>;
|
||||
|
||||
describe("DeviceUtils", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getDeviceType", () => {
|
||||
it("应该在宽度 >= 1024 时返回 tv", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1024, height: 768 });
|
||||
expect(DeviceUtils.getDeviceType()).toBe("tv");
|
||||
});
|
||||
|
||||
it("应该在宽度 >= 768 且 < 1024 时返回 tablet", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.getDeviceType()).toBe("tablet");
|
||||
});
|
||||
|
||||
it("应该在宽度 < 768 时返回 mobile", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.getDeviceType()).toBe("mobile");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTV", () => {
|
||||
it("应该在 TV 设备上返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.isTV()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在非 TV 设备上返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.isTV()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMobile", () => {
|
||||
it("应该在移动设备上返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.isMobile()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在非移动设备上返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1024, height: 768 });
|
||||
expect(DeviceUtils.isMobile()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTablet", () => {
|
||||
it("应该在平板设备上返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.isTablet()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在非平板设备上返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.isTablet()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsTouchInteraction", () => {
|
||||
it("应该在非 TV 设备上返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.supportsTouchInteraction()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在 TV 设备上返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.supportsTouchInteraction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsRemoteControlInteraction", () => {
|
||||
it("应该在 TV 设备上返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.supportsRemoteControlInteraction()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在非 TV 设备上返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.supportsRemoteControlInteraction()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMinTouchTargetSize", () => {
|
||||
it("应该为 mobile 设备返回 44", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.getMinTouchTargetSize()).toBe(44);
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回 48", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.getMinTouchTargetSize()).toBe(48);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回 60", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.getMinTouchTargetSize()).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOptimalFontSize", () => {
|
||||
it("应该为 mobile 设备返回基础大小 * 1.0", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.getOptimalFontSize(16)).toBe(16);
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回基础大小 * 1.1", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.getOptimalFontSize(16)).toBe(18);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回基础大小 * 1.25", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.getOptimalFontSize(16)).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOptimalSpacing", () => {
|
||||
it("应该为 mobile 设备返回基础间距 * 0.8", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.getOptimalSpacing(20)).toBe(16);
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回基础间距 * 1.0", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.getOptimalSpacing(20)).toBe(20);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回基础间距 * 1.5", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.getOptimalSpacing(20)).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLandscape", () => {
|
||||
it("应该在横屏模式下返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 812, height: 375 });
|
||||
expect(DeviceUtils.isLandscape()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在竖屏模式下返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.isLandscape()).toBe(false);
|
||||
});
|
||||
|
||||
it("应该在宽高相等时返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 500, height: 500 });
|
||||
expect(DeviceUtils.isLandscape()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPortrait", () => {
|
||||
it("应该在竖屏模式下返回 true", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.isPortrait()).toBe(true);
|
||||
});
|
||||
|
||||
it("应该在横屏模式下返回 false", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 812, height: 375 });
|
||||
expect(DeviceUtils.isPortrait()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeColumnCount", () => {
|
||||
it("应该在 mobile 设备上返回安全列数", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
// minCardWidth = 120, maxColumns = 375 / 120 = 3.125 = 3
|
||||
expect(DeviceUtils.getSafeColumnCount(5)).toBe(3);
|
||||
expect(DeviceUtils.getSafeColumnCount(2)).toBe(2);
|
||||
});
|
||||
|
||||
it("应该在 tablet 设备上返回安全列数", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
// minCardWidth = 140, maxColumns = 768 / 140 = 5.485 = 5
|
||||
expect(DeviceUtils.getSafeColumnCount(6)).toBe(5);
|
||||
expect(DeviceUtils.getSafeColumnCount(3)).toBe(3);
|
||||
});
|
||||
|
||||
it("应该在 tv 设备上返回安全列数", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
// minCardWidth = 160, maxColumns = 1920 / 160 = 12
|
||||
expect(DeviceUtils.getSafeColumnCount(15)).toBe(12);
|
||||
expect(DeviceUtils.getSafeColumnCount(8)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAnimationDuration", () => {
|
||||
it("应该为 mobile 设备返回基础持续时间 * 1.0", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 375, height: 812 });
|
||||
expect(DeviceUtils.getAnimationDuration(300)).toBe(300);
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回基础持续时间 * 1.0", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 768, height: 1024 });
|
||||
expect(DeviceUtils.getAnimationDuration(300)).toBe(300);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回基础持续时间 * 1.2", () => {
|
||||
mockedDimensions.get.mockReturnValue({ width: 1920, height: 1080 });
|
||||
expect(DeviceUtils.getAnimationDuration(300)).toBe(360);
|
||||
});
|
||||
});
|
||||
});
|
||||
237
utils/__tests__/ResponsiveStyles.test.ts
Normal file
237
utils/__tests__/ResponsiveStyles.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { StyleSheet } from "react-native";
|
||||
import {
|
||||
createResponsiveStyles,
|
||||
useResponsiveStyles,
|
||||
getCommonResponsiveStyles,
|
||||
getResponsiveTextSize,
|
||||
getResponsiveSpacing,
|
||||
ResponsiveStyleCreator,
|
||||
} from "../ResponsiveStyles";
|
||||
import { ResponsiveConfig } from "@/hooks/useResponsiveLayout";
|
||||
import { DeviceUtils } from "../DeviceUtils";
|
||||
|
||||
jest.mock("react-native", () => ({
|
||||
StyleSheet: {
|
||||
create: jest.fn((styles) => styles),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/useResponsiveLayout", () => ({
|
||||
useResponsiveLayout: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("@/utils/DeviceUtils", () => ({
|
||||
DeviceUtils: {
|
||||
getMinTouchTargetSize: jest.fn(),
|
||||
getOptimalFontSize: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedStyleSheet = StyleSheet as jest.Mocked<typeof StyleSheet>;
|
||||
const mockedDeviceUtils = DeviceUtils as jest.Mocked<typeof DeviceUtils>;
|
||||
|
||||
describe("ResponsiveStyles", () => {
|
||||
const mockConfig: ResponsiveConfig = {
|
||||
deviceType: "mobile",
|
||||
spacing: 16,
|
||||
safeAreaInsets: { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
windowWidth: 375,
|
||||
windowHeight: 812,
|
||||
isLandscape: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockedDeviceUtils.getMinTouchTargetSize.mockReturnValue(44);
|
||||
mockedDeviceUtils.getOptimalFontSize.mockImplementation((size) => size);
|
||||
});
|
||||
|
||||
describe("createResponsiveStyles", () => {
|
||||
it("应该创建响应式样式函数", () => {
|
||||
const styleCreator: ResponsiveStyleCreator<any> = (config) => ({
|
||||
container: {
|
||||
padding: config.spacing,
|
||||
},
|
||||
});
|
||||
|
||||
const responsiveStylesFunc = createResponsiveStyles(styleCreator);
|
||||
const styles = responsiveStylesFunc(mockConfig);
|
||||
|
||||
expect(mockedStyleSheet.create).toHaveBeenCalledWith({
|
||||
container: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
expect(styles).toEqual({
|
||||
container: {
|
||||
padding: 16,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCommonResponsiveStyles", () => {
|
||||
beforeEach(() => {
|
||||
mockedDeviceUtils.getOptimalFontSize.mockImplementation((size) => {
|
||||
const deviceType = "mobile";
|
||||
const scaleFactor = {
|
||||
mobile: 1.0,
|
||||
tablet: 1.1,
|
||||
tv: 1.25,
|
||||
}[deviceType];
|
||||
return Math.round(size * scaleFactor);
|
||||
});
|
||||
});
|
||||
|
||||
it("应该为 mobile 设备返回正确的样式", () => {
|
||||
const mobileConfig: ResponsiveConfig = {
|
||||
...mockConfig,
|
||||
deviceType: "mobile",
|
||||
spacing: 16,
|
||||
};
|
||||
|
||||
mockedDeviceUtils.getMinTouchTargetSize.mockReturnValue(44);
|
||||
|
||||
const styles = getCommonResponsiveStyles(mobileConfig);
|
||||
|
||||
expect(styles.container).toEqual({
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
});
|
||||
|
||||
expect(styles.safeContainer).toEqual({
|
||||
flex: 1,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 20,
|
||||
});
|
||||
|
||||
expect(styles.primaryButton).toEqual({
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
borderRadius: 8,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
});
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回正确的样式", () => {
|
||||
const tabletConfig: ResponsiveConfig = {
|
||||
...mockConfig,
|
||||
deviceType: "tablet",
|
||||
spacing: 20,
|
||||
};
|
||||
|
||||
mockedDeviceUtils.getMinTouchTargetSize.mockReturnValue(48);
|
||||
|
||||
const styles = getCommonResponsiveStyles(tabletConfig);
|
||||
|
||||
expect(styles.safeContainer.paddingTop).toBe(30);
|
||||
expect(styles.primaryButton.borderRadius).toBe(10);
|
||||
expect(styles.primaryButton.minHeight).toBe(48);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回正确的样式", () => {
|
||||
const tvConfig: ResponsiveConfig = {
|
||||
...mockConfig,
|
||||
deviceType: "tv",
|
||||
spacing: 24,
|
||||
};
|
||||
|
||||
mockedDeviceUtils.getMinTouchTargetSize.mockReturnValue(60);
|
||||
|
||||
const styles = getCommonResponsiveStyles(tvConfig);
|
||||
|
||||
expect(styles.safeContainer.paddingTop).toBe(40);
|
||||
expect(styles.primaryButton.borderRadius).toBe(12);
|
||||
expect(styles.primaryButton.minHeight).toBe(60);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备不包含阴影样式", () => {
|
||||
const tvConfig: ResponsiveConfig = {
|
||||
...mockConfig,
|
||||
deviceType: "tv",
|
||||
spacing: 24,
|
||||
};
|
||||
|
||||
const styles = getCommonResponsiveStyles(tvConfig);
|
||||
|
||||
expect(styles.shadow).toEqual({});
|
||||
});
|
||||
|
||||
it("应该为非 tv 设备包含阴影样式", () => {
|
||||
const mobileConfig: ResponsiveConfig = {
|
||||
...mockConfig,
|
||||
deviceType: "mobile",
|
||||
spacing: 16,
|
||||
};
|
||||
|
||||
const styles = getCommonResponsiveStyles(mobileConfig);
|
||||
|
||||
expect(styles.shadow).toEqual({
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsiveTextSize", () => {
|
||||
it("应该为 mobile 设备返回基础大小", () => {
|
||||
const result = getResponsiveTextSize(16, "mobile");
|
||||
expect(result).toBe(16);
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回缩放后的大小", () => {
|
||||
const result = getResponsiveTextSize(16, "tablet");
|
||||
expect(result).toBe(18); // 16 * 1.1 = 17.6, rounded to 18
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回缩放后的大小", () => {
|
||||
const result = getResponsiveTextSize(16, "tv");
|
||||
expect(result).toBe(20); // 16 * 1.25 = 20
|
||||
});
|
||||
|
||||
it("应该为未知设备类型返回基础大小", () => {
|
||||
const result = getResponsiveTextSize(16, "unknown");
|
||||
expect(result).toBe(16);
|
||||
});
|
||||
|
||||
it("应该正确处理小数点", () => {
|
||||
const result = getResponsiveTextSize(15, "tablet");
|
||||
expect(result).toBe(17); // 15 * 1.1 = 16.5, rounded to 17
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponsiveSpacing", () => {
|
||||
it("应该为 mobile 设备返回缩放后的间距", () => {
|
||||
const result = getResponsiveSpacing(20, "mobile");
|
||||
expect(result).toBe(16); // 20 * 0.8 = 16
|
||||
});
|
||||
|
||||
it("应该为 tablet 设备返回基础间距", () => {
|
||||
const result = getResponsiveSpacing(20, "tablet");
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it("应该为 tv 设备返回缩放后的间距", () => {
|
||||
const result = getResponsiveSpacing(20, "tv");
|
||||
expect(result).toBe(30); // 20 * 1.5 = 30
|
||||
});
|
||||
|
||||
it("应该为未知设备类型返回基础间距", () => {
|
||||
const result = getResponsiveSpacing(20, "unknown");
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it("应该正确处理小数点", () => {
|
||||
const result = getResponsiveSpacing(15, "mobile");
|
||||
expect(result).toBe(12); // 15 * 0.8 = 12
|
||||
});
|
||||
});
|
||||
});
|
||||
36
yarn.lock
36
yarn.lock
@@ -3064,6 +3064,11 @@ babel-plugin-transform-flow-enums@^0.0.2:
|
||||
dependencies:
|
||||
"@babel/plugin-syntax-flow" "^7.12.1"
|
||||
|
||||
babel-plugin-transform-remove-console@^6.9.4:
|
||||
version "6.9.4"
|
||||
resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
|
||||
integrity sha512-88blrUrMX3SPiGkT1GnvVY8E/7A+k6oj3MNvUtTIxJflFzXTw1bHkuJ/y039ouhFMp2prRn5cQGzokViYi1dsg==
|
||||
|
||||
babel-preset-current-node-syntax@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz#9a929eafece419612ef4ae4f60b1862ebad8ef30"
|
||||
@@ -8827,16 +8832,7 @@ string-length@^5.0.1:
|
||||
char-regex "^2.0.0"
|
||||
strip-ansi "^7.0.1"
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
dependencies:
|
||||
emoji-regex "^8.0.0"
|
||||
is-fullwidth-code-point "^3.0.0"
|
||||
strip-ansi "^6.0.1"
|
||||
|
||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||
version "4.2.3"
|
||||
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||
@@ -8927,7 +8923,7 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
@@ -8941,13 +8937,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
|
||||
dependencies:
|
||||
ansi-regex "^4.1.0"
|
||||
|
||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||
dependencies:
|
||||
ansi-regex "^5.0.1"
|
||||
|
||||
strip-ansi@^7.0.1:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
|
||||
@@ -9779,7 +9768,7 @@ word-wrap@^1.2.5:
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
@@ -9797,15 +9786,6 @@ wrap-ansi@^6.2.0:
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^7.0.0:
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||
dependencies:
|
||||
ansi-styles "^4.0.0"
|
||||
string-width "^4.1.0"
|
||||
strip-ansi "^6.0.0"
|
||||
|
||||
wrap-ansi@^8.1.0:
|
||||
version "8.1.0"
|
||||
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
|
||||
|
||||
Reference in New Issue
Block a user