97 Commits

Author SHA1 Message Date
zimplexing
0f8cc49019 fix: add disclaimer alert after successful login and update error message 2025-07-26 14:57:51 +08:00
zimplexing
8ea588617d fix: update placeholder and error message 2025-07-26 14:50:53 +08:00
zimplexing
89b5f1df9d chore: delete docs 2025-07-26 12:54:46 +08:00
zimplexing
2ba7782f5d chore: delete backend 2025-07-26 12:53:47 +08:00
zimplexing
48b983c2b4 Merge remote-tracking branch 'origin/master' into v1.2.9 2025-07-26 12:53:11 +08:00
Xin
0c3b8f753e Update README.md 2025-07-26 12:46:25 +08:00
Xin
76bbbb9439 Update README.md 2025-07-26 12:32:19 +08:00
zimplexing
e5a40da8ad chore: bump version to 1.2.9 in package.json 2025-07-25 18:25:39 +08:00
zimplexing
80cb5310c4 fix: UI issue 2025-07-25 18:24:42 +08:00
Xin
928432e81c Merge pull request #94 from zimplexing/v1.2.8
refactor: Update scroll experience
2025-07-25 16:32:47 +08:00
zimplexing
d1f0a2eb87 feat: Optimize availability checking of api addresses 2025-07-25 16:32:15 +08:00
zimplexing
62c03beb5e fix: search input focus issue 2025-07-25 16:00:11 +08:00
zimplexing
5992a89db4 feat: Update scroll experience 2025-07-25 15:39:23 +08:00
zimplexing
c9587d7070 chore: bump version to 1.2.8 in package.json 2025-07-25 14:15:18 +08:00
zimplexing
75d7f675f7 fix: reload video with new source while preserving playback position 2025-07-25 13:58:26 +08:00
zimplexing
9cbd23c36a refactor: improve focus management and input handling in LoginModal 2025-07-25 13:34:11 +08:00
zimplexing
3fa2eb3159 feat: add useKeepAwake to LivePlayer and update version to 1.2.7 2025-07-21 19:03:12 +08:00
zimplexing
e4e4417ef6 feat: enhance LivePlayer messages with localized text and improve M3U parsing logic 2025-07-21 14:06:44 +08:00
Xin
64cdcb78b6 Merge pull request #73 from Moon3r/fix-m3u-without-extinf
Fix fetch m3u failed when m3u file without extinf.
2025-07-21 11:04:35 +08:00
Moon3r
809422f702 Fix fetch m3u failed when m3u file without extinf. 2025-07-21 10:16:42 +08:00
Xin
1c9b3b2553 Update README.md 2025-07-21 09:16:01 +08:00
Xin
e02b3c512f Update README.md 2025-07-21 09:14:50 +08:00
zimplexing
fe05525805 fix: add padding to badge text for improved spacing 2025-07-18 23:00:42 +08:00
zimplexing
1be777825b fix: update VideoCard styles for improved visual consistency and accessibility 2025-07-18 22:57:56 +08:00
zimplexing
813ca40576 chore: bump version to 1.2.5 in package.json 2025-07-18 22:21:53 +08:00
zimplexing
4c633febdc feat: update color scheme and styles across components for improved UI consistency 2025-07-18 22:21:33 +08:00
zimplexing
2fd30c8fd7 chore: bump version to 1.2.4 in package.json 2025-07-18 19:59:42 +08:00
zimplexing
f09f103d59 feat: add favorites screen and integrate it into navigation; enhance detail screen with favorite toggle functionality 2025-07-18 19:57:39 +08:00
zimplexing
828a0b3d72 chore: bump version to 1.2.3 in package.json 2025-07-18 19:09:29 +08:00
zimplexing
e8a1ea2717 feat: update PlayScreen and VideoLoadingAnimation styles, and log play record saving in playerStore 2025-07-18 19:08:49 +08:00
zimplexing
bd7087264d feat: add VideoLoadingAnimation component and integrate it into detail, play, and search screens for improved loading experience 2025-07-18 17:15:24 +08:00
zimplexing
990745eba9 feat: modify LoginModal to conditionally handle visibility based on pathname, preventing display on settings page 2025-07-18 12:19:29 +08:00
zimplexing
cab3e2ed12 feat: refactor playerStore to improve loadVideo and _savePlayRecord methods with enhanced options and throttling logic 2025-07-18 11:26:06 +08:00
zimplexing
3fdd1fc587 feat: update screenshot assets with new images 2025-07-17 22:15:50 +08:00
zimplexing
4b3d1c620b chore: update version to 1.2.2 in package.json 2025-07-17 22:06:15 +08:00
zimplexing
1f694f9245 feat: add timeout handling for loading state with error notification 2025-07-17 22:04:28 +08:00
zimplexing
ec949029fa feat: enhance saveSettings function to process API base URL and ensure valid format 2025-07-17 21:14:03 +08:00
Xin
2325b76f77 Update README.md 2025-07-17 12:19:22 +08:00
Xin
4473fd6ab3 Update README.md 2025-07-16 23:09:12 +08:00
zimplexing
c514a6d03e chore: update version to 1.2.1 in package.json 2025-07-16 22:06:28 +08:00
zimplexing
f6baa0523c feat: enhance authentication flow by adding server configuration check and login handling in authStore 2025-07-16 22:06:04 +08:00
zimplexing
9540aaa3b9 chore: update version to 1.2.0 in package.json 2025-07-16 21:31:21 +08:00
Xin
8a1c26991b Merge pull request #38 from zimplexing/v1.2.0
Adapt moontv api
2025-07-16 21:29:51 +08:00
zimplexing
d83c4483ff refactor: remove padding and margin from searchButton for cleaner styling 2025-07-16 21:29:09 +08:00
zimplexing
9f4299004a refactor: comment out unused components in layout and detail screens for cleaner code 2025-07-16 21:28:12 +08:00
zimplexing
e0aa40eea0 feat: enhance PlayScreen and VideoCard with improved video loading and app state handling; update player store for better episode management 2025-07-16 21:26:37 +08:00
zimplexing
daba164998 refactor: update storage management to use centralized storage configuration and improve README documentation 2025-07-16 16:36:46 +08:00
zimplexing
57bc0b3582 feat: enhance PlayScreen and storage management with dynamic source handling and local storage support 2025-07-15 22:59:10 +08:00
zimplexing
0b1fa9df6d feat: enhance LoginModal with TV event handling and input focus management 2025-07-15 22:33:11 +08:00
zimplexing
d44e9fe9ae feat: enhance login status management and improve error logging across services 2025-07-15 21:41:38 +08:00
zimplexing
116cf12ca3 Merge remote-tracking branch 'origin/master' into v1.2.0 2025-07-15 18:54:04 +08:00
zimplexing
948368c3c8 feat: integrate settings and authentication stores for improved login status management 2025-07-15 18:53:57 +08:00
zimplexing
30cbf6846e feat: add favorites functionality and enhance detail screen 2025-07-15 17:07:53 +08:00
zimplexing
8985781865 refactor: enhance LoginModal and StyledButton components for improved functionality 2025-07-15 15:49:46 +08:00
zimplexing
bf99aee5f2 refactor: update play time property and enhance player settings management
- Changed the play time property from 'play_time' to 'time' in HomeScreen.
- Removed unused player control functions from PlayScreen.
- Added PlayerSettings interface and implemented PlayerSettingsManager for local storage of player settings.
- Refactored PlayRecordManager to merge API records with local player settings.
- Updated authentication logic in authStore to handle optional username parameter in login function.
- Cleaned up and optimized imports across various components.
2025-07-15 15:03:58 +08:00
Xin
bb9b8891c3 Update README.md 2025-07-15 14:18:27 +08:00
zimplexing
2bed3a4d00 feat: implement user authentication and logout functionality
- Added login/logout buttons to the HomeScreen and SettingsScreen.
- Integrated authentication state management using Zustand and cookies.
- Updated API to support username and password for login.
- Enhanced PlayScreen to handle video playback based on user authentication.
- Created a new detailStore to manage video details and sources.
- Refactored playerStore to utilize detailStore for episode management.
- Added sourceStore to manage video source toggling.
- Updated settingsStore to fetch server configuration.
- Improved error handling and user feedback with Toast notifications.
- Cleaned up unused code and optimized imports across components.
2025-07-14 22:55:55 +08:00
zimplexing
0452bfe21f feat: Implement user authentication and data management features
- Added LoginModal component for user login functionality.
- Introduced API routes for user login, favorites, play records, and search history management.
- Created JSON files for storing favorites, play records, and search history.
- Updated API service to handle new endpoints and refactored data management to use API calls instead of local storage.
- Adjusted data structures in types and services to align with new API responses.
2025-07-14 16:21:28 +08:00
zimplexing
f06b10feec fix: Update outro start time calculation to handle cases with undefined durationMillis 2025-07-14 13:51:25 +08:00
zimplexing
1c7c1cfd47 fix: Adjust jump position logic in PlayScreen to ensure a default value of 0 is used when both introEndTime and initialPosition are undefined 2025-07-14 13:43:54 +08:00
zimplexing
02eb19055b Update README.md to improve deployment instructions and add star history section 2025-07-14 13:39:54 +08:00
zimplexing
ee805960cc Add HTML charset 2025-07-14 13:31:02 +08:00
Xin
2d1d6be6b0 Update ANDROID_5_COMPATIBILITY_ANALYSIS.md 2025-07-11 22:18:33 +08:00
Xin
a471889c17 Update README.md 2025-07-11 22:12:09 +08:00
Xin
8ea09a18b8 Merge pull request #30 from zimplexing/v1.1.3
feat: Add Android 5.0 compatibility analysis report detailing risks, …
2025-07-11 22:11:40 +08:00
zimplexing
58bc857325 feat: Add Android 5.0 compatibility analysis report detailing risks, downgrade options, and implementation steps 2025-07-11 22:11:07 +08:00
Xin
22926a686b Merge pull request #29 from zimplexing/v1.1.2
fix: Update channel change logic to use useCallback for better perfor…
2025-07-11 21:45:03 +08:00
zimplexing
fbe858715a fix: Update channel change logic to use useCallback for better performance; adjust resource check in VideoSourceSection 2025-07-11 21:44:15 +08:00
Xin
5e1f7520d2 Merge pull request #28 from zimplexing/v1.1.1
Enhance category and tag selection functionality in HomeScreen
2025-07-11 19:19:22 +08:00
zimplexing
6df4f256e9 feat: Enhance settings screen with section tracking and success notifications; update remote control UI to support localization 2025-07-11 19:11:25 +08:00
zimplexing
7947a532ec fix: Update error handling in startServer to provide user-friendly message on failure 2025-07-11 18:21:22 +08:00
zimplexing
5f92f76f4b feat: Enable remote input functionality and enhance settings management for remote control 2025-07-11 18:13:06 +08:00
zimplexing
bda7329c1a Merge remote-tracking branch 'origin/master' into v1.1.1 2025-07-11 17:23:59 +08:00
zimplexing
03d80c42cd feat: Refactor settings management into a dedicated page with new configuration options, including live stream source and remote input settings 2025-07-11 17:23:36 +08:00
Xin
a881917c72 Update README.md 2025-07-11 16:33:39 +08:00
zimplexing
fc8da352fb feat: Refactor settings management into a dedicated page with new configuration options 2025-07-11 13:49:45 +08:00
zimplexing
7b3fd4b9d5 docs: Add comprehensive documentation for OrionTV native HTTP server implementation 2025-07-11 11:27:32 +08:00
zimplexing
ea601ba640 Refactor http-server implemention 2025-07-11 11:09:29 +08:00
zimplexing
9e4d4ca242 feat: Support remote input 2025-07-10 22:18:34 +08:00
zimplexing
eaa783824d Refactor LivePlayer component to improve loading state handling and error messaging 2025-07-10 17:26:12 +08:00
zimplexing
2ab64a683c Revert "Add voice search functionality to SearchScreen and update dependencies"
This reverts commit 8000cde907.
2025-07-10 16:47:18 +08:00
zimplexing
9b242497d0 Add Live functionality with LiveScreen and LivePlayer components; enhance SearchScreen with optimized speech handling 2025-07-10 16:45:54 +08:00
zimplexing
8000cde907 Add voice search functionality to SearchScreen and update dependencies 2025-07-10 14:34:36 +08:00
zimplexing
caba0f3d70 Enhance category and tag selection functionality in HomeScreen 2025-07-10 13:09:01 +08:00
Xin
d42a3e014e Update README.md 2025-07-09 12:28:36 +08:00
Xin
83bf083a6f Merge pull request #16 from zimplexing/store-refactor
Refactor components to use Zustand for state management
2025-07-08 22:08:22 +08:00
zimplexing
c9e5464000 Implement toggle functionality for intro and outro time settings with user feedback 2025-07-08 22:07:20 +08:00
zimplexing
30724a1e19 Add toast notifications for intro and outro time settings, update player store and media button for new time tracking 2025-07-08 22:07:14 +08:00
zimplexing
5043b33222 Refactor color scheme handling to use a fixed 'dark' theme and implement SourceSelectionModal for source management in the player 2025-07-08 20:57:38 +08:00
zimplexing
b238ffe3ba Refactor button animation logic to focus only on isFocused state and update version to 1.1.0 2025-07-08 20:33:06 +08:00
zimplexing
74ad0872cb Refactor components for consistent styling and improve button animations 2025-07-08 19:52:20 +08:00
zimplexing
504f12067b Refactor components to use StyledButton for consistent button styling
- Replaced custom button implementations with StyledButton in various components including DetailScreen, HomeScreen, SearchScreen, and SettingsModal.
- Updated button styles and behaviors to align with the new StyledButton component.
- Removed the obsolete DetailButton component to streamline the codebase.
2025-07-08 17:24:55 +08:00
zimplexing
9f721c22d5 Implement back navigation and control visibility in PlayScreen
- Added back navigation functionality using the router in PlayScreen.
- Implemented hardware back button handling to toggle controls visibility.
- Updated useTVRemoteHandler to show controls on down key press.
2025-07-08 17:03:15 +08:00
zimplexing
5b4c8db317 Add Prettier configuration and refactor code for consistent formatting
- Introduced a .prettierrc file to standardize code formatting.
- Updated import statements and JSX attributes in NotFoundScreen, HomeScreen, PlayScreen, and PlayerControls for consistent use of double quotes.
- Refactored styles in various components to use double quotes for string values.
- Added SeekingBar component to enhance video playback experience.
2025-07-08 16:58:06 +08:00
Xin
d8f7953109 Update README.md 2025-07-08 09:48:00 +08:00
zimplexing
bd22fa2996 Enhance video playback features by adding playTime and initialPosition handling, and update PlayerControls for better focus management 2025-07-07 22:14:56 +08:00
zimplexing
08e24dd748 Refactor components to use Zustand for state management
- Updated EpisodeSelectionModal to utilize Zustand for episode selection state.
- Refactored PlayerControls to manage playback state and controls using Zustand.
- Simplified SettingsModal to handle settings state with Zustand.
- Introduced homeStore for managing home screen categories and content data.
- Created playerStore for managing video playback and episode details.
- Added settingsStore for managing API settings and modal visibility.
- Updated package.json to include Zustand as a dependency.
- Cleaned up code formatting and improved readability across components.
2025-07-06 20:45:42 +08:00
81 changed files with 6320 additions and 3982 deletions

View File

@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(find:*)",
"Bash(rm:*)",
"Bash(yarn install)",
"Bash(yarn lint)",
"Bash(yarn prebuild-tv:*)",
"Bash(mkdir:*)",
"Bash(yarn lint:*)"
],
"deny": []
}
}

4
.eslintrc.js Normal file
View File

