51 Commits

Author SHA1 Message Date
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
67 changed files with 6073 additions and 1151 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

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

@@ -18,10 +18,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 +27,6 @@
.
├── app/ # Expo Router 路由和页面
├── assets/ # 静态资源 (字体, 图片, TV 图标)
├── backend/ # 后端 Express 应用
├── components/ # React 组件
├── constants/ # 应用常量 (颜色, 样式)
├── hooks/ # 自定义 Hooks
@@ -52,24 +47,7 @@
- [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. 前端应用
### 项目启动
接下来,在项目根目录运行前端应用:
@@ -93,25 +71,16 @@ yarn android-tv
## 部署
### 后端部署
- 1.2.x 以上版本需配合 [MoonTV](https://github.com/senshinya/MoonTV) 部署使用api 地址填部MoonTV署后的访问地址。
#### [Vercel](https://vercel.com/) 部署
- **注意:** 地址后面不要带 `/` ,不要遗漏 `http://` 或者 `https://`
[![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.edu.deal 不保证稳定和可用性。
- 如果不想依赖 MoonTV可以使用 1.1.x 版本。
## 其他
- 最低版本是android 7可用但是不推荐
- 如果使用https的后端接口无法访问在确认服务没有问题的情况下请检查https的TLS协议Android 10 之后版本才支持 TLS1.3
- 最低版本是 android 6.0,可用,但是不推荐
- 如果使用 https 的后端接口无法访问,在确认服务没有问题的情况下,请检查 https 的 TLS 协议Android 10 之后版本才支持 TLS1.3
## 📜 主要脚本
@@ -125,9 +94,9 @@ yarn android-tv
## 📸 应用截图
![首页界面](screenshot/image.png)
![详情页面](screenshot/image1.png)
![搜索界面](screenshot/image3.png)
![视频播放](screenshot/image2.png)
![搜索界面](screenshot/image3.png)
![详情页面](screenshot/image1.png)
## 📝 License
@@ -139,9 +108,18 @@ 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 语言开发
- [one-balance](https://github.com/glidea/one-balance) - Make ai KEY rotation SMARTER and more SECURE

View File

@@ -7,6 +7,9 @@ import { Platform } from "react-native";
import Toast from "react-native-toast-message";
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();
@@ -16,11 +19,19 @@ export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const initializeSettings = useSettingsStore((state) => state.loadSettings);
const { loadSettings, remoteInputEnabled, apiBaseUrl } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => {
initializeSettings();
}, [initializeSettings]);
loadSettings();
}, [loadSettings]);
useEffect(() => {
if (apiBaseUrl) {
checkLoginStatus(apiBaseUrl);
}
}, [apiBaseUrl, checkLoginStatus]);
useEffect(() => {
if (loaded || error) {
@@ -31,6 +42,14 @@ export default function RootLayout() {
}
}, [loaded, error]);
useEffect(() => {
if (remoteInputEnabled) {
startServer();
} else {
stopServer();
}
}, [remoteInputEnabled, startServer, stopServer]);
if (!loaded && !error) {
return null;
}
@@ -42,9 +61,13 @@ export default function RootLayout() {
<Stack.Screen name="detail" 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,121 +1,49 @@
import React, { useEffect, useState, useRef } from "react";
import { View, Text, StyleSheet, Image, ScrollView, ActivityIndicator } from "react-native";
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 { api, SearchResult } from "@/services/api";
import { getResolutionFromM3U8 } from "@/services/m3u8";
import { StyledButton } from "@/components/StyledButton";
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",
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,
},
});
};
@@ -131,7 +59,9 @@ export default function DetailScreen() {
if (error) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle">{error}</ThemedText>
<ThemedText type="subtitle" style={styles.text}>
{error}
</ThemedText>
</ThemedView>
);
}
@@ -157,6 +87,10 @@ export default function DetailScreen() {
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View>
{/* <Pressable onPress={toggleFavorite} style={styles.favoriteButton}>
<FontAwesome name={isFavorited ? "star" : "star-o"} size={24} color={isFavorited ? "#FFD700" : "#ccc"} />
<ThemedText style={styles.favoriteButtonText}>{isFavorited ? "已收藏" : "收藏"}</ThemedText>
</Pressable> */}
<ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView>
@@ -202,7 +136,7 @@ export default function DetailScreen() {
<StyledButton
key={index}
style={styles.episodeButton}
onPress={() => handlePlay(episode, index)}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
/>
@@ -222,6 +156,10 @@ const styles = StyleSheet.create({
flexDirection: "row",
padding: 20,
},
text: {
padding: 20,
textAlign: "center",
},
poster: {
width: 200,
height: 300,
@@ -255,6 +193,19 @@ const styles = StyleSheet.create({
color: "#ccc",
lineHeight: 22,
},
favoriteButton: {
flexDirection: "row",
alignItems: "center",
marginTop: 10,
padding: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderRadius: 5,
alignSelf: "flex-start",
},
favoriteButtonText: {
marginLeft: 8,
fontSize: 16,
},
bottomContainer: {
paddingHorizontal: 20,
},

124
app/favorites.tsx Normal file
View File

@@ -0,0 +1,124 @@
import React, { useEffect } from "react";
import { View, FlatList, StyleSheet, ActivityIndicator, Image, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import useFavoritesStore from "@/stores/favoritesStore";
import { Favorite } from "@/services/storage";
export default function FavoritesScreen() {
const router = useRouter();
const { favorites, loading, error, fetchFavorites } = useFavoritesStore();
useEffect(() => {
fetchFavorites();
}, [fetchFavorites]);
const handlePress = (favorite: Favorite & { key: string }) => {
const [source, id] = favorite.key.split("+");
router.push({
pathname: "/detail",
params: { q: favorite.title, source, id },
});
};
if (loading) {
return (
<ThemedView style={styles.centered}>
<ActivityIndicator size="large" />
</ThemedView>
);
}
if (error) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle">{error}</ThemedText>
</ThemedView>
);
}
if (favorites.length === 0) {
return (
<ThemedView style={styles.centered}>
<ThemedText type="subtitle"></ThemedText>
</ThemedView>
);
}
const renderItem = ({ item }: { item: Favorite & { key: string } }) => (
<Pressable onPress={() => handlePress(item)} style={styles.itemContainer}>
<Image source={{ uri: item.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<ThemedText style={styles.title} numberOfLines={1}>
{item.title}
</ThemedText>
<ThemedText style={styles.year}>{item.year}</ThemedText>
</View>
</Pressable>
);
return (
<ThemedView style={styles.container}>
<View style={styles.headerContainer}>
<ThemedText style={styles.headerTitle}></ThemedText>
</View>
<FlatList
data={favorites}
renderItem={renderItem}
keyExtractor={(item) => item.key}
numColumns={3}
contentContainerStyle={styles.list}
/>
</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,
},
itemContainer: {
flex: 1,
margin: 10,
alignItems: "center",
},
poster: {
width: 120,
height: 180,
borderRadius: 8,
},
infoContainer: {
marginTop: 8,
alignItems: "center",
},
title: {
fontSize: 16,
fontWeight: "bold",
},
year: {
fontSize: 14,
color: "#888",
},
});

View File

@@ -1,15 +1,14 @@
import React, { useEffect, useCallback, useRef } from "react";
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 } from "lucide-react-native";
import { SettingsModal } from "@/components/SettingsModal";
import { Search, Settings, LogOut, Heart } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import { useSettingsStore } from "@/stores/settingsStore";
import useAuthStore from "@/stores/authStore";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
@@ -19,6 +18,7 @@ export default function HomeScreen() {
const router = useRouter();
const colorScheme = "dark";
const flatListRef = useRef<FlatList>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const {
categories,
@@ -32,8 +32,7 @@ export default function HomeScreen() {
selectCategory,
refreshPlayRecords,
} = useHomeStore();
const showSettingsModal = useSettingsStore((state) => state.showModal);
const { isLoggedIn, logout } = useAuthStore();
useFocusEffect(
useCallback(() => {
@@ -42,14 +41,38 @@ export default function HomeScreen() {
);
useEffect(() => {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
}, [selectedCategory, fetchInitialData]);
if (selectedCategory && !selectedCategory.tags) {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
} 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]);
useEffect(() => {
if (selectedCategory && selectedCategory.tag) {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
}
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
const handleCategorySelect = (category: 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;
return (
@@ -92,8 +115,18 @@ 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}>
{/* <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" })}
@@ -101,9 +134,14 @@ export default function HomeScreen() {
>
<Search color={colorScheme === "dark" ? "white" : "black"} size={24} />
</StyledButton>
<StyledButton style={styles.searchButton} onPress={showSettingsModal} variant="ghost">
<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>
@@ -119,6 +157,33 @@ export default function HomeScreen() {
/>
</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}>
@@ -143,12 +208,11 @@ export default function HomeScreen() {
ListFooterComponent={renderFooter}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText></ThemedText>
<ThemedText>{selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}</ThemedText>
</View>
}
/>
)}
<SettingsModal />
</ThemedView>
);
}
@@ -182,9 +246,7 @@ const styles = StyleSheet.create({
alignItems: "center",
},
searchButton: {
padding: 10,
borderRadius: 30,
marginLeft: 10,
},
// Category Selector
categoryContainer: {

205
app/live.tsx Normal file
View File

@@ -0,0 +1,205 @@
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) => item}
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) => item.id}
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,
},
groupButtonText: {
fontSize: 13,
},
channelItem: {
paddingVertical: 6,
paddingHorizontal: 4,
marginVertical: 3,
},
channelItemText: {
fontSize: 12,
},
});

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { View, StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler, AppState, AppStateStatus } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
@@ -10,53 +10,74 @@ import { SourceSelectionModal } from "@/components/SourceSelectionModal";
import { SeekingBar } from "@/components/SeekingBar";
import { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import usePlayerStore from "@/stores/playerStore";
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 videoRef = useRef<Video>(null);
const router = useRouter();
useKeepAwake();
const { source, id, episodeIndex, position } = useLocalSearchParams<{
source: string;
id: string;
episodeIndex: string;
position: string;
}>();
const {
detail,
episodes,
currentEpisodeIndex,
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,
showControls,
showEpisodeModal,
showSourceModal,
showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
loadVideo,
playEpisode,
togglePlayPause,
seek,
handlePlaybackStatusUpdate,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay,
reset,
loadVideo,
} = usePlayerStore();
const currentEpisode = usePlayerStore(selectCurrentEpisode);
useEffect(() => {
setVideoRef(videoRef);
if (source && id) {
loadVideo(source, id, parseInt(episodeIndex || "0", 10), parseInt(position || "0", 10));
if (source && id && title) {
loadVideo({ source, id, episodeIndex, position, title });
}
return () => {
reset(); // Reset state when component unmounts
};
}, [source, id, episodeIndex, position, setVideoRef, loadVideo, reset]);
}, [episodeIndex, source, position, setVideoRef, reset, loadVideo, id, title]);
useEffect(() => {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === "background" || nextAppState === "inactive") {
videoRef.current?.pauseAsync();
}
};
const subscription = AppState.addEventListener("change", handleAppStateChange);
return () => {
subscription.remove();
};
}, []);
const { onScreenPress } = useTVRemoteHandler();
@@ -73,17 +94,28 @@ export default function PlayScreen() {
const backHandler = BackHandler.addEventListener("hardwareBackPress", backAction);
return () => backHandler.remove();
}, [
showControls,
showEpisodeModal,
showSourceModal,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
router,
]);
}, [showControls, setShowControls, router]);
if (!detail && isLoading) {
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 (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
@@ -91,19 +123,19 @@ export default function PlayScreen() {
);
}
const currentEpisode = episodes[currentEpisodeIndex];
return (
<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 || "" }}
usePoster
posterSource={{ uri: detail?.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {
const jumpPosition = introEndTime || initialPosition;
const jumpPosition = initialPosition || introEndTime || 0;
if (jumpPosition > 0) {
videoRef.current?.setPositionAsync(jumpPosition);
}

View File

@@ -1,11 +1,15 @@
import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Text, Keyboard } from "react-native";
import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Alert, Keyboard } 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 { 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";
export default function SearchScreen() {
const [keyword, setKeyword] = useState("");
@@ -15,6 +19,19 @@ export default function SearchScreen() {
const textInputRef = useRef<TextInput>(null);
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(() => {
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]);
useEffect(() => {
// Focus the text input when the screen loads
@@ -24,8 +41,9 @@ export default function SearchScreen() {
return () => clearTimeout(timer);
}, []);
const handleSearch = async () => {
if (!keyword.trim()) {
const handleSearch = async (searchText?: string) => {
const term = typeof searchText === "string" ? searchText : keyword;
if (!term.trim()) {
Keyboard.dismiss();
return;
}
@@ -33,7 +51,7 @@ 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 {
@@ -41,12 +59,25 @@ export default function SearchScreen() {
}
} catch (err) {
setError("搜索失败,请稍后重试。");
console.error("Search failed:", err);
console.info("Search failed:", err);
} finally {
setLoading(false);
}
};
const onSearchPress = () => handleSearch();
const handleQrPress = () => {
if (!remoteInputEnabled) {
Alert.alert("远程输入未启用", "请先在设置页面中启用远程输入功能", [
{ text: "取消", style: "cancel" },
{ text: "去设置", onPress: () => router.push("/settings") },
]);
return;
}
showRemoteModal();
};
const renderItem = ({ item }: { item: SearchResult }) => (
<VideoCard
id={item.id.toString()}
@@ -78,12 +109,15 @@ export default function SearchScreen() {
onChangeText={setKeyword}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onSubmitEditing={handleSearch} // Allow searching with remote 'enter' button
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
<StyledButton style={styles.searchButton} onPress={handleSearch}>
<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 ? (
@@ -108,6 +142,7 @@ export default function SearchScreen() {
}
/>
)}
<RemoteControlModal />
</ThemedView>
);
}
@@ -140,6 +175,11 @@ const styles = StyleSheet.create({
// backgroundColor is now set dynamically
borderRadius: 8,
},
qrButton: {
padding: 12,
borderRadius: 8,
marginLeft: 10,
},
centerContainer: {
flex: 1,
justifyContent: "center",

210
app/settings.tsx Normal file
View File

@@ -0,0 +1,210 @@
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 { isLoggedIn, logout } = useAuthStore();
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

@@ -3,8 +3,7 @@
"api_site": {
"dyttzy": {
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
"name": "电影天堂资源",
"detail": "http://caiji.dyttzyapi.com"
"name": "电影天堂资源"
},
"ruyi": {
"api": "https://cj.rycjapi.com/api.php/provide/vod",
@@ -16,8 +15,7 @@
},
"heimuer": {
"api": "https://json.heimuer.xyz/api.php/provide/vod",
"name": "黑木耳",
"detail": "https://heimuer.tv"
"name": "黑木耳"
},
"bfzy": {
"api": "https://bfzyapi.com/api.php/provide/vod",
@@ -29,8 +27,7 @@
},
"ffzy": {
"api": "http://ffzy5.tv/api.php/provide/vod",
"name": "非凡影视",
"detail": "http://ffzy5.tv"
"name": "非凡影视"
},
"zy360": {
"api": "https://360zy.com/api.php/provide/vod",
@@ -50,8 +47,7 @@
},
"jisu": {
"api": "https://jszyapi.com/api.php/provide/vod",
"name": "极速资源",
"detail": "https://jszyapi.com"
"name": "极速资源"
},
"dbzy": {
"api": "https://dbzy.tv/api.php/provide/vod",

View File

@@ -54,7 +54,7 @@ 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);
console.info(`Error reading or parsing config.json at ${configPath}`, error);
// Provide a default fallback config to prevent crashes
cachedConfig = {
api_site: {},

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
[]

View File

@@ -8,10 +8,7 @@ const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
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);
@@ -30,8 +27,7 @@ async function handleSpecialSourceDetail(
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
@@ -48,32 +44,22 @@ async function handleSpecialSourceDetail(
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
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,
},
id,
title: titleText,
poster: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
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);
@@ -89,12 +75,7 @@ async function getDetailFromApi(
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
throw new Error("获取到的详情内容无效");
}
@@ -111,10 +92,7 @@ async function getDetailFromApi(
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter(
(url: string) =>
url && (url.startsWith("http://") || url.startsWith("https://"))
);
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
}
}
@@ -124,30 +102,22 @@ async function getDetailFromApi(
}
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,
},
id,
title: videoDetail.vod_name,
poster: 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,
};
}
async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}

View File

@@ -0,0 +1,67 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "favorites.json");
// Helper function to read data
const readFavorites = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
// If file doesn't exist or is invalid json, return empty object
return {};
}
};
// Helper function to write data
const writeFavorites = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/favorites
router.get("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
const favorites = await readFavorites();
if (key) {
res.json(favorites[key as string] || null);
} else {
res.json(favorites);
}
});
// POST /api/favorites
router.post("/favorites", async (req: Request, res: Response) => {
const { key, favorite } = req.body;
if (!key || !favorite) {
return res.status(400).json({ message: "Missing key or favorite data" });
}
const favorites = await readFavorites();
favorites[key] = { ...favorite, save_time: Math.floor(Date.now() / 1000) };
await writeFavorites(favorites);
res.json({ success: true });
});
// DELETE /api/favorites
router.delete("/favorites", async (req: Request, res: Response) => {
const { key } = req.query;
let favorites = await readFavorites();
if (key) {
delete favorites[key as string];
} else {
// Clear all favorites if no key is provided
favorites = {};
}
await writeFavorites(favorites);
res.json({ success: true });
});
export default router;

View File

@@ -35,7 +35,7 @@ router.get("/", async (req: Request, res: Response) => {
res.status(500).send("Image response has no body");
}
} catch (error) {
console.error("Image proxy error:", error);
console.info("Image proxy error:", error);
res.status(500).send("Error fetching image");
}
});

View File

@@ -3,9 +3,19 @@ import searchRouter from "./search";
import detailRouter from "./detail";
import doubanRouter from "./douban";
import imageProxyRouter from "./image-proxy";
import serverConfigRouter from "./server-config";
import loginRouter from "./login";
import favoritesRouter from "./favorites";
import playRecordsRouter from "./playrecords";
import searchHistoryRouter from "./searchhistory";
const router = Router();
router.use(serverConfigRouter);
router.use(loginRouter);
router.use(favoritesRouter);
router.use(playRecordsRouter);
router.use(searchHistoryRouter);
router.use("/search", searchRouter);
router.use("/detail", detailRouter);
router.use("/douban", doubanRouter);

View File

@@ -0,0 +1,58 @@
import express, { Request, Response } from "express";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const username = process.env.USERNAME;
const password = process.env.PASSWORD;
/**
* @api {post} /api/login User Login
* @apiName UserLogin
* @apiGroup User
*
* @apiBody {String} username User's username.
* @apiBody {String} password User's password.
*
* @apiSuccess {Boolean} ok Indicates if the login was successful.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "ok": true
* }
*
* @apiError {String} message Error message.
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid password"
* }
*/
router.post("/login", (req: Request, res: Response) => {
const { username: inputUsername, password: inputPassword } = req.body;
// Compatibility with old versions, if username is not set, only password is required
if (!username || !password) {
if (inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
return res.json({ ok: true });
} else if (!password) {
// If no password is set, login is always successful.
return res.json({ ok: true });
} else {
return res.status(400).json({ message: "Invalid password" });
}
}
if (inputUsername === username && inputPassword === password) {
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
res.json({ ok: true });
} else {
res.status(400).json({ message: "Invalid username or password" });
}
});
export default router;

View File

@@ -0,0 +1,59 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "playrecords.json");
// Helper function to read data
const readPlayRecords = async () => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return {};
}
};
// Helper function to write data
const writePlayRecords = async (data: any) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/playrecords
router.get("/playrecords", async (req: Request, res: Response) => {
const records = await readPlayRecords();
res.json(records);
});
// POST /api/playrecords
router.post("/playrecords", async (req: Request, res: Response) => {
const { key, record } = req.body;
if (!key || !record) {
return res.status(400).json({ message: "Missing key or record data" });
}
const records = await readPlayRecords();
records[key] = { ...record, time: Math.floor(Date.now() / 1000) };
await writePlayRecords(records);
res.json({ success: true });
});
// DELETE /api/playrecords
router.delete("/playrecords", async (req: Request, res: Response) => {
const { key } = req.query;
let records = await readPlayRecords();
if (key) {
delete records[key as string];
} else {
records = {};
}
await writePlayRecords(records);
res.json({ success: true });
});
export default router;

View File

@@ -0,0 +1,66 @@
import express, { Request, Response } from "express";
import fs from "fs/promises";
import path from "path";
const router = express.Router();
const dataPath = path.join(__dirname, "..", "data", "searchhistory.json");
// Helper function to read data
const readSearchHistory = async (): Promise<string[]> => {
try {
const data = await fs.readFile(dataPath, "utf-8");
return JSON.parse(data);
} catch (error) {
return [];
}
};
// Helper function to write data
const writeSearchHistory = async (data: string[]) => {
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), "utf-8");
};
// GET /api/searchhistory
router.get("/searchhistory", async (req: Request, res: Response) => {
const history = await readSearchHistory();
res.json(history);
});
// POST /api/searchhistory
router.post("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.body;
if (!keyword) {
return res.status(400).json({ message: "Missing keyword" });
}
let history = await readSearchHistory();
// Remove keyword if it already exists to move it to the front
history = history.filter((item) => item !== keyword);
// Add to the beginning of the array
history.unshift(keyword);
// Optional: Limit history size
if (history.length > 100) {
history = history.slice(0, 100);
}
await writeSearchHistory(history);
res.json(history);
});
// DELETE /api/searchhistory
router.delete("/searchhistory", async (req: Request, res: Response) => {
const { keyword } = req.query;
let history = await readSearchHistory();
if (keyword) {
history = history.filter((item) => item !== keyword);
} else {
history = [];
}
await writeSearchHistory(history);
res.json({ success: true });
});
export default router;

View File

@@ -0,0 +1,38 @@
import express, { Request, Response } from "express";
import { getConfig } from "../config";
const router = express.Router();
/**
* @api {get} /api/server-config Get Server Configuration
* @apiName GetServerConfig
* @apiGroup Server
*
* @apiSuccess {String} SiteName The name of the site.
* @apiSuccess {String} StorageType The storage type used by the server ("localstorage" or "database").
*
* @apiSuccessExample {json} Success-Response (LocalStorage):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Local",
* "StorageType": "localstorage"
* }
*
* @apiSuccessExample {json} Success-Response (Database):
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Cloud",
* "StorageType": "database"
* }
*/
router.get("/server-config", (req: Request, res: Response) => {
const config = getConfig();
const storageType = config.storage?.type || "database"; // Default to 'database' if not specified
res.json({
SiteName: storageType === "localstorage" ? "OrionTV-Local" : "OrionTV-Cloud",
StorageType: storageType,
});
});
export default router;

View File

@@ -2,7 +2,7 @@
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
poster: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
@@ -13,21 +13,16 @@ export interface PlayRecord {
// 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;
};
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;
}

147
components/LivePlayer.tsx Normal file
View File

@@ -0,0 +1,147 @@
import React, { useRef, useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { Video, ResizeMode, AVPlaybackStatus } from "expo-av";
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);
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}>Select a channel to play.</Text>
</View>
);
}
if (isTimeout) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Failed to load stream. It might be offline or unavailable.</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}>Loading...</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)",
},
});

