diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 388fa8c..5f63d0a 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -11,7 +11,8 @@
"Bash(yarn add:*)",
"Bash(git reset:*)",
"Bash(git add:*)",
- "Bash(git commit:*)"
+ "Bash(git commit:*)",
+ "Bash(yarn test-ci:*)"
],
"deny": []
}
diff --git a/.gitignore b/.gitignore
index 704dd47..33c274a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,4 +24,5 @@ web/**
.bmad-core
.kilocodemodes
.roomodes
-yarn-errors.log
\ No newline at end of file
+yarn-errors.log
+coverage/
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
index 3d12a7d..053a3e0 100644
--- a/CLAUDE.md
+++ b/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
diff --git a/app/play.tsx b/app/play.tsx
index 11eb37f..e994a80 100644
--- a/app/play.tsx
+++ b/app/play.tsx
@@ -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() {
+
);
}
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..df63172
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,7 @@
+module.exports = function (api) {
+ api.cache(true);
+ return {
+ presets: ['babel-preset-expo'],
+ plugins: [],
+ };
+};
\ No newline at end of file
diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx
index 6391f1c..4d2797e 100644
--- a/components/LoginModal.tsx
+++ b/components/LoginModal.tsx
@@ -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(
diff --git a/components/PlayerControls.tsx b/components/PlayerControls.tsx
index fcb3c4c..e15e5d7 100644
--- a/components/PlayerControls.tsx
+++ b/components/PlayerControls.tsx
@@ -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 = ({ showControls, se
isSeeking,
seekPosition,
progressPosition,
+ playbackRate,
togglePlayPause,
playEpisode,
setShowEpisodeModal,
setShowSourceModal,
+ setShowSpeedModal,
setIntroEndTime,
setOutroStartTime,
introEndTime,
@@ -109,6 +111,10 @@ export const PlayerControls: React.FC = ({ showControls, se
+ setShowSpeedModal(true)} timeLabel={playbackRate !== 1.0 ? `${playbackRate}x` : undefined}>
+
+
+
setShowSourceModal(true)}>
diff --git a/components/SpeedSelectionModal.tsx b/components/SpeedSelectionModal.tsx
new file mode 100644
index 0000000..4dbd3e9
--- /dev/null
+++ b/components/SpeedSelectionModal.tsx
@@ -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 (
+
+
+
+ 播放速度
+ `speed-${item.rate}`}
+ renderItem={({ item }) => (
+ onSelectSpeed(item.rate)}
+ isSelected={playbackRate === item.rate}
+ hasTVPreferredFocus={playbackRate === item.rate}
+ style={styles.speedItem}
+ textStyle={styles.speedItemText}
+ />
+ )}
+ />
+
+
+
+ );
+};
+
+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,
+ },
+});
\ No newline at end of file
diff --git a/services/storage.ts b/services/storage.ts
index 46442dd..d1fdf33 100644
--- a/services/storage.ts
+++ b/services/storage.ts
@@ -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 {
+ 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 {
+ 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 {
+ try {
+ await AsyncStorage.removeItem(STORAGE_KEYS.LOGIN_CREDENTIALS);
+ } catch (error) {
+ console.error("Failed to clear login credentials:", error);
+ }
+ }
+}
diff --git a/stores/playerStore.ts b/stores/playerStore.ts
index 76f9d5f..c60d93e 100644
--- a/stores/playerStore.ts
+++ b/stores/playerStore.ts
@@ -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