@@ -0,0 +1,4 @@
// https://docs.expo.dev/guides/using-eslint/
module.exports = {
extends: 'expo',
};

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ expo-env.d.ts
web/**
.bmad-core
.kilocodemodes
.roomodes
.roomodes
yarn-errors.log

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"printWidth": 120
}

107
CLAUDE.md Normal file
View File

@@ -0,0 +1,107 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
OrionTV is a React Native TVOS application for streaming video content, built with Expo and designed specifically for TV platforms (Apple TV and Android TV). The project includes both a frontend React Native app and a backend Express service.
## Key Commands
### Development Commands
- `yarn start-tv` - Start Metro bundler in TV mode
- `yarn ios-tv` - Build and run on Apple TV
- `yarn android-tv` - Build and run on Android TV
- `yarn prebuild-tv` - Generate native project files for TV (run this after dependency changes)
- `yarn lint` - Run linting checks
- `yarn test` - Run Jest tests with watch mode
- `yarn build-local` - Build Android APK locally
### Backend Commands (from `/backend` directory)
- `yarn dev` - Start backend development server with hot reload
- `yarn build` - Build TypeScript backend
- `yarn start` - Start production backend server
## Architecture Overview
### Frontend Structure
- **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, and remote control service
### Key Technologies
- React Native TVOS (0.74.x) - TV-optimized React Native
- Expo SDK 51 - Development platform and tooling
- TypeScript - Type safety throughout
- Zustand - Lightweight state management
- Expo AV - Video playback functionality
### State Management (Zustand Stores)
- `homeStore.ts` - Home screen content, categories, and play records
- `playerStore.ts` - Video player state and controls
- `settingsStore.ts` - App settings and configuration
- `remoteControlStore.ts` - Remote control server functionality
### TV-Specific Features
- Remote control navigation (`useTVRemoteHandler` hook)
- TV-optimized UI components with focus management
- Remote control server for external control via HTTP bridge
- Gesture handling for TV remote interactions
### Backend Architecture
- Express.js server providing API endpoints
- Routes for search, video details, and Douban integration
- Image proxy service for handling external images
- CORS enabled for cross-origin requests
## Development Workflow
### TV Development Notes
- Always use TV-specific commands (`*-tv` variants)
- Run `yarn prebuild-tv` after adding new dependencies
- Test on both Apple TV and Android TV simulators
- TV components require focus management and remote control support
### State Management 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`
### Component Structure
- 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`
## Testing
- Uses Jest with `jest-expo` preset
- Run tests with `yarn test`
- Component tests in `__tests__` directories
- Snapshot testing for UI components
## Common Issues
### 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`)
- Backend server must be running on port 3001 for full functionality
## File Structure Notes
- `/app` - Expo Router screens and navigation
- `/components` - Reusable UI components
- `/stores` - Zustand state management
- `/services` - API, storage, and external service integrations
- `/hooks` - Custom React hooks
- `/backend` - Express.js backend service
- `/constants` - App constants and theme definitions

View File

@@ -1,13 +1,12 @@
# OrionTV 📺
一个基于 React Native TVOS 和 Expo 构建的跨平台电视应用,旨在提供流畅的视频观看体验。项目包含一个用于数据服务的 Express 后端。
一个基于 React Native TVOS 和 Expo 构建的播放器,旨在提供流畅的视频观看体验。
## ✨ 功能特性
- **跨平台支持**: 同时支持 Apple TV 和 Android TV。
- **框架跨平台支持**: 同时支持构建 Apple TV 和 Android TV。
- **现代化前端**: 使用 Expo、React Native TVOS 和 TypeScript 构建,性能卓越。
- **Expo Router**: 基于文件系统的路由,使导航逻辑清晰简单。
- **后端服务**: 配套 Express 后端,用于处理数据获取、搜索和详情展示。
- **TV 优化的 UI**: 专为电视遥控器交互设计的用户界面。
## 🛠️ 技术栈
@@ -18,10 +17,6 @@
- [Expo Router](https://docs.expo.dev/router/introduction/)
- [Expo AV](https://docs.expo.dev/versions/latest/sdk/av/)
- TypeScript
- **后端**:
- [Node.js](https://nodejs.org/)
- [Express](https://expressjs.com/)
- [TypeScript](https://www.typescriptlang.org/)
## 📂 项目结构
@@ -31,7 +26,6 @@
.
├── app/ # Expo Router 路由和页面
├── assets/ # 静态资源 (字体, 图片, TV 图标)
├── backend/ # 后端 Express 应用
├── components/ # React 组件
├── constants/ # 应用常量 (颜色, 样式)
├── hooks/ # 自定义 Hooks
@@ -52,32 +46,13 @@
- [Xcode](https://developer.apple.com/xcode/) (用于 Apple TV 开发)
- [Android Studio](https://developer.android.com/studio) (用于 Android TV 开发)
### 1. 后端服务
首先,启动后端服务:
```sh
# 进入后端目录
cd backend
# 安装依赖
yarn
# 启动开发服务器
yarn dev
```
后端服务将运行在 `http://localhost:3001`
### 2. 前端应用
### 项目启动
接下来,在项目根目录运行前端应用:
```sh
# (如果还在 backend 目录) 返回根目录
cd ..
# 安装前端依赖
# 安装依赖
yarn
# [首次运行或依赖更新后] 生成原生项目文件
@@ -93,21 +68,12 @@ yarn android-tv
## 部署
### 后端部署
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 使用。
#### [Vercel](https://vercel.com/) 部署
## 其他
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fzimplexing%2FOrionTV&root-directory=backend)
#### Docker 部署
1. `docker pull zimpel1/tv-host`
2. `docker run -d -p 3001:3001 zimpel1/tv-host`
#### 使用 demo 地址
在设置中可以使用 demo 地址: https://orion-tv.vercel.app 需要代理且不保证稳定和可用性。
- 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本
@@ -116,14 +82,7 @@ yarn android-tv
- `yarn ios-tv`: 在 Apple TV 上构建并运行应用。
- `yarn android-tv`: 在 Android TV 上构建并运行应用。
- `yarn prebuild-tv`: 为 TV 构建生成原生项目文件。
- `yarn lint`: 检查代码风格
## 📸 应用截图
![首页界面](screenshot/image.png)
![详情页面](screenshot/image1.png)
![搜索界面](screenshot/image3.png)
![视频播放](screenshot/image2.png)
- `yarn lint`: 检查代码风格
## 📝 License
@@ -135,9 +94,17 @@ OrionTV 仅作为视频搜索工具,不存储、上传或分发任何视频内
本项目开发者不对使用本项目产生的任何后果负责。使用本项目时,您必须遵守当地的法律法规。
## 🌟 Star History
[![Star History Chart](https://api.star-history.com/svg?repos=zimplexing/OrionTV&type=Date)](https://www.star-history.com/#zimplexing/OrionTV&Date)
## 🙏 致谢
本项目受到以下开源项目的启发:
- [MoonTV](https://github.com/senshinya/MoonTV) - 一个基于 Next.js 的视频聚合应用
- [LibreTV](https://github.com/LibreSpark/LibreTV) - 一个开源的视频流媒体应用
感谢以下项目提供 API Key 的赞助
- [gpt-load](https://github.com/tbphp/gpt-load) - 一个高性能的 OpenAI 格式 API 多密钥轮询代理服务器,支持负载均衡,使用 Go 语言开发

View File

@@ -38,6 +38,7 @@
"android": {
"package": "com.oriontv",
"usesCleartextTraffic": true,
"hardwareAcceleration": true,
"networkSecurityConfig": "@xml/network_security_config",
"icon": "./assets/images/icon.png",
"permissions": [

View File

@@ -1,13 +1,14 @@
import {Link, Stack} from 'expo-router';
import {StyleSheet} from 'react-native';
import { Link, Stack } from "expo-router";
import { StyleSheet } from "react-native";
import {ThemedText} from '@/components/ThemedText';
import {ThemedView} from '@/components/ThemedView';
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import React from "react";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{title: 'Oops!'}} />
<Stack.Screen options={{ title: "Oops!" }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}>
@@ -21,8 +22,8 @@ export default function NotFoundScreen() {
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
alignItems: "center",
justifyContent: "center",
padding: 20,
},
link: {

View File

@@ -1,25 +1,37 @@
import {
DarkTheme,
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { Stack } from "expo-router";
import * as SplashScreen from "expo-splash-screen";
import { useEffect } from "react";
import { Platform } from "react-native";
import Toast from "react-native-toast-message";
import { useColorScheme } from "@/hooks/useColorScheme";
import { initializeApi } from "@/services/api";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import LoginModal from "@/components/LoginModal";
import useAuthStore from "@/stores/authStore";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const colorScheme = useColorScheme();
const colorScheme = "dark";
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const { loadSettings, remoteInputEnabled, apiBaseUrl } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => {
loadSettings();
}, [loadSettings]);
useEffect(() => {
if (apiBaseUrl) {
checkLoginStatus(apiBaseUrl);
}
}, [apiBaseUrl, checkLoginStatus]);
useEffect(() => {
if (loaded || error) {
@@ -31,8 +43,12 @@ export default function RootLayout() {
}, [loaded, error]);
useEffect(() => {
initializeApi();
}, []);
if (remoteInputEnabled) {
startServer();
} else {
stopServer();
}
}, [remoteInputEnabled, startServer, stopServer]);
if (!loaded && !error) {
return null;
@@ -43,12 +59,15 @@ export default function RootLayout() {
<Stack>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="detail" options={{ headerShown: false }} />
{Platform.OS !== "web" && (
<Stack.Screen name="play" options={{ headerShown: false }} />
)}
{Platform.OS !== "web" && <Stack.Screen name="play" options={{ headerShown: false }} />}
<Stack.Screen name="search" options={{ headerShown: false }} />
<Stack.Screen name="live" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="favorites" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<Toast />
<LoginModal />
</ThemeProvider>
);
}

View File

@@ -1,137 +1,64 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { ThemedView } from '@/components/ThemedView';
import { ThemedText } from '@/components/ThemedText';
import { api, SearchResult } from '@/services/api';
import { getResolutionFromM3U8 } from '@/services/m3u8';
import { DetailButton } from '@/components/DetailButton';
import React, { useEffect } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator, Pressable } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { StyledButton } from "@/components/StyledButton";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { FontAwesome } from "@expo/vector-icons";
export default function DetailScreen() {
const { source, q } = useLocalSearchParams();
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
const router = useRouter();
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const {
detail,
searchResults,
loading,
error,
allSourcesLoaded,
init,
setDetail,
abort,
isFavorited,
toggleFavorite,
} = useDetailStore();
useEffect(() => {
if (controllerRef.current) {
controllerRef.current.abort();
if (q) {
init(q, source, id);
}
controllerRef.current = new AbortController();
const signal = controllerRef.current.signal;
if (typeof q === 'string') {
const fetchDetailData = async () => {
setLoading(true);
setSearchResults([]);
setDetail(null);
setError(null);
setAllSourcesLoaded(false);
try {
const resources = await api.getResources(signal);
if (!resources || resources.length === 0) {
setError('没有可用的播放源');
setLoading(false);
return;
}
let foundFirstResult = false;
// Prioritize source from params if available
if (typeof source === 'string') {
const index = resources.findIndex(r => r.key === source);
if (index > 0) {
resources.unshift(resources.splice(index, 1)[0]);
}
}
for (const resource of resources) {
try {
const { results } = await api.searchVideo(q, resource.key, signal);
if (results && results.length > 0) {
const searchResult = results[0];
let resolution;
try {
if (searchResult.episodes && searchResult.episodes.length > 0) {
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
console.error(`Failed to get resolution for ${resource.name}`, e);
}
}
const resultWithResolution = { ...searchResult, resolution };
setSearchResults(prev => [...prev, resultWithResolution]);
if (!foundFirstResult) {
setDetail(resultWithResolution);
foundFirstResult = true;
setLoading(false);
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
console.error(`Error searching in resource ${resource.name}:`, e);
}
}
}
if (!foundFirstResult) {
setError('未找到播放源');
setLoading(false);
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
setError(e instanceof Error ? e.message : '获取资源列表失败');
setLoading(false);
}
} finally {
setAllSourcesLoaded(true);
}
};
fetchDetailData();
}
return () => {
controllerRef.current?.abort();
abort();
};
}, [q, source]);
}, [abort, init, q, source, id]);
const handlePlay = (episodeName: string, episodeIndex: number) => {
const handlePlay = (episodeIndex: number) => {
if (!detail) return;
controllerRef.current?.abort(); // Cancel any ongoing fetches
abort(); // Cancel any ongoing fetches
router.push({
pathname: '/play',
pathname: "/play",
params: {
// Pass necessary identifiers, the rest will be in the store
q: detail.title,
source: detail.source,
id: detail.id.toString(),
episodeUrl: episodeName, // The "episode" is actually the URL
episodeIndex: episodeIndex.toString(),
title: detail.title,
poster: detail.poster,
},
});
};
if (loading) {
return (
<ThemedView style={styles.centered}>
<ActivityIndicator size="large" />
</ThemedView>
);
return <VideoLoadingAnimation showProgressBar={false} />;
}
if (error) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle">{error}</ThemedText>
<ThemedText type="subtitle" style={styles.text}>
{error}
</ThemedText>
</ThemedView>
);
}
@@ -150,13 +77,23 @@ export default function DetailScreen() {
<View style={styles.topContainer}>
<Image source={{ uri: detail.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<ThemedText style={styles.title} numberOfLines={1}>
{detail.title}
</ThemedText>
<View style={styles.titleContainer}>
<ThemedText style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={styles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={styles.metaContainer}>
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View>
<ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView>
@@ -170,37 +107,45 @@ export default function DetailScreen() {
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={styles.sourceList}>
{searchResults.map((item, index) => (
<DetailButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
style={[styles.sourceButton, detail?.source === item.source && styles.sourceButtonSelected]}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? '99+' : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: '#28a745' }]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</DetailButton>
))}
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
);
})}
</View>
</View>
<View style={styles.episodesContainer}>
<ThemedText style={styles.episodesTitle}></ThemedText>
<ScrollView contentContainerStyle={styles.episodeList}>
{detail.episodes.map((episode, index) => (
<DetailButton key={index} style={styles.episodeButton} onPress={() => handlePlay(episode, index)}>
<ThemedText style={styles.episodeButtonText}>{`${index + 1}`}</ThemedText>
</DetailButton>
<StyledButton
key={index}
style={styles.episodeButton}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
/>
))}
</ScrollView>
</View>
@@ -212,11 +157,15 @@ export default function DetailScreen() {
const styles = StyleSheet.create({
container: { flex: 1 },
centered: { flex: 1, justifyContent: 'center', alignItems: 'center' },
centered: { flex: 1, justifyContent: "center", alignItems: "center" },
topContainer: {
flexDirection: 'row',
flexDirection: "row",
padding: 20,
},
text: {
padding: 20,
textAlign: "center",
},
poster: {
width: 200,
height: 300,
@@ -225,20 +174,24 @@ const styles = StyleSheet.create({
infoContainer: {
flex: 1,
marginLeft: 20,
justifyContent: 'flex-start',
justifyContent: "flex-start",
},
titleContainer: {
flexDirection: "row",
alignItems: "center",
},
title: {
paddingTop: 16,
fontSize: 28,
fontWeight: 'bold',
marginBottom: 10,
paddingTop: 20,
fontWeight: "bold",
flexShrink: 1,
},
metaContainer: {
flexDirection: 'row',
flexDirection: "row",
marginBottom: 10,
},
metaText: {
color: '#aaa',
color: "#aaa",
marginRight: 10,
fontSize: 14,
},
@@ -247,9 +200,18 @@ const styles = StyleSheet.create({
},
description: {
fontSize: 14,
color: '#ccc',
color: "#ccc",
lineHeight: 22,
},
favoriteButton: {
padding: 10,
marginLeft: 10,
backgroundColor: "transparent",
},
favoriteButtonText: {
marginLeft: 8,
fontSize: 16,
},
bottomContainer: {
paddingHorizontal: 20,
},
@@ -257,70 +219,60 @@ const styles = StyleSheet.create({
marginTop: 20,
},
sourcesTitleContainer: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
marginBottom: 10,
},
sourcesTitle: {
fontSize: 20,
fontWeight: 'bold',
fontWeight: "bold",
},
sourceList: {
flexDirection: 'row',
flexWrap: 'wrap',
flexDirection: "row",
flexWrap: "wrap",
},
sourceButton: {
backgroundColor: '#333',
paddingHorizontal: 15,
paddingVertical: 10,
borderRadius: 8,
margin: 5,
flexDirection: 'row',
alignItems: 'center',
borderWidth: 2,
borderColor: 'transparent',
},
sourceButtonSelected: {
backgroundColor: '#007bff',
margin: 8,
},
sourceButtonText: {
color: 'white',
color: "white",
fontSize: 16,
},
badge: {
backgroundColor: 'red',
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: 'white',
color: "#fff",
fontSize: 12,
fontWeight: 'bold',
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
selectedbadgeText: {
color: "#333",
},
episodesContainer: {
marginTop: 20,
},
episodesTitle: {
fontSize: 20,
fontWeight: 'bold',
fontWeight: "bold",
marginBottom: 10,
},
episodeList: {
flexDirection: 'row',
flexWrap: 'wrap',
flexDirection: "row",
flexWrap: "wrap",
},
episodeButton: {
backgroundColor: '#333',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
margin: 5,
borderWidth: 2,
borderColor: 'transparent',
},
episodeButtonText: {
color: 'white',
color: "white",
},
});

77
app/favorites.tsx Normal file
View File

@@ -0,0 +1,77 @@
import React, { useEffect } from "react";
import { View, StyleSheet, ActivityIndicator } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import useFavoritesStore from "@/stores/favoritesStore";
import { Favorite } from "@/services/storage";
import VideoCard from "@/components/VideoCard.tv";
import { api } from "@/services/api";
import CustomScrollView from "@/components/CustomScrollView";
export default function FavoritesScreen() {
const { favorites, loading, error, fetchFavorites } = useFavoritesStore();
useEffect(() => {
fetchFavorites();
}, [fetchFavorites]);
const renderItem = ({ item }: { item: Favorite & { key: string }; index: number }) => {
const [source, id] = item.key.split("+");
return (
<VideoCard
id={id}
source={source}
title={item.title}
sourceName={item.source_name}
poster={item.cover}
year={item.year}
api={api}
episodeIndex={1}
progress={0}
/>
);
};
return (
<ThemedView style={styles.container}>
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
</View>
<CustomScrollView
data={favorites}
renderItem={renderItem}
numColumns={5}
loading={loading}
error={error}
emptyMessage="暂无收藏"
/>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
paddingTop: 40,
},
headerContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 10,
},
headerTitle: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 16,
},
centered: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
list: {
padding: 10,
},
});

View File

@@ -1,213 +1,92 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from 'react-native';
import { ThemedView } from '@/components/ThemedView';
import { ThemedText } from '@/components/ThemedText';
import { api } from '@/services/api';
import { SearchResult } from '@/services/api';
import { PlayRecord } from '@/services/storage';
export type RowItem = (SearchResult | PlayRecord) & {
id: string;
source: string;
title: string;
poster: string;
progress?: number;
lastPlayed?: number;
episodeIndex?: number;
sourceName?: string;
totalEpisodes?: number;
year?: string;
rate?: string;
};
import VideoCard from '@/components/VideoCard.tv';
import { PlayRecordManager } from '@/services/storage';
import { useFocusEffect, useRouter } from 'expo-router';
import { useColorScheme } from 'react-native';
import { Search, Settings } from 'lucide-react-native';
import { SettingsModal } from '@/components/SettingsModal';
// --- 类别定义 ---
interface Category {
title: string;
type?: 'movie' | 'tv' | 'record';
tag?: string;
}
const initialCategories: Category[] = [
{ title: '最近播放', type: 'record' },
{ title: '热门剧集', type: 'tv', tag: '热门' },
{ title: '综艺', type: 'tv', tag: '综艺' },
{ title: '热门电影', type: 'movie', tag: '热门' },
{ title: '豆瓣 Top250', type: 'movie', tag: 'top250' },
{ title: '儿童', type: 'movie', tag: '少儿' },
{ title: '美剧', type: 'tv', tag: '美剧' },
{ title: '韩剧', type: 'tv', tag: '韩剧' },
{ title: '日剧', type: 'tv', tag: '日剧' },
{ title: '日漫', type: 'tv', tag: '日本动画' },
];
import React, { useEffect, useCallback, useRef, useState } from "react";
import { View, StyleSheet, ActivityIndicator, FlatList, Pressable, Dimensions } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import { api } from "@/services/api";
import VideoCard from "@/components/VideoCard.tv";
import { useFocusEffect, useRouter } from "expo-router";
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 CustomScrollView from "@/components/CustomScrollView";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get('window');
const ITEM_WIDTH = width / NUM_COLUMNS - 24;
const { width } = Dimensions.get("window");
// Threshold for triggering load more data (in pixels)
const LOAD_MORE_THRESHOLD = 200;
export default function HomeScreen() {
const router = useRouter();
const colorScheme = useColorScheme();
const colorScheme = "dark";
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [categories, setCategories] = useState<Category[]>(initialCategories);
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0]);
const [contentData, setContentData] = useState<RowItem[]>([]);
const {
categories,
selectedCategory,
contentData,
loading,
loadingMore,
error,
fetchInitialData,
loadMoreData,
selectCategory,
refreshPlayRecords,
} = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isSettingsVisible, setSettingsVisible] = useState(false);
const [pageStart, setPageStart] = useState(0);
const [hasMore, setHasMore] = useState(true);
const flatListRef = useRef<FlatList>(null);
// --- 数据获取逻辑 ---
const fetchPlayRecords = async () => {
const records = await PlayRecordManager.getAll();
return Object.entries(records)
.map(([key, record]) => {
const [source, id] = key.split('+');
return {
id,
source,
title: record.title,
poster: record.cover,
progress: record.play_time / record.total_time,
lastPlayed: record.save_time,
episodeIndex: record.index,
sourceName: record.source_name,
totalEpisodes: record.total_episodes,
} as RowItem;
})
.filter(record => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
};
const fetchData = async (category: Category, start: number, preloadedRecords?: RowItem[]) => {
if (category.type === 'record') {
const records = preloadedRecords ?? (await fetchPlayRecords());
if (records.length === 0 && categories.some(c => c.type === 'record')) {
// 如果没有播放记录,则移除"最近播放"分类并选择第一个真实分类
const newCategories = categories.filter(c => c.type !== 'record');
setCategories(newCategories);
if (newCategories.length > 0) {
handleCategorySelect(newCategories[0]);
}
} else {
setContentData(records);
setHasMore(false);
}
setLoading(false);
return;
}
if (!category.type || !category.tag) return;
setLoadingMore(start > 0);
setError(null);
try {
const result = await api.getDoubanData(category.type, category.tag, 20, start);
if (result.list.length === 0) {
setHasMore(false);
} else {
const newItems = result.list.map(item => ({
...item,
id: item.title, // 临时ID
source: 'douban',
})) as RowItem[];
setContentData(prev => (start === 0 ? newItems : [...prev, ...newItems]));
setPageStart(prev => prev + result.list.length);
setHasMore(true);
}
} catch (err: any) {
if (err.message === 'API_URL_NOT_SET') {
setError('请点击右上角设置按钮,配置您的 API 地址');
} else {
setError('加载失败,请重试');
}
} finally {
setLoading(false);
setLoadingMore(false);
}
};
// --- Effects ---
useFocusEffect(
useCallback(() => {
const manageRecordCategory = async () => {
const records = await fetchPlayRecords();
const hasRecords = records.length > 0;
setCategories(currentCategories => {
const recordCategoryExists = currentCategories.some(c => c.type === 'record');
if (hasRecords && !recordCategoryExists) {
// Add 'Recent Plays' if records exist and the tab doesn't
return [initialCategories[0], ...currentCategories];
}
return currentCategories;
});
// If 'Recent Plays' is selected, always refresh its data.
// This will also handle removing the tab if records have disappeared.
if (selectedCategory.type === 'record') {
loadInitialData(records);
}
};
manageRecordCategory();
}, [selectedCategory])
refreshPlayRecords();
}, [refreshPlayRecords])
);
useEffect(() => {
loadInitialData();
}, [selectedCategory]);
if (selectedCategory && !selectedCategory.tags) {
fetchInitialData();
} else if (selectedCategory?.tags && !selectedCategory.tag) {
// Category with tags selected, but no specific tag yet. Select the first one.
const defaultTag = selectedCategory.tags[0];
setSelectedTag(defaultTag);
selectCategory({ ...selectedCategory, tag: defaultTag });
}
}, [selectedCategory, fetchInitialData, selectCategory]);
const loadInitialData = (records?: RowItem[]) => {
setLoading(true);
setContentData([]);
setPageStart(0);
setHasMore(true);
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
fetchData(selectedCategory, 0, records);
};
const loadMoreData = () => {
if (loading || loadingMore || !hasMore || selectedCategory.type === 'record') return;
fetchData(selectedCategory, pageStart);
};
useEffect(() => {
if (selectedCategory && selectedCategory.tag) {
fetchInitialData();
}
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
const handleCategorySelect = (category: Category) => {
setSelectedCategory(category);
setSelectedTag(null);
selectCategory(category);
};
const handleTagSelect = (tag: string) => {
setSelectedTag(tag);
if (selectedCategory) {
// Create a new category object with the selected tag
const categoryWithTag = { ...selectedCategory, tag: tag };
selectCategory(categoryWithTag);
}
};
// --- 渲染组件 ---
const renderCategory = ({ item }: { item: Category }) => {
const isSelected = selectedCategory.title === item.title;
const isSelected = selectedCategory?.title === item.title;
return (
<Pressable
style={({ focused }) => [
styles.categoryButton,
isSelected && styles.categoryButtonSelected,
focused && styles.categoryButtonFocused,
]}
<StyledButton
text={item.title}
onPress={() => handleCategorySelect(item)}
>
<ThemedText style={[styles.categoryText, isSelected && styles.categoryTextSelected]}>{item.title}</ThemedText>
</Pressable>
isSelected={isSelected}
style={styles.categoryButton}
textStyle={styles.categoryText}
/>
);
};
const renderContentItem = ({ item }: { item: RowItem }) => (
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
<View style={styles.itemContainer}>
<VideoCard
id={item.id}
@@ -217,11 +96,12 @@ export default function HomeScreen() {
year={item.year}
rate={item.rate}
progress={item.progress}
playTime={item.play_time}
episodeIndex={item.episodeIndex}
sourceName={item.sourceName}
totalEpisodes={item.totalEpisodes}
api={api}
onRecordDeleted={loadInitialData} // For "Recent Plays"
onRecordDeleted={fetchInitialData} // For "Recent Plays"
/>
</View>
);
@@ -235,20 +115,33 @@ export default function HomeScreen() {
<ThemedView style={styles.container}>
{/* 顶部导航 */}
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
<View style={{ flexDirection: "row", alignItems: "center" }}>
<ThemedText style={styles.headerTitle}></ThemedText>
<Pressable style={{ marginLeft: 20 }} onPress={() => router.push("/live")}>
{({ focused }) => (
<ThemedText style={[styles.headerTitle, { color: focused ? "white" : "grey" }]}></ThemedText>
)}
</Pressable>
</View>
<View style={styles.rightHeaderButtons}>
<Pressable
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
onPress={() => router.push({ pathname: '/search' })}
<StyledButton style={styles.searchButton} onPress={() => router.push("/favorites")} variant="ghost">
<Heart color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton
style={styles.searchButton}
onPress={() => router.push({ pathname: "/search" })}
variant="ghost"
>
<Search color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
</Pressable>
<Pressable
style={({ focused }) => [styles.searchButton, focused && styles.searchButtonFocused]}
onPress={() => setSettingsVisible(true)}
>
<Settings color={colorScheme === 'dark' ? 'white' : 'black'} size={24} />
</Pressable>
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton style={styles.searchButton} onPress={() => router.push("/settings")} variant="ghost">
<Settings color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
{isLoggedIn && (
<StyledButton style={styles.searchButton} onPress={logout} variant="ghost">
<LogOut color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
)}
</View>
</View>
@@ -257,13 +150,40 @@ export default function HomeScreen() {
<FlatList
data={categories}
renderItem={renderCategory}
keyExtractor={item => item.title}
keyExtractor={(item) => item.title}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent}
/>
</View>
{/* Sub-category Tags */}
{selectedCategory && selectedCategory.tags && (
<View style={styles.categoryContainer}>
<FlatList
data={selectedCategory.tags}
renderItem={({ item, index }) => {
const isSelected = selectedTag === item;
return (
<StyledButton
hasTVPreferredFocus={index === 0} // Focus the first tag by default
text={item}
onPress={() => handleTagSelect(item)}
isSelected={isSelected}
style={styles.categoryButton}
textStyle={styles.categoryText}
variant="ghost"
/>
);
}}
keyExtractor={(item) => item}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.categoryListContent}
/>
</View>
)}
{/* 内容网格 */}
{loading ? (
<View style={styles.centerContainer}>
@@ -276,31 +196,19 @@ export default function HomeScreen() {
</ThemedText>
</View>
) : (
<FlatList
ref={flatListRef}
<CustomScrollView
data={contentData}
renderItem={renderContentItem}
keyExtractor={(item, index) => `${item.source}-${item.id}-${index}`}
numColumns={NUM_COLUMNS}
contentContainerStyle={styles.listContent}
loading={loading}
loadingMore={loadingMore}
error={error}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
loadMoreThreshold={LOAD_MORE_THRESHOLD}
emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}
ListFooterComponent={renderFooter}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText></ThemedText>
</View>
}
/>
)}
<SettingsModal
visible={isSettingsVisible}
onCancel={() => setSettingsVisible(false)}
onSave={() => {
setSettingsVisible(false);
loadInitialData();
}}
/>
</ThemedView>
);
}
@@ -313,61 +221,45 @@ const styles = StyleSheet.create({
centerContainer: {
flex: 1,
paddingTop: 20,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
// Header
headerContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 24,
marginBottom: 10,
},
headerTitle: {
fontSize: 32,
fontWeight: 'bold',
fontWeight: "bold",
paddingTop: 16,
},
rightHeaderButtons: {
flexDirection: 'row',
alignItems: 'center',
flexDirection: "row",
alignItems: "center",
},
searchButton: {
padding: 10,
borderRadius: 30,
marginLeft: 10,
},
searchButtonFocused: {
backgroundColor: '#007AFF',
transform: [{ scale: 1.1 }],
},
// Category Selector
categoryContainer: {
paddingBottom: 10,
paddingBottom: 6,
},
categoryListContent: {
paddingHorizontal: 16,
},
categoryButton: {
paddingHorizontal: 12,
paddingHorizontal: 2,
paddingVertical: 6,
borderRadius: 8,
marginHorizontal: 5,
},
categoryButtonSelected: {
backgroundColor: '#007AFF', // A bright blue for selected state
},
categoryButtonFocused: {
backgroundColor: '#0056b3', // A darker blue for focused state
elevation: 5,
marginHorizontal: 6,
},
categoryText: {
fontSize: 16,
fontWeight: '500',
},
categoryTextSelected: {
color: '#FFFFFF',
fontWeight: "500",
},
// Content Grid
listContent: {
@@ -376,7 +268,6 @@ const styles = StyleSheet.create({
},
itemContainer: {
margin: 8,
width: ITEM_WIDTH,
alignItems: 'center',
alignItems: "center",
},
});

209
app/live.tsx Normal file
View File

@@ -0,0 +1,209 @@
import React, { useState, useEffect, useCallback, useRef } from "react";
import { View, FlatList, StyleSheet, ActivityIndicator, Modal, useTVEventHandler, HWEvent, Text } from "react-native";
import LivePlayer from "@/components/LivePlayer";
import { fetchAndParseM3u, getPlayableUrl, Channel } from "@/services/m3u";
import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { useSettingsStore } from "@/stores/settingsStore";
export default function LiveScreen() {
const { m3uUrl } = useSettingsStore();
const [channels, setChannels] = useState<Channel[]>([]);
const [groupedChannels, setGroupedChannels] = useState<Record<string, Channel[]>>({});
const [channelGroups, setChannelGroups] = useState<string[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string>("");
const [currentChannelIndex, setCurrentChannelIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isChannelListVisible, setIsChannelListVisible] = useState(false);
const [channelTitle, setChannelTitle] = useState<string | null>(null);
const titleTimer = useRef<NodeJS.Timeout | null>(null);
const selectedChannelUrl = channels.length > 0 ? getPlayableUrl(channels[currentChannelIndex].url) : null;
useEffect(() => {
const loadChannels = async () => {
if (!m3uUrl) return;
setIsLoading(true);
const parsedChannels = await fetchAndParseM3u(m3uUrl);
setChannels(parsedChannels);
const groups: Record<string, Channel[]> = parsedChannels.reduce((acc, channel) => {
const groupName = channel.group || "Other";
if (!acc[groupName]) {
acc[groupName] = [];
}
acc[groupName].push(channel);
return acc;
}, {} as Record<string, Channel[]>);
const groupNames = Object.keys(groups);
setGroupedChannels(groups);
setChannelGroups(groupNames);
setSelectedGroup(groupNames[0] || "");
if (parsedChannels.length > 0) {
showChannelTitle(parsedChannels[0].name);
}
setIsLoading(false);
};
loadChannels();
}, [m3uUrl]);
const showChannelTitle = (title: string) => {
setChannelTitle(title);
if (titleTimer.current) clearTimeout(titleTimer.current);
titleTimer.current = setTimeout(() => setChannelTitle(null), 3000);
};
const handleSelectChannel = (channel: Channel) => {
const globalIndex = channels.findIndex((c) => c.id === channel.id);
if (globalIndex !== -1) {
setCurrentChannelIndex(globalIndex);
showChannelTitle(channel.name);
setIsChannelListVisible(false);
}
};
const changeChannel = useCallback(
(direction: "next" | "prev") => {
if (channels.length === 0) return;
let newIndex =
direction === "next"
? (currentChannelIndex + 1) % channels.length
: (currentChannelIndex - 1 + channels.length) % channels.length;
setCurrentChannelIndex(newIndex);
showChannelTitle(channels[newIndex].name);
},
[channels, currentChannelIndex]
);
const handleTVEvent = useCallback(
(event: HWEvent) => {
if (isChannelListVisible) return;
if (event.eventType === "down") setIsChannelListVisible(true);
else if (event.eventType === "left") changeChannel("prev");
else if (event.eventType === "right") changeChannel("next");
},
[changeChannel, isChannelListVisible]
);
useTVEventHandler(handleTVEvent);
return (
<ThemedView style={styles.container}>
<LivePlayer streamUrl={selectedChannelUrl} channelTitle={channelTitle} onPlaybackStatusUpdate={() => {}} />
<Modal
animationType="slide"
transparent={true}
visible={isChannelListVisible}
onRequestClose={() => setIsChannelListVisible(false)}
>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<View style={styles.listContainer}>
<View style={styles.groupColumn}>
<FlatList
data={channelGroups}
keyExtractor={(item, index) => `group-${item}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item}
onPress={() => setSelectedGroup(item)}
isSelected={selectedGroup === item}
style={styles.groupButton}
textStyle={styles.groupButtonText}
/>
)}
/>
</View>
<View style={styles.channelColumn}>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<FlatList
data={groupedChannels[selectedGroup] || []}
keyExtractor={(item, index) => `${item.id}-${item.group}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item.name || "Unknown Channel"}
onPress={() => handleSelectChannel(item)}
isSelected={channels[currentChannelIndex]?.id === item.id}
hasTVPreferredFocus={channels[currentChannelIndex]?.id === item.id}
style={styles.channelItem}
textStyle={styles.channelItemText}
/>
)}
/>
)}
</View>
</View>
</View>
</View>
</Modal>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.5)",
justifyContent: "center",
alignItems: "center",
},
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: 450,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 15,
},
modalTitle: {
color: "white",
marginBottom: 10,
textAlign: "center",
fontSize: 16,
fontWeight: "bold",
},
listContainer: {
flex: 1,
flexDirection: "row",
},
groupColumn: {
flex: 1,
marginRight: 10,
},
channelColumn: {
flex: 2,
},
groupButton: {
paddingVertical: 8,
paddingHorizontal: 4,
marginVertical: 4,
paddingLeft: 10,
paddingRight: 10,
},
groupButtonText: {
fontSize: 13,
},
channelItem: {
paddingVertical: 6,
paddingHorizontal: 4,
marginVertical: 3,
paddingLeft: 16,
paddingRight: 16,
},
channelItemText: {
fontSize: 12,
},
});

View File

