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