174
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,174 @@
import React, { useState, useRef, useEffect } from "react";
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, useTVEventHandler } from "react-native";
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 loginButtonRef = useRef<View>(null);
const [focused, setFocused] = useState("username");
const tvEventHandler = (evt: any) => {
if (!evt || !isLoginModalVisible) {
return;
}
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
if (evt.eventType === "down") {
if (focused === "username" && isUsernameVisible) {
passwordInputRef.current?.focus();
} else if (focused === "password") {
loginButtonRef.current?.focus();
}
}
if (evt.eventType === "up") {
if (focused === "button") {
passwordInputRef.current?.focus();
} else if (focused === "password" && isUsernameVisible) {
usernameInputRef.current?.focus();
}
}
};
useTVEventHandler(tvEventHandler);
useEffect(() => {
if (isLoginModalVisible) {
const isUsernameVisible = serverConfig?.StorageType !== "localstorage";
setTimeout(() => {
if (isUsernameVisible) {
usernameInputRef.current?.focus();
} else {
passwordInputRef.current?.focus();
}
}, 200);
}
}, [isLoginModalVisible, serverConfig]);
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("");
} catch {
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
} finally {
setIsLoading(false);
}
};
return (
<Modal transparent={true} visible={isLoginModalVisible} 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"
onFocus={() => setFocused("username")}
/>
)}
<TextInput
ref={passwordInputRef}
style={styles.input}
placeholder="请输入密码"
placeholderTextColor="#888"
secureTextEntry
value={password}
onChangeText={setPassword}
returnKeyType="go"
onFocus={() => setFocused("password")}
onSubmitEditing={handleLogin}
/>
<StyledButton
ref={loginButtonRef}
onFocus={() => setFocused("button")}
text={isLoading ? "" : "登录"}
onPress={handleLogin}
disabled={isLoading}
style={styles.button}
>
{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,22 +1,12 @@
import React, { useCallback, useState } from "react";
import { View, Text, StyleSheet, TouchableOpacity, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { AVPlaybackStatus } from "expo-av";
import {
Pause,
Play,
SkipForward,
List,
ChevronsRight,
ChevronsLeft,
Tv,
ArrowDownToDot,
ArrowUpFromDot,
} from "lucide-react-native";
import React from "react";
import { View, Text, StyleSheet, Pressable } from "react-native";
import { Pause, Play, SkipForward, List, Tv, ArrowDownToDot, ArrowUpFromDot } from "lucide-react-native";
import { 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 {
showControls: boolean;
@@ -24,16 +14,13 @@ interface PlayerControlsProps {
}
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const router = useRouter();
const {
detail,
currentEpisodeIndex,
currentSourceIndex,
episodes,
status,
isSeeking,
seekPosition,
progressPosition,
seek,
togglePlayPause,
playEpisode,
setShowEpisodeModal,
@@ -44,12 +31,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, se
outroStartTime,
} = usePlayerStore();
const videoTitle = detail?.videoInfo?.title || "";
const currentEpisode = detail?.episodes[currentEpisodeIndex];
const { detail } = useDetailStore();
const resources = useSources();
const videoTitle = detail?.title || "";
const currentEpisode = episodes[currentEpisodeIndex];
const currentEpisodeTitle = currentEpisode?.title;
const currentSource = detail?.sources[currentSourceIndex];
const currentSource = resources.find((r) => r.source === detail?.source);
const currentSourceName = currentSource?.source_name;
const hasNextEpisode = currentEpisodeIndex < (detail?.episodes.length || 0) - 1;
const hasNextEpisode = currentEpisodeIndex < (episodes.length || 0) - 1;
const formatTime = (milliseconds: number) => {
if (!milliseconds) return "00:00";

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

View File

@@ -1,118 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import { Modal, View, Text, TextInput, StyleSheet } from "react-native";
import { ThemedText } from "./ThemedText";
import { ThemedView } from "./ThemedView";
import { useSettingsStore } from "@/stores/settingsStore";
import { StyledButton } from "./StyledButton";
export const SettingsModal: React.FC = () => {
const { isModalVisible, hideModal, apiBaseUrl, setApiBaseUrl, saveSettings, loadSettings } = useSettingsStore();
const [isInputFocused, setIsInputFocused] = useState(false);
const colorScheme = "dark"; // Replace with useColorScheme() if needed
const inputRef = useRef<TextInput>(null);
useEffect(() => {
if (isModalVisible) {
loadSettings();
const timer = setTimeout(() => {
inputRef.current?.focus();
}, 200);
return () => clearTimeout(timer);
}
}, [isModalVisible, loadSettings]);
const handleSave = () => {
saveSettings();
};
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,
marginHorizontal: 8,
},
buttonText: {
fontSize: 18,
},
});
return (
<Modal animationType="fade" transparent={true} visible={isModalVisible} onRequestClose={hideModal}>
<View style={styles.modalContainer}>
<ThemedView style={styles.modalContent}>
<ThemedText style={styles.title}></ThemedText>
<TextInput
ref={inputRef}
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiBaseUrl}
onChangeText={setApiBaseUrl}
placeholder="输入 API 地址"
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
autoCapitalize="none"
autoCorrect={false}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
/>
<View style={styles.buttonContainer}>
<StyledButton
text="取消"
onPress={hideModal}
style={styles.button}
textStyle={styles.buttonText}
variant="default"
/>
<StyledButton
text="保存"
onPress={handleSave}
style={styles.button}
textStyle={styles.buttonText}
variant="primary"
/>
</View>
</ThemedView>
</View>
</Modal>
);
};

View File

@@ -1,14 +1,16 @@
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, sources, currentSourceIndex, switchSource, setShowSourceModal } = usePlayerStore();
const { showSourceModal, setShowSourceModal } = usePlayerStore();
const { searchResults, detail, setDetail } = useDetailStore();
const onSelectSource = (index: number) => {
if (index !== currentSourceIndex) {
switchSource(index);
if (searchResults[index].source !== detail?.source) {
setDetail(searchResults[index]);
}
setShowSourceModal(false);
};
@@ -23,16 +25,16 @@ export const SourceSelectionModal: React.FC = () => {
<View style={styles.modalContent}>
<Text style={styles.modalTitle}></Text>
<FlatList
data={sources}
data={searchResults}
numColumns={3}
contentContainerStyle={styles.sourceList}
keyExtractor={(item, index) => `source-${item.source}-${item.id}-${index}`}
keyExtractor={(item, index) => `source-${item.source}-${index}`}
renderItem={({ item, index }) => (
<StyledButton
text={item.source_name}
onPress={() => onSelectSource(index)}
isSelected={currentSourceIndex === index}
hasTVPreferredFocus={currentSourceIndex === index}
isSelected={detail?.source === item.source}
hasTVPreferredFocus={detail?.source === item.source}
style={styles.sourceItem}
textStyle={styles.sourceItemText}
/>

View File

@@ -1,8 +1,8 @@
import React from "react";
import { Animated, Pressable, StyleSheet, StyleProp, ViewStyle, PressableProps, TextStyle } from "react-native";
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/useButtonAnimation";
import { useButtonAnimation } from "@/hooks/useAnimation";
interface StyledButtonProps extends PressableProps {
children?: React.ReactNode;
@@ -13,133 +13,130 @@ interface StyledButtonProps extends PressableProps {
textStyle?: StyleProp<TextStyle>;
}
export const StyledButton: React.FC<StyledButtonProps> = ({
children,
text,
variant = "default",
isSelected = false,
style,
textStyle,
...rest
}) => {
const colorScheme = "dark";
const colors = Colors[colorScheme];
const [isFocused, setIsFocused] = React.useState(false);
const animationStyle = useButtonAnimation(isFocused);
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({
const variantStyles = {
default: StyleSheet.create({
button: {
backgroundColor: colors.border,
},
text: {
color: colors.text,
},
selectedButton: {
backgroundColor: colors.tint,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
},
selectedButton: {},
selectedText: {},
}),
};
const styles = StyleSheet.create({
button: {
backgroundColor: colors.border,
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 2,
borderColor: "transparent",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
},
text: {
color: colors.text,
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,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
text: {
fontSize: 16,
fontWeight: "500",
color: colors.text,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.link,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: "rgba(0, 122, 255, 0.3)",
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.9)",
},
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>
);
}
);
return (
<Animated.View style={[animationStyle, style]}>
<Pressable
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

@@ -34,11 +34,10 @@ export default function VideoCard({
sourceName,
progress,
episodeIndex,
totalEpisodes,
onFocus,
onRecordDeleted,
api,
playTime,
playTime = 0,
}: VideoCardProps) {
const router = useRouter();
const [isFocused, setIsFocused] = useState(false);
@@ -62,7 +61,7 @@ export default function VideoCard({
if (progress !== undefined && episodeIndex !== undefined) {
router.push({
pathname: "/play",
params: { source, id, episodeIndex, position: playTime },
params: { source, id, episodeIndex: episodeIndex - 1, title, position: playTime * 1000 },
});
} else {
router.push({
@@ -112,7 +111,7 @@ export default function VideoCard({
router.replace("/");
}
} catch (error) {
console.error("Failed to delete play record:", error);
console.info("Failed to delete play record:", error);
Alert.alert("错误", "删除观看记录失败,请重试");
}
},

View File

@@ -0,0 +1,136 @@
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";
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="输入 API 地址"
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: "#007AFF",
shadowColor: "#007AFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
});

View File

@@ -0,0 +1,130 @@
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";
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: "#007AFF",
shadowColor: "#007AFF",
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,
elevation: 5,
},
});

View File

@@ -0,0 +1,143 @@
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";
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: "#007AFF" }}
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 ? "#00FF00" : "#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,66 @@
import React, { useState } from "react";
import { StyleSheet, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
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: "#007AFF",
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

@@ -0,0 +1,284 @@
# OrionTV Android 5.0 兼容性分析报告
## 项目概述
OrionTV是一个基于React Native TVOS和Expo SDK的电视端视频流媒体应用专为Apple TV和Android TV平台设计。本文档分析了将项目降级到支持Android 5.0 (API Level 21)的兼容性风险和实施方案。
## 当前技术栈
### 核心框架版本
- **React Native**: `npm:react-native-tvos@~0.74.2-0`
- **Expo SDK**: `~51.0.13`
- **React**: `18.2.0`
- **TypeScript**: `~5.3.3`
- **最小Android API级别**: 23 (Android 6.0)
- **目标Android API级别**: 34 (Android 14)
### 关键依赖
- `expo-av`: `~14.0.7` (视频播放)
- `expo-router`: `~3.5.16` (路由导航)
- `react-native-reanimated`: `~3.10.1` (动画)
- `react-native-tcp-socket`: `^6.0.6` (网络服务)
- `zustand`: `^5.0.6` (状态管理)
## 兼容性限制分析
### React Native 0.74 限制
根据官方文档React Native 0.74已将最低Android API级别要求提升到23 (Android 6.0)不再支持Android 5.0 (API Level 21)。
### Expo SDK 51 限制
Expo SDK 51基于React Native 0.74同样不支持Android 5.0。
## 降级方案
### 推荐的版本组合
#### 方案A: 保持TV功能的最新兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.73.8-0",
"expo": "~50.0.0",
"expo-av": "~13.10.x",
"expo-router": "~3.4.x",
"react-native-reanimated": "~3.8.x"
}
```
#### 方案B: 最大向后兼容版本
```json
{
"react-native": "npm:react-native-tvos@~0.72.12-0",
"expo": "~49.0.0"
}
```
### Android配置修改
```gradle
// android/build.gradle
android {
minSdkVersion = 21 // 支持Android 5.0
targetSdkVersion = 30 // 降级到Android 11
compileSdkVersion = 33 // 对应的编译SDK版本
}
```
## 风险评估
### 🔴 高风险组件
#### 1. 视频播放功能 (expo-av)
- **影响文件**: `hooks/usePlaybackManager.ts`, `app/play.tsx`, `stores/playerStore.ts`
- **风险**: API变化可能影响播放控制
- **关键代码**:
```typescript
import { Video, AVPlaybackStatus } from "expo-av";
// 可能受影响的API调用
videoRef?.current?.replayAsync();
videoRef?.current?.pauseAsync();
videoRef?.current?.playAsync();
```
#### 2. TV遥控器功能 (react-native-tvos)
- **影响文件**: `hooks/useTVRemoteHandler.ts` + 7个组件文件
- **风险**: 遥控器事件处理可能有变化
- **关键代码**:
```typescript
import { useTVEventHandler, HWEvent } from "react-native";
// 长按事件处理可能需要调整
case "longRight":
case "longLeft":
```
#### 3. 路由导航 (expo-router)
- **影响文件**: 9个页面文件
- **风险**: 路由配置和参数传递可能有变化
- **关键代码**:
```typescript
import { useRouter, useLocalSearchParams } from "expo-router";
```
### 🟡 中等风险组件
#### 1. 远程控制服务 (react-native-tcp-socket)
- **影响文件**: `services/tcpHttpServer.ts`
- **风险**: 网络API兼容性问题
- **关键代码**:
```typescript
import TcpSocket from 'react-native-tcp-socket';
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {});
```
#### 2. 动画效果 (react-native-reanimated)
- **影响文件**: `components/VideoCard.tv.tsx`
- **风险**: 动画性能可能下降
- **关键代码**:
```typescript
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
```
### 🟢 低风险组件
#### 1. 状态管理 (zustand)
- **影响**: 与React Native版本无直接关系
- **风险**: 极低
#### 2. 数据存储 (AsyncStorage)
- **影响文件**: `services/storage.ts`
- **风险**: 极低API稳定
## 平台特定风险
### Android API 23 → 21 降级影响
#### 1. 运行时权限模型
- **API 23+**: 需要运行时权限请求
- **API 21-22**: 安装时权限模型
- **影响**: 网络权限处理可能需要调整
#### 2. 网络安全配置
- **风险**: HTTP cleartext流量处理
- **当前配置**: `android.usesCleartextTraffic = true`
- **建议**: 保持当前配置确保向后兼容
#### 3. 后台服务限制
- **API 23+**: 更严格的后台服务限制
- **API 21-22**: 相对宽松的后台服务策略
- **影响**: 远程控制服务可能表现不同
## 实施步骤
### 1. 准备阶段
```bash
# 1. 备份当前项目
git checkout -b android-5-compatibility
# 2. 清理现有依赖
rm -rf node_modules
rm yarn.lock
```
### 2. 版本降级
```bash
# 3. 修改package.json依赖版本
# 4. 重新安装依赖
yarn install
# 5. 清理原生代码
yarn prebuild-tv --clean
```
### 3. 配置修改
```bash
# 6. 修改android/build.gradle
# 7. 更新app.json配置
# 8. 复制TV相关配置
yarn copy-config
```
### 4. 测试构建
```bash
# 9. 本地构建测试
yarn build-local
# 10. 运行测试
yarn test
```
## 测试清单
### 核心功能测试
- [ ] 视频播放、暂停、进度控制
- [ ] 遥控器所有按键响应(上下左右、选择、菜单、返回)
- [ ] 长按快进/快退功能
- [ ] 页面导航和参数传递
- [ ] 焦点管理和视觉反馈
### TV特定功能测试
- [ ] 控制条自动显示/隐藏
- [ ] 剧集切换功能
- [ ] 远程控制HTTP服务
- [ ] 设置页面各项配置
- [ ] 搜索功能
### 兼容性测试
- [ ] Android 5.0真机测试
- [ ] Android TV模拟器测试
- [ ] Apple TV模拟器测试
- [ ] 不同屏幕尺寸适配
- [ ] 内存使用情况
- [ ] 启动性能测试
## 依赖版本对照表
| 组件 | 当前版本 | 目标版本 | 风险等级 | 备注 |
|------|----------|----------|----------|------|
| react-native-tvos | ~0.74.2-0 | ~0.73.8-0 | 🔴 高 | TV功能核心 |
| expo | ~51.0.13 | ~50.0.0 | 🔴 高 | 框架基础 |
| expo-av | ~14.0.7 | ~13.10.x | 🔴 高 | 视频播放 |
| expo-router | ~3.5.16 | ~3.4.x | 🔴 高 | 路由导航 |
| react-native-reanimated | ~3.10.1 | ~3.8.x | 🟡 中 | 动画效果 |
| react-native-tcp-socket | ^6.0.6 | ^6.0.4 | 🟡 中 | 网络服务 |
| zustand | ^5.0.6 | ^5.0.6 | 🟢 低 | 状态管理 |
| @react-native-async-storage/async-storage | ^2.2.0 | ^2.1.x | 🟢 低 | 数据存储 |
## 潜在问题和解决方案
### 1. 视频播放问题
**问题**: expo-av版本降级可能导致某些视频格式不支持
**解决方案**:
- 测试主要视频格式(MP4, M3U8)
- 必要时实现格式转换
- 提供播放失败的友好提示
### 2. 遥控器响应问题
**问题**: TV事件处理可能有差异
**解决方案**:
- 仔细测试所有遥控器按键
- 调整事件处理逻辑
- 增加兼容性检查
### 3. 路由导航问题
**问题**: 页面跳转参数传递可能有变化
**解决方案**:
- 测试所有页面跳转
- 验证参数正确传递
- 必要时调整路由配置
### 4. 动画性能问题
**问题**: 动画可能在低端设备上表现不佳
**解决方案**:
- 简化动画效果
- 增加性能检测
- 提供动画开关选项
## 建议与结论
### 风险总结
- **总体风险等级**: 🔴 **高等风险**
- **主要风险点**: 视频播放、遥控器功能、路由导航
- **预计工作量**: 2-3周开发 + 1-2周测试
### 成本效益分析
- **开发成本**: 高(需要大量测试和调试)
- **维护成本**: 高(使用较旧版本,安全更新有限)
- **用户覆盖**: 低Android 5用户占比通常<2%
### 最终建议
**不建议进行降级**,原因如下:
1. 技术风险高,可能影响核心功能稳定性
2. 维护成本高,需要长期支持多个版本
3. 用户收益有限Android 5用户占比极低
4. 与业界趋势不符,各大平台都在提升最低版本要求
### 替代方案
1. **统计用户分布**: 收集实际用户设备数据确认Android 5用户占比
2. **渐进式升级**: 引导用户升级设备,提供升级指南
3. **精简版本**: 为老设备提供功能精简的独立版本
4. **Web版本**: 提供Web端访问方式作为补充
## 参考资料
- [React Native 0.74 Release Notes](https://reactnative.dev/blog/2024/04/22/release-0.74)
- [Expo SDK 51 Changelog](https://expo.dev/changelog/2024-05-07-sdk-51)
- [React Native TV OS Documentation](https://github.com/react-native-tvos/react-native-tvos)
- [Android API Level Distribution](https://developer.android.com/about/dashboards)

313
docs/API.md Normal file
View File

@@ -0,0 +1,313 @@
### 服务器配置
- **接口地址**: `/api/server-config`
- **请求方法**: `GET`
- **功能说明**: 获取服务器配置信息
- **请求参数**: 无
- **返回格式**:
```json
{
"SiteName": "string",
"StorageType": "string"
}
```
StorageType 可选值:
- "localstorage"
- "redis"
localstorage 方式部署的实例,收藏、播放记录和搜索历史无服务器同步,客户端自行处理即可
localstorage 方式部署的实例,登录时只需输入密码,无用户名
### 登录校验
- **接口地址**: `/api/login`
- **请求方法**: `POST`
- **功能说明**: 用户登录认证
- **请求参数**:
```json
{
"password": "string", // 必填,用户密码
"username": "string" // 选填,用户名(非 localStorage 模式时必填)
}
```
- **返回格式**:
```json
{
"ok": true
}
```
- **错误码**:
- `400`: 参数错误或密码错误
- `500`: 服务器内部错误
response 会设置 set-cookie 的 auth 字段,用于后续请求的鉴权
后续的所有接口请求时都需要携带 auth 字段,否则会返回 401 错误
建议客户端保存用户输入的用户名和密码,在每次 app 启动时请求登录接口获取 cookie
### 视频搜索接口
- **接口地址**: `/api/search`
- **请求方法**: `GET`
- **功能说明**: 搜索视频内容
- **请求参数**:
- `q`: 搜索关键词(可选,不传返回空结果)
- **返回格式**:
```json
{
"results": [
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
]
}
```
- **错误码**:
- `500`: 搜索失败
### 视频详情接口
- **接口地址**: `/api/detail`
- **请求方法**: `GET`
- **功能说明**: 获取视频详细信息
- **请求参数**:
- `id`: 视频 ID必填
- `source`: 视频来源代码(必填)
- **返回格式**:
```json
{
"id": "string", // 视频在源站中的 id
"title": "string", // 视频标题
"poster": "string", // 视频封面
"source": "string", // 视频源站 key
"source_name": "string", // 视频源站名称
"class": "string", // 视频分类
"year": "string", // 视频年份
"desc": "string", // 视频描述
"type_name": "string", // 视频类型
"douban_id": "string" // 视频豆瓣 id
}
```
- **错误码**:
- `400`: 缺少必要参数或无效参数
- `500`: 获取详情失败
### 豆瓣数据接口
- **接口地址**: `/api/douban`
- **请求方法**: `GET`
- **功能说明**: 获取豆瓣电影/电视剧数据
- **请求参数**:
- `type`: 类型,必须是 `tv` 或 `movie`(必填)
- `tag`: 标签,如 `热门`、`最新` 等(必填)
- `pageSize`: 每页数量1-100 之间(可选,默认 16
- `pageStart`: 起始位置,不能小于 0可选默认 0
- **返回格式**:
```json
{
"code": 200,
"message": "获取成功",
"list": [
{
"id": "string",
"title": "string",
"poster": "string",
"rate": "string"
}
]
}
```
- **错误码**:
- `400`: 参数错误
- `500`: 获取豆瓣数据失败
### 用户数据接口
#### 收藏管理
- **接口地址**: `/api/favorites`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户收藏
- **认证**: 需要认证
##### GET 请求 - 获取收藏
- **请求参数**:
- `key`: 收藏项 key可选格式为 `source+id`
- **返回格式**:
```json
// 不带key参数时返回所有收藏
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
// 带key参数时返回单个收藏或null
{
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
```
##### POST 请求 - 添加收藏
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"favorite": {
"title": "string",
"poster": "string",
"source_name": "string",
"save_time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除收藏
- **请求参数**:
- `key`: 收藏项 key可选不传则清空所有收藏
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 播放记录管理
- **接口地址**: `/api/playrecords`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户播放记录
- **认证**: 需要认证
##### GET 请求 - 获取播放记录
- **请求参数**: 无
- **返回格式**:
```json
{
"source+id": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
##### POST 请求 - 保存播放记录
- **请求参数**:
```json
{
"key": "string", // 必填,格式为 source+id
"record": {
"title": "string",
"poster": "string",
"source_name": "string",
"index": 1,
"time": 1234567890
}
}
```
- **返回格式**:
```json
{
"success": true
}
```
##### DELETE 请求 - 删除播放记录
- **请求参数**:
- `key`: 播放记录 key可选不传则清空所有记录
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误
#### 搜索历史管理
- **接口地址**: `/api/searchhistory`
- **请求方法**: `GET` / `POST` / `DELETE`
- **功能说明**: 管理用户搜索历史
- **认证**: 需要认证
##### GET 请求 - 获取搜索历史
- **请求参数**: 无
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### POST 请求 - 添加搜索历史
- **请求参数**:
```json
{
"keyword": "string" // 必填,搜索关键词
}
```
- **返回格式**:
```json
["搜索关键词1", "搜索关键词2"]
```
##### DELETE 请求 - 删除搜索历史
- **请求参数**:
- `keyword`: 要删除的关键词(可选,不传则清空所有历史)
- **返回格式**:
```json
{
"success": true
}
```
- **错误码**:
- `400`: 参数错误
- `401`: 未认证
- `500`: 服务器内部错误

View File

@@ -0,0 +1,305 @@
# OrionTV Native HTTP Server Implementation Documentation
## Overview
OrionTV implements a sophisticated native HTTP server solution that enables remote control functionality for the TV application. This implementation uses TCP sockets to create a custom HTTP server directly within the React Native application, providing a web-based remote control interface accessible from mobile devices.
## Architecture
### Core Components
#### 1. TCPHttpServer (`/services/tcpHttpServer.ts`)
A custom HTTP server implementation built on top of `react-native-tcp-socket` that handles raw TCP connections and implements HTTP protocol parsing and response formatting.
**Key Features:**
- Custom HTTP request/response parsing
- Fixed port configuration (12346)
- Automatic IP address detection via `@react-native-community/netinfo`
- Support for GET and POST methods
- Error handling and connection management
**Class Structure:**
```typescript
class TCPHttpServer {
private server: TcpSocket.Server | null = null;
private isRunning = boolean;
private requestHandler: RequestHandler | null = null;
}
```
**Core Methods:**
- `start()`: Initializes server and binds to `0.0.0.0:12346`
- `stop()`: Gracefully shuts down the server
- `setRequestHandler()`: Sets the request handling logic
- `parseHttpRequest()`: Parses raw HTTP request data
- `formatHttpResponse()`: Formats HTTP responses
#### 2. RemoteControlService (`/services/remoteControlService.ts`)
A service layer that wraps the TCPHttpServer and provides the remote control functionality with predefined routes and HTML interface.
**API Endpoints:**
- `GET /` - Serves HTML remote control interface
- `POST /message` - Receives messages from mobile devices
- `POST /handshake` - Connection handshake for mobile clients
**Features:**
- Built-in HTML interface generation
- JSON message parsing
- Callback-based event handling
- Error handling and validation
#### 3. RemoteControlStore (`/stores/remoteControlStore.ts`)
Zustand store that manages the remote control server state and provides React component integration.
**State Management:**
```typescript
interface RemoteControlState {
isServerRunning: boolean;
serverUrl: string | null;
error: string | null;
isModalVisible: boolean;
lastMessage: string | null;
startServer: () => Promise<void>;
stopServer: () => void;
showModal: () => void;
hideModal: () => void;
setMessage: (message: string) => void;
}
```
## Technical Implementation Details
### HTTP Protocol Implementation
#### Request Parsing
The server implements custom HTTP request parsing that handles:
- HTTP method and URL extraction
- Header parsing with case-insensitive keys
- Body content extraction
- Malformed request detection
#### Response Formatting
Responses are formatted according to HTTP/1.1 specification:
- Status line with appropriate status codes (200, 400, 404, 500)
- Content-Length header calculation
- Connection: close header for stateless operation
- Proper CRLF line endings
### Network Configuration
#### IP Address Detection
The server automatically detects the device's IP address using `@react-native-community/netinfo`:
- Supports WiFi and Ethernet connections
- Validates network connectivity before starting
- Provides clear error messages for network issues
#### Server Binding
- Binds to `0.0.0.0:12346` for universal access
- Fixed port configuration for consistency
- Supports all network interfaces on the device
### Security Considerations
#### Current Implementation
- No authentication mechanism
- Open access on local network
- Basic request validation
- Error handling prevents information disclosure
#### Limitations
- Suitable only for local network use
- No HTTPS/TLS encryption
- No rate limiting or DDoS protection
- Assumes trusted network environment
## Web Interface
### HTML Template
The service provides a responsive web interface optimized for mobile devices:
- Dark theme design matching TV app aesthetics
- Touch-friendly controls with large buttons
- Real-time message sending capability
- Automatic handshake on page load
### JavaScript Functionality
- Automatic handshake POST request on page load
- Message submission via JSON POST requests
- Input field clearing after submission
- Error handling for network issues
## Integration with React Native App
### App Initialization
The server is automatically started when the app launches (`/app/_layout.tsx`):
```typescript
useEffect(() => {
const { setMessage, hideModal } = useRemoteControlStore.getState();
remoteControlService.init({
onMessage: setMessage,
onHandshake: hideModal,
});
useRemoteControlStore.getState().startServer();
return () => {
useRemoteControlStore.getState().stopServer();
};
}, []);
```
### Message Handling
Messages received from mobile devices are processed and displayed as Toast notifications in the TV app, providing visual feedback for remote interactions.
### QR Code Integration
The app generates QR codes containing the server URL (`http://{device_ip}:12346`) for easy mobile device connection via `RemoteControlModal.tsx`.
## Dependencies
### Required Packages
- `react-native-tcp-socket@^6.0.6` - TCP socket implementation
- `@react-native-community/netinfo@^11.3.2` - Network interface information
- `react-native-qrcode-svg@^6.3.1` - QR code generation for UI
### Platform Compatibility
- iOS (Apple TV)
- Android (Android TV)
- Requires network connectivity (WiFi or Ethernet)
## Performance Characteristics
### Connection Handling
- Single-threaded event-driven architecture
- Stateless HTTP connections with immediate closure
- Memory-efficient request buffering
- Graceful error recovery
### Resource Usage
- Minimal CPU overhead for HTTP parsing
- Low memory footprint
- Network I/O bound operations
- Automatic connection cleanup
## Error Handling
### Server Level
- Network binding failures with descriptive messages
- Socket error handling and logging
- Graceful server shutdown procedures
- IP address detection error handling
### Request Level
- Malformed HTTP request detection
- JSON parsing error handling
- 400/404/500 status code responses
- Request timeout and connection cleanup
## Debugging and Monitoring
### Logging
Comprehensive logging throughout the system:
- Server startup/shutdown events
- Client connection/disconnection
- Request processing details
- Error conditions and stack traces
### Console Output Format
```
[TCPHttpServer] Server listening on 192.168.1.100:12346
[RemoteControl] Received request: POST /message
[RemoteControlStore] Server started, URL: http://192.168.1.100:12346
```
## Usage Example
### Starting the Server
```typescript
// Automatic startup via store
const { startServer } = useRemoteControlStore();
await startServer();
// Manual service usage
await remoteControlService.startServer();
```
### Stopping the Server
```typescript
// Via store
const { stopServer } = useRemoteControlStore();
stopServer();
// Direct service call
remoteControlService.stopServer();
```
### Mobile Device Access
1. Ensure mobile device is on the same network as TV
2. Scan QR code displayed in TV app
3. Access web interface at `http://{tv_ip}:12346`
4. Send messages that appear as notifications on TV
## Comparison with Alternatives
### vs react-native-http-bridge
- **Advantages**: More control over HTTP implementation, custom error handling
- **Disadvantages**: More complex implementation, requires manual HTTP parsing
### vs External Backend Server
- **Advantages**: No additional infrastructure, embedded in app
- **Disadvantages**: Limited scalability, single device constraint
## Future Enhancement Opportunities
### Security
- Authentication token implementation
- HTTPS/TLS encryption support
- Request rate limiting
- CORS configuration
### Functionality
- Multi-device support
- WebSocket integration for real-time communication
- File upload/download capabilities
- Advanced remote control commands
### Performance
- Connection pooling
- Request caching
- Compression support
- IPv6 compatibility
## Troubleshooting
### Common Issues
#### "Unable to get IP address" Error
- Verify WiFi/Ethernet connection
- Check network interface availability
- Restart network services
#### Server Won't Start
- Check if port 12346 is already in use
- Verify network permissions
- Restart the application
#### Mobile Device Can't Connect
- Confirm both devices on same network
- Verify firewall settings
- Check IP address in QR code
### Diagnostic Commands
```bash
# Check network connectivity
yarn react-native log-ios # View iOS logs
yarn react-native log-android # View Android logs
# Network debugging
netstat -an | grep 12346 # Check port binding (debugging environment)
```
## Conclusion
The OrionTV native HTTP server implementation provides a robust, embedded solution for remote control functionality without requiring external infrastructure. The custom TCP-based approach offers flexibility and control while maintaining simplicity and performance suitable for TV applications.
The implementation demonstrates sophisticated understanding of HTTP protocol handling, React Native integration, and TV-specific user experience requirements, making it an effective solution for cross-device interaction in smart TV environments.

View File

@@ -0,0 +1,136 @@
# 手机遥控功能实现方案 (V2)
本文档详细描述了在 OrionTV 应用中集成一个基于 **HTTP 请求** 的手机遥控功能的完整方案。
---
## 1. 核心功能与流程
该功能允许用户通过手机浏览器向 TV 端发送文本消息TV 端接收后以 Toast 形式进行展示。服务将在应用启动时自动开启,用户可在设置中找到入口以显示连接二维码。
### 流程图
```mermaid
sequenceDiagram
participant App as App 启动
participant RemoteControlStore as 状态管理 (TV)
participant RemoteControlService as 遥控服务 (TV)
participant User as 用户
participant SettingsUI as 设置界面 (TV)
participant PhoneBrowser as 手机浏览器
App->>RemoteControlStore: App 启动, 触发 startHttpServer()
RemoteControlStore->>RemoteControlService: 启动 HTTP 服务
RemoteControlService-->>RemoteControlStore: 更新服务状态 (IP, Port)
User->>SettingsUI: 打开设置, 点击“手机遥控”按钮
SettingsUI->>RemoteControlStore: 获取服务 URL
RemoteControlStore-->>SettingsUI: 返回 serverUrl
SettingsUI-->>User: 显示二维码弹窗
User->>PhoneBrowser: 扫描二维码
PhoneBrowser->>RemoteControlService: (HTTP GET) 请求网页
RemoteControlService-->>PhoneBrowser: 返回 HTML 页面
User->>PhoneBrowser: 输入文本并发送
PhoneBrowser->>RemoteControlService: (HTTP POST /message) 发送消息
RemoteControlService->>RemoteControlStore: 处理消息 (显示 Toast)
```
---
## 2. 技术选型
* **HTTP 服务**: `react-native-http-bridge`
* **二维码生成**: `react-native-qrcode-svg`
* **网络信息 (IP 地址)**: `@react-native-community/netinfo`
* **状态管理**: `zustand` (项目已集成)
---
## 3. 项目结构变更
### 新增文件
* `services/remoteControlService.ts`: 封装 HTTP 服务的核心逻辑。
* `stores/remoteControlStore.ts`: 使用 Zustand 管理远程控制服务的状态。
* `components/RemoteControlModal.tsx`: 显示二维码和连接信息的弹窗组件。
* `types/react-native-http-bridge.d.ts`: `react-native-http-bridge` 的 TypeScript 类型定义。
### 修改文件
* `app/_layout.tsx`: 在应用根组件中调用服务启动逻辑。
* `components/SettingsModal.tsx`: 添加“手机遥控”按钮,用于触发二维码弹窗。
* `package.json`: 添加新依赖。
---
## 4. 实现细节
### a. 状态管理 (`stores/remoteControlStore.ts`)
创建一个 Zustand store 来管理遥控服务的状态。
* **State**:
* `isServerRunning`: `boolean` - 服务是否正在运行。
* `serverUrl`: `string | null` - 完整的 HTTP 服务 URL (e.g., `http://192.168.1.5:12346`)。
* `error`: `string | null` - 错误信息。
* **Actions**:
* `startServer()`: 异步 action调用 `remoteControlService.startServer` 并更新 state。
* `stopServer()`: 调用 `remoteControlService.stopServer` 并更新 state。
### b. 服务层 (`services/remoteControlService.ts`)
实现服务的启动、停止和消息处理。
* **`startServer()`**:
1. 使用 `@react-native-community/netinfo` 获取 IP 地址。
2. 定义一个包含 `fetch` API 调用逻辑的 HTML 字符串。
3. 使用 `react-native-http-bridge` 在固定端口(如 `12346`)启动 HTTP 服务。
4. 配置 `GET /` 路由以返回 HTML 页面。
5. 配置 `POST /message` 路由来接收手机端发送的消息,并使用 `Toast` 显示。
6. 返回服务器 URL。
* **`stopServer()`**:
1. 调用 `httpBridge.stop()`
### c. UI 集成
* **`app/_layout.tsx`**:
* 在根组件 `useEffect` 中调用 `useRemoteControlStore.getState().startServer()`,实现服务自启。
* **`components/SettingsModal.tsx`**:
* 添加一个 `<StyledButton text="手机遥控" />`
* 点击按钮时,触发 `RemoteControlModal` 的显示。
* **`components/RemoteControlModal.tsx`**:
*`remoteControlStore` 中获取 `serverUrl`
* 如果 `serverUrl` 存在,则使用 `react-native-qrcode-svg``<QRCode />` 组件显示二维码。
* 如果不存在,则显示加载中或错误信息。
### d. 网页内容 (HTML)
一个简单的 HTML 页面,包含一个输入框和一个按钮。
```html
<html>
<head>
<title>OrionTV Remote</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style> /* ... some basic styles ... */ </style>
</head>
<body>
<h3>发送消息到 TV</h3>
<input id="text" />
<button onclick="send()">发送</button>
<script>
function send() {
const val = document.getElementById("text").value;
if (val) {
fetch("/message", {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: val })
});
document.getElementById("text").value = '';
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,131 @@
# 设置页面重构方案
## 目标
1. 将设置从弹窗模式改为独立页面
2. 新增直播源配置功能
3. 新增远程输入开关配置
4. 新增播放源启用配置
## 现有架构分析
### 当前设置相关文件:
- `stores/settingsStore.ts` - 设置状态管理目前只有API地址配置
- `components/SettingsModal.tsx` - 设置弹窗组件
- `stores/remoteControlStore.ts` - 远程控制状态管理
### 现有功能:
- API基础地址配置
- 远程控制服务器(但未集成到设置中)
## 重构方案
### 1. 创建独立设置页面
- 新建 `app/settings.tsx` 页面
- 使用 Expo Router 的文件路由系统
- 删除现有的 `SettingsModal.tsx` 组件
### 2. 扩展设置Store
`settingsStore.ts` 中新增以下配置项:
```typescript
interface SettingsState {
// 现有配置
apiBaseUrl: string;
// 新增配置项
liveStreamSources: LiveStreamSource[]; // 直播源配置
remoteInputEnabled: boolean; // 远程输入开关
videoSourceConfig: VideoSourceConfig; // 播放源配置
}
interface LiveStreamSource {
id: string;
name: string;
url: string;
enabled: boolean;
}
interface VideoSourceConfig {
primarySource: string;
fallbackSources: string[];
enabledSources: string[];
}
```
### 3. 设置页面UI结构
```
设置页面 (app/settings.tsx)
├── API 配置区域
│ └── API 基础地址输入框
├── 直播源配置区域
│ ├── 直播源列表
│ ├── 添加直播源按钮
│ └── 编辑/删除直播源功能
├── 远程输入配置区域
│ └── 远程输入开关
└── 播放源配置区域
├── 主播放源选择
├── 备用播放源配置
└── 启用的播放源选择
```
### 4. 组件设计
- 使用 TV 适配的组件和样式
- 实现焦点管理和遥控器导航
- 遵循现有的设计规范ThemedView, ThemedText, StyledButton
### 5. 导航集成
- 在主页面添加设置入口
- 使用 Expo Router 的 router.push('/settings') 进行导航
## 实施步骤
1. **扩展 settingsStore.ts**
- 添加新的状态接口
- 实现新配置项的增删改查方法
- 集成本地存储
2. **创建设置页面**
- 新建 `app/settings.tsx`
- 实现基础页面结构和导航
3. **实现配置组件**
- API 配置组件(复用现有逻辑)
- 直播源配置组件
- 远程输入开关组件
- 播放源配置组件
4. **集成远程控制**
- 将远程控制功能集成到设置页面
- 统一管理所有设置项
5. **更新导航**
- 在主页面添加设置入口
- 移除现有的设置弹窗触发逻辑
6. **测试验证**
- 测试所有配置项的保存和加载
- 验证TV平台的交互体验
- 确保配置项生效
## 技术考虑
### TV平台适配
- 使用 `useTVRemoteHandler` 处理遥控器事件
- 实现合适的焦点管理
- 确保所有交互元素可通过遥控器操作
### 数据持久化
- 使用现有的 `SettingsManager` 进行本地存储
- 确保新配置项能正确保存和恢复
### 向后兼容
- 保持现有API配置功能不变
- 为新配置项提供默认值
- 处理旧版本设置数据的迁移
## 预期收益
1. **更好的用户体验**:独立页面提供更多空间展示配置选项
2. **功能扩展性**:为未来添加更多配置项提供良好基础
3. **代码组织**:将设置相关功能集中管理
4. **TV平台适配**:更好的遥控器交互体验

View File

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

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

@@ -2,7 +2,7 @@
"name": "OrionTV",
"private": true,
"main": "expo-router/entry",
"version": "1.1.0",
"version": "1.2.2",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
@@ -28,6 +28,8 @@
"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",
@@ -46,10 +48,12 @@
"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-tcp-socket": "^6.0.6",
"react-native-toast-message": "^2.3.3",
"react-native-web": "~0.19.10",
"zustand": "^5.0.6"
@@ -60,6 +64,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",

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,27 @@ export interface SearchResult {
type_name?: string;
}
// Data structure for play records
export interface Favorite {
cover: string;
title: string;
poster: 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 +70,11 @@ export interface ApiSite {
detail?: string;
}
export interface ServerConfig {
SiteName: string;
StorageType: "localstorage" | "redis" | string;
}
export class API {
public baseURL: string = "";
@@ -78,91 +88,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);
};

70
services/m3u.ts Normal file
View File

@@ -0,0 +1,70 @@
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 = { id: '', name: '', url: '', logo: '', group: '' };
const commaIndex = trimmedLine.indexOf(',');
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
parsedChannels.push(currentChannelInfo as Channel);
currentChannelInfo = null;
}
}
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,85 +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 interface PlayRecord extends 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 PlayerSettings {
introEndTime?: number;
outroStartTime?: number;
}
export interface FavoriteItem {
id: string;
source: string;
title: string;
poster: string;
source_name: string;
save_time: 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);
@@ -91,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;
}
}
@@ -197,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;

61
stores/authStore.ts Normal file
View File

@@ -0,0 +1,61 @@
import { create } from "zustand";
import Cookies from "@react-native-cookies/cookies";
import { api } from "@/services/api";
import { useSettingsStore } from "./settingsStore";
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;
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;

View File

@@ -1,6 +1,8 @@
import { create } from 'zustand';
import { api, SearchResult, PlayRecord } from '@/services/api';
import { PlayRecordManager } from '@/services/storage';
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;
@@ -19,21 +21,38 @@ export type RowItem = (SearchResult | PlayRecord) & {
export interface Category {
title: string;
type?: 'movie' | 'tv' | 'record';
type?: "movie" | "tv" | "record";
tag?: string;
tags?: 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: '日本动画' },
{ 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 {
@@ -62,6 +81,8 @@ const useHomeStore = create<HomeState>((set, get) => ({
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();
},
@@ -75,41 +96,60 @@ const useHomeStore = create<HomeState>((set, get) => ({
}
try {
if (selectedCategory.type === 'record') {
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 };
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)
// .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 => ({
const newItems = result.list.map((item) => ({
...item,
id: item.title,
source: 'douban',
source: "douban",
})) as RowItem[];
set(state => ({
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: '请点击右上角设置按钮,配置您的 API 地址' });
if (err.message === "API_URL_NOT_SET") {
set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
} else {
set({ error: '加载失败,请重试' });
set({ error: "加载失败,请重试" });
}
} finally {
set({ loading: false, loadingMore: false });
@@ -117,31 +157,50 @@ const useHomeStore = create<HomeState>((set, get) => ({
},
selectCategory: (category: Category) => {
set({ selectedCategory: category });
get().fetchInitialData();
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');
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);
const newCategories = state.categories.filter((c) => c.type !== "record");
if (state.selectedCategory.type === "record") {
get().selectCategory(newCategories[0] || null);
}
return { categories: newCategories };
}
return {};
});
if (get().selectedCategory.type === 'record') {
get().fetchInitialData();
}
get().fetchInitialData();
},
}));
export default useHomeStore;
export default useHomeStore;

View File

@@ -2,27 +2,18 @@ import { create } from "zustand";
import Toast from "react-native-toast-message";
import { AVPlaybackStatus, Video } from "expo-av";
import { RefObject } from "react";
import { api, VideoDetail as ApiVideoDetail, SearchResult } from "@/services/api";
import { PlayRecord, PlayRecordManager } from "@/services/storage";
import useDetailStore, { episodesSelectorBySource } from "./detailStore";
interface Episode {
url: string;
title: string;
}
interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"];
episodes: Episode[];
sources: SearchResult[];
}
interface PlayerState {
videoRef: RefObject<Video> | null;
detail: VideoDetail | null;
episodes: Episode[];
sources: SearchResult[];
currentSourceIndex: number;
currentEpisodeIndex: number;
episodes: Episode[];
status: AVPlaybackStatus | null;
isLoading: boolean;
showControls: boolean;
@@ -36,8 +27,7 @@ interface PlayerState {
introEndTime?: number;
outroStartTime?: number;
setVideoRef: (ref: RefObject<Video>) => void;
loadVideo: (source: string, id: string, episodeIndex: number, position?: number) => Promise<void>;
switchSource: (newSourceIndex: number) => Promise<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;
@@ -57,11 +47,8 @@ interface PlayerState {
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
currentEpisodeIndex: -1,
status: null,
isLoading: true,
showControls: false,
@@ -78,72 +65,45 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
setVideoRef: (ref) => set({ videoRef: ref }),
loadVideo: async (source, id, episodeIndex, position) => {
loadVideo: async ({ source, id, episodeIndex, position, title }) => {
let detail = useDetailStore.getState().detail;
let episodes = episodesSelectorBySource(source)(useDetailStore.getState());
set({
isLoading: true,
detail: null,
episodes: [],
sources: [],
currentEpisodeIndex: 0,
initialPosition: position || 0,
});
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 videoDetail = await api.getVideoDetail(source, id);
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
const searchResults = await api.searchVideos(videoDetail.videoInfo.title);
const sources = searchResults.results.filter((r) => r.title === videoDetail.videoInfo.title);
const currentSourceIndex = sources.findIndex((s) => s.source === source && s.id.toString() === id);
const playRecord = await PlayRecordManager.get(source, id);
const playRecord = await PlayRecordManager.get(detail.source, detail.id.toString());
const initialPositionFromRecord = playRecord?.play_time ? playRecord.play_time * 1000 : 0;
set({
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
currentEpisodeIndex: episodeIndex,
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.error("Failed to load video details", error);
console.info("Failed to load play record", error);
set({ isLoading: false });
}
},
switchSource: async (newSourceIndex: number) => {
const { sources, currentEpisodeIndex, status, detail } = get();
if (!detail || newSourceIndex < 0 || newSourceIndex >= sources.length) return;
const newSource = sources[newSourceIndex];
const position = status?.isLoaded ? status.positionMillis : 0;
set({ isLoading: true, showSourceModal: false });
try {
const videoDetail = await api.getVideoDetail(newSource.source, newSource.id.toString());
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
set({
detail: {
...detail,
videoInfo: videoDetail.videoInfo,
episodes,
},
episodes,
currentSourceIndex: newSourceIndex,
currentEpisodeIndex: currentEpisodeIndex < episodes.length ? currentEpisodeIndex : 0,
initialPosition: position,
isLoading: false,
});
} catch (error) {
console.error("Failed to switch source", error);
set({ isLoading: false });
}
},
playEpisode: (index) => {
playEpisode: async (index) => {
const { episodes, videoRef } = get();
if (index >= 0 && index < episodes.length) {
set({
@@ -153,27 +113,42 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
progressPosition: 0,
seekPosition: 0,
});
videoRef?.current?.replayAsync();
}
},
togglePlayPause: () => {
const { status, videoRef } = get();
if (status?.isLoaded) {
if (status.isPlaying) {
videoRef?.current?.pauseAsync();
} else {
videoRef?.current?.playAsync();
try {
await videoRef?.current?.replayAsync();
} catch (error) {
console.error("Failed to replay video:", error);
Toast.show({ type: "error", text1: "播放失败" });
}
}
},
seek: (duration) => {
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));
videoRef?.current?.setPositionAsync(newPosition);
try {
await videoRef?.current?.setPositionAsync(newPosition);
} catch (error) {
console.error("Failed to seek video:", error);
Toast.show({ type: "error", text1: "快进/快退失败" });
}
set({
isSeeking: true,
@@ -188,7 +163,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
setIntroEndTime: () => {
const { status, detail, introEndTime: existingIntroEndTime } = get();
const { status, introEndTime: existingIntroEndTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingIntroEndTime) {
@@ -213,7 +189,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
setOutroStartTime: () => {
const { status, detail, outroStartTime: existingOutroStartTime } = get();
const { status, outroStartTime: existingOutroStartTime } = get();
const detail = useDetailStore.getState().detail;
if (!status?.isLoaded || !detail) return;
if (existingOutroStartTime) {
@@ -226,7 +203,8 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
});
} else {
// Set the time
const newOutroStartTime = status.positionMillis;
if (!status.durationMillis) return;
const newOutroStartTime = status.durationMillis - status.positionMillis;
set({ outroStartTime: newOutroStartTime });
get()._savePlayRecord({ outroStartTime: newOutroStartTime });
Toast.show({
@@ -238,21 +216,22 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
},
_savePlayRecord: (updates = {}) => {
const { detail, currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const { videoInfo } = detail;
const existingRecord = {
introEndTime,
outroStartTime,
};
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
title: videoInfo.title,
cover: videoInfo.cover || "",
index: currentEpisodeIndex,
PlayRecordManager.save(detail.source, detail.id.toString(), {
title: detail.title,
cover: detail.poster || "",
index: currentEpisodeIndex + 1,
total_episodes: episodes.length,
play_time: status.positionMillis,
total_time: status.durationMillis || 0,
source_name: videoInfo.source_name,
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,
});
@@ -262,15 +241,20 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
handlePlaybackStatusUpdate: (newStatus) => {
if (!newStatus.isLoaded) {
if (newStatus.error) {
console.error(`Playback Error: ${newStatus.error}`);
console.info(`Playback Error: ${newStatus.error}`);
}
set({ status: newStatus });
return;
}
const { detail, currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const { currentEpisodeIndex, episodes, outroStartTime, playEpisode } = get();
const detail = useDetailStore.getState().detail;
if (outroStartTime && newStatus.positionMillis >= outroStartTime) {
if (
outroStartTime &&
newStatus.durationMillis &&
newStatus.positionMillis >= newStatus.durationMillis - outroStartTime
) {
if (currentEpisodeIndex < episodes.length - 1) {
playEpisode(currentEpisodeIndex + 1);
return; // Stop further processing for this update
@@ -306,10 +290,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
reset: () => {
set({
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null,
isLoading: true,
@@ -325,3 +306,9 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}));
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()}` });
},
}));

View File

@@ -1,34 +1,104 @@
import { create } from 'zustand';
import { SettingsManager } from '@/services/storage';
import { api } from '@/services/api';
import useHomeStore from './homeStore';
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: 'https://orion-tv.edu.deal',
apiBaseUrl: "",
m3uUrl: "",
liveStreamSources: [],
remoteInputEnabled: false,
isModalVisible: false,
serverConfig: null,
videoSource: {
enabledAll: true,
sources: {},
},
loadSettings: async () => {
const settings = await SettingsManager.get();
set({ apiBaseUrl: settings.apiBaseUrl });
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) {
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 } = get();
await SettingsManager.save({ apiBaseUrl });
api.setBaseUrl(apiBaseUrl);
set({ isModalVisible: false });
useHomeStore.getState().fetchInitialData();
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;

1051
yarn.lock

File diff suppressed because it is too large Load Diff