@@ -1,183 +1,161 @@
import React, { useState, useRef } from "react";
import {
View,
StyleSheet,
TouchableOpacity,
ActivityIndicator,
} from "react-native";
import { useRouter } from "expo-router";
import React, { useEffect, useRef } from "react";
import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
import { ThemedView } from "@/components/ThemedView";
import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { usePlaybackManager } from "@/hooks/usePlaybackManager";
import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
// import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
export default function PlayScreen() {
const router = useRouter();
const videoRef = useRef<Video>(null);
const router = useRouter();
useKeepAwake();
const {
detail,
episodes,
currentEpisodeIndex,
status,
episodeIndex: episodeIndexStr,
position: positionStr,
source: sourceStr,
id: videoId,
title: videoTitle,
} = useLocalSearchParams<{
episodeIndex: string;
position?: string;
source?: string;
id?: string;
title?: string;
}>();
const episodeIndex = parseInt(episodeIndexStr || "0", 10);
const position = positionStr ? parseInt(positionStr, 10) : undefined;
const { detail } = useDetailStore();
const source = sourceStr || detail?.source;
const id = videoId || detail?.id.toString();
const title = videoTitle || detail?.title;
const {
isLoading,
setIsLoading,
showNextEpisodeOverlay,
playEpisode,
togglePlayPause,
seek,
handlePlaybackStatusUpdate,
setShowNextEpisodeOverlay,
} = usePlaybackManager(videoRef);
const [showControls, setShowControls] = useState(true);
const [showEpisodeModal, setShowEpisodeModal] = useState(false);
const [episodeGroupSize] = useState(30);
const [selectedEpisodeGroup, setSelectedEpisodeGroup] = useState(
Math.floor(currentEpisodeIndex / episodeGroupSize)
);
const { currentFocus, setCurrentFocus } = useTVRemoteHandler({
showControls,
// showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
handlePlaybackStatusUpdate,
setShowControls,
showEpisodeModal,
onPlayPause: togglePlayPause,
onSeek: seek,
onShowEpisodes: () => setShowEpisodeModal(true),
onPlayNextEpisode: () => {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
// setShowNextEpisodeOverlay,
reset,
loadVideo,
} = usePlayerStore();
const currentEpisode = usePlayerStore(selectCurrentEpisode);
useEffect(() => {
setVideoRef(videoRef);
if (source && id && title) {
loadVideo({ source, id, episodeIndex, position, title });
}
return () => {
reset(); // Reset state when component unmounts
};
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "background" || nextAppState === "inactive") {
videoRef.current?.pauseAsync();
}
},
});
};
const [isSeeking, setIsSeeking] = useState(false);
const [seekPosition, setSeekPosition] = useState(0);
const [progressPosition, setProgressPosition] = useState(0);
const subscription = AppState.addEventListener("change", handleAppStateChange);
const formatTime = (milliseconds: number) => {
if (!milliseconds) return "00:00";
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
};
return () => {
subscription.remove();
};
}, []);
const handleSeekStart = () => setIsSeeking(true);
const { onScreenPress } = useTVRemoteHandler();
const handleSeekMove = (event: { nativeEvent: { locationX: number } }) => {
if (!status?.isLoaded || !status.durationMillis) return;
const { locationX } = event.nativeEvent;
const progressBarWidth = 300;
const progress = Math.max(0, Math.min(locationX / progressBarWidth, 1));
setSeekPosition(progress);
};
const handleSeekRelease = (event: { nativeEvent: { locationX: number } }) => {
if (!videoRef.current || !status?.isLoaded || !status.durationMillis)
return;
const wasPlaying = status.isPlaying;
const { locationX } = event.nativeEvent;
const progressBarWidth = 300;
const progress = Math.max(0, Math.min(locationX / progressBarWidth, 1));
const newPosition = progress * status.durationMillis;
videoRef.current.setPositionAsync(newPosition).then(() => {
if (wasPlaying) {
videoRef.current?.playAsync();
useEffect(() => {
const backAction = () => {
if (showControls) {
setShowControls(false);
return true;
}
});
setIsSeeking(false);
};
router.back();
return true;
};
if (!detail && isLoading) {
return (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
</ThemedView>
);
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove();
}, [showControls, setShowControls, router]);
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
if (isLoading) {
timeoutId = setTimeout(() => {
if (usePlayerStore.getState().isLoading) {
usePlayerStore.setState({ isLoading: false });
Toast.show({ type: "error", text1: "播放超时,请重试" });
}
}, 60000); // 1 minute
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading]);
if (!detail) {
return <VideoLoadingAnimation showProgressBar />;
}
const currentEpisode = episodes[currentEpisodeIndex];
const videoTitle = detail?.videoInfo?.title || "";
const hasNextEpisode = currentEpisodeIndex < episodes.length - 1;
return (
<ThemedView style={styles.container}>
<TouchableOpacity
activeOpacity={1}
style={styles.videoContainer}
onPress={() => {
setShowControls(!showControls);
setCurrentFocus(null);
}}
>
<ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<Video
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }}
source={{ uri: currentEpisode?.url || "" }}
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={(s) => {
handlePlaybackStatusUpdate(s);
if (s.isLoaded && !isSeeking) {
setProgressPosition(s.positionMillis / (s.durationMillis || 1));
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
const jumpPosition = initialPosition || introEndTime || 0;
if (jumpPosition > 0) {
videoRef.current?.setPositionAsync(jumpPosition);
}
usePlayerStore.setState({ isLoading: false });
}}
onLoad={() => setIsLoading(false)}
onLoadStart={() => setIsLoading(true)}
onLoadStart={() => usePlayerStore.setState({ isLoading: true })}
useNativeControls={false}
shouldPlay
/>
{showControls && (
<PlayerControls
videoTitle={videoTitle}
currentEpisodeTitle={currentEpisode?.title}
status={status}
isSeeking={isSeeking}
seekPosition={seekPosition}
progressPosition={progressPosition}
currentFocus={currentFocus}
hasNextEpisode={hasNextEpisode}
onSeekStart={handleSeekStart}
onSeekMove={handleSeekMove}
onSeekRelease={handleSeekRelease}
onSeek={seek}
onTogglePlayPause={togglePlayPause}
onPlayNextEpisode={() => playEpisode(currentEpisodeIndex + 1)}
onShowEpisodes={() => setShowEpisodeModal(true)}
formatTime={formatTime}
/>
{showControls && <PlayerControls showControls={showControls} setShowControls={setShowControls} />}
<SeekingBar />
{isLoading && (
<View style={styles.videoContainer}>
<VideoLoadingAnimation showProgressBar />
</View>
)}
<LoadingOverlay visible={isLoading} />
<NextEpisodeOverlay
visible={showNextEpisodeOverlay}
onCancel={() => setShowNextEpisodeOverlay(false)}
/>
{/* <NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> */}
</TouchableOpacity>
<EpisodeSelectionModal
visible={showEpisodeModal}
episodes={episodes}
currentEpisodeIndex={currentEpisodeIndex}
episodeGroupSize={episodeGroupSize}
selectedEpisodeGroup={selectedEpisodeGroup}
setSelectedEpisodeGroup={setSelectedEpisodeGroup}
onSelectEpisode={(index) => {
playEpisode(index);
setShowEpisodeModal(false);
}}
onClose={() => setShowEpisodeModal(false)}
/>
<EpisodeSelectionModal />
<SourceSelectionModal />
</ThemedView>
);
}

View File

@@ -1,40 +1,52 @@
import React, { useState, useRef, useEffect } from 'react';
import {
View,
TextInput,
StyleSheet,
FlatList,
ActivityIndicator,
Pressable,
Text,
Keyboard,
useColorScheme,
} from 'react-native';
import { ThemedView } from '@/components/ThemedView';
import { ThemedText } from '@/components/ThemedText';
import VideoCard from '@/components/VideoCard.tv';
import { api, SearchResult } from '@/services/api';
import { Search } from 'lucide-react-native';
import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import VideoCard from "@/components/VideoCard.tv";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import { api, SearchResult } from "@/services/api";
import { Search, QrCode } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { RemoteControlModal } from "@/components/RemoteControlModal";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRouter } from "expo-router";
import { Colors } from "@/constants/Colors";
import CustomScrollView from "@/components/CustomScrollView";
export default function SearchScreen() {
const [keyword, setKeyword] = useState('');
const [keyword, setKeyword] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const textInputRef = useRef<TextInput>(null);
const colorScheme = useColorScheme();
const colorScheme = "dark"; // Replace with useColorScheme() if needed
const [isInputFocused, setIsInputFocused] = useState(false);
const { showModal: showRemoteModal, lastMessage } = useRemoteControlStore();
const { remoteInputEnabled } = useSettingsStore();
const router = useRouter();
useEffect(() => {
// Focus the text input when the screen loads
const timer = setTimeout(() => {
textInputRef.current?.focus();
}, 200);
return () => clearTimeout(timer);
}, []);
if (lastMessage) {
console.log("Received remote input:", lastMessage);
const realMessage = lastMessage.split("_")[0];
setKeyword(realMessage);
handleSearch(realMessage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessage]);
const handleSearch = async () => {
if (!keyword.trim()) {
// useEffect(() => {
// // Focus the text input when the screen loads
// const timer = setTimeout(() => {
// textInputRef.current?.focus();
// }, 200);
// return () => clearTimeout(timer);
// }, []);
const handleSearch = async (searchText?: string) => {
const term = typeof searchText === "string" ? searchText : keyword;
if (!term.trim()) {
Keyboard.dismiss();
return;
}
@@ -42,21 +54,34 @@ export default function SearchScreen() {
setLoading(true);
setError(null);
try {
const response = await api.searchVideos(keyword);
const response = await api.searchVideos(term);
if (response.results.length > 0) {
setResults(response.results);
} else {
setError('没有找到相关内容');
setError("没有找到相关内容");
}
} catch (err) {
setError('搜索失败,请稍后重试。');
console.error('Search failed:', err);
setError("搜索失败,请稍后重试。");
console.info("Search failed:", err);
} finally {
setLoading(false);
}
};
const renderItem = ({ item }: { item: SearchResult }) => (
const onSearchPress = () => handleSearch();
const handleQrPress = () => {
if (!remoteInputEnabled) {
Alert.alert("远程输入未启用", "请先在设置页面中启用远程输入功能", [
{ text: "取消", style: "cancel" },
{ text: "去设置", onPress: () => router.push("/settings") },
]);
return;
}
showRemoteModal();
};
const renderItem = ({ item, index }: { item: SearchResult; index: number }) => (
<VideoCard
id={item.id.toString()}
source={item.source}
@@ -71,61 +96,61 @@ export default function SearchScreen() {
return (
<ThemedView style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
ref={textInputRef}
<TouchableOpacity
activeOpacity={1}
style={[
styles.input,
{
backgroundColor: colorScheme === 'dark' ? '#2c2c2e' : '#f0f0f0',
color: colorScheme === 'dark' ? 'white' : 'black',
borderColor: isInputFocused ? '#007bff' : 'transparent',
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
borderWidth: 2,
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'}
value={keyword}
onChangeText={setKeyword}
onPress={() => textInputRef.current?.focus()}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onSubmitEditing={handleSearch} // Allow searching with remote 'enter' button
returnKeyType="search"
/>
<Pressable
style={({ focused }) => [
styles.searchButton,
{
backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#e0e0e0',
},
focused && styles.focusedButton,
]}
onPress={handleSearch}
>
<Search size={24} color={colorScheme === 'dark' ? 'white' : 'black'} />
</Pressable>
<TextInput
ref={textInputRef}
style={[
styles.input,
{
color: colorScheme === "dark" ? "white" : "black",
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
</TouchableOpacity>
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
</StyledButton>
<StyledButton style={styles.qrButton} onPress={handleQrPress}>
<QrCode size={24} color={colorScheme === "dark" ? "white" : "black"} />
</StyledButton>
</View>
{loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" />
</View>
<VideoLoadingAnimation showProgressBar={false} />
) : error ? (
<View style={styles.centerContainer}>
<ThemedText style={styles.errorText}>{error}</ThemedText>
</View>
) : (
<FlatList
<CustomScrollView
data={results}
renderItem={renderItem}
keyExtractor={(item, index) => `${item.id}-${item.source}-${index}`}
numColumns={5} // Adjust based on your card size and desired layout
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText></ThemedText>
</View>
}
numColumns={5}
loading={loading}
error={error}
emptyMessage="输入关键词开始搜索"
/>
)}
<RemoteControlModal />
</ThemedView>
);
}
@@ -136,39 +161,40 @@ const styles = StyleSheet.create({
paddingTop: 50,
},
searchContainer: {
flexDirection: 'row',
flexDirection: "row",
paddingHorizontal: 20,
marginBottom: 20,
alignItems: 'center',
alignItems: "center",
},
input: {
flex: 1,
height: 50,
backgroundColor: '#2c2c2e', // Default for dark mode, overridden inline
backgroundColor: "#2c2c2e", // Default for dark mode, overridden inline
borderRadius: 8,
paddingHorizontal: 15,
color: 'white', // Default for dark mode, overridden inline
color: "white", // Default for dark mode, overridden inline
fontSize: 18,
marginRight: 10,
borderWidth: 2,
borderColor: 'transparent', // Default, overridden for focus
borderColor: "transparent", // Default, overridden for focus
},
searchButton: {
padding: 12,
// backgroundColor is now set dynamically
borderRadius: 8,
},
focusedButton: {
backgroundColor: '#007bff',
transform: [{ scale: 1.1 }],
qrButton: {
padding: 12,
borderRadius: 8,
marginLeft: 10,
},
centerContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
justifyContent: "center",
alignItems: "center",
},
errorText: {
color: 'red',
color: "red",
},
listContent: {
paddingHorizontal: 10,

209
app/settings.tsx Normal file
View File

@@ -0,0 +1,209 @@
import React, { useState, useEffect, useRef } from "react";
import { View, StyleSheet, FlatList, Alert, KeyboardAvoidingView, Platform } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { ThemedView } from "@/components/ThemedView";
import { StyledButton } from "@/components/StyledButton";
import { useThemeColor } from "@/hooks/useThemeColor";
import { useSettingsStore } from "@/stores/settingsStore";
// import useAuthStore from "@/stores/authStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { APIConfigSection } from "@/components/settings/APIConfigSection";
import { LiveStreamSection } from "@/components/settings/LiveStreamSection";
import { RemoteInputSection } from "@/components/settings/RemoteInputSection";
// import { VideoSourceSection } from "@/components/settings/VideoSourceSection";
import Toast from "react-native-toast-message";
export default function SettingsScreen() {
const { loadSettings, saveSettings, setApiBaseUrl, setM3uUrl } = useSettingsStore();
const { lastMessage } = useRemoteControlStore();
const backgroundColor = useThemeColor({}, "background");
const [hasChanges, setHasChanges] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
const [currentSection, setCurrentSection] = useState<string | null>(null);
const saveButtonRef = useRef<any>(null);
const apiSectionRef = useRef<any>(null);
const liveStreamSectionRef = useRef<any>(null);
useEffect(() => {
loadSettings();
}, [loadSettings]);
useEffect(() => {
if (lastMessage) {
const realMessage = lastMessage.split("_")[0];
handleRemoteInput(realMessage);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessage]);
const handleRemoteInput = (message: string) => {
// Handle remote input based on currently focused section
if (currentSection === "api" && apiSectionRef.current) {
// API Config Section
setApiBaseUrl(message);
} else if (currentSection === "livestream" && liveStreamSectionRef.current) {
// Live Stream Section
setM3uUrl(message);
}
};
const handleSave = async () => {
setIsLoading(true);
try {
await saveSettings();
setHasChanges(false);
Toast.show({
type: "success",
text1: "保存成功",
});
} catch {
Alert.alert("错误", "保存设置失败");
} finally {
setIsLoading(false);
}
};
const markAsChanged = () => {
setHasChanges(true);
};
const sections = [
{
component: (
<RemoteInputSection
onChanged={markAsChanged}
onFocus={() => {
setCurrentFocusIndex(0);
setCurrentSection("remote");
}}
/>
),
key: "remote",
},
{
component: (
<APIConfigSection
ref={apiSectionRef}
onChanged={markAsChanged}
onFocus={() => {
setCurrentFocusIndex(1);
setCurrentSection("api");
}}
/>
),
key: "api",
},
{
component: (
<LiveStreamSection
ref={liveStreamSectionRef}
onChanged={markAsChanged}
onFocus={() => {
setCurrentFocusIndex(2);
setCurrentSection("livestream");
}}
/>
),
key: "livestream",
},
// {
// component: (
// <VideoSourceSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(3);
// setCurrentSection("videoSource");
// }}
// />
// ),
// key: "videoSource",
// },
];
// TV遥控器事件处理
const handleTVEvent = React.useCallback(
(event: any) => {
if (event.eventType === "down") {
const nextIndex = Math.min(currentFocusIndex + 1, sections.length);
setCurrentFocusIndex(nextIndex);
if (nextIndex === sections.length) {
saveButtonRef.current?.focus();
}
} else if (event.eventType === "up") {
const prevIndex = Math.max(currentFocusIndex - 1, 0);
setCurrentFocusIndex(prevIndex);
}
},
[currentFocusIndex, sections.length]
);
useTVEventHandler(handleTVEvent);
return (
<KeyboardAvoidingView style={{ flex: 1, backgroundColor }} behavior={Platform.OS === "ios" ? "padding" : "height"}>
<ThemedView style={styles.container}>
<View style={styles.header}>
<ThemedText style={styles.title}></ThemedText>
</View>
<View style={styles.scrollView}>
<FlatList
data={sections}
renderItem={({ item }) => item.component}
keyExtractor={(item) => item.key}
showsVerticalScrollIndicator={false}
/>
</View>
<View style={styles.footer}>
<StyledButton
text={isLoading ? "保存中..." : "保存设置"}
onPress={handleSave}
variant="primary"
disabled={!hasChanges || isLoading}
style={[styles.saveButton, (!hasChanges || isLoading) && styles.disabledButton]}
/>
</View>
</ThemedView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 12,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 10,
},
title: {
fontSize: 32,
fontWeight: "bold",
paddingTop: 24,
},
backButton: {
minWidth: 100,
},
scrollView: {
flex: 1,
},
footer: {
paddingTop: 12,
alignItems: "flex-end",
},
saveButton: {
minHeight: 50,
width: 120,
},
disabledButton: {
opacity: 0.5,
},
});

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,5 +0,0 @@
# The port the backend server will run on
PORT=3001
# Optional: The password for the login endpoint. If not provided, login is disabled.
PASSWORD=

View File

@@ -1,38 +0,0 @@
# --- Build Stage ---
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package.json and yarn.lock first to leverage Docker cache
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Copy the rest of the source code
COPY . .
# Compile TypeScript to JavaScript
RUN yarn build
# Prune development dependencies
RUN yarn install --production --ignore-scripts --prefer-offline
# --- Production Stage ---
FROM node:18-alpine
WORKDIR /app
# Copy production dependencies and compiled code from the builder stage
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
# Copy config.json from the project root relative to the Docker build context
# IMPORTANT: When building, run `docker build -f backend/Dockerfile .` from the project root.
COPY src/config/config.json dist/config/
# Expose the port the app runs on
EXPOSE 3001
# The command to run the application
# You can override the port using -e PORT=... in `docker run`
CMD [ "node", "dist/index.docker.js" ]

View File

@@ -1,26 +0,0 @@
{
"name": "OrionTV-proxy",
"version": "1.0.1",
"description": "Backend service for MyTV application",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"build": "tsc",
"dev": "ts-node-dev --respawn --transpile-only src/index.docker.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.19.2",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.14.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.4.5"
}
}

View File

@@ -1,85 +0,0 @@
{
"cache_time": 7200,
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源",
"detail": "http://caiji.dyttzyapi.com"
},
"ruyi": {
"api": "https://cj.rycjapi.com/api.php/provide/vod",
"name": "如意资源"
},
"mozhua": {
"api": "https://mozhuazy.com/api.php/provide/vod",
"name": "魔爪资源"
},
"heimuer": {
"api": "https://json.heimuer.xyz/api.php/provide/vod",
"name": "黑木耳",
"detail": "https://heimuer.tv"
},
"bfzy": {
"api": "https://bfzyapi.com/api.php/provide/vod",
"name": "暴风资源"
},
"tyyszy": {
"api": "https://tyyszy.com/api.php/provide/vod",
"name": "天涯资源"
},
"ffzy": {
"api": "http://ffzy5.tv/api.php/provide/vod",
"name": "非凡影视",
"detail": "http://ffzy5.tv"
},
"zy360": {
"api": "https://360zy.com/api.php/provide/vod",
"name": "360资源"
},
"iqiyi": {
"api": "https://www.iqiyizyapi.com/api.php/provide/vod",
"name": "iqiyi资源"
},
"wolong": {
"api": "https://wolongzyw.com/api.php/provide/vod",
"name": "卧龙资源"
},
"hwba": {
"api": "https://cjhwba.com/api.php/provide/vod",
"name": "华为吧资源"
},
"jisu": {
"api": "https://jszyapi.com/api.php/provide/vod",
"name": "极速资源",
"detail": "https://jszyapi.com"
},
"dbzy": {
"api": "https://dbzy.tv/api.php/provide/vod",
"name": "豆瓣资源"
},
"mdzy": {
"api": "https://www.mdzyapi.com/api.php/provide/vod",
"name": "魔都资源"
},
"zuid": {
"api": "https://api.zuidapi.com/api.php/provide/vod",
"name": "最大资源"
},
"yinghua": {
"api": "https://m3u8.apiyhzy.com/api.php/provide/vod",
"name": "樱花资源"
},
"wujin": {
"api": "https://api.wujinapi.me/api.php/provide/vod",
"name": "无尽资源"
},
"wwzy": {
"api": "https://wwzy.tv/api.php/provide/vod",
"name": "旺旺短剧"
},
"ikun": {
"api": "https://ikunzyapi.com/api.php/provide/vod",
"name": "iKun资源"
}
}
}

View File

@@ -1,80 +0,0 @@
import fs from "fs";
import path from "path";
export interface ApiSite {
key: string;
api: string;
name: string;
detail?: string;
}
export interface StorageConfig {
type: "localstorage" | "database";
database?: {
host?: string;
port?: number;
username?: string;
password?: string;
database?: string;
};
}
export interface Config {
cache_time?: number;
api_site: {
[key: string]: ApiSite;
};
storage?: StorageConfig;
}
export const API_CONFIG = {
search: {
path: "?ac=videolist&wd=",
pagePath: "?ac=videolist&wd={query}&pg={page}",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
detail: {
path: "?ac=videolist&ids=",
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
Accept: "application/json",
},
},
};
// Adjust path to read from project root, not from `backend/`
const configPath = path.join(__dirname, "config.json");
let cachedConfig: Config;
try {
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
} catch (error) {
console.error(`Error reading or parsing config.json at ${configPath}`, error);
// Provide a default fallback config to prevent crashes
cachedConfig = {
api_site: {},
cache_time: 300,
};
}
export function getConfig(): Config {
return cachedConfig;
}
export function getCacheTime(): number {
const config = getConfig();
return config.cache_time || 300; // 默认5分钟缓存
}
export function getApiSites(): ApiSite[] {
const config = getConfig();
return Object.entries(config.api_site).map(([key, site]) => ({
...site,
key,
}));
}

View File

@@ -1,28 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
export default app;

View File

@@ -1,24 +0,0 @@
import express, { Express, Request, Response } from "express";
import cors from "cors";
import dotenv from "dotenv";
dotenv.config();
const app: Express = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json());
// Health check route
app.get("/", (req: Request, res: Response) => {
res.send("MyTV Backend Service is running!");
});
import apiRouter from "./routes";
// API routes
app.use("/api", apiRouter);
export default app;

View File

@@ -1,186 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { VideoDetail } from "../types";
import { cleanHtmlTags } from "../utils";
const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情页请求失败: ${response.status}`);
}
const html = await response.text();
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
if (matches.length === 0) {
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
matches = html.match(generalPattern) || [];
}
matches = Array.from(new Set(matches)).map((link: string) => {
link = link.substring(1);
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : "";
return {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const response = await fetch(detailUrl, {
headers: API_CONFIG.detail.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`详情请求失败: ${response.status}`);
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
throw new Error("获取到的详情内容无效");
}
const videoDetail = data.list[0];
let episodes: string[] = [];
if (videoDetail.vod_play_url) {
const playSources = videoDetail.vod_play_url.split("$$$");
if (playSources.length > 0) {
const mainSource = playSources[0];
const episodeList = mainSource.split("#");
episodes = episodeList
.map((ep: string) => {
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter(
(url: string) =>
url && (url.startsWith("http://") || url.startsWith("https://"))
);
}
}
if (episodes.length === 0 && videoDetail.vod_content) {
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
episodes = matches.map((link: string) => link.replace(/^\$/, ""));
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
};
}
async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}
if (!/^[\w-]+$/.test(id)) {
throw new Error("无效的视频ID格式");
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {
throw new Error("无效的API来源");
}
if (apiSite.detail) {
return handleSpecialSourceDetail(id, apiSite);
}
return getDetailFromApi(apiSite, id);
}
router.get("/", async (req: Request, res: Response) => {
const id = req.query.id as string;
const sourceCode = req.query.source as string;
if (!id || !sourceCode) {
return res.status(400).json({ error: "缺少必要参数" });
}
try {
const result = await getVideoDetail(id, sourceCode);
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
export default router;

View File

@@ -1,161 +0,0 @@
import { Router, Request, Response } from "express";
import { getCacheTime } from "../config";
const router = Router();
// --- Interfaces ---
interface DoubanItem {
title: string;
poster: string;
rate: string;
}
interface DoubanResponse {
code: number;
message: string;
list: DoubanItem[];
}
interface DoubanApiResponse {
subjects: Array<{
title: string;
cover: string;
rate: string;
}>;
}
// --- Helper Functions ---
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept: "application/json, text/plain, */*",
},
};
try {
const response = await fetch(url, fetchOptions);
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return await response.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async function handleTop250(pageStart: number, res: Response) {
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const fetchOptions = {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
Referer: "https://movie.douban.com/",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
},
};
try {
const fetchResponse = await fetch(target, fetchOptions);
clearTimeout(timeoutId);
if (!fetchResponse.ok) {
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
}
const html = await fetchResponse.text();
const moviePattern =
/<div class="item">[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]+)<\/span>[\s\S]*?<\/div>/g;
const movies: DoubanItem[] = [];
let match;
while ((match = moviePattern.exec(html)) !== null) {
const title = match[1];
const cover = match[2];
const rate = match[3] || "";
const processedCover = cover.replace(/^http:/, "https:");
movies.push({ title, poster: processedCover, rate });
}
const apiResponse: DoubanResponse = {
code: 200,
message: "获取成功",
list: movies,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiResponse);
} catch (error) {
clearTimeout(timeoutId);
res.status(500).json({
error: "获取豆瓣 Top250 数据失败",
details: (error as Error).message,
});
}
}
// --- Main Route Handler ---
router.get("/", async (req: Request, res: Response) => {
const { type, tag } = req.query;
const pageSize = parseInt((req.query.pageSize as string) || "16");
const pageStart = parseInt((req.query.pageStart as string) || "0");
if (!type || !tag) {
return res.status(400).json({ error: "缺少必要参数: type 或 tag" });
}
if (typeof type !== "string" || !["tv", "movie"].includes(type)) {
return res.status(400).json({ error: "type 参数必须是 tv 或 movie" });
}
if (pageSize < 1 || pageSize > 100) {
return res.status(400).json({ error: "pageSize 必须在 1-100 之间" });
}
if (pageStart < 0) {
return res.status(400).json({ error: "pageStart 不能小于 0" });
}
if (tag === "top250") {
return handleTop250(pageStart, res);
}
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
try {
const doubanData = await fetchDoubanData(target);
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
title: item.title,
poster: item.cover,
rate: item.rate,
}));
const response: DoubanResponse = {
code: 200,
message: "获取成功",
list: list,
};
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(response);
} catch (error) {
res.status(500).json({
error: "获取豆瓣数据失败",
details: (error as Error).message,
});
}
});
export default router;

View File

@@ -1,43 +0,0 @@
import { Router, Request, Response } from "express";
import { Readable } from "node:stream";
const router = Router();
router.get("/", async (req: Request, res: Response) => {
const imageUrl = req.query.url as string;
if (!imageUrl) {
return res.status(400).send("Missing image URL");
}
try {
const imageResponse = await fetch(imageUrl, {
headers: {
Referer: "https://movie.douban.com/",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
},
});
if (!imageResponse.ok) {
return res.status(imageResponse.status).send(imageResponse.statusText);
}
const contentType = imageResponse.headers.get("content-type");
if (contentType) {
res.setHeader("Content-Type", contentType);
}
if (imageResponse.body) {
const nodeStream = Readable.fromWeb(imageResponse.body as any);
nodeStream.pipe(res);
} else {
res.status(500).send("Image response has no body");
}
} catch (error) {
console.error("Image proxy error:", error);
res.status(500).send("Error fetching image");
}
});
export default router;

View File

@@ -1,14 +0,0 @@
import { Router } from "express";
import searchRouter from "./search";
import detailRouter from "./detail";
import doubanRouter from "./douban";
import imageProxyRouter from "./image-proxy";
const router = Router();
router.use("/search", searchRouter);
router.use("/detail", detailRouter);
router.use("/douban", doubanRouter);
router.use("/image-proxy", imageProxyRouter);
export default router;

View File

@@ -1,270 +0,0 @@
import { Router, Request, Response } from "express";
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
import { cleanHtmlTags } from "../utils";
const router = Router();
// 根据环境变量决定最大搜索页数,默认 5
const MAX_SEARCH_PAGES: number = Number(process.env.SEARCH_MAX_PAGE) || 5;
export interface SearchResult {
id: string;
title: string;
poster: string;
episodes: string[];
source: string;
source_name: string;
class?: string;
year: string;
desc?: string;
type_name?: string;
}
interface ApiSearchItem {
vod_id: string;
vod_name: string;
vod_pic: string;
vod_remarks?: string;
vod_play_url?: string;
vod_class?: string;
vod_year?: string;
vod_content?: string;
type_name?: string;
}
async function searchFromApi(
apiSite: ApiSite,
query: string
): Promise<SearchResult[]> {
try {
const apiBaseUrl = apiSite.api;
const apiUrl =
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
const apiName = apiSite.name;
// 添加超时处理
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(apiUrl, {
headers: API_CONFIG.search.headers,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return [];
}
const data = await response.json();
console.log(
"apiUrl",
apiSite.name,
"response status",
response.ok,
"response data",
data.list.length
);
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
return [];
}
// 处理第一页结果
const results = data.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
// 先用 $$$ 分割
const vod_play_url_array = item.vod_play_url.split("$$$");
// 对每个分片做匹配,取匹配到最多的作为结果
vod_play_url_array.forEach((url: string) => {
const matches = url.match(m3u8Regex) || [];
if (matches.length > episodes.length) {
episodes = matches;
}
});
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || "" : "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
// 获取总页数
const pageCount = data.pagecount || 1;
// 确定需要获取的额外页数
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
// 如果有额外页数,获取更多页的结果
if (pagesToFetch > 0) {
const additionalPagePromises = [];
for (let page = 2; page <= pagesToFetch + 1; page++) {
const pageUrl =
apiBaseUrl +
API_CONFIG.search.pagePath
.replace("{query}", encodeURIComponent(query))
.replace("{page}", page.toString());
const pagePromise = (async () => {
try {
const pageController = new AbortController();
const pageTimeoutId = setTimeout(
() => pageController.abort(),
8000
);
const pageResponse = await fetch(pageUrl, {
headers: API_CONFIG.search.headers,
signal: pageController.signal,
});
clearTimeout(pageTimeoutId);
if (!pageResponse.ok) return [];
const pageData = await pageResponse.json();
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
return [];
return pageData.list.map((item: ApiSearchItem) => {
let episodes: string[] = [];
if (item.vod_play_url) {
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
episodes = item.vod_play_url.match(m3u8Regex) || [];
}
episodes = Array.from(new Set(episodes)).map((link: string) => {
link = link.substring(1); // 去掉开头的 $
const parenIndex = link.indexOf("(");
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
});
return {
id: item.vod_id,
title: item.vod_name,
poster: item.vod_pic,
episodes,
source: apiSite.key,
source_name: apiName,
class: item.vod_class,
year: item.vod_year
? item.vod_year.match(/\d{4}/)?.[0] || ""
: "",
desc: cleanHtmlTags(item.vod_content || ""),
type_name: item.type_name,
};
});
} catch (error) {
return [];
}
})();
additionalPagePromises.push(pagePromise);
}
const additionalResults = await Promise.all(additionalPagePromises);
additionalResults.forEach((pageResults) => {
if (pageResults.length > 0) {
results.push(...pageResults);
}
});
}
return results;
} catch (error) {
return [];
}
}
router.get("/", async (req: Request, res: Response) => {
const query = req.query.q as string;
if (!query) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
return res.json({ results: [] });
}
const apiSites = getApiSites();
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
try {
const results = await Promise.all(searchPromises);
const flattenedResults = results.flat();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({ results: flattenedResults });
} catch (error) {
res.status(500).json({ error: "搜索失败" });
}
});
// 按资源 url 单个获取数据
router.get("/one", async (req: Request, res: Response) => {
const { resourceId, q } = req.query;
if (!resourceId || !q) {
return res.status(400).json({ error: "resourceId and q are required" });
}
const apiSites = getApiSites();
const apiSite = apiSites.find((site) => site.key === (resourceId as string));
if (!apiSite) {
return res.status(404).json({ error: "Resource not found" });
}
try {
const results = await searchFromApi(apiSite, q as string);
const result = results.filter((r) => r.title === (q as string));
if (results) {
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json({results: result});
} else {
res.status(404).json({ error: "Resource not found with the given query" });
}
} catch (error) {
res.status(500).json({ error: "Failed to fetch resource details" });
}
});
// 获取所有可用的资源列表
router.get("/resources", async (req: Request, res: Response) => {
const apiSites = getApiSites();
const cacheTime = getCacheTime();
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
res.json(apiSites);
});
export default router;

View File

@@ -1,33 +0,0 @@
// Data structure for play records
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
total_time: number; // Total duration in seconds
save_time: number; // Timestamp of when the record was saved
user_id: number; // User ID, always 0 in this version
}
// You can add other shared types here
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover: string;
desc: string;
source_name: string;
source: string;
id: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
};
}

View File

@@ -1,10 +0,0 @@
export function cleanHtmlTags(text: string): string {
if (!text) return "";
return text
.replace(/<[^>]+>/g, "\n") // 将 HTML 标签替换为换行
.replace(/\n+/g, "\n") // 将多个连续换行合并为一个
.replace(/[ \t]+/g, " ") // 将多个连续空格和制表符合并为一个空格,但保留换行符
.replace(/^\n+|\n+$/g, "") // 去掉首尾换行
.replace(/&nbsp;/g, " ") // 将 &nbsp; 替换为空格
.trim(); // 去掉首尾空格
}

View File

@@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}

View File

@@ -1,18 +0,0 @@
{
"version": 2,
"builds": [
{
"src": "src/index.ts",
"use": "@vercel/node",
"config": {
"includeFiles": ["./config.json"]
}
}
],
"routes": [
{
"src": "/(.*)",
"dest": "src/index.ts"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
import React, { useCallback } from "react";
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
import { ThemedText } from "@/components/ThemedText";
interface CustomScrollViewProps {
data: any[];
renderItem: ({ item, index }: { item: any; index: number }) => React.ReactNode;
numColumns?: number;
loading?: boolean;
loadingMore?: boolean;
error?: string | null;
onEndReached?: () => void;
loadMoreThreshold?: number;
emptyMessage?: string;
ListFooterComponent?: React.ComponentType<any> | React.ReactElement | null;
}
const { width } = Dimensions.get("window");
const CustomScrollView: React.FC<CustomScrollViewProps> = ({
data,
renderItem,
numColumns = 1,
loading = false,
loadingMore = false,
error = null,
onEndReached,
loadMoreThreshold = 200,
emptyMessage = "暂无内容",
ListFooterComponent,
}) => {
const ITEM_WIDTH = numColumns > 0 ? width / numColumns - 24 : width - 24;
const handleScroll = useCallback(
({ nativeEvent }: { nativeEvent: any }) => {
const { layoutMeasurement, contentOffset, contentSize } = nativeEvent;
const isCloseToBottom = layoutMeasurement.height + contentOffset.y >= contentSize.height - loadMoreThreshold;
if (isCloseToBottom && !loadingMore && onEndReached) {
onEndReached();
}
},
[onEndReached, loadingMore, loadMoreThreshold]
);
const renderFooter = () => {
if (ListFooterComponent) {
if (React.isValidElement(ListFooterComponent)) {
return ListFooterComponent;
} else if (typeof ListFooterComponent === "function") {
const Component = ListFooterComponent as React.ComponentType<any>;
return <Component />;
}
return null;
}
if (loadingMore) {
return <ActivityIndicator style={{ marginVertical: 20 }} size="large" />;
}
return null;
};
if (loading) {
return (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" />
</View>
);
}
if (error) {
return (
<View style={styles.centerContainer}>
<ThemedText type="subtitle" style={{ padding: 10 }}>
{error}
</ThemedText>
</View>
);
}
if (data.length === 0) {
return (
<View style={styles.centerContainer}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.listContent} onScroll={handleScroll} scrollEventThrottle={16}>
{data.length > 0 ? (
<>
{/* Render content in a grid layout */}
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
<View key={rowIndex} style={styles.rowContainer}>
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
{renderItem({ item, index: rowIndex * numColumns + index })}
</View>
))}
</View>
))}
{renderFooter()}
</>
) : (
<View style={styles.centerContainer}>
<ThemedText>{emptyMessage}</ThemedText>
</View>
)}
</ScrollView>
);
};
const styles = StyleSheet.create({
centerContainer: {
flex: 1,
paddingTop: 20,
justifyContent: "center",
alignItems: "center",
},
listContent: {
paddingHorizontal: 16,
paddingBottom: 20,
},
rowContainer: {
flexDirection: "row",
justifyContent: "flex-start",
flexWrap: "wrap",
},
itemContainer: {
margin: 8,
alignItems: "center",
},
});
export default CustomScrollView;

View File

@@ -1,55 +0,0 @@
import React from "react";
import {
Pressable,
StyleSheet,
StyleProp,
ViewStyle,
PressableProps,
} from "react-native";
interface DetailButtonProps extends PressableProps {
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}
export const DetailButton: React.FC<DetailButtonProps> = ({
children,
style,
...rest
}) => {
return (
<Pressable
style={({ focused }) => [
styles.button,
style,
focused && styles.buttonFocused,
]}
{...rest}
>
{children}
</Pressable>
);
};
const styles = StyleSheet.create({
button: {
backgroundColor: "#333",
paddingHorizontal: 15,
paddingVertical: 10,
borderRadius: 8,
margin: 5,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
},
buttonFocused: {
backgroundColor: "#0056b3",
borderColor: "#fff",
elevation: 5,
shadowColor: "#fff",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 15,
},
});

View File

@@ -1,74 +1,52 @@
import React from "react";
import {
View,
Text,
StyleSheet,
Modal,
FlatList,
Pressable,
TouchableOpacity,
} from "react-native";
import { View, Text, StyleSheet, Modal, FlatList, Pressable } from "react-native";
import { StyledButton } from "./StyledButton";
import usePlayerStore from "@/stores/playerStore";
import { useState } from "react";
interface Episode {
title?: string;
url: string;
}
interface EpisodeSelectionModalProps {
visible: boolean;
episodes: Episode[];
currentEpisodeIndex: number;
episodeGroupSize: number;
selectedEpisodeGroup: number;
setSelectedEpisodeGroup: (group: number) => void;
onSelectEpisode: (index: number) => void;
onClose: () => void;
}
interface EpisodeSelectionModalProps {}
export const EpisodeSelectionModal: React.FC<EpisodeSelectionModalProps> = () => {
const { showEpisodeModal, episodes, currentEpisodeIndex, playEpisode, setShowEpisodeModal } = usePlayerStore();
const [episodeGroupSize] = useState(30);
const [selectedEpisodeGroup, setSelectedEpisodeGroup] = useState(Math.floor(currentEpisodeIndex / episodeGroupSize));
const onSelectEpisode = (index: number) => {
playEpisode(index);
setShowEpisodeModal(false);
};
const onClose = () => {
setShowEpisodeModal(false);
};
export const EpisodeSelectionModal: React.FC<EpisodeSelectionModalProps> = ({
visible,
episodes,
currentEpisodeIndex,
episodeGroupSize,
selectedEpisodeGroup,
setSelectedEpisodeGroup,
onSelectEpisode,
onClose,
}) => {
return (
<Modal
visible={visible}
transparent={true}
animationType="slide"
onRequestClose={onClose}
>
<Modal visible={showEpisodeModal} transparent={true} animationType="slide" onRequestClose={onClose}>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
{episodes.length > episodeGroupSize && (
<View style={styles.episodeGroupContainer}>
{Array.from(
{ length: Math.ceil(episodes.length / episodeGroupSize) },
(_, groupIndex) => (
<TouchableOpacity
key={groupIndex}
style={[
styles.episodeGroupButton,
selectedEpisodeGroup === groupIndex &&
styles.episodeGroupButtonSelected,
]}
onPress={() => setSelectedEpisodeGroup(groupIndex)}
>
<Text style={styles.episodeGroupButtonText}>
{`${groupIndex * episodeGroupSize + 1}-${Math.min(
(groupIndex + 1) * episodeGroupSize,
episodes.length
)}`}
</Text>
</TouchableOpacity>
)
)}
{Array.from({ length: Math.ceil(episodes.length / episodeGroupSize) }, (_, groupIndex) => (
<StyledButton
key={groupIndex}
text={`${groupIndex * episodeGroupSize + 1}-${Math.min(
(groupIndex + 1) * episodeGroupSize,
episodes.length
)}`}
onPress={() => setSelectedEpisodeGroup(groupIndex)}
isSelected={selectedEpisodeGroup === groupIndex}
style={styles.episodeGroupButton}
textStyle={styles.episodeGroupButtonText}
/>
))}
</View>
)}
<FlatList
@@ -77,40 +55,22 @@ export const EpisodeSelectionModal: React.FC<EpisodeSelectionModalProps> = ({
(selectedEpisodeGroup + 1) * episodeGroupSize
)}
numColumns={5}
keyExtractor={(_, index) =>
`episode-${selectedEpisodeGroup * episodeGroupSize + index}`
}
contentContainerStyle={styles.episodeList}
keyExtractor={(_, index) => `episode-${selectedEpisodeGroup * episodeGroupSize + index}`}
renderItem={({ item, index }) => {
const absoluteIndex =
selectedEpisodeGroup * episodeGroupSize + index;
const absoluteIndex = selectedEpisodeGroup * episodeGroupSize + index;
return (
<Pressable
style={({ focused }) => [
styles.episodeItem,
currentEpisodeIndex === absoluteIndex &&
styles.episodeItemSelected,
focused && styles.focusedButton,
]}
<StyledButton
text={item.title || `${absoluteIndex + 1}`}
onPress={() => onSelectEpisode(absoluteIndex)}
isSelected={currentEpisodeIndex === absoluteIndex}
hasTVPreferredFocus={currentEpisodeIndex === absoluteIndex}
>
<Text style={styles.episodeItemText}>
{item.title || `${absoluteIndex + 1}`}
</Text>
</Pressable>
style={styles.episodeItem}
textStyle={styles.episodeItemText}
/>
);
}}
/>
<Pressable
style={({ focused }) => [
styles.closeButton,
focused && styles.focusedButton,
]}
onPress={onClose}
>
<Text style={{ color: "white" }}></Text>
</Pressable>
</View>
</View>
</Modal>
@@ -125,64 +85,40 @@ const styles = StyleSheet.create({
backgroundColor: "transparent",
},
modalContent: {
width: 400,
width: 600,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 20,
},
modalTitle: {
color: "white",
marginBottom: 20,
marginBottom: 12,
textAlign: "center",
fontSize: 18,
fontWeight: "bold",
},
episodeItem: {
backgroundColor: "#333",
paddingVertical: 12,
borderRadius: 8,
margin: 4,
flex: 1,
alignItems: "center",
justifyContent: "center",
episodeList: {
justifyContent: "flex-start",
},
episodeItemSelected: {
backgroundColor: "#007bff",
episodeItem: {
paddingVertical: 2,
margin: 4,
width: "18%",
},
episodeItemText: {
color: "white",
fontSize: 14,
},
episodeGroupContainer: {
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
marginBottom: 15,
paddingHorizontal: 10,
},
episodeGroupButton: {
backgroundColor: "#444",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 15,
margin: 5,
},
episodeGroupButtonSelected: {
backgroundColor: "#007bff",
paddingHorizontal: 6,
margin: 8,
},
episodeGroupButtonText: {
color: "white",
fontSize: 12,
},
closeButton: {
backgroundColor: "#333",
padding: 15,
borderRadius: 8,
alignItems: "center",
marginTop: 20,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
transform: [{ scale: 1.1 }],
},
});

149
components/LivePlayer.tsx Normal file
View File

@@ -0,0 +1,149 @@
import React, { useRef, useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { Video, ResizeMode, AVPlaybackStatus } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
interface LivePlayerProps {
streamUrl: string | null;
channelTitle?: string | null;
onPlaybackStatusUpdate: (status: AVPlaybackStatus) => void;
}
const PLAYBACK_TIMEOUT = 15000; // 15 seconds
export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUpdate }: LivePlayerProps) {
const video = useRef<Video>(null);
const [isLoading, setIsLoading] = useState(false);
const [isTimeout, setIsTimeout] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useKeepAwake();
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (streamUrl) {
setIsLoading(true);
setIsTimeout(false);
timeoutRef.current = setTimeout(() => {
setIsTimeout(true);
setIsLoading(false);
}, PLAYBACK_TIMEOUT);
} else {
setIsLoading(false);
setIsTimeout(false);
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [streamUrl]);
const handlePlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status.isLoaded) {
if (status.isPlaying) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setIsLoading(false);
setIsTimeout(false);
} else if (status.isBuffering) {
setIsLoading(true);
}
} else {
if (status.error) {
setIsLoading(false);
setIsTimeout(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}
}
onPlaybackStatusUpdate(status);
};
if (!streamUrl) {
return (
<View style={styles.container}>
<Text style={styles.messageText}></Text>
</View>
);
}
if (isTimeout) {
return (
<View style={styles.container}>
<Text style={styles.messageText}></Text>
</View>
);
}
return (
<View style={styles.container}>
<Video
ref={video}
style={styles.video}
source={{
uri: streamUrl,
}}
resizeMode={ResizeMode.CONTAIN}
shouldPlay
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onError={(e) => {
setIsTimeout(true);
setIsLoading(false);
}}
/>
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.messageText}>...</Text>
</View>
)}
{channelTitle && !isLoading && !isTimeout && (
<View style={styles.overlay}>
<Text style={styles.title}>{channelTitle}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#000",
},
video: {
flex: 1,
alignSelf: "stretch",
},
overlay: {
position: "absolute",
top: 20,
left: 20,
backgroundColor: "rgba(0, 0, 0, 0.5)",
padding: 10,
borderRadius: 5,
},
title: {
color: "#fff",
fontSize: 18,
},
messageText: {
color: "#fff",
fontSize: 16,
marginTop: 10,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.5)",
},
});

170
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,170 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
import { usePathname } from "expo-router";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
import { useSettingsStore } from "@/stores/settingsStore";
import useHomeStore from "@/stores/homeStore";
import { api } from "@/services/api";
import { ThemedView } from "./ThemedView";
import { ThemedText } from "./ThemedText";
import { StyledButton } from "./StyledButton";
const LoginModal = () => {
const { isLoginModalVisible, hideLoginModal, checkLoginStatus } = useAuthStore();
const { serverConfig, apiBaseUrl } = useSettingsStore();
const { refreshPlayRecords } = useHomeStore();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const usernameInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const pathname = usePathname();
const isSettingsPage = pathname.includes("settings");
// Focus management with better TV remote handling
useEffect(() => {
if (isLoginModalVisible && !isSettingsPage) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
// Use a small delay to ensure the modal is fully rendered
const focusTimeout = setTimeout(() => {
if (isUsernameVisible) {
usernameInputRef.current?.focus();
} else {
passwordInputRef.current?.focus();
}
}, 100);
return () => clearTimeout(focusTimeout);
}
}, [isLoginModalVisible, serverConfig, isSettingsPage]);
const handleLogin = async () => {
const isLocalStorage = serverConfig?.StorageType === "localstorage";
if (!password || (!isLocalStorage && !username)) {
Toast.show({ type: "error", text1: "请输入用户名和密码" });
return;
}
setIsLoading(true);
try {
await api.login(isLocalStorage ? undefined : username, password);
await checkLoginStatus(apiBaseUrl);
await refreshPlayRecords();
Toast.show({ type: "success", text1: "登录成功" });
hideLoginModal();
setUsername("");
setPassword("");
// Show disclaimer alert after successful login
Alert.alert(
"免责声明",
"本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
[{ text: "确定" }]
);
} catch {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
} finally {
setIsLoading(false);
}
};
// Handle navigation between inputs using returnKeyType
const handleUsernameSubmit = () => {
passwordInputRef.current?.focus();
};
return (
<Modal
transparent={true}
visible={isLoginModalVisible && !isSettingsPage}
animationType="fade"
onRequestClose={hideLoginModal}
>
<View style={styles.overlay}>
<ThemedView style={styles.container}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
{serverConfig?.StorageType !== "localstorage" && (
<TextInput
ref={usernameInputRef}
style={styles.input}
placeholder="请输入用户名"
placeholderTextColor="#888"
value={username}
onChangeText={setUsername}
returnKeyType="next"
onSubmitEditing={handleUsernameSubmit}
blurOnSubmit={false}
/>
)}
<TextInput
ref={passwordInputRef}
style={styles.input}
placeholder="请输入密码"
placeholderTextColor="#888"
secureTextEntry
value={password}
onChangeText={setPassword}
returnKeyType="go"
onSubmitEditing={handleLogin}
/>
<StyledButton
text={isLoading ? "" : "登录"}
onPress={handleLogin}
disabled={isLoading}
style={styles.button}
hasTVPreferredFocus={!serverConfig || serverConfig.StorageType === "localstorage"}
>
{isLoading && <ActivityIndicator color="#fff" />}
</StyledButton>
</ThemedView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
},
container: {
width: "80%",
maxWidth: 400,
padding: 24,
borderRadius: 12,
alignItems: "center",
},
title: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#ccc",
marginBottom: 20,
textAlign: "center",
},
input: {
width: "100%",
height: 50,
backgroundColor: "#333",
borderRadius: 8,
paddingHorizontal: 16,
color: "#fff",
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: "#555",
},
button: {
width: "100%",
height: 50,
},
});
export default LoginModal;

View File

@@ -1,52 +1,32 @@
import React from "react";
import { Pressable, StyleSheet, StyleProp, ViewStyle } from "react-native";
import React, { ComponentProps } from "react";
import { StyledButton } from "./StyledButton";
import { StyleSheet, View, Text } from "react-native";
interface MediaButtonProps {
onPress: () => void;
children: React.ReactNode;
isFocused?: boolean;
isDisabled?: boolean;
style?: StyleProp<ViewStyle>;
}
export const MediaButton: React.FC<MediaButtonProps> = ({
onPress,
children,
isFocused = false,
isDisabled = false,
style,
}) => {
return (
<Pressable
onPress={onPress}
disabled={isDisabled}
style={[
styles.mediaControlButton,
isFocused && styles.focusedButton,
isDisabled && styles.disabledButton,
style,
]}
>
{children}
</Pressable>
);
type StyledButtonProps = ComponentProps<typeof StyledButton> & {
timeLabel?: string;
};
export const MediaButton = ({ timeLabel, ...props }: StyledButtonProps) => (
<View>
<StyledButton {...props} style={[styles.mediaControlButton, props.style]} variant="ghost" />
{timeLabel && <Text style={styles.timeLabel}>{timeLabel}</Text>}
</View>
);
const styles = StyleSheet.create({
mediaControlButton: {
backgroundColor: "rgba(51, 51, 51, 0.8)",
padding: 12,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
minWidth: 80,
margin: 5,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
transform: [{ scale: 1.1 }],
},
disabledButton: {
opacity: 0.5,
timeLabel: {
position: "absolute",
top: 14,
right: 12,
color: "white",
fontSize: 10,
fontWeight: "bold",
backgroundColor: "rgba(0,0,0,0.6)",
paddingHorizontal: 4,
borderRadius: 3,
},
});

View File

@@ -1,16 +1,14 @@
import React from "react";
import { View, StyleSheet, TouchableOpacity } from "react-native";
import { View, StyleSheet } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { StyledButton } from "./StyledButton";
interface NextEpisodeOverlayProps {
visible: boolean;
onCancel: () => void;
}
export const NextEpisodeOverlay: React.FC<NextEpisodeOverlayProps> = ({
visible,
onCancel,
}) => {
export const NextEpisodeOverlay: React.FC<NextEpisodeOverlayProps> = ({ visible, onCancel }) => {
if (!visible) {
return null;
}
@@ -18,12 +16,13 @@ export const NextEpisodeOverlay: React.FC<NextEpisodeOverlayProps> = ({
return (
<View style={styles.nextEpisodeOverlay}>
<View style={styles.nextEpisodeContent}>
<ThemedText style={styles.nextEpisodeTitle}>
...
</ThemedText>
<TouchableOpacity style={styles.nextEpisodeButton} onPress={onCancel}>
<ThemedText style={styles.nextEpisodeButtonText}></ThemedText>
</TouchableOpacity>
<ThemedText style={styles.nextEpisodeTitle}>...</ThemedText>
<StyledButton
text="取消"
onPress={onCancel}
style={styles.nextEpisodeButton}
textStyle={styles.nextEpisodeButtonText}
/>
</View>
</View>
);
@@ -48,10 +47,8 @@ const styles = StyleSheet.create({
marginBottom: 10,
},
nextEpisodeButton: {
backgroundColor: "#333",
padding: 8,
paddingHorizontal: 15,
borderRadius: 5,
},
nextEpisodeButtonText: {
fontSize: 14,

View File

@@ -1,76 +1,66 @@
import React from "react";
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Pressable,
} from "react-native";
import { useRouter } from "expo-router";
import { AVPlaybackStatus } from "expo-av";
import {
ArrowLeft,
Pause,
Play,
SkipForward,
List,
ChevronsRight,
ChevronsLeft,
} from "lucide-react-native";
import { View, Text, StyleSheet, Pressable } from "react-native";
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot } from "lucide-react-native";
import { ThemedText } from "@/components/ThemedText";
import { MediaButton } from "@/components/MediaButton";
import usePlayerStore from "@/stores/playerStore";
import useDetailStore from "@/stores/detailStore";
import { useSources } from "@/stores/sourceStore";
interface PlayerControlsProps {
videoTitle: string;
currentEpisodeTitle?: string;
status: AVPlaybackStatus | null;
isSeeking: boolean;
seekPosition: number;
progressPosition: number;
currentFocus: string | null;
hasNextEpisode: boolean;
onSeekStart: () => void;
onSeekMove: (event: { nativeEvent: { locationX: number } }) => void;
onSeekRelease: (event: { nativeEvent: { locationX: number } }) => void;
onSeek: (forward: boolean) => void;
onTogglePlayPause: () => void;
onPlayNextEpisode: () => void;
onShowEpisodes: () => void;
formatTime: (time: number) => string;
showControls: boolean;
setShowControls: (show: boolean) => void;
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({
videoTitle,
currentEpisodeTitle,
status,
isSeeking,
seekPosition,
progressPosition,
currentFocus,
hasNextEpisode,
onSeekStart,
onSeekMove,
onSeekRelease,
onSeek,
onTogglePlayPause,
onPlayNextEpisode,
onShowEpisodes,
formatTime,
}) => {
const router = useRouter();
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const {
currentEpisodeIndex,
episodes,
status,
isSeeking,
seekPosition,
progressPosition,
togglePlayPause,
playEpisode,
setShowEpisodeModal,
setShowSourceModal,
setIntroEndTime,
setOutroStartTime,
introEndTime,
outroStartTime,
} = usePlayerStore();
const { detail } = useDetailStore();
const resources = useSources();
const videoTitle = detail?.title || "";
const currentEpisode = episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title;
const currentSource = resources.find((r) => r.source === detail?.source);
const currentSourceName = currentSource?.source_name;
const hasNextEpisode = currentEpisodeIndex < (episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => {
if (!milliseconds) return "00:00";
const totalSeconds = Math.floor(milliseconds / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
const onPlayNextEpisode = () => {
if (hasNextEpisode) {
playEpisode(currentEpisodeIndex + 1);
}
};
return (
<View style={styles.controlsOverlay}>
<View style={styles.topControls}>
<TouchableOpacity
style={styles.controlButton}
onPress={() => router.back()}
>
<ArrowLeft color="white" size={24} />
</TouchableOpacity>
<Text style={styles.controlTitle}>
{videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}
{videoTitle} {currentEpisodeTitle ? `- ${currentEpisodeTitle}` : ""}{" "}
{currentSourceName ? `(${currentSourceName})` : ""}
</Text>
</View>
@@ -81,40 +71,25 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
style={[
styles.progressBarFilled,
{
width: `${
(isSeeking ? seekPosition : progressPosition) * 100
}%`,
width: `${(isSeeking ? seekPosition : progressPosition) * 100}%`,
},
]}
/>
<Pressable
style={styles.progressBarTouchable}
onPressIn={onSeekStart}
onTouchMove={onSeekMove}
onTouchEnd={onSeekRelease}
/>
<Pressable style={styles.progressBarTouchable} />
</View>
<ThemedText style={{ color: "white", marginTop: 5 }}>
{status?.isLoaded
? `${formatTime(status.positionMillis)} / ${formatTime(
status.durationMillis || 0
)}`
? `${formatTime(status.positionMillis)} / ${formatTime(status.durationMillis || 0)}`
: "00:00 / 00:00"}
</ThemedText>
<View style={styles.bottomControls}>
<MediaButton
onPress={() => onSeek(false)}
isFocused={currentFocus === "skipBack"}
>
<ChevronsLeft color="white" size={24} />
<MediaButton onPress={setIntroEndTime} timeLabel={introEndTime ? formatTime(introEndTime) : undefined}>
<ArrowDownToDot color="white" size={24} />
</MediaButton>
<MediaButton
onPress={onTogglePlayPause}
isFocused={currentFocus === "playPause"}
>
<MediaButton onPress={togglePlayPause} hasTVPreferredFocus={showControls}>
{status?.isLoaded && status.isPlaying ? (
<Pause color="white" size={24} />
) : (
@@ -122,27 +97,21 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
)}
</MediaButton>
<MediaButton
onPress={onPlayNextEpisode}
isFocused={currentFocus === "nextEpisode"}
isDisabled={!hasNextEpisode}
>
<MediaButton onPress={onPlayNextEpisode} disabled={!hasNextEpisode}>
<SkipForward color={hasNextEpisode ? "white" : "#666"} size={24} />
</MediaButton>
<MediaButton
onPress={() => onSeek(true)}
isFocused={currentFocus === "skipForward"}
>
<ChevronsRight color="white" size={24} />
<MediaButton onPress={setOutroStartTime} timeLabel={outroStartTime ? formatTime(outroStartTime) : undefined}>
<ArrowUpFromDot color="white" size={24} />
</MediaButton>
<MediaButton
onPress={onShowEpisodes}
isFocused={currentFocus === "episodes"}
>
<MediaButton onPress={() => setShowEpisodeModal(true)}>
<List color="white" size={24} />
</MediaButton>
<MediaButton onPress={() => setShowSourceModal(true)}>
<Tv color="white" size={24} />
</MediaButton>
</View>
</View>
</View>
@@ -199,7 +168,7 @@ const styles = StyleSheet.create({
position: "absolute",
left: 0,
height: 8,
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 4,
},
progressBarTouchable: {

View File

@@ -0,0 +1,82 @@
import React from "react";
import { Modal, View, Text, StyleSheet } from "react-native";
import QRCode from "react-native-qrcode-svg";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { ThemedView } from "./ThemedView";
import { ThemedText } from "./ThemedText";
import { StyledButton } from "./StyledButton";
export const RemoteControlModal: React.FC = () => {
const { isModalVisible, hideModal, serverUrl, error } = useRemoteControlStore();
return (
<Modal animationType="fade" transparent={true} visible={isModalVisible} onRequestClose={hideModal}>
<View style={styles.modalContainer}>
<ThemedView style={styles.modalContent}>
<ThemedText style={styles.title}></ThemedText>
<View style={styles.qrContainer}>
{serverUrl ? (
<>
<QRCode value={serverUrl} size={200} backgroundColor="white" color="black" />
</>
) : (
<ThemedText style={styles.statusText}>{error ? `错误: ${error}` : "正在生成二维码..."}</ThemedText>
)}
</View>
<ThemedText style={styles.instructions}>
使 TV 访{serverUrl}
</ThemedText>
<StyledButton text="关闭" onPress={hideModal} style={styles.button} variant="primary" />
</ThemedView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
modalContent: {
width: "85%",
maxWidth: 400,
padding: 24,
borderRadius: 12,
alignItems: "center",
},
title: {
fontSize: 24,
fontWeight: "bold",
marginBottom: 10,
paddingTop: 10,
},
qrContainer: {
width: 220,
height: 220,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#f0f0f0",
borderRadius: 8,
marginBottom: 20,
},
statusText: {
textAlign: "center",
fontSize: 16,
},
serverUrlText: {
marginTop: 10,
fontSize: 12,
},
instructions: {
textAlign: "center",
marginBottom: 24,
fontSize: 16,
color: "#ccc",
},
button: {
width: "100%",
},
});

86
components/SeekingBar.tsx Normal file
View File

@@ -0,0 +1,86 @@
import React from "react";
import { View, StyleSheet, Text } from "react-native";
import usePlayerStore from "@/stores/playerStore";
const formatTime = (milliseconds: number) => {
if (isNaN(milliseconds) || milliseconds < 0) {
return "00:00";
}
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
};
export const SeekingBar = () => {
const { isSeeking, seekPosition, status } = usePlayerStore();
if (!isSeeking || !status?.isLoaded) {
return null;
}
const durationMillis = status.durationMillis || 0;
const currentPositionMillis = seekPosition * durationMillis;
return (
<View style={styles.seekingContainer}>
<Text style={styles.timeText}>
{formatTime(currentPositionMillis)} / {formatTime(durationMillis)}
</Text>
<View style={styles.seekingBarContainer}>
<View style={styles.seekingBarBackground} />
<View
style={[
styles.seekingBarFilled,
{
width: `${seekPosition * 100}%`,
},
]}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
seekingContainer: {
position: "absolute",
bottom: 80,
left: "5%",
right: "5%",
alignItems: "center",
},
timeText: {
color: "white",
fontSize: 18,
fontWeight: "bold",
backgroundColor: "rgba(0,0,0,0.6)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 8,
marginBottom: 10,
},
seekingBarContainer: {
width: "100%",
height: 5,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2.5,
},
seekingBarBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2.5,
},
seekingBarFilled: {
height: "100%",
backgroundColor: "#fff",
borderRadius: 2.5,
},
});

View File

@@ -1,144 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { Modal, View, Text, TextInput, StyleSheet, Pressable, useColorScheme } from 'react-native';
import { SettingsManager } from '@/services/storage';
import { api } from '@/services/api';
import { ThemedText } from './ThemedText';
import { ThemedView } from './ThemedView';
interface SettingsModalProps {
visible: boolean;
onCancel: () => void;
onSave: () => void;
}
export const SettingsModal: React.FC<SettingsModalProps> = ({ visible, onCancel, onSave }) => {
const [apiUrl, setApiUrl] = useState('');
const [isInputFocused, setIsInputFocused] = useState(false);
const colorScheme = useColorScheme();
const inputRef = useRef<TextInput>(null);
useEffect(() => {
if (visible) {
SettingsManager.get().then(settings => {
setApiUrl(settings.apiBaseUrl);
});
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 200);
return () => clearTimeout(timer);
}
}, [visible]);
const handleSave = async () => {
await SettingsManager.save({ apiBaseUrl: apiUrl });
api.setBaseUrl(apiUrl);
onSave();
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
modalContent: {
width: '80%',
maxWidth: 500,
padding: 24,
borderRadius: 12,
elevation: 10,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
textAlign: 'center',
},
input: {
height: 50,
borderWidth: 2,
borderRadius: 8,
paddingHorizontal: 15,
fontSize: 16,
marginBottom: 24,
backgroundColor: colorScheme === 'dark' ? '#3a3a3c' : '#f0f0f0',
color: colorScheme === 'dark' ? 'white' : 'black',
borderColor: 'transparent',
},
inputFocused: {
borderColor: '#007AFF',
shadowColor: '#007AFF',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
buttonContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
},
button: {
flex: 1,
paddingVertical: 12,
borderRadius: 8,
alignItems: 'center',
marginHorizontal: 8,
},
buttonSave: {
backgroundColor: '#007AFF',
},
buttonCancel: {
backgroundColor: colorScheme === 'dark' ? '#444' : '#ccc',
},
buttonText: {
color: 'white',
fontSize: 18,
fontWeight: '500',
},
focusedButton: {
transform: [{ scale: 1.05 }],
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 5,
elevation: 8,
},
});
return (
<Modal animationType="fade" transparent={true} visible={visible} onRequestClose={onCancel}>
<View style={styles.modalContainer}>
<ThemedView style={styles.modalContent}>
<ThemedText style={styles.title}></ThemedText>
<TextInput
ref={inputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiUrl}
onChangeText={setApiUrl}
placeholder="输入 API 地址"
placeholderTextColor={colorScheme === 'dark' ? '#888' : '#555'}
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<View style={styles.buttonContainer}>
<Pressable
style={({ focused }) => [styles.button, styles.buttonCancel, focused && styles.focusedButton]}
onPress={onCancel}
>
<Text style={styles.buttonText}></Text>
</Pressable>
<Pressable
style={({ focused }) => [styles.button, styles.buttonSave, focused && styles.focusedButton]}
onPress={handleSave}
>
<Text style={styles.buttonText}></Text>
</Pressable>
</View>
</ThemedView>
</View>
</Modal>
);
};

View File

@@ -0,0 +1,94 @@
import React from "react";
import { View, Text, StyleSheet, Modal, FlatList } from "react-native";
import { StyledButton } from "./StyledButton";
import useDetailStore from "@/stores/detailStore";
import usePlayerStore from "@/stores/playerStore";
export const SourceSelectionModal: React.FC = () => {
const { showSourceModal, setShowSourceModal, loadVideo, currentEpisodeIndex, status } = usePlayerStore();
const { searchResults, detail, setDetail } = useDetailStore();
const onSelectSource = (index: number) => {
console.log("onSelectSource", index, searchResults[index].source, detail?.source);
if (searchResults[index].source !== detail?.source) {
const newDetail = searchResults[index];
setDetail(newDetail);
// Reload the video with the new source, preserving current position
const currentPosition = status?.isLoaded ? status.positionMillis : undefined;
loadVideo({
source: newDetail.source,
id: newDetail.id.toString(),
episodeIndex: currentEpisodeIndex,
title: newDetail.title,
position: currentPosition
});
}
setShowSourceModal(false);
};
const onClose = () => {
setShowSourceModal(false);
};
return (
<Modal visible={showSourceModal} transparent={true} animationType="slide" onRequestClose={onClose}>
<View style={styles.modalContainer}>
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<FlatList
data={searchResults}
numColumns={3}
contentContainerStyle={styles.sourceList}
keyExtractor={(item, index) => `source-${item.source}-${index}`}
renderItem={({ item, index }) => (
<StyledButton
text={item.source_name}
onPress={() => onSelectSource(index)}
isSelected={detail?.source === item.source}
hasTVPreferredFocus={detail?.source === item.source}
style={styles.sourceItem}
textStyle={styles.sourceItemText}
/>
)}
/>
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
flexDirection: "row",
justifyContent: "flex-end",
backgroundColor: "transparent",
},
modalContent: {
width: 600,
height: "100%",
backgroundColor: "rgba(0, 0, 0, 0.85)",
padding: 20,
},
modalTitle: {
color: "white",
marginBottom: 12,
textAlign: "center",
fontSize: 18,
fontWeight: "bold",
},
sourceList: {
justifyContent: "flex-start",
},
sourceItem: {
paddingVertical: 2,
margin: 4,
marginLeft: 10,
marginRight: 8,
width: "30%",
},
sourceItemText: {
fontSize: 14,
},
});

142
components/StyledButton.tsx Normal file
View File

@@ -0,0 +1,142 @@
import React, { forwardRef } from "react";
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle, View } from "react-native";
import { ThemedText } from "./ThemedText";
import { Colors } from "@/constants/Colors";
import { useButtonAnimation } from "@/hooks/useAnimation";
interface StyledButtonProps extends PressableProps {
children?: React.ReactNode;
text?: string;
variant?: "default" | "primary" | "ghost";
isSelected?: boolean;
style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>;
}
export const StyledButton = forwardRef<View, StyledButtonProps>(
({ children, text, variant = "default", isSelected = false, style, textStyle, ...rest }, ref) => {
const colorScheme = "dark";
const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused);
const variantStyles = {
default: StyleSheet.create({
button: {
backgroundColor: colors.border,
},
text: {
color: colors.text,
},
selectedButton: {
backgroundColor: colors.primary,
},
focusedButton: {
borderColor: colors.primary,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.primary,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: colors.primary,
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.2)",
borderColor: colors.primary,
},
selectedButton: {},
selectedText: {},
}),
};
const styles = StyleSheet.create({
button: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
elevation: 5,
shadowColor: colors.link,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 1,
shadowRadius: 15,
},
selectedButton: {
backgroundColor: colors.tint,
},
text: {
fontSize: 16,
fontWeight: "500",
color: colors.text,
},
selectedText: {
color: Colors.dark.text,
},
});
return (
<Animated.View style={[animationStyle, style]}>
<Pressable
ref={ref}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
style={({ focused }) => [
styles.button,
variantStyles[variant].button,
isSelected && (variantStyles[variant].selectedButton ?? styles.selectedButton),
focused && (variantStyles[variant].focusedButton ?? styles.focusedButton),
]}
{...rest}
>
{text ? (
<ThemedText
style={[
styles.text,
variantStyles[variant].text,
isSelected && (variantStyles[variant].selectedText ?? styles.selectedText),
textStyle,
]}
>
{text}
</ThemedText>
) : (
children
)}
</Pressable>
</Animated.View>
);
}
);
StyledButton.displayName = "StyledButton";

View File

@@ -1,13 +1,14 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, Text, Image, StyleSheet, Pressable, TouchableOpacity, Alert } from 'react-native';
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from 'react-native-reanimated';
import { useRouter } from 'expo-router';
import { Heart, Star, Play, Trash2 } from 'lucide-react-native';
import { FavoriteManager, PlayRecordManager } from '@/services/storage';
import { API, api } from '@/services/api';
import { ThemedText } from '@/components/ThemedText';
import React, { useState, useCallback, useRef, forwardRef } from "react";
import { View, Text, Image, StyleSheet, TouchableOpacity, Alert } from "react-native";
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
import { useRouter } from "expo-router";
import { Star, Play } from "lucide-react-native";
import { PlayRecordManager } from "@/services/storage";
import { API } from "@/services/api";
import { ThemedText } from "@/components/ThemedText";
import { Colors } from "@/constants/Colors";
interface VideoCardProps {
interface VideoCardProps extends React.ComponentProps<typeof TouchableOpacity> {
id: string;
source: string;
title: string;
@@ -16,6 +17,7 @@ interface VideoCardProps {
rate?: string;
sourceName?: string;
progress?: number; // 播放进度0-1之间的小数
playTime?: number; // 播放时间 in ms
episodeIndex?: number; // 剧集索引
totalEpisodes?: number; // 总集数
onFocus?: () => void;
@@ -23,166 +25,175 @@ interface VideoCardProps {
api: API;
}
export default function VideoCard({
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
totalEpisodes,
onFocus,
onRecordDeleted,
api,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const VideoCard = forwardRef<View, VideoCardProps>(
(
{
id,
source,
title,
poster,
year,
rate,
sourceName,
progress,
episodeIndex,
onFocus,
onRecordDeleted,
api,
playTime = 0,
}: VideoCardProps,
ref
) => {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
const longPressTriggered = useRef(false);
const longPressTriggered = useRef(false);
const scale = useSharedValue(1);
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: scale.value }],
};
});
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
pathname: "/detail",
params: { source, q: title },
});
}
};
});
const handlePress = () => {
if (longPressTriggered.current) {
longPressTriggered.current = false;
return;
}
// 如果有播放进度,直接转到播放页面
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: '/play',
params: { source, id, episodeIndex },
});
} else {
router.push({
pathname: '/detail',
params: { source, q: title },
});
}
};
const handleFocus = useCallback(() => {
setIsFocused(true);
scale.value = withSpring(1.05, { damping: 15, stiffness: 200 });
onFocus?.();
}, [scale, onFocus]);
const handleFocus = useCallback(() => {
setIsFocused(true);
scale.value = withSpring(1.05, { damping: 15, stiffness: 200 });
onFocus?.();
}, [scale, onFocus]);
const handleBlur = useCallback(() => {
setIsFocused(false);
scale.value = withSpring(1.0);
}, [scale]);
const handleBlur = useCallback(() => {
setIsFocused(false);
scale.value = withSpring(1.0);
}, [scale]);
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
const handleLongPress = () => {
// Only allow long press for items with progress (play records)
if (progress === undefined) return;
longPressTriggered.current = true;
longPressTriggered.current = true;
// Show confirmation dialog to delete play record
Alert.alert('删除观看记录', `确定要删除"${title}"的观看记录吗?`, [
{
text: '取消',
style: 'cancel',
},
{
text: '删除',
style: 'destructive',
onPress: async () => {
try {
// Delete from local storage
await PlayRecordManager.remove(source, id);
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace('/');
}
} catch (error) {
console.error('Failed to delete play record:', error);
Alert.alert('错误', '删除观看记录失败,请重试');
}
// Show confirmation dialog to delete play record
Alert.alert("删除观看记录", `确定要删除"${title}"的观看记录吗?`, [
{
text: "取消",
style: "cancel",
},
},
]);
};
{
text: "删除",
style: "destructive",
onPress: async () => {
try {
// Delete from local storage
await PlayRecordManager.remove(source, id);
// 是否是继续观看的视频
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
// Call the onRecordDeleted callback
if (onRecordDeleted) {
onRecordDeleted();
}
// 如果没有回调函数,则使用导航刷新作为备选方案
else if (router.canGoBack()) {
router.replace("/");
}
} catch (error) {
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},
},
]);
};
return (
<Animated.View style={[styles.wrapper, animatedStyle]}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
// 是否是继续观看的视频
const isContinueWatching = progress !== undefined && progress > 0 && progress < 1;
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
return (
<Animated.View style={[styles.wrapper, animatedStyle]}>
<TouchableOpacity
onPress={handlePress}
onLongPress={handleLongPress}
onFocus={handleFocus}
onBlur={handleBlur}
style={styles.pressable}
activeOpacity={1}
delayLongPress={1000}
>
<View style={styles.card}>
<Image source={{ uri: api.getImageProxyUrl(poster) }} style={styles.poster} />
{isFocused && (
<View style={styles.overlay}>
{isContinueWatching && (
<View style={styles.continueWatchingBadge}>
<Play size={16} color="#ffffff" fill="#ffffff" />
<ThemedText style={styles.continueWatchingText}></ThemedText>
</View>
)}
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && !isFocused && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
{/* 进度条 */}
{isContinueWatching && (
<View style={styles.progressContainer}>
<View style={[styles.progressBar, { width: `${(progress || 0) * 100}%` }]} />
</View>
)}
{rate && (
<View style={styles.ratingContainer}>
<Star size={12} color="#FFD700" fill="#FFD700" />
<ThemedText style={styles.ratingText}>{rate}</ThemedText>
</View>
)}
{year && (
<View style={styles.yearBadge}>
<Text style={styles.badgeText}>{year}</Text>
</View>
)}
{sourceName && (
<View style={styles.sourceNameBadge}>
<Text style={styles.badgeText}>{sourceName}</Text>
</View>
)}
</View>
<View style={styles.infoContainer}>
<ThemedText numberOfLines={1}>{title}</ThemedText>
{isContinueWatching && (
<View style={styles.infoRow}>
<ThemedText style={styles.continueLabel}>
{episodeIndex! + 1} {Math.round((progress || 0) * 100)}%
</ThemedText>
</View>
)}
</View>
</TouchableOpacity>
</Animated.View>
);
}
);
VideoCard.displayName = "VideoCard";
export default VideoCard;
const CARD_WIDTH = 160;
const CARD_HEIGHT = 240;
@@ -192,126 +203,129 @@ const styles = StyleSheet.create({
marginHorizontal: 8,
},
pressable: {
alignItems: 'center',
alignItems: "center",
},
card: {
width: CARD_WIDTH,
height: CARD_HEIGHT,
borderRadius: 8,
backgroundColor: '#222',
overflow: 'hidden',
backgroundColor: "#222",
overflow: "hidden",
},
poster: {
width: '100%',
height: '100%',
width: "100%",
height: "100%",
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.3)',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: "rgba(0,0,0,0.3)",
borderColor: Colors.dark.primary,
borderWidth: 2,
borderRadius: 8,
justifyContent: "center",
alignItems: "center",
},
buttonRow: {
position: 'absolute',
position: "absolute",
top: 8,
left: 8,
flexDirection: 'row',
flexDirection: "row",
gap: 8,
},
iconButton: {
padding: 4,
},
favButton: {
position: 'absolute',
position: "absolute",
top: 8,
left: 8,
},
ratingContainer: {
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
ratingText: {
color: '#FFD700',
color: "#FFD700",
fontSize: 12,
fontWeight: 'bold',
fontWeight: "bold",
marginLeft: 4,
},
infoContainer: {
width: CARD_WIDTH,
marginTop: 8,
alignItems: 'flex-start', // Align items to the start
alignItems: "flex-start", // Align items to the start
marginBottom: 16,
paddingHorizontal: 4, // Add some padding
},
infoRow: {
flexDirection: 'row',
justifyContent: 'space-between',
width: '100%',
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
},
title: {
color: 'white',
color: "white",
fontSize: 16,
fontWeight: 'bold',
textAlign: 'center',
fontWeight: "bold",
textAlign: "center",
},
yearBadge: {
position: 'absolute',
position: "absolute",
top: 8,
right: 8,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
sourceNameBadge: {
position: 'absolute',
position: "absolute",
top: 8,
left: 8,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
backgroundColor: "rgba(0, 0, 0, 0.7)",
borderRadius: 6,
paddingHorizontal: 6,
paddingVertical: 3,
},
badgeText: {
color: 'white',
color: "white",
fontSize: 12,
fontWeight: 'bold',
fontWeight: "bold",
},
progressContainer: {
position: 'absolute',
position: "absolute",
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
height: 4,
backgroundColor: "rgba(0, 0, 0, 0.8)",
},
progressBar: {
height: 3,
backgroundColor: '#ff0000',
height: 4,
backgroundColor: Colors.dark.primary,
},
continueWatchingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 0, 0, 0.8)',
flexDirection: "row",
alignItems: "center",
backgroundColor: Colors.dark.primary,
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 5,
},
continueWatchingText: {
color: 'white',
color: "white",
marginLeft: 5,
fontSize: 12,
fontWeight: 'bold',
fontWeight: "bold",
},
continueLabel: {
color: '#ff5252',
color: Colors.dark.primary,
fontSize: 12,
},
});

View File

@@ -0,0 +1,334 @@
import React, { useEffect, useRef } from "react";
import { View, StyleSheet, Animated, Easing } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
interface VideoLoadingAnimationProps {
showProgressBar?: boolean;
}
const VideoLoadingAnimation: React.FC<VideoLoadingAnimationProps> = ({ showProgressBar = true }) => {
const floatAnim = useRef(new Animated.Value(0)).current;
const pulseAnim = useRef(new Animated.Value(0)).current;
const bounceAnims = [
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
];
const progressAnim = useRef(new Animated.Value(0)).current;
const gradientAnim = useRef(new Animated.Value(0)).current;
const textFadeAnim = useRef(new Animated.Value(0)).current;
const shapeAnims = [
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
useRef(new Animated.Value(0)).current,
];
useEffect(() => {
const floatAnimation = Animated.loop(
Animated.sequence([
Animated.timing(floatAnim, {
toValue: -20,
duration: 1500,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(floatAnim, {
toValue: 0,
duration: 1500,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(pulseAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const bounceAnimations = bounceAnims.map((anim, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(anim, {
toValue: 1,
duration: 700,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(anim, {
toValue: 0,
duration: 700,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
)
);
const progressAnimation = Animated.loop(
Animated.timing(progressAnim, {
toValue: 1,
duration: 4000,
useNativeDriver: false, // width animation not supported by native driver
easing: Easing.inOut(Easing.ease),
})
);
const gradientAnimation = Animated.loop(
Animated.timing(gradientAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: false, // gradient animation not supported by native driver
easing: Easing.inOut(Easing.ease),
})
);
const textFadeAnimation = Animated.loop(
Animated.sequence([
Animated.timing(textFadeAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
Animated.timing(textFadeAnim, {
toValue: 0,
duration: 1000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
);
const shapeAnimations = shapeAnims.map((anim, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 2000),
Animated.timing(anim, {
toValue: 1,
duration: 8000,
useNativeDriver: true,
easing: Easing.inOut(Easing.ease),
}),
])
)
);
Animated.parallel([
floatAnimation,
pulseAnimation,
...bounceAnimations,
progressAnimation,
gradientAnimation,
textFadeAnimation,
...shapeAnimations,
]).start();
}, []);
const animatedStyles = {
float: {
transform: [{ translateY: floatAnim }],
},
pulse: {
opacity: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0.7] }),
transform: [
{ translateX: -12.5 },
{ translateY: -15 },
{
scale: pulseAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 1.1] }),
},
],
},
bounce: bounceAnims.map((anim) => ({
transform: [{ scale: anim.interpolate({ inputRange: [0, 1], outputRange: [0.8, 1.2] }) }],
opacity: anim.interpolate({ inputRange: [0, 1], outputRange: [0.5, 1] }),
})),
progress: {
width: progressAnim.interpolate({
inputRange: [0, 0.7, 1],
outputRange: ["0%", "100%", "100%"],
}),
},
textFade: {
opacity: textFadeAnim.interpolate({ inputRange: [0, 1], outputRange: [0.6, 1] }),
},
shapes: shapeAnims.map((anim, i) => ({
transform: [
{
translateY: anim.interpolate({
inputRange: [0, 0.33, 0.66, 1],
outputRange: [0, -30, 10, 0],
}),
},
{
rotate: anim.interpolate({
inputRange: [0, 1],
outputRange: ["0deg", "360deg"],
}),
},
],
})),
};
return (
<View style={styles.container}>
<View style={styles.bgShapes}>
<Animated.View style={[styles.shape, styles.shape1, animatedStyles.shapes[0]]} />
<Animated.View style={[styles.shape, styles.shape2, animatedStyles.shapes[1]]} />
<Animated.View style={[styles.shape, styles.shape3, animatedStyles.shapes[2]]} />
<Animated.View style={[styles.shape, styles.shape4, animatedStyles.shapes[3]]} />
</View>
<View style={styles.loadingContainer}>
<Animated.View style={[styles.videoIcon, animatedStyles.float]}>
<View style={styles.videoFrame}>
<Animated.View style={[styles.playButton, animatedStyles.pulse]} />
</View>
</Animated.View>
{/* <View style={styles.loadingDots}>
<Animated.View style={[styles.dot, animatedStyles.bounce[0]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[1]]} />
<Animated.View style={[styles.dot, animatedStyles.bounce[2]]} />
</View> */}
{showProgressBar && (
<View style={styles.progressBar}>
<Animated.View style={[styles.progressFill, animatedStyles.progress]}>
<LinearGradient
colors={["#00bb5e", "#feff5f"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
</View>
)}
<Animated.Text style={[styles.loadingText, animatedStyles.textFade]}></Animated.Text>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
loadingContainer: {
alignItems: "center",
zIndex: 10,
},
videoIcon: {
width: 100,
height: 100,
marginBottom: 30,
},
videoFrame: {
width: "100%",
height: "100%",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 3,
borderColor: "rgba(255, 255, 255, 0.2)",
borderRadius: 12,
justifyContent: "center",
alignItems: "center",
},
playButton: {
width: 0,
height: 0,
borderStyle: "solid",
borderLeftWidth: 25,
borderLeftColor: "rgba(255, 255, 255, 0.9)",
borderTopWidth: 15,
borderTopColor: "transparent",
borderBottomWidth: 15,
borderBottomColor: "transparent",
},
loadingDots: {
flexDirection: "row",
justifyContent: "center",
gap: 8,
marginBottom: 20,
},
dot: {
width: 12,
height: 12,
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: 6,
},
progressBar: {
width: 300,
height: 6,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 3,
marginVertical: 20,
overflow: "hidden",
},
progressFill: {
height: "100%",
borderRadius: 3,
},
loadingText: {
color: "rgba(255, 255, 255, 0.9)",
fontSize: 18,
fontWeight: "300",
letterSpacing: 2,
marginTop: 10,
},
bgShapes: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
zIndex: 1,
},
shape: {
position: "absolute",
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderRadius: 50,
},
shape1: {
width: 80,
height: 80,
top: "20%",
left: "10%",
},
shape2: {
width: 60,
height: 60,
top: "60%",
right: "15%",
},
shape3: {
width: 100,
height: 100,
bottom: "20%",
left: "20%",
},
shape4: {
width: 40,
height: 40,
top: "30%",
right: "30%",
},
});
export default VideoLoadingAnimation;

View File

@@ -0,0 +1,137 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { View, TextInput, StyleSheet, Animated } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface APIConfigSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
export interface APIConfigSectionRef {
setInputValue: (value: string) => void;
}
export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSectionProps>(
({ onChanged, onFocus, onBlur }, ref) => {
const { apiBaseUrl, setApiBaseUrl, remoteInputEnabled } = useSettingsStore();
const { serverUrl } = useRemoteControlStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
const handleUrlChange = (url: string) => {
setApiBaseUrl(url);
onChanged();
};
useImperativeHandle(ref, () => ({
setInputValue: (value: string) => {
setApiBaseUrl(value);
onChanged();
},
}));
const handleSectionFocus = () => {
setIsSectionFocused(true);
onFocus?.();
};
const handleSectionBlur = () => {
setIsSectionFocused(false);
onBlur?.();
};
// TV遥控器事件处理
const handleTVEvent = React.useCallback(
(event: any) => {
if (isSectionFocused && event.eventType === "select") {
inputRef.current?.focus();
}
},
[isSectionFocused]
);
useTVEventHandler(handleTVEvent);
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<View style={styles.inputContainer}>
<View style={styles.titleContainer}>
<ThemedText style={styles.sectionTitle}>API </ThemedText>
{remoteInputEnabled && serverUrl && (
<ThemedText style={styles.subtitle}>访 {serverUrl}</ThemedText>
)}
</View>
<Animated.View style={inputAnimationStyle}>
<TextInput
ref={inputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiBaseUrl}
onChangeText={handleUrlChange}
placeholder="输入服务器地址"
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
</Animated.View>
</View>
</SettingsSection>
);
}
);
APIConfigSection.displayName = "APIConfigSection";
const styles = StyleSheet.create({
titleContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
sectionTitle: {
fontSize: 16,
fontWeight: "bold",
marginRight: 12,
},
subtitle: {
fontSize: 12,
color: "#888",
fontStyle: "italic",
},
inputContainer: {
marginBottom: 12,
},
label: {
fontSize: 16,
marginBottom: 8,
color: "#ccc",
},
input: {
height: 50,
borderWidth: 2,
borderRadius: 8,
paddingHorizontal: 15,
fontSize: 16,
backgroundColor: "#3a3a3c",
color: "white",
borderColor: "transparent",
},
inputFocused: {
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
});

View File

@@ -0,0 +1,131 @@
import React, { useState, useRef, useImperativeHandle, forwardRef } from "react";
import { View, TextInput, StyleSheet, Animated } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface LiveStreamSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
export interface LiveStreamSectionRef {
setInputValue: (value: string) => void;
}
export const LiveStreamSection = forwardRef<LiveStreamSectionRef, LiveStreamSectionProps>(
({ onChanged, onFocus, onBlur }, ref) => {
const { m3uUrl, setM3uUrl, remoteInputEnabled } = useSettingsStore();
const { serverUrl } = useRemoteControlStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const inputRef = useRef<TextInput>(null);
const inputAnimationStyle = useButtonAnimation(isSectionFocused, 1.01);
const handleUrlChange = (url: string) => {
setM3uUrl(url);
onChanged();
};
useImperativeHandle(ref, () => ({
setInputValue: (value: string) => {
setM3uUrl(value);
onChanged();
},
}));
const handleSectionFocus = () => {
setIsSectionFocused(true);
onFocus?.();
};
const handleSectionBlur = () => {
setIsSectionFocused(false);
onBlur?.();
};
const handleTVEvent = React.useCallback(
(event: any) => {
if (isSectionFocused && event.eventType === "select") {
inputRef.current?.focus();
}
},
[isSectionFocused]
);
useTVEventHandler(handleTVEvent);
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<View style={styles.inputContainer}>
<View style={styles.titleContainer}>
<ThemedText style={styles.sectionTitle}></ThemedText>
{remoteInputEnabled && serverUrl && (
<ThemedText style={styles.subtitle}>访 {serverUrl}</ThemedText>
)}
</View>
<Animated.View style={inputAnimationStyle}>
<TextInput
ref={inputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
value={m3uUrl}
onChangeText={handleUrlChange}
placeholder="输入 M3U 直播源地址"
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
</Animated.View>
</View>
</SettingsSection>
);
}
);
LiveStreamSection.displayName = "LiveStreamSection";
const styles = StyleSheet.create({
titleContainer: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
sectionTitle: {
fontSize: 16,
fontWeight: "bold",
marginRight: 12,
},
subtitle: {
fontSize: 12,
color: "#888",
fontStyle: "italic",
},
inputContainer: {
marginBottom: 12,
},
input: {
height: 50,
borderWidth: 2,
borderRadius: 8,
paddingHorizontal: 15,
fontSize: 16,
backgroundColor: "#3a3a3c",
color: "white",
borderColor: "transparent",
},
inputFocused: {
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
});

View File

@@ -0,0 +1,144 @@
import React, { useCallback } from "react";
import { View, Switch, StyleSheet, Pressable, Animated } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface RemoteInputSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChanged, onFocus, onBlur }) => {
const { remoteInputEnabled, setRemoteInputEnabled } = useSettingsStore();
const { isServerRunning, serverUrl, error } = useRemoteControlStore();
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused, 1.2);
const handleToggle = useCallback(
(enabled: boolean) => {
setRemoteInputEnabled(enabled);
onChanged();
},
[setRemoteInputEnabled, onChanged]
);
const handleSectionFocus = () => {
setIsFocused(true);
onFocus?.();
};
const handleSectionBlur = () => {
setIsFocused(false);
onBlur?.();
};
// TV遥控器事件处理
const handleTVEvent = React.useCallback(
(event: any) => {
if (isFocused && event.eventType === "select") {
handleToggle(!remoteInputEnabled);
}
},
[isFocused, remoteInputEnabled, handleToggle]
);
useTVEventHandler(handleTVEvent);
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<Pressable style={styles.settingItem} onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<View style={styles.settingInfo}>
<ThemedText style={styles.settingName}></ThemedText>
</View>
<Animated.View style={animationStyle}>
<Switch
value={remoteInputEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
</Animated.View>
</Pressable>
{remoteInputEnabled && (
<View style={styles.statusContainer}>
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}></ThemedText>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? Colors.dark.primary : "#FF6B6B" }]}>
{isServerRunning ? "运行中" : "已停止"}
</ThemedText>
</View>
{serverUrl && (
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}>访</ThemedText>
<ThemedText style={styles.statusValue}>{serverUrl}</ThemedText>
</View>
)}
{error && (
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}></ThemedText>
<ThemedText style={[styles.statusValue, { color: "#FF6B6B" }]}>{error}</ThemedText>
</View>
)}
</View>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
settingItem: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 12,
},
settingInfo: {
flex: 1,
},
settingName: {
fontSize: 16,
fontWeight: "bold",
marginBottom: 4,
},
settingDescription: {
fontSize: 14,
color: "#888",
},
statusContainer: {
marginTop: 16,
padding: 16,
backgroundColor: "#2a2a2c",
borderRadius: 8,
},
statusItem: {
flexDirection: "row",
marginBottom: 8,
},
statusLabel: {
fontSize: 14,
color: "#ccc",
minWidth: 80,
},
statusValue: {
fontSize: 14,
flex: 1,
},
actionButtons: {
flexDirection: "row",
gap: 12,
marginTop: 12,
},
actionButton: {
flex: 1,
},
});

View File

@@ -0,0 +1,54 @@
import React, { useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { Colors } from "@/constants/Colors";
interface SettingsSectionProps {
children: React.ReactNode;
onFocus?: () => void;
onBlur?: () => void;
focusable?: boolean;
}
export const SettingsSection: React.FC<SettingsSectionProps> = ({ children, onFocus, onBlur, focusable = false }) => {
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
setIsFocused(true);
onFocus?.();
};
const handleBlur = () => {
setIsFocused(false);
onBlur?.();
};
if (!focusable) {
return <ThemedView style={styles.section}>{children}</ThemedView>;
}
return (
<ThemedView style={[styles.section, isFocused && styles.sectionFocused]}>
<Pressable style={styles.sectionPressable} onFocus={handleFocus} onBlur={handleBlur}>
{children}
</Pressable>
</ThemedView>
);
};
const styles = StyleSheet.create({
section: {
padding: 20,
marginBottom: 16,
borderRadius: 12,
borderWidth: 1,
borderColor: "#333",
},
sectionFocused: {
borderColor: Colors.dark.primary,
backgroundColor: "#007AFF10",
},
sectionPressable: {
width: "100%",
},
});

View File

@@ -0,0 +1,150 @@
import React, { useState, useCallback } from "react";
import { StyleSheet, Switch, FlatList, Pressable, Animated } from "react-native";
import { useTVEventHandler } from "react-native";
import { ThemedText } from "@/components/ThemedText";
import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import useSourceStore, { useSources } from "@/stores/sourceStore";
interface VideoSourceSectionProps {
onChanged: () => void;
onFocus?: () => void;
onBlur?: () => void;
}
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const { videoSource } = useSettingsStore();
const resources = useSources();
const { toggleResourceEnabled } = useSourceStore();
const handleToggle = useCallback(
(resourceKey: string) => {
toggleResourceEnabled(resourceKey);
onChanged();
},
[onChanged, toggleResourceEnabled]
);
const handleSectionFocus = () => {
setIsSectionFocused(true);
onFocus?.();
};
const handleSectionBlur = () => {
setIsSectionFocused(false);
setFocusedIndex(null);
onBlur?.();
};
// TV遥控器事件处理
const handleTVEvent = useCallback(
(event: any) => {
if (event.eventType === "select") {
if (focusedIndex !== null) {
const resource = resources[focusedIndex];
if (resource) {
handleToggle(resource.source);
}
} else if (isSectionFocused) {
setFocusedIndex(0);
}
}
},
[isSectionFocused, focusedIndex, resources, handleToggle]
);
useTVEventHandler(handleTVEvent);
const renderResourceItem = ({ item, index }: { item: { source: string; source_name: string }; index: number }) => {
const isEnabled = videoSource.enabledAll || videoSource.sources[item.source];
const isFocused = focusedIndex === index;
return (
<Animated.View style={[styles.resourceItem]}>
<Pressable
hasTVPreferredFocus={isFocused}
style={[styles.resourcePressable, isFocused && styles.resourceFocused]}
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
>
<ThemedText style={styles.resourceName}>{item.source_name}</ThemedText>
<Switch
value={isEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: "#007AFF" }}
thumbColor={isEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
</Pressable>
</Animated.View>
);
};
return (
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<ThemedText style={styles.sectionTitle}></ThemedText>
{resources.length > 0 && (
<FlatList
data={resources}
renderItem={renderResourceItem}
keyExtractor={(item) => item.source}
numColumns={3}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.flatListContainer}
scrollEnabled={false}
/>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
sectionTitle: {
fontSize: 20,
fontWeight: "bold",
marginBottom: 16,
},
flatListContainer: {
gap: 12,
},
row: {
justifyContent: "flex-start",
},
resourceItem: {
width: "32%",
marginHorizontal: 6,
marginVertical: 6,
borderRadius: 8,
overflow: "hidden",
justifyContent: "flex-start",
},
resourcePressable: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: "#2a2a2a",
borderRadius: 8,
minHeight: 56,
},
resourceFocused: {
backgroundColor: "#3a3a3c",
borderWidth: 2,
borderColor: "#007AFF",
shadowColor: "#007AFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
resourceName: {
fontSize: 14,
fontWeight: "600",
flex: 1,
marginRight: 8,
},
});

View File

@@ -26,5 +26,6 @@ export const Colors = {
tabIconSelected: tintColorDark,
link: "#0a7ea4",
border: "#333",
primary: "#00bb5e",
},
};

18
hooks/useAnimation.ts Normal file
View File

@@ -0,0 +1,18 @@
import { useRef, useEffect } from 'react';
import { Animated } from 'react-native';
export const useButtonAnimation = (isFocused: boolean, size: number = 1.1) => {
const scaleValue = useRef(new Animated.Value(1)).current;
useEffect(() => {
Animated.spring(scaleValue, {
toValue: isFocused ? size : 1,
friction: 5,
useNativeDriver: true,
}).start();
}, [ isFocused, scaleValue, size]);
return {
transform: [{ scale: scaleValue }],
};
};

View File

@@ -1 +0,0 @@
export {useColorScheme} from 'react-native';

View File

@@ -1,8 +0,0 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

View File

@@ -1,229 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useLocalSearchParams } from "expo-router";
import { Video, AVPlaybackStatus } from "expo-av";
import { api, VideoDetail } from "@/services/api";
import { PlayRecordManager } from "@/services/storage";
import { getResolutionFromM3U8 } from "@/services/m3u8";
interface Episode {
title?: string;
url: string;
}
interface Source {
name?: string;
url: string;
}
export const usePlaybackManager = (videoRef: React.RefObject<Video>) => {
const params = useLocalSearchParams();
const [detail, setDetail] = useState<VideoDetail | null>(null);
const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(
params.episodeIndex ? parseInt(params.episodeIndex as string) : 0
);
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [sources, setSources] = useState<Source[]>([]);
const [currentSourceIndex, setCurrentSourceIndex] = useState(0);
const [resolution, setResolution] = useState<string | null>(null);
const [status, setStatus] = useState<AVPlaybackStatus | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [initialSeekApplied, setInitialSeekApplied] = useState(false);
const [showNextEpisodeOverlay, setShowNextEpisodeOverlay] = useState(false);
const autoPlayTimer = useRef<NodeJS.Timeout | null>(null);
const saveRecordTimer = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
fetchVideoDetail();
saveRecordTimer.current = setInterval(() => {
saveCurrentPlayRecord();
}, 30000);
return () => {
saveCurrentPlayRecord();
if (saveRecordTimer.current) {
clearInterval(saveRecordTimer.current);
}
if (autoPlayTimer.current) {
clearTimeout(autoPlayTimer.current);
}
};
}, []);
useEffect(() => {
if (status?.isLoaded && "isPlaying" in status && !status.isPlaying) {
saveCurrentPlayRecord();
}
}, [status]);
useEffect(() => {
if (!detail || !videoRef.current || initialSeekApplied) return;
loadPlayRecord();
}, [detail, currentEpisodeIndex, videoRef.current]);
const fetchVideoDetail = async () => {
try {
setIsLoading(true);
const source = (params.source as string) || "1";
const id = (params.id as string) || "1";
const data = await api.getVideoDetail(source, id);
setDetail(data);
const processedEpisodes = data.episodes.map((url, index) => ({
title: `${index + 1}`,
url,
}));
setEpisodes(processedEpisodes);
if (data.episodes.length > 0) {
const demoSources = [
{ name: "默认线路", url: data.episodes[0] },
{ name: "备用线路", url: data.episodes[0] },
];
setSources(demoSources);
}
} catch (error) {
console.error("Error fetching video detail:", error);
} finally {
setIsLoading(false);
}
};
const loadPlayRecord = async () => {
if (typeof params.source !== "string" || typeof params.id !== "string")
return;
try {
const record = await PlayRecordManager.get(params.source, params.id);
if (record && videoRef.current && record.index === currentEpisodeIndex) {
setTimeout(async () => {
if (videoRef.current) {
await videoRef.current.setPositionAsync(record.play_time * 1000);
setInitialSeekApplied(true);
}
}, 2000);
}
} catch (error) {
console.error("Error loading play record:", error);
}
};
const saveCurrentPlayRecord = async () => {
if (!status?.isLoaded || !detail?.videoInfo) return;
const { source, id } = params;
if (typeof source !== "string" || typeof id !== "string") return;
try {
await PlayRecordManager.save(source, id, {
title: detail.videoInfo.title,
source_name: detail.videoInfo.source_name,
cover: detail.videoInfo.cover || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: Math.floor(status.positionMillis / 1000),
total_time: Math.floor((status.durationMillis || 0) / 1000),
});
} catch (error) {
console.error("Failed to save play record:", error);
}
};
const playEpisode = async (episodeIndex: number) => {
if (autoPlayTimer.current) {
clearTimeout(autoPlayTimer.current);
autoPlayTimer.current = null;
}
setShowNextEpisodeOverlay(false);
setCurrentEpisodeIndex(episodeIndex);
setIsLoading(true);
setInitialSeekApplied(false);
setResolution(null); // Reset resolution
if (videoRef.current && episodes[episodeIndex]) {
const episodeUrl = episodes[episodeIndex].url;
getResolutionFromM3U8(episodeUrl).then(setResolution);
await videoRef.current.unloadAsync();
setTimeout(async () => {
try {
await videoRef.current?.loadAsync(
{ uri: episodeUrl },
{ shouldPlay: true }
);
} catch (error) {
console.error("Error loading video:", error);
} finally {
setIsLoading(false);
}
}, 200);
}
};
const playNextEpisode = () => {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
};
const togglePlayPause = async () => {
if (!videoRef.current) return;
if (status?.isLoaded && status.isPlaying) {
await videoRef.current.pauseAsync();
} else {
await videoRef.current.playAsync();
}
};
const seek = async (forward: boolean) => {
if (!videoRef.current || !status?.isLoaded) return;
const wasPlaying = status.isPlaying;
const seekTime = forward ? 10000 : -10000;
const position = status.positionMillis + seekTime;
await videoRef.current.setPositionAsync(Math.max(0, position));
if (wasPlaying) {
await videoRef.current.playAsync();
}
};
const handlePlaybackStatusUpdate = (newStatus: AVPlaybackStatus) => {
setStatus(newStatus);
if (newStatus.isLoaded) {
if (
newStatus.durationMillis &&
newStatus.positionMillis &&
newStatus.durationMillis - newStatus.positionMillis < 2000 &&
currentEpisodeIndex < episodes.length - 1 &&
!showNextEpisodeOverlay
) {
setShowNextEpisodeOverlay(true);
if (autoPlayTimer.current) clearTimeout(autoPlayTimer.current);
autoPlayTimer.current = setTimeout(() => {
playNextEpisode();
}, 2000);
}
}
};
return {
detail,
episodes,
sources,
currentEpisodeIndex,
currentSourceIndex,
status,
isLoading,
showNextEpisodeOverlay,
resolution,
setCurrentSourceIndex,
setStatus,
setShowNextEpisodeOverlay,
setIsLoading,
playEpisode,
playNextEpisode,
togglePlayPause,
seek,
handlePlaybackStatusUpdate,
};
};

View File

@@ -1,114 +1,133 @@
import { useState, useEffect, useRef } from "react";
import { useTVEventHandler } from "react-native";
import { useEffect, useRef, useCallback } from "react";
import { useTVEventHandler, HWEvent } from "react-native";
import usePlayerStore from "@/stores/playerStore";
interface TVRemoteHandlerProps {
showControls: boolean;
setShowControls: (show: boolean) => void;
showEpisodeModal: boolean;
onPlayPause: () => void;
onSeek: (forward: boolean) => void;
onShowEpisodes: () => void;
onPlayNextEpisode: () => void;
}
const SEEK_STEP = 20 * 1000; // 快进/快退的时间步长(毫秒)
const focusGraph: Record<string, Record<string, string>> = {
skipBack: { right: "playPause" },
playPause: { left: "skipBack", right: "nextEpisode" },
nextEpisode: { left: "playPause", right: "skipForward" },
skipForward: { left: "nextEpisode", right: "episodes" },
episodes: { left: "skipForward" },
};
// 定时器延迟时间(毫秒)
const CONTROLS_TIMEOUT = 5000;
/**
* 管理播放器控件的显示/隐藏、遥控器事件和自动隐藏定时器。
* @returns onScreenPress - 一个函数,用于处理屏幕点击事件,以显示控件并重置定时器。
*/
export const useTVRemoteHandler = () => {
const { showControls, setShowControls, showEpisodeModal, togglePlayPause, seek } = usePlayerStore();
export const useTVRemoteHandler = ({
showControls,
setShowControls,
showEpisodeModal,
onPlayPause,
onSeek,
onShowEpisodes,
onPlayNextEpisode,
}: TVRemoteHandlerProps) => {
const [currentFocus, setCurrentFocus] = useState<string | null>(null);
const controlsTimer = useRef<NodeJS.Timeout | null>(null);
const fastForwardIntervalRef = useRef<NodeJS.Timeout | null>(null);
const actionMap: Record<string, () => void> = {
playPause: onPlayPause,
skipBack: () => onSeek(false),
skipForward: () => onSeek(true),
nextEpisode: onPlayNextEpisode,
episodes: onShowEpisodes,
};
// Centralized timer logic driven by state changes.
useEffect(() => {
// 重置或启动隐藏控件的定时器
const resetTimer = useCallback(() => {
// 清除之前的定时器
if (controlsTimer.current) {
clearTimeout(controlsTimer.current);
}
// 设置新的定时器
controlsTimer.current = setTimeout(() => {
setShowControls(false);
}, CONTROLS_TIMEOUT);
}, [setShowControls]);
// Only set a timer to hide controls if they are shown AND no element is focused.
if (showControls && currentFocus === null) {
controlsTimer.current = setTimeout(() => {
setShowControls(false);
}, 5000);
// 当控件显示时,启动定时器
useEffect(() => {
if (showControls) {
resetTimer();
} else {
// 如果控件被隐藏,清除定时器
if (controlsTimer.current) {
clearTimeout(controlsTimer.current);
}
}
// 组件卸载时清除定时器
return () => {
if (controlsTimer.current) {
clearTimeout(controlsTimer.current);
}
};
}, [showControls, currentFocus]);
}, [showControls, resetTimer]);
useTVEventHandler((event) => {
if (showEpisodeModal) {
return;
}
// If controls are hidden, the first interaction will just show them.
if (!showControls) {
if (["up", "down", "left", "right", "select"].includes(event.eventType)) {
setShowControls(true);
// 组件卸载时清除快进定时器
useEffect(() => {
return () => {
if (fastForwardIntervalRef.current) {
clearInterval(fastForwardIntervalRef.current);
}
return;
}
};
}, []);
// --- Event handling when controls are visible ---
// 处理遥控器事件
const handleTVEvent = useCallback(
(event: HWEvent) => {
if (showEpisodeModal) {
return;
}
if (event.eventType === "longRight" || event.eventType === "longLeft") {
if (event.eventKeyAction === 1) {
if (fastForwardIntervalRef.current) {
clearInterval(fastForwardIntervalRef.current);
fastForwardIntervalRef.current = null;
}
}
}
resetTimer();
if (showControls) {
// 如果控制条已显示,则不处理后台的快进/快退等操作
// 避免与控制条上的按钮焦点冲突
return;
}
if (currentFocus === null) {
// When no specific element is focused on the control bar
switch (event.eventType) {
case "left":
onSeek(false);
break;
case "right":
onSeek(true);
break;
case "select":
onPlayPause();
togglePlayPause();
setShowControls(true);
break;
case "down":
setCurrentFocus("playPause");
break;
}
} else {
// When an element on the control bar is focused
switch (event.eventType) {
case "left":
case "right":
const nextFocus = focusGraph[currentFocus]?.[event.eventType];
if (nextFocus) {
setCurrentFocus(nextFocus);
seek(-SEEK_STEP); // 快退15秒
break;
case "longLeft":
if (!fastForwardIntervalRef.current && event.eventKeyAction === 0) {
fastForwardIntervalRef.current = setInterval(() => {
seek(-SEEK_STEP);
}, 200);
}
break;
case "up":
setCurrentFocus(null);
case "right":
seek(SEEK_STEP);
break;
case "select":
actionMap[currentFocus]?.();
case "longRight":
// 长按开始: 启动连续快进
if (!fastForwardIntervalRef.current && event.eventKeyAction === 0) {
fastForwardIntervalRef.current = setInterval(() => {
seek(SEEK_STEP);
}, 200);
}
break;
case "down":
setShowControls(true);
break;
}
}
});
},
[showControls, showEpisodeModal, setShowControls, resetTimer, togglePlayPause, seek]
);
return { currentFocus, setCurrentFocus };
useTVEventHandler(handleTVEvent);
// 处理屏幕点击事件
const onScreenPress = () => {
// 切换控件的显示状态
const newShowControls = !showControls;
setShowControls(newShowControls);
// 如果控件变为显示状态,则重置定时器
if (newShowControls) {
resetTimer();
}
};
return { onScreenPress };
};

View File

@@ -3,15 +3,13 @@
* https://docs.expo.dev/guides/color-schemes/
*/
import {useColorScheme} from 'react-native';
import {Colors} from '@/constants/Colors';
export function useThemeColor(
props: {light?: string; dark?: string},
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? 'light';
const theme = 'dark';
const colorFromProps = props[theme];
if (colorFromProps) {

View File

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.0.6",
"version": "1.2.9",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
@@ -28,12 +28,15 @@
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.3.2",
"@react-native-cookies/cookies": "^6.2.1",
"@react-navigation/native": "^6.0.2",
"expo": "~51.0.13",
"expo-av": "~14.0.7",
"expo-build-properties": "~0.12.3",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.7",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.16",
"expo-splash-screen": "~0.27.5",
@@ -46,11 +49,15 @@
"react-native": "npm:react-native-tvos@~0.74.2-0",
"react-native-gesture-handler": "~2.16.1",
"react-native-media-console": "*",
"react-native-qrcode-svg": "^6.3.1",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "3.31.1",
"react-native-svg": "^15.12.0",
"react-native-web": "~0.19.10"
"react-native-tcp-socket": "^6.0.6",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.19.10",
"zustand": "^5.0.6"
},
"devDependencies": {
"@babel/core": "^7.20.0",
@@ -58,6 +65,8 @@
"@types/jest": "^29.5.12",
"@types/react": "~18.2.45",
"@types/react-test-renderer": "^18.0.7",
"eslint": "^8.57.0",
"eslint-config-expo": "~7.1.2",
"jest": "^29.2.1",
"jest-expo": "~51.0.1",
"react-test-renderer": "18.2.0",
@@ -71,4 +80,4 @@
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 672 KiB

View File

@@ -1,5 +1,5 @@
import { SettingsManager } from "./storage";
// region: --- Interface Definitions ---
export interface DoubanItem {
title: string;
poster: string;
@@ -13,23 +13,18 @@ export interface DoubanResponse {
}
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover?: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
source_name: string;
source: string;
id: string;
};
id: string;
title: string;
poster: string;
source: string;
source_name: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
}
export interface SearchResult {
@@ -45,17 +40,26 @@ export interface SearchResult {
type_name?: string;
}
// Data structure for play records
export interface Favorite {
cover: string;
title: string;
source_name: string;
total_episodes: number;
search_title: string;
year: string;
save_time?: number;
}
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
total_time: number; // Total duration in seconds
save_time: number; // Timestamp of when the record was saved
user_id: number; // User ID, always 0 in this version
index: number;
total_episodes: number;
play_time: number;
total_time: number;
save_time: number;
year: string;
}
export interface ApiSite {
@@ -65,6 +69,11 @@ export interface ApiSite {
detail?: string;
}
export interface ServerConfig {
SiteName: string;
StorageType: "localstorage" | "redis" | string;
}
export class API {
public baseURL: string = "";
@@ -78,91 +87,138 @@ export class API {
this.baseURL = url;
}
/**
* 生成图片代理 URL
*/
getImageProxyUrl(imageUrl: string): string {
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(
imageUrl
)}`;
private async _fetch(url: string, options: RequestInit = {}): Promise<Response> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const response = await fetch(`${this.baseURL}${url}`, options);
if (response.status === 401) {
throw new Error("UNAUTHORIZED");
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
}
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}
async login(username?: string | undefined, password?: string): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
return response.json();
}
async getFavorites(key?: string): Promise<Record<string, Favorite> | Favorite | null> {
const url = key ? `/api/favorites?key=${encodeURIComponent(key)}` : "/api/favorites";
const response = await this._fetch(url);
return response.json();
}
async addFavorite(key: string, favorite: Omit<Favorite, "save_time">): Promise<{ success: boolean }> {
const response = await this._fetch("/api/favorites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, favorite }),
});
return response.json();
}
async deleteFavorite(key?: string): Promise<{ success: boolean }> {
const url = key ? `/api/favorites?key=${encodeURIComponent(key)}` : "/api/favorites";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
async getPlayRecords(): Promise<Record<string, PlayRecord>> {
const response = await this._fetch("/api/playrecords");
return response.json();
}
async savePlayRecord(key: string, record: Omit<PlayRecord, "save_time">): Promise<{ success: boolean }> {
const response = await this._fetch("/api/playrecords", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, record }),
});
return response.json();
}
async deletePlayRecord(key?: string): Promise<{ success: boolean }> {
const url = key ? `/api/playrecords?key=${encodeURIComponent(key)}` : "/api/playrecords";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
async getSearchHistory(): Promise<string[]> {
const response = await this._fetch("/api/searchhistory");
return response.json();
}
async addSearchHistory(keyword: string): Promise<string[]> {
const response = await this._fetch("/api/searchhistory", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ keyword }),
});
return response.json();
}
async deleteSearchHistory(keyword?: string): Promise<{ success: boolean }> {
const url = keyword ? `/api/searchhistory?keyword=${keyword}` : "/api/searchhistory";
const response = await this._fetch(url, { method: "DELETE" });
return response.json();
}
getImageProxyUrl(imageUrl: string): string {
return `${this.baseURL}/api/image-proxy?url=${encodeURIComponent(imageUrl)}`;
}
/**
* 获取豆瓣数据
*/
async getDoubanData(
type: "movie" | "tv",
tag: string,
pageSize: number = 16,
pageStart: number = 0
): Promise<DoubanResponse> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${
this.baseURL
}/api/douban?type=${type}&tag=${encodeURIComponent(
tag
)}&pageSize=${pageSize}&pageStart=${pageStart}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/douban?type=${type}&tag=${encodeURIComponent(tag)}&pageSize=${pageSize}&pageStart=${pageStart}`;
const response = await this._fetch(url);
return response.json();
}
/**
* 搜索视频
*/
async searchVideos(query: string): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search?q=${encodeURIComponent(query)}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search?q=${encodeURIComponent(query)}`;
const response = await this._fetch(url);
return response.json();
}
async searchVideo(query: string, resourceId: string, signal?: AbortSignal): Promise<{ results: SearchResult[] }> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search/one?q=${encodeURIComponent(query)}&resourceId=${encodeURIComponent(resourceId)}`;
const response = await this._fetch(url, { signal });
return response.json();
}
async getResources(signal?: AbortSignal): Promise<ApiSite[]> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/search/resources`;
const response = await fetch(url, { signal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/search/resources`;
const response = await this._fetch(url, { signal });
return response.json();
}
/**
* 获取视频详情
*/
async getVideoDetail(source: string, id: string): Promise<VideoDetail> {
if (!this.baseURL) {
throw new Error("API_URL_NOT_SET");
}
const url = `${this.baseURL}/api/detail?source=${source}&id=${id}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const url = `/api/detail?source=${source}&id=${id}`;
const response = await this._fetch(url);
return response.json();
}
}
// 默认实例
export let api = new API();
// 初始化 API
export const initializeApi = async () => {
const settings = await SettingsManager.get();
api.setBaseUrl(settings.apiBaseUrl);
};

