12 Commits

Author SHA1 Message Date
zimplexing
e4ecd1339e feat(login): implement credential management for login modal 2025-08-15 15:20:37 +08:00
zimplexing
9b7833b430 feat(config): add Babel configuration and improve project structure 2025-08-14 15:29:52 +08:00
zimplexing
1ef5a6b445 feat(player): implement playback speed control with persistent settings
- Add playback rate state and actions to player store
- Create SpeedSelectionModal with 7 speed options (0.5x - 2x)
- Add speed control button with Gauge icon to PlayerControls
- Integrate rate prop with Expo AV Video component
- Extend PlayerSettings storage to persist playback rate per video
- Support speed control across TV, mobile, and tablet platforms
2025-08-14 15:14:37 +08:00
zimplexing
09c3931117 chore(version): bump version to 1.3.1 2025-08-14 14:08:30 +08:00
zimplexing
10a806a657 feat(api): implement API configuration validation and error handling in home screen and update section 2025-08-14 14:08:11 +08:00
zimplexing
cb3f694cdc refactor(build): simplify prebuild and build commands in workflow configuration 2025-08-14 11:14:01 +08:00
zimplexing
1cf3733ee2 refactor(config): clean up and standardize code formatting in configuration files 2025-08-14 11:08:54 +08:00
zimplexing
108c20cd26 refactor(build): streamline prebuild and build scripts for consistency 2025-08-13 21:23:51 +08:00
Xin
250c42e1ff Update package.json 2025-08-13 21:04:12 +08:00
Xin
68a1bc2081 Update app.json 2025-08-13 20:21:13 +08:00
Xin
d8e47dee7b Update build-apk.yml 2025-08-13 20:16:36 +08:00
Xin
5bf0d05820 Merge pull request #107 from zimplexing/v1.3.0
feat: Enhance mobile and tablet support with responsive layout and auto update
2025-08-13 20:11:34 +08:00
25 changed files with 1109 additions and 322 deletions

View File

@@ -11,7 +11,8 @@
"Bash(yarn add:*)",
"Bash(git reset:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
"Bash(git commit:*)",
"Bash(yarn test-ci:*)"
],
"deny": []
}

View File

@@ -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
View File

@@ -24,4 +24,5 @@ web/**
.bmad-core
.kilocodemodes
.roomodes
yarn-errors.log
yarn-errors.log
coverage/

172
CLAUDE.md
View File

@@ -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

View File

@@ -40,11 +40,6 @@
"orientation": "default",
"icon": "./assets/images/icon.png",
"userInterfaceStyle": "dark",
"splash": {
"image": "./assets/images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#000000"
},
"assetBundlePatterns": [
"**/*"
],
@@ -54,10 +49,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 +84,6 @@
"usesNonExemptEncryption": false
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"scheme": "oriontv",
"extra": {
"router": {

View File

@@ -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 ? (

View File

@@ -7,6 +7,7 @@ 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";
@@ -50,6 +51,7 @@ export default function PlayScreen() {
// showNextEpisodeOverlay,
initialPosition,
introEndTime,
playbackRate,
setVideoRef,
handlePlaybackStatusUpdate,
setShowControls,
@@ -147,6 +149,7 @@ export default function PlayScreen() {
source={{ uri: currentEpisode?.url || "" }}
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
rate={playbackRate}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
const jumpPosition = initialPosition || introEndTime || 0;
@@ -175,6 +178,7 @@ export default function PlayScreen() {
<EpisodeSelectionModal />
<SourceSelectionModal />
<SpeedSelectionModal />
</ThemedView>
);
}

7
babel.config.js Normal file
View File

@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [],
};
};

View File

@@ -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(

View File

@@ -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>

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

View File

@@ -195,7 +195,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 +343,4 @@ const styles = StyleSheet.create({
color: Colors.dark.primary,
fontSize: 12,
},
});
});

View File

@@ -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,

View File

@@ -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://gh-proxy.com/https://raw.githubusercontent.com/zimplexing/OrionTV/refs/heads/master/package.json",
// 获取平台特定的下载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://gh-proxy.com/https://github.com/zimplexing/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
View 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 '加载失败,请重试';
};

View File

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

View File

@@ -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;

View File

@@ -2,24 +2,14 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.3.0",
"version": "1.3.1",
"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": "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",

View File

@@ -9,6 +9,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 +23,7 @@ export type Favorite = ApiFavorite;
export interface PlayerSettings {
introEndTime?: number;
outroStartTime?: number;
playbackRate?: number;
}
export interface AppSettings {
@@ -36,6 +38,11 @@ export interface AppSettings {
m3uUrl: string;
}
export interface LoginCredentials {
username: string;
password: string;
}
// --- Helper ---
const generateKey = (source: string, id: string) => `${source}+${id}`;
@@ -60,10 +67,10 @@ export class PlayerSettingsManager {
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));
@@ -301,3 +308,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) {
console.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) {
console.error("Failed to save login credentials:", error);
}
}
static async clear(): Promise<void> {
try {
await AsyncStorage.removeItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
} catch (error) {
console.error("Failed to clear login credentials:", error);
}
}
}

View File

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

View File

@@ -2,7 +2,7 @@ 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";
interface Episode {
@@ -19,11 +19,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 +44,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;
@@ -61,11 +65,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,
@@ -93,17 +99,21 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
try {
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const playerSettings = await PlayerSettingsManager.get(detail.source, detail.id.toString());
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
const savedPlaybackRate = playerSettings?.playbackRate || 1.0;
set({
isLoading: false,
currentEpisodeIndex: episodeIndex,
initialPosition: position || initialPositionFromRecord,
playbackRate: savedPlaybackRate,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
})),
introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime,
introEndTime: playRecord?.introEndTime || playerSettings?.introEndTime,
outroStartTime: playRecord?.outroStartTime || playerSettings?.outroStartTime,
});
} catch (error) {
console.info("Failed to load play record", error);
@@ -305,8 +315,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) {
console.info("Failed to set playback rate:", error);
}
},
reset: () => {
set({
episodes: [],
@@ -316,8 +344,10 @@ 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,
});

View File

@@ -1,6 +1,7 @@
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';
interface UpdateState {
// 状态
@@ -15,6 +16,7 @@ interface UpdateState {
lastCheckTime: number;
skipVersion: string | null;
showUpdateModal: boolean;
isLatestVersion: boolean; // 新增:是否已是最新版本
// 操作
checkForUpdate: (silent?: boolean) => Promise<void>;
@@ -43,11 +45,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 +61,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 +71,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 +93,8 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
// console.info('检查更新失败:', error);
set({
error: error instanceof Error ? error.message : '检查更新失败',
updateAvailable: false
updateAvailable: false,
isLatestVersion: false,
});
}
},
@@ -166,6 +184,7 @@ export const useUpdateStore = create<UpdateState>((set, get) => ({
downloadedPath: null,
error: null,
showUpdateModal: false,
isLatestVersion: false, // 重置时也要重置这个状态
});
},
}));

View File

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

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

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