mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-11 10:04:44 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0085361b | ||
|
|
3e3796ab5c | ||
|
|
9fcdf4b5aa | ||
|
|
db7372d732 | ||
|
|
e4ecd1339e | ||
|
|
9b7833b430 | ||
|
|
1ef5a6b445 |
@@ -11,7 +11,8 @@
|
||||
"Bash(yarn add:*)",
|
||||
"Bash(git reset:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Bash(yarn test-ci:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
1
app.json
1
app.json
@@ -36,7 +36,6 @@
|
||||
},
|
||||
"name": "OrionTV",
|
||||
"slug": "OrionTV",
|
||||
"version": "1.3.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
|
||||
@@ -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
7
babel.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
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>
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -7,11 +7,11 @@ export const UPDATE_CONFIG = {
|
||||
|
||||
// GitHub相关URL
|
||||
GITHUB_RAW_URL:
|
||||
"https://gh-proxy.com/https://raw.githubusercontent.com/zimplexing/OrionTV/refs/heads/master/package.json",
|
||||
"https://gh-proxy.com/https://raw.githubusercontent.com/orion-lib/OrionTV/refs/heads/master/package.json",
|
||||
|
||||
// 获取平台特定的下载URL
|
||||
getDownloadUrl(version: string): string {
|
||||
return `https://gh-proxy.com/https://github.com/zimplexing/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
|
||||
return `https://gh-proxy.com/https://github.com/orion-lib/OrionTV/releases/download/v${version}/orionTV.${version}.apk`;
|
||||
},
|
||||
|
||||
// 是否显示更新日志
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "OrionTV",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.3",
|
||||
"scripts": {
|
||||
"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",
|
||||
@@ -82,4 +82,4 @@
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user