84
services/m3u.ts Normal file
View File

@@ -0,0 +1,84 @@
import { api } from "./api";
export interface Channel {
id: string;
name: string;
url: string;
logo: string;
group: string;
}
export const parseM3U = (m3uText: string): Channel[] => {
const parsedChannels: Channel[] = [];
const lines = m3uText.split('\n');
let currentChannelInfo: Partial<Channel> | null = null;
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = {}; // Start a new channel
const commaIndex = trimmedLine.lastIndexOf(',');
if (commaIndex !== -1) {
currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim();
const attributesPart = trimmedLine.substring(8, commaIndex);
const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i);
if (logoMatch && logoMatch[1]) {
currentChannelInfo.logo = logoMatch[1];
}
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) {
currentChannelInfo.group = groupMatch[1];
}
} else {
currentChannelInfo.name = trimmedLine.substring(8).trim();
}
} else if (currentChannelInfo && trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('://')) {
currentChannelInfo.url = trimmedLine;
currentChannelInfo.id = currentChannelInfo.url; // Use URL as ID
// Ensure all required fields are present, providing defaults if necessary
const finalChannel: Channel = {
id: currentChannelInfo.id,
url: currentChannelInfo.url,
name: currentChannelInfo.name || 'Unknown',
logo: currentChannelInfo.logo || '',
group: currentChannelInfo.group || 'Default',
};
parsedChannels.push(finalChannel);
currentChannelInfo = null; // Reset for the next channel
}
}
return parsedChannels;
};
export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
try {
const response = await fetch(m3uUrl);
if (!response.ok) {
throw new Error(`Failed to fetch M3U: ${response.statusText}`);
}
const m3uText = await response.text();
return parseM3U(m3uText);
} catch (error) {
console.info("Error fetching or parsing M3U:", error);
return []; // Return empty array on error
}
};
export const getPlayableUrl = (originalUrl: string | null): string | null => {
if (!originalUrl) {
return null;
}
// In React Native, we use the proxy for all http streams to avoid potential issues.
// if (originalUrl.toLowerCase().startsWith('http://')) {
// // Use the baseURL from the existing api instance.
// if (!api.baseURL) {
// console.warn("API base URL is not set. Cannot create proxy URL.")
// return originalUrl; // Fallback to original URL
// }
// return `${api.baseURL}/proxy?url=${encodeURIComponent(originalUrl)}`;
// }
// HTTPS streams can be played directly.
return originalUrl;
};

View File

@@ -0,0 +1,143 @@
import TCPHttpServer from "./tcpHttpServer";
const getRemotePageHTML = () => {
return `
<!DOCTYPE html>
<html>
<head>
<title>OrionTV Remote</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #121212; color: white; }
h3 { color: #eee; }
#container { display: flex; flex-direction: column; align-items: center; width: 90%; max-width: 400px; }
#text { width: 100%; padding: 15px; font-size: 16px; border-radius: 8px; border: 1px solid #333; background-color: #2a2a2a; color: white; margin-bottom: 20px; box-sizing: border-box; }
button { width: 100%; padding: 15px; font-size: 18px; font-weight: bold; border: none; border-radius: 8px; background-color: #007AFF; color: white; cursor: pointer; }
button:active { background-color: #0056b3; }
</style>
</head>
<body>
<div id="container">
<h3>向电视发送文本</h3>
<input id="text" placeholder="请输入..." />
<button onclick="send()">发送</button>
</div>
<script>
window.addEventListener('DOMContentLoaded', () => {
fetch('/handshake', { method: 'POST' }).catch(console.info);
});
function send() {
const input = document.getElementById("text");
const value = input.value;
if (value) {
fetch("/message", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value })
})
.catch(err => console.info(err));
input.value = '';
}
}
</script>
</body>
</html>
`;
};
class RemoteControlService {
private httpServer: TCPHttpServer;
private onMessage: (message: string) => void = () => {};
private onHandshake: () => void = () => {};
constructor() {
this.httpServer = new TCPHttpServer();
this.setupRequestHandler();
}
private setupRequestHandler() {
this.httpServer.setRequestHandler((request) => {
console.log("[RemoteControl] Received request:", request.method, request.url);
try {
if (request.method === "GET" && request.url === "/") {
return {
statusCode: 200,
headers: { "Content-Type": "text/html; charset=utf-8" },
body: getRemotePageHTML(),
};
} else if (request.method === "POST" && request.url === "/message") {
try {
const parsedBody = JSON.parse(request.body || "{}");
const message = parsedBody.message;
if (message) {
this.onMessage(message);
}
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} catch (parseError) {
console.info("[RemoteControl] Failed to parse message body:", parseError);
return {
statusCode: 400,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Invalid JSON" }),
};
}
} else if (request.method === "POST" && request.url === "/handshake") {
this.onHandshake();
return {
statusCode: 200,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} else {
return {
statusCode: 404,
headers: { "Content-Type": "text/plain" },
body: "Not Found",
};
}
} catch (error) {
console.info("[RemoteControl] Request handler error:", error);
return {
statusCode: 500,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Internal Server Error" }),
};
}
});
}
public init(actions: { onMessage: (message: string) => void; onHandshake: () => void }) {
this.onMessage = actions.onMessage;
this.onHandshake = actions.onHandshake;
}
public async startServer(): Promise<string> {
console.log("[RemoteControl] Attempting to start server...");
try {
const url = await this.httpServer.start();
console.log(`[RemoteControl] Server started successfully at: ${url}`);
return url;
} catch (error) {
console.info("[RemoteControl] Failed to start server:", error);
throw new Error(error instanceof Error ? error.message : "Failed to start server");
}
}
public stopServer() {
console.log("[RemoteControl] Stopping server...");
this.httpServer.stop();
}
public isRunning(): boolean {
return this.httpServer.getIsRunning();
}
}
export const remoteControlService = new RemoteControlService();

View File

@@ -1,82 +1,137 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { PlayRecord as ApiPlayRecord } from "./api"; // Use a consistent type
import { api, PlayRecord as ApiPlayRecord, Favorite as ApiFavorite } from "./api";
import { storageConfig } from "./storageConfig";
// --- Storage Keys ---
const STORAGE_KEYS = {
SETTINGS: "mytv_settings",
PLAYER_SETTINGS: "mytv_player_settings",
FAVORITES: "mytv_favorites",
PLAY_RECORDS: "mytv_play_records",
SEARCH_HISTORY: "mytv_search_history",
SETTINGS: "mytv_settings",
} as const;
// --- Type Definitions (aligned with api.ts) ---
export type PlayRecord = ApiPlayRecord;
// Re-exporting for consistency, though they are now primarily API types
export type PlayRecord = ApiPlayRecord & {
introEndTime?: number;
outroStartTime?: number;
};
export type Favorite = ApiFavorite;
export interface FavoriteItem {
id: string;
source: string;
title: string;
poster: string;
source_name: string;
save_time: number;
export interface PlayerSettings {
introEndTime?: number;
outroStartTime?: number;
}
export interface AppSettings {
theme: "light" | "dark" | "auto";
autoPlay: boolean;
playbackSpeed: number;
apiBaseUrl: string;
remoteInputEnabled: boolean;
videoSource: {
enabledAll: boolean;
sources: {
[key: string]: boolean;
};
};
m3uUrl: string;
}
// --- Helper ---
const generateKey = (source: string, id: string) => `${source}+${id}`;
// --- FavoriteManager ---
export class FavoriteManager {
static async getAll(): Promise<Record<string, FavoriteItem>> {
// --- PlayerSettingsManager (Uses AsyncStorage) ---
export class PlayerSettingsManager {
static async getAll(): Promise<Record<string, PlayerSettings>> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAYER_SETTINGS);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error("Failed to get favorites:", error);
console.info("Failed to get all player settings:", error);
return {};
}
}
static async save(
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<void> {
const favorites = await this.getAll();
static async get(source: string, id: string): Promise<PlayerSettings | null> {
const allSettings = await this.getAll();
return allSettings[generateKey(source, id)] || null;
}
static async save(source: string, id: string, settings: PlayerSettings): Promise<void> {
const allSettings = await this.getAll();
const key = generateKey(source, id);
favorites[key] = { ...item, id, source, save_time: Date.now() };
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
// Only save if there are actual values to save
if (settings.introEndTime !== undefined || settings.outroStartTime !== undefined) {
allSettings[key] = { ...allSettings[key], ...settings };
} else {
// If both are undefined, remove the key
delete allSettings[key];
}
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
}
static async remove(source: string, id: string): Promise<void> {
const favorites = await this.getAll();
const allSettings = await this.getAll();
delete allSettings[generateKey(source, id)];
await AsyncStorage.setItem(STORAGE_KEYS.PLAYER_SETTINGS, JSON.stringify(allSettings));
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAYER_SETTINGS);
}
}
// --- FavoriteManager (Dynamic: API or LocalStorage) ---
export class FavoriteManager {
private static getStorageType() {
return storageConfig.getStorageType();
}
static async getAll(): Promise<Record<string, Favorite>> {
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
} catch (error) {
console.info("Failed to get all local favorites:", error);
return {};
}
}
return (await api.getFavorites()) as Record<string, Favorite>;
}
static async save(source: string, id: string, item: Favorite): Promise<void> {
const key = generateKey(source, id);
delete favorites[key];
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
allFavorites[key] = { ...item, save_time: Date.now() };
await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(allFavorites));
return;
}
await api.addFavorite(key, item);
}
static async remove(source: string, id: string): Promise<void> {
const key = generateKey(source, id);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
delete allFavorites[key];
await AsyncStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(allFavorites));
return;
}
await api.deleteFavorite(key);
}
static async isFavorited(source: string, id: string): Promise<boolean> {
const favorites = await this.getAll();
return generateKey(source, id) in favorites;
const key = generateKey(source, id);
if (this.getStorageType() === "localstorage") {
const allFavorites = await this.getAll();
return !!allFavorites[key];
}
const favorite = await api.getFavorites(key);
return favorite !== null;
}
static async toggle(
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<boolean> {
static async toggle(source: string, id: string, item: Favorite): Promise<boolean> {
const isFav = await this.isFavorited(source, id);
if (isFav) {
await this.remove(source, id);
@@ -88,105 +143,150 @@ export class FavoriteManager {
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.FAVORITES);
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.FAVORITES);
return;
}
await api.deleteFavorite();
}
}
// --- PlayRecordManager ---
// --- PlayRecordManager (Dynamic: API or LocalStorage) ---
export class PlayRecordManager {
static async getAll(): Promise<Record<string, PlayRecord>> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error("Failed to get play records:", error);
return {};
}
private static getStorageType() {
return storageConfig.getStorageType();
}
static async save(
source: string,
id: string,
record: Omit<PlayRecord, "user_id" | "save_time">
): Promise<void> {
const records = await this.getAll();
static async getAll(): Promise<Record<string, PlayRecord>> {
let apiRecords: Record<string, PlayRecord> = {};
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.PLAY_RECORDS);
apiRecords = data ? JSON.parse(data) : {};
} catch (error) {
console.info("Failed to get all local play records:", error);
return {};
}
} else {
apiRecords = await api.getPlayRecords();
}
const localSettings = await PlayerSettingsManager.getAll();
const mergedRecords: Record<string, PlayRecord> = {};
for (const key in apiRecords) {
mergedRecords[key] = {
...apiRecords[key],
...localSettings[key],
};
}
return mergedRecords;
}
static async save(source: string, id: string, record: Omit<PlayRecord, "save_time">): Promise<void> {
const key = generateKey(source, id);
records[key] = { ...record, user_id: 0, save_time: Date.now() };
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS,
JSON.stringify(records)
);
const { introEndTime, outroStartTime, ...apiRecord } = record;
// Player settings are always saved locally
await PlayerSettingsManager.save(source, id, { introEndTime, outroStartTime });
if (this.getStorageType() === "localstorage") {
const allRecords = await this.getAll();
const fullRecord = { ...apiRecord, save_time: Date.now() };
allRecords[key] = { ...allRecords[key], ...fullRecord };
await AsyncStorage.setItem(STORAGE_KEYS.PLAY_RECORDS, JSON.stringify(allRecords));
} else {
await api.savePlayRecord(key, apiRecord);
}
}
static async get(source: string, id: string): Promise<PlayRecord | null> {
const key = generateKey(source, id);
const records = await this.getAll();
return records[generateKey(source, id)] || null;
return records[key] || null;
}
static async remove(source: string, id: string): Promise<void> {
const records = await this.getAll();
delete records[generateKey(source, id)];
await AsyncStorage.setItem(
STORAGE_KEYS.PLAY_RECORDS,
JSON.stringify(records)
);
const key = generateKey(source, id);
await PlayerSettingsManager.remove(source, id); // Always remove local settings
if (this.getStorageType() === "localstorage") {
const allRecords = await this.getAll();
delete allRecords[key];
await AsyncStorage.setItem(STORAGE_KEYS.PLAY_RECORDS, JSON.stringify(allRecords));
} else {
await api.deletePlayRecord(key);
}
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAY_RECORDS);
await PlayerSettingsManager.clearAll(); // Always clear local settings
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAY_RECORDS);
} else {
await api.deletePlayRecord();
}
}
}
// --- SearchHistoryManager ---
const SEARCH_HISTORY_LIMIT = 20;
// --- SearchHistoryManager (Dynamic: API or LocalStorage) ---
export class SearchHistoryManager {
private static getStorageType() {
return storageConfig.getStorageType();
}
static async get(): Promise<string[]> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error("Failed to get search history:", error);
return [];
if (this.getStorageType() === "localstorage") {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SEARCH_HISTORY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.info("Failed to get local search history:", error);
return [];
}
}
return api.getSearchHistory();
}
static async add(keyword: string): Promise<void> {
const trimmed = keyword.trim();
if (!trimmed) return;
const history = await this.get();
const newHistory = [trimmed, ...history.filter((k) => k !== trimmed)];
if (newHistory.length > SEARCH_HISTORY_LIMIT) {
newHistory.length = SEARCH_HISTORY_LIMIT;
if (this.getStorageType() === "localstorage") {
let history = await this.get();
history = [trimmed, ...history.filter((k) => k !== trimmed)].slice(0, 20); // Keep latest 20
await AsyncStorage.setItem(STORAGE_KEYS.SEARCH_HISTORY, JSON.stringify(history));
return;
}
await AsyncStorage.setItem(
STORAGE_KEYS.SEARCH_HISTORY,
JSON.stringify(newHistory)
);
await api.addSearchHistory(trimmed);
}
static async clear(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.SEARCH_HISTORY);
if (this.getStorageType() === "localstorage") {
await AsyncStorage.removeItem(STORAGE_KEYS.SEARCH_HISTORY);
return;
}
await api.deleteSearchHistory();
}
}
// --- SettingsManager ---
// --- SettingsManager (Uses AsyncStorage) ---
export class SettingsManager {
static async get(): Promise<AppSettings> {
const defaultSettings: AppSettings = {
theme: "auto",
autoPlay: true,
playbackSpeed: 1.0,
apiBaseUrl: "",
remoteInputEnabled: true,
videoSource: {
enabledAll: true,
sources: {},
},
m3uUrl: "",
};
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.SETTINGS);
return data
? { ...defaultSettings, ...JSON.parse(data) }
: defaultSettings;
return data ? { ...defaultSettings, ...JSON.parse(data) } : defaultSettings;
} catch (error) {
console.error("Failed to get settings:", error);
console.info("Failed to get settings:", error);
return defaultSettings;
}
}
@@ -194,10 +294,7 @@ export class SettingsManager {
static async save(settings: Partial<AppSettings>): Promise<void> {
const currentSettings = await this.get();
const updatedSettings = { ...currentSettings, ...settings };
await AsyncStorage.setItem(
STORAGE_KEYS.SETTINGS,
JSON.stringify(updatedSettings)
);
await AsyncStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(updatedSettings));
}
static async reset(): Promise<void> {

20
services/storageConfig.ts Normal file
View File

@@ -0,0 +1,20 @@
// Define a simple storage configuration service
export interface StorageConfig {
storageType: string | undefined;
getStorageType: () => string | undefined;
setStorageType: (type: string | undefined) => void;
}
// Create a singleton instance
export const storageConfig: StorageConfig = {
// Default to undefined (will fallback to local storage)
storageType: undefined,
getStorageType() {
return this.storageType;
},
setStorageType(type: string | undefined) {
this.storageType = type;
},
};

199
services/tcpHttpServer.ts Normal file
View File

@@ -0,0 +1,199 @@
import TcpSocket from 'react-native-tcp-socket';
import NetInfo from '@react-native-community/netinfo';
const PORT = 12346;
interface HttpRequest {
method: string;
url: string;
headers: { [key: string]: string };
body: string;
}
interface HttpResponse {
statusCode: number;
headers: { [key: string]: string };
body: string;
}
type RequestHandler = (request: HttpRequest) => HttpResponse | Promise<HttpResponse>;
class TCPHttpServer {
private server: TcpSocket.Server | null = null;
private isRunning = false;
private requestHandler: RequestHandler | null = null;
constructor() {
this.server = null;
}
private parseHttpRequest(data: string): HttpRequest | null {
try {
const lines = data.split('\r\n');
const requestLine = lines[0].split(' ');
if (requestLine.length < 3) {
return null;
}
const method = requestLine[0];
const url = requestLine[1];
const headers: { [key: string]: string } = {};
let bodyStartIndex = -1;
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (line === '') {
bodyStartIndex = i + 1;
break;
}
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
headers[key] = value;
}
}
const body = bodyStartIndex > 0 ? lines.slice(bodyStartIndex).join('\r\n') : '';
return { method, url, headers, body };
} catch (error) {
console.info('[TCPHttpServer] Error parsing HTTP request:', error);
return null;
}
}
private formatHttpResponse(response: HttpResponse): string {
const statusTexts: { [key: number]: string } = {
200: 'OK',
400: 'Bad Request',
404: 'Not Found',
500: 'Internal Server Error'
};
const statusText = statusTexts[response.statusCode] || 'Unknown';
const headers = {
'Content-Length': new TextEncoder().encode(response.body).length.toString(),
'Connection': 'close',
...response.headers
};
let httpResponse = `HTTP/1.1 ${response.statusCode} ${statusText}\r\n`;
for (const [key, value] of Object.entries(headers)) {
httpResponse += `${key}: ${value}\r\n`;
}
httpResponse += '\r\n';
httpResponse += response.body;
return httpResponse;
}
public setRequestHandler(handler: RequestHandler) {
this.requestHandler = handler;
}
public async start(): Promise<string> {
const netState = await NetInfo.fetch();
let ipAddress: string | null = null;
if (netState.type === 'wifi' || netState.type === 'ethernet') {
ipAddress = (netState.details as any)?.ipAddress ?? null;
}
if (!ipAddress) {
throw new Error('无法获取IP地址请确认设备已连接到WiFi或以太网。');
}
if (this.isRunning) {
console.log('[TCPHttpServer] Server is already running.');
return `http://${ipAddress}:${PORT}`;
}
return new Promise((resolve, reject) => {
try {
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {
console.log('[TCPHttpServer] Client connected');
let requestData = '';
socket.on('data', async (data: string | Buffer) => {
requestData += data.toString();
// Check if we have a complete HTTP request
if (requestData.includes('\r\n\r\n')) {
try {
const request = this.parseHttpRequest(requestData);
if (request && this.requestHandler) {
const response = await this.requestHandler(request);
const httpResponse = this.formatHttpResponse(response);
socket.write(httpResponse);
} else {
// Send 400 Bad Request for malformed requests
const errorResponse = this.formatHttpResponse({
statusCode: 400,
headers: { 'Content-Type': 'text/plain' },
body: 'Bad Request'
});
socket.write(errorResponse);
}
} catch (error) {
console.info('[TCPHttpServer] Error handling request:', error);
const errorResponse = this.formatHttpResponse({
statusCode: 500,
headers: { 'Content-Type': 'text/plain' },
body: 'Internal Server Error'
});
socket.write(errorResponse);
}
socket.end();
requestData = '';
}
});
socket.on('error', (error: Error) => {
console.info('[TCPHttpServer] Socket error:', error);
});
socket.on('close', () => {
console.log('[TCPHttpServer] Client disconnected');
});
});
this.server.listen({ port: PORT, host: '0.0.0.0' }, () => {
console.log(`[TCPHttpServer] Server listening on ${ipAddress}:${PORT}`);
this.isRunning = true;
resolve(`http://${ipAddress}:${PORT}`);
});
this.server.on('error', (error: Error) => {
console.info('[TCPHttpServer] Server error:', error);
this.isRunning = false;
reject(error);
});
} catch (error) {
console.info('[TCPHttpServer] Failed to start server:', error);
reject(error);
}
});
}
public stop() {
if (this.server && this.isRunning) {
this.server.close();
this.server = null;
this.isRunning = false;
console.log('[TCPHttpServer] Server stopped');
}
}
public getIsRunning(): boolean {
return this.isRunning;
}
}
export default TCPHttpServer;

66
stores/authStore.ts Normal file
View File

@@ -0,0 +1,66 @@
import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore";
import Toast from "react-native-toast-message";
interface AuthState {
isLoggedIn: boolean;
isLoginModalVisible: boolean;
showLoginModal: () => void;
hideLoginModal: () => void;
checkLoginStatus: (apiBaseUrl?: string) => Promise<void>;
logout: () => Promise<void>;
}
const useAuthStore = create<AuthState>((set) => ({
isLoggedIn: false,
isLoginModalVisible: false,
showLoginModal: () => set({ isLoginModalVisible: true }),
hideLoginModal: () => set({ isLoginModalVisible: false }),
checkLoginStatus: async (apiBaseUrl?: string) => {
if (!apiBaseUrl) {
set({ isLoggedIn: false, isLoginModalVisible: false });
return;
}
try {
const serverConfig = useSettingsStore.getState().serverConfig;
if (!serverConfig?.StorageType) {
Toast.show({ type: "error", text1: "请检查网络或者服务器地址是否可用" });
return
}
const cookies = await Cookies.get(api.baseURL);
if (serverConfig && serverConfig.StorageType === "localstorage" && !cookies.auth) {
const loginResult = await api.login().catch(() => {
set({ isLoggedIn: false, isLoginModalVisible: true });
});
if (loginResult && loginResult.ok) {
set({ isLoggedIn: true });
}
} else {
const isLoggedIn = cookies && !!cookies.auth;
set({ isLoggedIn });
if (!isLoggedIn) {
set({ isLoginModalVisible: true });
}
}
} catch (error) {
console.info("Failed to check login status:", error);
if (error instanceof Error && error.message === "UNAUTHORIZED") {
set({ isLoggedIn: false, isLoginModalVisible: true });
} else {
set({ isLoggedIn: false });
}
}
},
logout: async () => {
try {
await Cookies.clearAll();
set({ isLoggedIn: false, isLoginModalVisible: true });
} catch (error) {
console.info("Failed to logout:", error);
}
},
}));
export default useAuthStore;

186
stores/detailStore.ts Normal file
View File

@@ -0,0 +1,186 @@
import { create } from "zustand";
import { SearchResult, api } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { useSettingsStore } from "@/stores/settingsStore";
import { FavoriteManager } from "@/services/storage";
export type SearchResultWithResolution = SearchResult & { resolution?: string | null };
interface DetailState {
q: string | null;
searchResults: SearchResultWithResolution[];
sources: { source: string; source_name: string; resolution: string | null | undefined }[];
detail: SearchResultWithResolution | null;
loading: boolean;
error: string | null;
allSourcesLoaded: boolean;
controller: AbortController | null;
isFavorited: boolean;
init: (q: string, preferredSource?: string, id?: string) => Promise<void>;
setDetail: (detail: SearchResultWithResolution) => void;
abort: () => void;
toggleFavorite: () => Promise<void>;
}
const useDetailStore = create<DetailState>((set, get) => ({
q: null,
searchResults: [],
sources: [],
detail: null,
loading: true,
error: null,
allSourcesLoaded: false,
controller: null,
isFavorited: false,
init: async (q, preferredSource, id) => {
const { controller: oldController } = get();
if (oldController) {
oldController.abort();
}
const newController = new AbortController();
const signal = newController.signal;
set({
q,
loading: true,
searchResults: [],
detail: null,
error: null,
allSourcesLoaded: false,
controller: newController,
});
const { videoSource } = useSettingsStore.getState();
const processAndSetResults = async (results: SearchResult[], merge = false) => {
const resultsWithResolution = await Promise.all(
results.map(async (searchResult) => {
let resolution;
try {
if (searchResult.episodes && searchResult.episodes.length > 0) {
resolution = await getResolutionFromM3U8(searchResult.episodes[0], signal);
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
console.info(`Failed to get resolution for ${searchResult.source_name}`, e);
}
}
return { ...searchResult, resolution };
})
);
if (signal.aborted) return;
set((state) => {
const existingSources = new Set(state.searchResults.map((r) => r.source));
const newResults = resultsWithResolution.filter((r) => !existingSources.has(r.source));
const finalResults = merge ? [...state.searchResults, ...newResults] : resultsWithResolution;
return {
searchResults: finalResults,
sources: finalResults.map((r) => ({
source: r.source,
source_name: r.source_name,
resolution: r.resolution,
})),
detail: state.detail ?? finalResults[0] ?? null,
};
});
};
try {
// Optimization for favorite navigation
if (preferredSource && id) {
const { results: preferredResult } = await api.searchVideo(q, preferredSource, signal);
if (signal.aborted) return;
if (preferredResult.length > 0) {
await processAndSetResults(preferredResult, false);
set({ loading: false });
}
// Then load all others in background
const { results: allResults } = await api.searchVideos(q);
if (signal.aborted) return;
await processAndSetResults(allResults, true);
} else {
// Standard navigation: fetch resources, then fetch details one by one
const allResources = await api.getResources(signal);
const enabledResources = videoSource.enabledAll
? allResources
: allResources.filter((r) => videoSource.sources[r.key]);
let firstResultFound = false;
const searchPromises = enabledResources.map(async (resource) => {
try {
const { results } = await api.searchVideo(q, resource.key, signal);
if (results.length > 0) {
await processAndSetResults(results, true);
if (!firstResultFound) {
set({ loading: false }); // Stop loading indicator on first result
firstResultFound = true;
}
}
} catch (error) {
console.info(`Failed to fetch from ${resource.name}:`, error);
}
});
await Promise.all(searchPromises);
}
if (get().searchResults.length === 0) {
set({ error: "未找到任何播放源" });
}
if (get().detail) {
const { source, id } = get().detail!;
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
set({ isFavorited });
}
} catch (e) {
if ((e as Error).name !== "AbortError") {
set({ error: e instanceof Error ? e.message : "获取数据失败" });
}
} finally {
if (!signal.aborted) {
set({ loading: false, allSourcesLoaded: true });
}
}
},
setDetail: async (detail) => {
set({ detail });
const { source, id } = detail;
const isFavorited = await FavoriteManager.isFavorited(source, id.toString());
set({ isFavorited });
},
abort: () => {
get().controller?.abort();
},
toggleFavorite: async () => {
const { detail } = get();
if (!detail) return;
const { source, id, title, poster, source_name, episodes, year } = detail;
const favoriteItem = {
cover: poster,
title,
poster,
source_name,
total_episodes: episodes.length,
search_title: get().q!,
year: year || "",
};
const newIsFavorited = await FavoriteManager.toggle(source, id.toString(), favoriteItem);
set({ isFavorited: newIsFavorited });
},
}));
export const sourcesSelector = (state: DetailState) => state.sources;
export default useDetailStore;
export const episodesSelectorBySource = (source: string) => (state: DetailState) =>
state.searchResults.find((r) => r.source === source)?.episodes || [];

32
stores/favoritesStore.ts Normal file
View File

@@ -0,0 +1,32 @@
import { create } from "zustand";
import { Favorite, FavoriteManager } from "@/services/storage";
interface FavoritesState {
favorites: (Favorite & { key: string })[];
loading: boolean;
error: string | null;
fetchFavorites: () => Promise<void>;
}
const useFavoritesStore = create<FavoritesState>((set) => ({
favorites: [],
loading: false,
error: null,
fetchFavorites: async () => {
set({ loading: true, error: null });
try {
const favoritesData = await FavoriteManager.getAll();
const favoritesArray = Object.entries(favoritesData).map(([key, value]) => ({
...value,
key,
}));
// favoritesArray.sort((a, b) => (b.save_time || 0) - (a.save_time || 0));
set({ favorites: favoritesArray, loading: false });
} catch (e) {
const error = e instanceof Error ? e.message : "获取收藏列表失败";
set({ error, loading: false });
}
},
}));
export default useFavoritesStore;

206
stores/homeStore.ts Normal file
View File

@@ -0,0 +1,206 @@
import { create } from "zustand";
import { api, SearchResult, PlayRecord } from "@/services/api";
import { PlayRecordManager } from "@/services/storage";
import useAuthStore from "./authStore";
import { useSettingsStore } from "./settingsStore";
export type RowItem = (SearchResult | PlayRecord) & {
id: string;
source: string;
title: string;
poster: string;
progress?: number;
play_time?: number;
lastPlayed?: number;
episodeIndex?: number;
sourceName?: string;
totalEpisodes?: number;
year?: string;
rate?: string;
};
export interface Category {
title: string;
type?: "movie" | "tv" | "record";
tag?: string;
tags?: string[];
}
const initialCategories: Category[] = [
{ title: "最近播放", type: "record" },
{ title: "热门剧集", type: "tv", tag: "热门" },
{ title: "电视剧", type: "tv", tags: ["国产剧", "美剧", "英剧", "韩剧", "日剧", "港剧", "日本动画", "动画"] },
{
title: "电影",
type: "movie",
tags: [
"热门",
"最新",
"经典",
"豆瓣高分",
"冷门佳片",
"华语",
"欧美",
"韩国",
"日本",
"动作",
"喜剧",
"爱情",
"科幻",
"悬疑",
"恐怖",
],
},
{ title: "综艺", type: "tv", tag: "综艺" },
{ title: "豆瓣 Top250", type: "movie", tag: "top250" },
];
interface HomeState {
categories: Category[];
selectedCategory: Category;
contentData: RowItem[];
loading: boolean;
loadingMore: boolean;
pageStart: number;
hasMore: boolean;
error: string | null;
fetchInitialData: () => Promise<void>;
loadMoreData: () => Promise<void>;
selectCategory: (category: Category) => void;
refreshPlayRecords: () => Promise<void>;
}
const useHomeStore = create<HomeState>((set, get) => ({
categories: initialCategories,
selectedCategory: initialCategories[0],
contentData: [],
loading: true,
loadingMore: false,
pageStart: 0,
hasMore: true,
error: null,
fetchInitialData: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
set({ loading: true, contentData: [], pageStart: 0, hasMore: true, error: null });
await get().loadMoreData();
},
loadMoreData: async () => {
const { selectedCategory, pageStart, loadingMore, hasMore } = get();
if (loadingMore || !hasMore) return;
if (pageStart > 0) {
set({ loadingMore: true });
}
try {
if (selectedCategory.type === "record") {
const { isLoggedIn } = useAuthStore.getState();
if (!isLoggedIn) {
set({ contentData: [], hasMore: false });
return;
}
const records = await PlayRecordManager.getAll();
const rowItems = Object.entries(records)
.map(([key, record]) => {
const [source, id] = key.split("+");
return {
...record,
id,
source,
progress: record.play_time / record.total_time,
poster: record.cover,
sourceName: record.source_name,
episodeIndex: record.index,
totalEpisodes: record.total_episodes,
lastPlayed: record.save_time,
play_time: record.play_time,
};
})
// .filter((record) => record.progress !== undefined && record.progress > 0 && record.progress < 1)
.sort((a, b) => (b.lastPlayed || 0) - (a.lastPlayed || 0));
set({ contentData: rowItems, hasMore: false });
} else if (selectedCategory.type && selectedCategory.tag) {
const result = await api.getDoubanData(selectedCategory.type, selectedCategory.tag, 20, pageStart);
if (result.list.length === 0) {
set({ hasMore: false });
} else {
const newItems = result.list.map((item) => ({
...item,
id: item.title,
source: "douban",
})) as RowItem[];
set((state) => ({
contentData: pageStart === 0 ? newItems : [...state.contentData, ...newItems],
pageStart: state.pageStart + result.list.length,
hasMore: true,
}));
}
} else if (selectedCategory.tags) {
// It's a container category, do not load content, but clear current content
set({ contentData: [], hasMore: false });
} else {
set({ hasMore: false });
}
} catch (err: any) {
if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的服务器地址" });
} else {
set({ error: "加载失败,请重试" });
}
} finally {
set({ loading: false, loadingMore: false });
}
},
selectCategory: (category: Category) => {
const currentCategory = get().selectedCategory;
// Only fetch new data if the category or tag actually changes
if (currentCategory.title !== category.title || currentCategory.tag !== category.tag) {
set({ selectedCategory: category, contentData: [], pageStart: 0, hasMore: true, error: null });
get().fetchInitialData();
}
},
refreshPlayRecords: async () => {
const { apiBaseUrl } = useSettingsStore.getState();
await useAuthStore.getState().checkLoginStatus(apiBaseUrl);
const { isLoggedIn } = useAuthStore.getState();
if (!isLoggedIn) {
set((state) => {
const recordCategoryExists = state.categories.some((c) => c.type === "record");
if (recordCategoryExists) {
const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null);
}
return { categories: newCategories };
}
return {};
});
return;
}
const records = await PlayRecordManager.getAll();
const hasRecords = Object.keys(records).length > 0;
set((state) => {
const recordCategoryExists = state.categories.some((c) => c.type === "record");
if (hasRecords && !recordCategoryExists) {
return { categories: [initialCategories[0], ...state.categories] };
}
if (!hasRecords && recordCategoryExists) {
const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null);
}
return { categories: newCategories };
}
return {};
});
get().fetchInitialData();
},
}));
export default useHomeStore;

333
stores/playerStore.ts Normal file
View File

@@ -0,0 +1,333 @@
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 useDetailStore, { episodesSelectorBySource } from "./detailStore";
interface Episode {
url: string;
title: string;
}
interface PlayerState {
videoRef: RefObject<Video> | null;
currentEpisodeIndex: number;
episodes: Episode[];
status: AVPlaybackStatus | null;
isLoading: boolean;
showControls: boolean;
showEpisodeModal: boolean;
showSourceModal: boolean;
showNextEpisodeOverlay: boolean;
isSeeking: boolean;
seekPosition: number;
progressPosition: number;
initialPosition: number;
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (options: {
source: string;
id: string;
title: string;
episodeIndex: number;
position?: number;
}) => Promise<void>;
playEpisode: (index: number) => void;
togglePlayPause: () => void;
seek: (duration: number) => void;
handlePlaybackStatusUpdate: (newStatus: AVPlaybackStatus) => void;
setLoading: (loading: boolean) => void;
setShowControls: (show: boolean) => void;
setShowEpisodeModal: (show: boolean) => void;
setShowSourceModal: (show: boolean) => void;
setShowNextEpisodeOverlay: (show: boolean) => void;
setIntroEndTime: () => void;
setOutroStartTime: () => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
_isRecordSaveThrottled: boolean;
// Internal helper
_savePlayRecord: (updates?: Partial<PlayRecord>, options?: { immediate?: boolean }) => void;
}
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
episodes: [],
currentEpisodeIndex: -1,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
isSeeking: false,
seekPosition: 0,
progressPosition: 0,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
_seekTimeout: undefined,
_isRecordSaveThrottled: false,
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
let detail = useDetailStore.getState().detail;
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
set({
isLoading: true,
});
if (!detail || !episodes || episodes.length === 0 || detail.title !== title) {
await useDetailStore.getState().init(title, source, id);
detail = useDetailStore.getState().detail;
episodes = episodesSelectorBySource(source)(useDetailStore.getState());
if (!detail) {
console.info("Detail not found after initialization");
return;
}
}
try {
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
set({
isLoading: false,
currentEpisodeIndex: episodeIndex,
initialPosition: position || initialPositionFromRecord,
episodes: episodes.map((ep, index) => ({
url: ep,
title: `${index + 1}`,
})),
introEndTime: playRecord?.introEndTime,
outroStartTime: playRecord?.outroStartTime,
});
} catch (error) {
console.info("Failed to load play record", error);
set({ isLoading: false });
}
},
playEpisode: async (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({
currentEpisodeIndex: index,
showNextEpisodeOverlay: false,
initialPosition: 0,
progressPosition: 0,
seekPosition: 0,
});
try {
await videoRef?.current?.replayAsync();
} catch (error) {
console.error("Failed to replay video:", error);
Toast.show({ type: "error", text1: "播放失败" });
}
}
},
togglePlayPause: async () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
try {
if (status.isPlaying) {
await videoRef?.current?.pauseAsync();
} else {
await videoRef?.current?.playAsync();
}
} catch (error) {
console.error("Failed to toggle play/pause:", error);
Toast.show({ type: "error", text1: "操作失败" });
}
}
},
seek: async (duration) => {
const { status, videoRef } = get();
if (!status?.isLoaded || !status.durationMillis) return;
const newPosition = Math.max(0, Math.min(status.positionMillis + duration, status.durationMillis));
try {
await videoRef?.current?.setPositionAsync(newPosition);
} catch (error) {
console.error("Failed to seek video:", error);
Toast.show({ type: "error", text1: "快进/快退失败" });
}
set({
isSeeking: true,
seekPosition: newPosition / status.durationMillis,
});
if (get()._seekTimeout) {
clearTimeout(get()._seekTimeout);
}
const timeoutId = setTimeout(() => set({ isSeeking: false }), 1000);
set({ _seekTimeout: timeoutId });
},
setIntroEndTime: () => {
const { status, introEndTime: existingIntroEndTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingIntroEndTime) {
// Clear the time
set({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片头时间",
});
} else {
// Set the time
const newIntroEndTime = status.positionMillis;
set({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片头时间已记录。",
});
}
},
setOutroStartTime: () => {
const { status, outroStartTime: existingOutroStartTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingOutroStartTime) {
// Clear the time
set({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片尾时间",
});
} else {
// Set the time
if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
text2: "片尾时间已记录。",
});
}
},
_savePlayRecord: (updates = {}, options = {}) => {
const { immediate = false } = options;
if (!immediate) {
if (get()._isRecordSaveThrottled) {
return;
}
set({ _isRecordSaveThrottled: true });
setTimeout(() => {
set({ _isRecordSaveThrottled: false });
}, 10000); // 10 seconds
}
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const existingRecord = {
introEndTime,
outroStartTime,
};
PlayRecordManager.save(detail.source, detail.id.toString(), {
title: detail.title,
cover: detail.poster || "",
index: currentEpisodeIndex + 1,
total_episodes: episodes.length,
play_time: Math.floor(status.positionMillis / 1000),
total_time: status.durationMillis ? Math.floor(status.durationMillis / 1000) : 0,
source_name: detail.source_name,
year: detail.year || "",
...existingRecord,
...updates,
});
}
},
handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) {
if (newStatus.error) {
console.info(`Playback Error: ${newStatus.error}`);
}
set({ status: newStatus });
return;
}
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const detail = useDetailStore.getState().detail;
if (
outroStartTime &&
newStatus.durationMillis &&
newStatus.positionMillis >= newStatus.durationMillis - outroStartTime
) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update
}
}
if (detail && newStatus.durationMillis) {
get()._savePlayRecord();
const isNearEnd = newStatus.positionMillis / newStatus.durationMillis > 0.95;
if (isNearEnd && currentEpisodeIndex < episodes.length - 1 && !outroStartTime) {
set({ showNextEpisodeOverlay: true });
} else {
set({ showNextEpisodeOverlay: false });
}
}
if (newStatus.didJustFinish) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
}
}
const progressPosition = newStatus.durationMillis ? newStatus.positionMillis / newStatus.durationMillis : 0;
set({ status: newStatus, progressPosition });
},
setLoading: (loading) => set({ isLoading: loading }),
setShowControls: (show) => set({ showControls: show }),
setShowEpisodeModal: (show) => set({ showEpisodeModal: show }),
setShowSourceModal: (show) => set({ showSourceModal: show }),
setShowNextEpisodeOverlay: (show) => set({ showNextEpisodeOverlay: show }),
reset: () => {
set({
episodes: [],
currentEpisodeIndex: 0,
status: null,
isLoading: true,
showControls: false,
showEpisodeModal: false,
showSourceModal: false,
showNextEpisodeOverlay: false,
initialPosition: 0,
introEndTime: undefined,
outroStartTime: undefined,
});
},
}));
export default usePlayerStore;
export const selectCurrentEpisode = (state: PlayerState) => {
if (state.episodes.length > state.currentEpisodeIndex) {
return state.episodes[state.currentEpisodeIndex];
}
};

View File

@@ -0,0 +1,62 @@
import { create } from 'zustand';
import { remoteControlService } from '@/services/remoteControlService';
interface RemoteControlState {
isServerRunning: boolean;
serverUrl: string | null;
error: string | null;
startServer: () => Promise<void>;
stopServer: () => void;
isModalVisible: boolean;
showModal: () => void;
hideModal: () => void;
lastMessage: string | null;
setMessage: (message: string) => void;
}
export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
isServerRunning: false,
serverUrl: null,
error: null,
isModalVisible: false,
lastMessage: null,
startServer: async () => {
if (get().isServerRunning) {
return;
}
remoteControlService.init({
onMessage: (message: string) => {
console.log('[RemoteControlStore] Received message:', message);
set({ lastMessage: message });
},
onHandshake: () => {
console.log('[RemoteControlStore] Handshake successful');
set({ isModalVisible: false })
},
});
try {
const url = await remoteControlService.startServer();
console.log(`[RemoteControlStore] Server started, URL: ${url}`);
set({ isServerRunning: true, serverUrl: url, error: null });
} catch {
const errorMessage = '启动失败,请强制退应用后重试。';
console.info('[RemoteControlStore] Failed to start server:', errorMessage);
set({ error: errorMessage });
}
},
stopServer: () => {
if (get().isServerRunning) {
remoteControlService.stopServer();
set({ isServerRunning: false, serverUrl: null });
}
},
showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }),
setMessage: (message: string) => {
set({ lastMessage: `${message}_${Date.now()}` });
},
}));

105
stores/settingsStore.ts Normal file
View File

@@ -0,0 +1,105 @@
import { create } from "zustand";
import { SettingsManager } from "@/services/storage";
import { api, ServerConfig } from "@/services/api";
import { storageConfig } from "@/services/storageConfig";
interface SettingsState {
apiBaseUrl: string;
m3uUrl: string;
remoteInputEnabled: boolean;
videoSource: {
enabledAll: boolean;
sources: {
[key: string]: boolean;
};
};
isModalVisible: boolean;
serverConfig: ServerConfig | null;
loadSettings: () => Promise<void>;
fetchServerConfig: () => Promise<void>;
setApiBaseUrl: (url: string) => void;
setM3uUrl: (url: string) => void;
setRemoteInputEnabled: (enabled: boolean) => void;
saveSettings: () => Promise<void>;
setVideoSource: (config: { enabledAll: boolean; sources: { [key: string]: boolean } }) => void;
showModal: () => void;
hideModal: () => void;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
apiBaseUrl: "",
m3uUrl: "",
liveStreamSources: [],
remoteInputEnabled: false,
isModalVisible: false,
serverConfig: null,
videoSource: {
enabledAll: true,
sources: {},
},
loadSettings: async () => {
const settings = await SettingsManager.get();
set({
apiBaseUrl: settings.apiBaseUrl,
m3uUrl: settings.m3uUrl,
remoteInputEnabled: settings.remoteInputEnabled || false,
videoSource: settings.videoSource || {
enabledAll: true,
sources: {},
},
});
api.setBaseUrl(settings.apiBaseUrl);
await get().fetchServerConfig();
},
fetchServerConfig: async () => {
try {
const config = await api.getServerConfig();
if (config) {
storageConfig.setStorageType(config.StorageType);
set({ serverConfig: config });
}
} catch (error) {
set({ serverConfig: null });
console.info("Failed to fetch server config:", error);
}
},
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
setM3uUrl: (url) => set({ m3uUrl: url }),
setRemoteInputEnabled: (enabled) => set({ remoteInputEnabled: enabled }),
setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
let processedApiBaseUrl = apiBaseUrl.trim();
if (processedApiBaseUrl.endsWith("/")) {
processedApiBaseUrl = processedApiBaseUrl.slice(0, -1);
}
if (!/^https?:\/\//i.test(processedApiBaseUrl)) {
const hostPart = processedApiBaseUrl.split("/")[0];
// Simple check for IP address format.
const isIpAddress = /^((\d{1,3}\.){3}\d{1,3})(:\d+)?$/.test(hostPart);
// Check if the domain includes a port.
const hasPort = /:\d+/.test(hostPart);
if (isIpAddress || hasPort) {
processedApiBaseUrl = "http://" + processedApiBaseUrl;
} else {
processedApiBaseUrl = "https://" + processedApiBaseUrl;
}
}
await SettingsManager.save({
apiBaseUrl: processedApiBaseUrl,
m3uUrl,
remoteInputEnabled,
videoSource,
});
api.setBaseUrl(processedApiBaseUrl);
// Also update the URL in the state so the input field shows the processed URL
set({ isModalVisible: false, apiBaseUrl: processedApiBaseUrl });
await get().fetchServerConfig();
},
showModal: () => set({ isModalVisible: true }),
hideModal: () => set({ isModalVisible: false }),
}));

24
stores/sourceStore.ts Normal file
View File

@@ -0,0 +1,24 @@
import { create } from "zustand";
import { useSettingsStore } from "@/stores/settingsStore";
import useDetailStore, { sourcesSelector } from "./detailStore";
interface SourceState {
toggleResourceEnabled: (resourceKey: string) => void;
}
const useSourceStore = create<SourceState>((set, get) => ({
toggleResourceEnabled: (resourceKey: string) => {
const { videoSource, setVideoSource } = useSettingsStore.getState();
const isEnabled = videoSource.sources[resourceKey];
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
setVideoSource({
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
sources: newEnabledSources,
});
},
}));
export const useSources = () => useDetailStore(sourcesSelector);
export default useSourceStore;

1066
yarn.lock

File diff suppressed because it is too large Load Diff