62 Commits

Author SHA1 Message Date
zimplexing
0f8cc49019 fix: add disclaimer alert after successful login and update error message 2025-07-26 14:57:51 +08:00
zimplexing
8ea588617d fix: update placeholder and error message 2025-07-26 14:50:53 +08:00
zimplexing
89b5f1df9d chore: delete docs 2025-07-26 12:54:46 +08:00
zimplexing
2ba7782f5d chore: delete backend 2025-07-26 12:53:47 +08:00
zimplexing
48b983c2b4 Merge remote-tracking branch 'origin/master' into v1.2.9 2025-07-26 12:53:11 +08:00
Xin
0c3b8f753e Update README.md 2025-07-26 12:46:25 +08:00
Xin
76bbbb9439 Update README.md 2025-07-26 12:32:19 +08:00
zimplexing
e5a40da8ad chore: bump version to 1.2.9 in package.json 2025-07-25 18:25:39 +08:00
zimplexing
80cb5310c4 fix: UI issue 2025-07-25 18:24:42 +08:00
Xin
928432e81c Merge pull request #94 from zimplexing/v1.2.8
refactor: Update scroll experience
2025-07-25 16:32:47 +08:00
zimplexing
d1f0a2eb87 feat: Optimize availability checking of api addresses 2025-07-25 16:32:15 +08:00
zimplexing
62c03beb5e fix: search input focus issue 2025-07-25 16:00:11 +08:00
zimplexing
5992a89db4 feat: Update scroll experience 2025-07-25 15:39:23 +08:00
zimplexing
c9587d7070 chore: bump version to 1.2.8 in package.json 2025-07-25 14:15:18 +08:00
zimplexing
75d7f675f7 fix: reload video with new source while preserving playback position 2025-07-25 13:58:26 +08:00
zimplexing
9cbd23c36a refactor: improve focus management and input handling in LoginModal 2025-07-25 13:34:11 +08:00
zimplexing
3fa2eb3159 feat: add useKeepAwake to LivePlayer and update version to 1.2.7 2025-07-21 19:03:12 +08:00
zimplexing
e4e4417ef6 feat: enhance LivePlayer messages with localized text and improve M3U parsing logic 2025-07-21 14:06:44 +08:00
Xin
64cdcb78b6 Merge pull request #73 from Moon3r/fix-m3u-without-extinf
Fix fetch m3u failed when m3u file without extinf.
2025-07-21 11:04:35 +08:00
Moon3r
809422f702 Fix fetch m3u failed when m3u file without extinf. 2025-07-21 10:16:42 +08:00
Xin
1c9b3b2553 Update README.md 2025-07-21 09:16:01 +08:00
Xin
e02b3c512f Update README.md 2025-07-21 09:14:50 +08:00
zimplexing
fe05525805 fix: add padding to badge text for improved spacing 2025-07-18 23:00:42 +08:00
zimplexing
1be777825b fix: update VideoCard styles for improved visual consistency and accessibility 2025-07-18 22:57:56 +08:00
zimplexing
813ca40576 chore: bump version to 1.2.5 in package.json 2025-07-18 22:21:53 +08:00
zimplexing
4c633febdc feat: update color scheme and styles across components for improved UI consistency 2025-07-18 22:21:33 +08:00
zimplexing
2fd30c8fd7 chore: bump version to 1.2.4 in package.json 2025-07-18 19:59:42 +08:00
zimplexing
f09f103d59 feat: add favorites screen and integrate it into navigation; enhance detail screen with favorite toggle functionality 2025-07-18 19:57:39 +08:00
zimplexing
828a0b3d72 chore: bump version to 1.2.3 in package.json 2025-07-18 19:09:29 +08:00
zimplexing
e8a1ea2717 feat: update PlayScreen and VideoLoadingAnimation styles, and log play record saving in playerStore 2025-07-18 19:08:49 +08:00
zimplexing
bd7087264d feat: add VideoLoadingAnimation component and integrate it into detail, play, and search screens for improved loading experience 2025-07-18 17:15:24 +08:00
zimplexing
990745eba9 feat: modify LoginModal to conditionally handle visibility based on pathname, preventing display on settings page 2025-07-18 12:19:29 +08:00
zimplexing
cab3e2ed12 feat: refactor playerStore to improve loadVideo and _savePlayRecord methods with enhanced options and throttling logic 2025-07-18 11:26:06 +08:00
zimplexing
3fdd1fc587 feat: update screenshot assets with new images 2025-07-17 22:15:50 +08:00
zimplexing
4b3d1c620b chore: update version to 1.2.2 in package.json 2025-07-17 22:06:15 +08:00
zimplexing
1f694f9245 feat: add timeout handling for loading state with error notification 2025-07-17 22:04:28 +08:00
zimplexing
ec949029fa feat: enhance saveSettings function to process API base URL and ensure valid format 2025-07-17 21:14:03 +08:00
Xin
2325b76f77 Update README.md 2025-07-17 12:19:22 +08:00
Xin
4473fd6ab3 Update README.md 2025-07-16 23:09:12 +08:00
zimplexing
c514a6d03e chore: update version to 1.2.1 in package.json 2025-07-16 22:06:28 +08:00
zimplexing
f6baa0523c feat: enhance authentication flow by adding server configuration check and login handling in authStore 2025-07-16 22:06:04 +08:00
zimplexing
9540aaa3b9 chore: update version to 1.2.0 in package.json 2025-07-16 21:31:21 +08:00
Xin
8a1c26991b Merge pull request #38 from zimplexing/v1.2.0
Adapt moontv api
2025-07-16 21:29:51 +08:00
zimplexing
d83c4483ff refactor: remove padding and margin from searchButton for cleaner styling 2025-07-16 21:29:09 +08:00
zimplexing
9f4299004a refactor: comment out unused components in layout and detail screens for cleaner code 2025-07-16 21:28:12 +08:00
zimplexing
e0aa40eea0 feat: enhance PlayScreen and VideoCard with improved video loading and app state handling; update player store for better episode management 2025-07-16 21:26:37 +08:00
zimplexing
daba164998 refactor: update storage management to use centralized storage configuration and improve README documentation 2025-07-16 16:36:46 +08:00
zimplexing
57bc0b3582 feat: enhance PlayScreen and storage management with dynamic source handling and local storage support 2025-07-15 22:59:10 +08:00
zimplexing
0b1fa9df6d feat: enhance LoginModal with TV event handling and input focus management 2025-07-15 22:33:11 +08:00
zimplexing
d44e9fe9ae feat: enhance login status management and improve error logging across services 2025-07-15 21:41:38 +08:00
zimplexing
116cf12ca3 Merge remote-tracking branch 'origin/master' into v1.2.0 2025-07-15 18:54:04 +08:00
zimplexing
948368c3c8 feat: integrate settings and authentication stores for improved login status management 2025-07-15 18:53:57 +08:00
zimplexing
30cbf6846e feat: add favorites functionality and enhance detail screen 2025-07-15 17:07:53 +08:00
zimplexing
8985781865 refactor: enhance LoginModal and StyledButton components for improved functionality 2025-07-15 15:49:46 +08:00
zimplexing
bf99aee5f2 refactor: update play time property and enhance player settings management
- Changed the play time property from 'play_time' to 'time' in HomeScreen.
- Removed unused player control functions from PlayScreen.
- Added PlayerSettings interface and implemented PlayerSettingsManager for local storage of player settings.
- Refactored PlayRecordManager to merge API records with local player settings.
- Updated authentication logic in authStore to handle optional username parameter in login function.
- Cleaned up and optimized imports across various components.
2025-07-15 15:03:58 +08:00
Xin
bb9b8891c3 Update README.md 2025-07-15 14:18:27 +08:00
zimplexing
2bed3a4d00 feat: implement user authentication and logout functionality
- Added login/logout buttons to the HomeScreen and SettingsScreen.
- Integrated authentication state management using Zustand and cookies.
- Updated API to support username and password for login.
- Enhanced PlayScreen to handle video playback based on user authentication.
- Created a new detailStore to manage video details and sources.
- Refactored playerStore to utilize detailStore for episode management.
- Added sourceStore to manage video source toggling.
- Updated settingsStore to fetch server configuration.
- Improved error handling and user feedback with Toast notifications.
- Cleaned up unused code and optimized imports across components.
2025-07-14 22:55:55 +08:00
zimplexing
0452bfe21f feat: Implement user authentication and data management features
- Added LoginModal component for user login functionality.
- Introduced API routes for user login, favorites, play records, and search history management.
- Created JSON files for storing favorites, play records, and search history.
- Updated API service to handle new endpoints and refactored data management to use API calls instead of local storage.
- Adjusted data structures in types and services to align with new API responses.
2025-07-14 16:21:28 +08:00
zimplexing
f06b10feec fix: Update outro start time calculation to handle cases with undefined durationMillis 2025-07-14 13:51:25 +08:00
zimplexing
1c7c1cfd47 fix: Adjust jump position logic in PlayScreen to ensure a default value of 0 is used when both introEndTime and initialPosition are undefined 2025-07-14 13:43:54 +08:00
zimplexing
02eb19055b Update README.md to improve deployment instructions and add star history section 2025-07-14 13:39:54 +08:00
zimplexing
ee805960cc Add HTML charset 2025-07-14 13:31:02 +08:00
71 changed files with 2372 additions and 13427 deletions

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -8,7 +8,8 @@ import Toast from "react-native-toast-message";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { remoteControlService } from "@/services/remoteControlService";
import LoginModal from "@/components/LoginModal";
import useAuthStore from "@/stores/authStore";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -18,13 +19,20 @@ export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"),
});
const { loadSettings, remoteInputEnabled } = useSettingsStore();
const { loadSettings, remoteInputEnabled, apiBaseUrl } = useSettingsStore();
const { startServer, stopServer } = useRemoteControlStore();
const { checkLoginStatus } = useAuthStore();
useEffect(() => {
loadSettings();
}, [loadSettings]);
useEffect(() => {
if (apiBaseUrl) {
checkLoginStatus(apiBaseUrl);
}
}, [apiBaseUrl, checkLoginStatus]);
useEffect(() => {
if (loaded || error) {
SplashScreen.hideAsync();
@@ -55,9 +63,11 @@ export default function RootLayout() {
<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,144 +1,56 @@
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 { useSettingsStore } from "@/stores/settingsStore";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { FontAwesome } from "@expo/vector-icons";
export default function DetailScreen() {
const { source, q } = useLocalSearchParams();
const { q, source, id } = useLocalSearchParams<{ q: string; source?: string; id?: string }>();
const router = useRouter();
const [searchResults, setSearchResults] = useState<(SearchResult & { resolution?: string | null })[]>([]);
const [detail, setDetail] = useState<(SearchResult & { resolution?: string | null }) | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [allSourcesLoaded, setAllSourcesLoaded] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const { videoSource } = useSettingsStore();
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 allResources = await api.getResources(signal);
if (!allResources || allResources.length === 0) {
setError("没有可用的播放源");
setLoading(false);
return;
}
// Filter resources based on enabled sources in settings
const resources = videoSource.enabledAll
? allResources
: allResources.filter((resource) => videoSource.sources[resource.key]);
if (!videoSource.enabledAll && 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, videoSource.enabledAll, videoSource.sources]);
}, [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,
},
});
};
if (loading) {
return (
<ThemedView style={styles.centered}>
<ActivityIndicator size="large" />
</ThemedView>
);
return <VideoLoadingAnimation showProgressBar={false} />;
}
if (error) {
@@ -165,13 +77,23 @@ export default function DetailScreen() {
<View style={styles.topContainer}>
<Image source={{ uri: detail.poster }} style={styles.poster} />
<View style={styles.infoContainer}>
<ThemedText style={styles.title} numberOfLines={1}>
{detail.title}
</ThemedText>
<View style={styles.titleContainer}>
<ThemedText style={styles.title} numberOfLines={1} ellipsizeMode="tail">
{detail.title}
</ThemedText>
<StyledButton onPress={toggleFavorite} variant="ghost" style={styles.favoriteButton}>
<FontAwesome
name={isFavorited ? "heart" : "heart-o"}
size={24}
color={isFavorited ? "#feff5f" : "#ccc"}
/>
</StyledButton>
</View>
<View style={styles.metaContainer}>
<ThemedText style={styles.metaText}>{detail.year}</ThemedText>
<ThemedText style={styles.metaText}>{detail.type_name}</ThemedText>
</View>
<ScrollView style={styles.descriptionScrollView}>
<ThemedText style={styles.description}>{detail.desc}</ThemedText>
</ScrollView>
@@ -185,29 +107,32 @@ export default function DetailScreen() {
{!allSourcesLoaded && <ActivityIndicator style={{ marginLeft: 10 }} />}
</View>
<View style={styles.sourceList}>
{searchResults.map((item, index) => (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={detail?.source === item.source}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#28a745" }]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
))}
{searchResults.map((item, index) => {
const isSelected = detail?.source === item.source;
return (
<StyledButton
key={index}
onPress={() => setDetail(item)}
hasTVPreferredFocus={index === 0}
isSelected={isSelected}
style={styles.sourceButton}
>
<ThemedText style={styles.sourceButtonText}>{item.source_name}</ThemedText>
{item.episodes.length > 1 && (
<View style={[styles.badge, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>
{item.episodes.length > 99 ? "99+" : `${item.episodes.length}`}
</Text>
</View>
)}
{item.resolution && (
<View style={[styles.badge, { backgroundColor: "#666" }, isSelected && styles.selectedBadge]}>
<Text style={styles.badgeText}>{item.resolution}</Text>
</View>
)}
</StyledButton>
);
})}
</View>
</View>
<View style={styles.episodesContainer}>
@@ -217,7 +142,7 @@ export default function DetailScreen() {
<StyledButton
key={index}
style={styles.episodeButton}
onPress={() => handlePlay(episode, index)}
onPress={() => handlePlay(index)}
text={`${index + 1}`}
textStyle={styles.episodeButtonText}
/>
@@ -251,11 +176,15 @@ const styles = StyleSheet.create({
marginLeft: 20,
justifyContent: "flex-start",
},
titleContainer: {
flexDirection: "row",
alignItems: "center",
},
title: {
paddingTop: 16,
fontSize: 28,
fontWeight: "bold",
marginBottom: 10,
paddingTop: 20,
flexShrink: 1,
},
metaContainer: {
flexDirection: "row",
@@ -274,6 +203,15 @@ const styles = StyleSheet.create({
color: "#ccc",
lineHeight: 22,
},
favoriteButton: {
padding: 10,
marginLeft: 10,
backgroundColor: "transparent",
},
favoriteButtonText: {
marginLeft: 8,
fontSize: 16,
},
bottomContainer: {
paddingHorizontal: 20,
},
@@ -301,16 +239,23 @@ const styles = StyleSheet.create({
fontSize: 16,
},
badge: {
backgroundColor: "red",
backgroundColor: "#666",
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 2,
marginLeft: 8,
},
badgeText: {
color: "white",
color: "#fff",
fontSize: 12,
fontWeight: "bold",
paddingBottom: 2.5,
},
selectedBadge: {
backgroundColor: "#4c4c4c",
},
selectedbadgeText: {
color: "#333",
},
episodesContainer: {
marginTop: 20,

77
app/favorites.tsx Normal file
View File

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

View File

@@ -5,18 +5,21 @@ 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 { Search, Settings, LogOut, Heart } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
import useHomeStore, { RowItem, Category } from "@/stores/homeStore";
import useAuthStore from "@/stores/authStore";
import CustomScrollView from "@/components/CustomScrollView";
const NUM_COLUMNS = 5;
const { width } = Dimensions.get("window");
const ITEM_WIDTH = width / NUM_COLUMNS - 24;
// Threshold for triggering load more data (in pixels)
const LOAD_MORE_THRESHOLD = 200;
export default function HomeScreen() {
const router = useRouter();
const colorScheme = "dark";
const flatListRef = useRef<FlatList>(null);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const {
@@ -31,6 +34,7 @@ export default function HomeScreen() {
selectCategory,
refreshPlayRecords,
} = useHomeStore();
const { isLoggedIn, logout } = useAuthStore();
useFocusEffect(
useCallback(() => {
@@ -41,7 +45,6 @@ export default function HomeScreen() {
useEffect(() => {
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];
@@ -53,7 +56,6 @@ export default function HomeScreen() {
useEffect(() => {
if (selectedCategory && selectedCategory.tag) {
fetchInitialData();
flatListRef.current?.scrollToOffset({ animated: false, offset: 0 });
}
}, [fetchInitialData, selectedCategory, selectedCategory.tag]);
@@ -84,7 +86,7 @@ export default function HomeScreen() {
);
};
const renderContentItem = ({ item }: { item: RowItem }) => (
const renderContentItem = ({ item, index }: { item: RowItem; index: number }) => (
<View style={styles.itemContainer}>
<VideoCard
id={item.id}
@@ -122,6 +124,9 @@ export default function HomeScreen() {
</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" })}
@@ -132,6 +137,11 @@ export default function HomeScreen() {
<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>
@@ -186,21 +196,17 @@ export default function HomeScreen() {
</ThemedText>
</View>
) : (
<FlatList
ref={flatListRef}
<CustomScrollView
data={contentData}
renderItem={renderContentItem}
keyExtractor={(item, index) => `${item.source}-${item.id}-${index}`}
numColumns={NUM_COLUMNS}
contentContainerStyle={styles.listContent}
loading={loading}
loadingMore={loadingMore}
error={error}
onEndReached={loadMoreData}
onEndReachedThreshold={0.5}
loadMoreThreshold={LOAD_MORE_THRESHOLD}
emptyMessage={selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}
ListFooterComponent={renderFooter}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText>{selectedCategory?.tags ? "请选择一个子分类" : "该分类下暂无内容"}</ThemedText>
</View>
}
/>
)}
</ThemedView>
@@ -236,9 +242,7 @@ const styles = StyleSheet.create({
alignItems: "center",
},
searchButton: {
padding: 10,
borderRadius: 30,
marginLeft: 10,
},
// Category Selector
categoryContainer: {
@@ -264,7 +268,6 @@ const styles = StyleSheet.create({
},
itemContainer: {
margin: 8,
width: ITEM_WIDTH,
alignItems: "center",
},
});

View File

@@ -106,7 +106,7 @@ export default function LiveScreen() {
<View style={styles.groupColumn}>
<FlatList
data={channelGroups}
keyExtractor={(item) => item}
keyExtractor={(item, index) => `group-${item}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item}
@@ -124,7 +124,7 @@ export default function LiveScreen() {
) : (
<FlatList
data={groupedChannels[selectedGroup] || []}
keyExtractor={(item) => item.id}
keyExtractor={(item, index) => `${item.id}-${item.group}-${index}`}
renderItem={({ item }) => (
<StyledButton
text={item.name || "Unknown Channel"}
@@ -190,6 +190,8 @@ const styles = StyleSheet.create({
paddingVertical: 8,
paddingHorizontal: 4,
marginVertical: 4,
paddingLeft: 10,
paddingRight: 10,
},
groupButtonText: {
fontSize: 13,
@@ -198,6 +200,8 @@ const styles = StyleSheet.create({
paddingVertical: 6,
paddingHorizontal: 4,
marginVertical: 3,
paddingLeft: 16,
paddingRight: 16,
},
channelItemText: {
fontSize: 12,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from "react";
import { StyleSheet, TouchableOpacity, ActivityIndicator, BackHandler } from "react-native";
import { StyleSheet, TouchableOpacity, BackHandler, AppState, AppStateStatus, View } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { Video, ResizeMode } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
@@ -8,52 +8,76 @@ import { PlayerControls } from "@/components/PlayerControls";
import { EpisodeSelectionModal } from "@/components/EpisodeSelectionModal";
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 { NextEpisodeOverlay } from "@/components/NextEpisodeOverlay";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import useDetailStore from "@/stores/detailStore";
import { useTVRemoteHandler } from "@/hooks/useTVRemoteHandler";
import Toast from "react-native-toast-message";
import usePlayerStore, { selectCurrentEpisode } from "@/stores/playerStore";
export default function PlayScreen() {
const 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,
// showNextEpisodeOverlay,
initialPosition,
introEndTime,
setVideoRef,
loadVideo,
handlePlaybackStatusUpdate,
setShowControls,
setShowEpisodeModal,
setShowSourceModal,
setShowNextEpisodeOverlay,
// 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();
@@ -70,39 +94,43 @@ 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) {
return (
<ThemedView style={[styles.container, styles.centered]}>
<ActivityIndicator size="large" color="#fff" />
</ThemedView>
);
useEffect(() => {
let timeoutId: NodeJS.Timeout | null = null;
if (isLoading) {
timeoutId = setTimeout(() => {
if (usePlayerStore.getState().isLoading) {
usePlayerStore.setState({ isLoading: false });
Toast.show({ type: "error", text1: "播放超时,请重试" });
}
}, 60000); // 1 minute
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [isLoading]);
if (!detail) {
return <VideoLoadingAnimation showProgressBar />;
}
const currentEpisode = episodes[currentEpisodeIndex];
return (
<ThemedView focusable style={styles.container}>
<TouchableOpacity activeOpacity={1} style={styles.videoContainer} onPress={onScreenPress}>
<Video
ref={videoRef}
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }}
usePoster
posterSource={{ uri: detail?.videoInfo.cover ?? "" }}
source={{ uri: currentEpisode?.url || "" }}
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);
}
@@ -117,9 +145,13 @@ export default function PlayScreen() {
<SeekingBar />
<LoadingOverlay visible={isLoading} />
{isLoading && (
<View style={styles.videoContainer}>
<VideoLoadingAnimation showProgressBar />
</View>
)}
<NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} />
{/* <NextEpisodeOverlay visible={showNextEpisodeOverlay} onCancel={() => setShowNextEpisodeOverlay(false)} /> */}
</TouchableOpacity>
<EpisodeSelectionModal />

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from "react";
import { View, TextInput, StyleSheet, FlatList, ActivityIndicator, Alert, Keyboard } from "react-native";
import { View, TextInput, StyleSheet, Alert, Keyboard, TouchableOpacity, Pressable } from "react-native";
import { ThemedView } from "@/components/ThemedView";
import { ThemedText } from "@/components/ThemedText";
import VideoCard from "@/components/VideoCard.tv";
import VideoLoadingAnimation from "@/components/VideoLoadingAnimation";
import { api, SearchResult } from "@/services/api";
import { Search, QrCode } from "lucide-react-native";
import { StyledButton } from "@/components/StyledButton";
@@ -10,6 +11,8 @@ import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { RemoteControlModal } from "@/components/RemoteControlModal";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRouter } from "expo-router";
import { Colors } from "@/constants/Colors";
import CustomScrollView from "@/components/CustomScrollView";
export default function SearchScreen() {
const [keyword, setKeyword] = useState("");
@@ -33,13 +36,13 @@ export default function SearchScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastMessage]);
useEffect(() => {
// Focus the text input when the screen loads
const timer = setTimeout(() => {
textInputRef.current?.focus();
}, 200);
return () => clearTimeout(timer);
}, []);
// useEffect(() => {
// // Focus the text input when the screen loads
// const timer = setTimeout(() => {
// textInputRef.current?.focus();
// }, 200);
// return () => clearTimeout(timer);
// }, []);
const handleSearch = async (searchText?: string) => {
const term = typeof searchText === "string" ? searchText : keyword;
@@ -59,7 +62,7 @@ export default function SearchScreen() {
}
} catch (err) {
setError("搜索失败,请稍后重试。");
console.error("Search failed:", err);
console.info("Search failed:", err);
} finally {
setLoading(false);
}
@@ -78,7 +81,7 @@ export default function SearchScreen() {
showRemoteModal();
};
const renderItem = ({ item }: { item: SearchResult }) => (
const renderItem = ({ item, index }: { item: SearchResult; index: number }) => (
<VideoCard
id={item.id.toString()}
source={item.source}
@@ -93,25 +96,36 @@ export default function SearchScreen() {
return (
<ThemedView style={styles.container}>
<View style={styles.searchContainer}>
<TextInput
ref={textInputRef}
<TouchableOpacity
activeOpacity={1}
style={[
styles.input,
{
backgroundColor: colorScheme === "dark" ? "#2c2c2e" : "#f0f0f0",
color: colorScheme === "dark" ? "white" : "black",
borderColor: isInputFocused ? "#007bff" : "transparent",
borderColor: isInputFocused ? Colors.dark.primary : "transparent",
borderWidth: 2,
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
value={keyword}
onChangeText={setKeyword}
onPress={() => textInputRef.current?.focus()}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
>
<TextInput
ref={textInputRef}
style={[
styles.input,
{
color: colorScheme === "dark" ? "white" : "black",
},
]}
placeholder="搜索电影、剧集..."
placeholderTextColor={colorScheme === "dark" ? "#888" : "#555"}
value={keyword}
onChangeText={setKeyword}
onSubmitEditing={onSearchPress}
returnKeyType="search"
/>
</TouchableOpacity>
<StyledButton style={styles.searchButton} onPress={onSearchPress}>
<Search size={24} color={colorScheme === "dark" ? "white" : "black"} />
</StyledButton>
@@ -121,25 +135,19 @@ export default function SearchScreen() {
</View>
{loading ? (
<View style={styles.centerContainer}>
<ActivityIndicator size="large" />
</View>
<VideoLoadingAnimation showProgressBar={false} />
) : error ? (
<View style={styles.centerContainer}>
<ThemedText style={styles.errorText}>{error}</ThemedText>
</View>
) : (
<FlatList
<CustomScrollView
data={results}
renderItem={renderItem}
keyExtractor={(item, index) => `${item.id}-${item.source}-${index}`}
numColumns={5} // Adjust based on your card size and desired layout
contentContainerStyle={styles.listContent}
ListEmptyComponent={
<View style={styles.centerContainer}>
<ThemedText></ThemedText>
</View>
}
numColumns={5}
loading={loading}
error={error}
emptyMessage="输入关键词开始搜索"
/>
)}
<RemoteControlModal />

View File

@@ -6,11 +6,12 @@ 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 { VideoSourceSection } from "@/components/settings/VideoSourceSection";
import Toast from "react-native-toast-message";
export default function SettingsScreen() {
@@ -109,18 +110,18 @@ export default function SettingsScreen() {
),
key: "livestream",
},
{
component: (
<VideoSourceSection
onChanged={markAsChanged}
onFocus={() => {
setCurrentFocusIndex(3);
setCurrentSection("videoSource");
}}
/>
),
key: "videoSource",
},
// {
// component: (
// <VideoSourceSection
// onChanged={markAsChanged}
// onFocus={() => {
// setCurrentFocusIndex(3);
// setCurrentSection("videoSource");
// }}
// />
// ),
// key: "videoSource",
// },
];
// TV遥控器事件处理

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from "react";
import { View, StyleSheet, Text, ActivityIndicator } from "react-native";
import { Video, ResizeMode, AVPlaybackStatus } from "expo-av";
import { useKeepAwake } from "expo-keep-awake";
interface LivePlayerProps {
streamUrl: string | null;
@@ -15,6 +16,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
const [isLoading, setIsLoading] = useState(false);
const [isTimeout, setIsTimeout] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useKeepAwake();
useEffect(() => {
if (timeoutRef.current) {
@@ -66,7 +68,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (!streamUrl) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Select a channel to play.</Text>
<Text style={styles.messageText}></Text>
</View>
);
}
@@ -74,7 +76,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
if (isTimeout) {
return (
<View style={styles.container}>
<Text style={styles.messageText}>Failed to load stream. It might be offline or unavailable.</Text>
<Text style={styles.messageText}></Text>
</View>
);
}
@@ -98,7 +100,7 @@ export default function LivePlayer({ streamUrl, channelTitle, onPlaybackStatusUp
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator size="large" color="#fff" />
<Text style={styles.messageText}>Loading...</Text>
<Text style={styles.messageText}>...</Text>
</View>
)}
{channelTitle && !isLoading && !isTimeout && (

170
components/LoginModal.tsx Normal file
View File

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

View File

@@ -5,6 +5,8 @@ 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;
@@ -13,9 +15,8 @@ interface PlayerControlsProps {
export const PlayerControls: React.FC<PlayerControlsProps> = ({ showControls, setShowControls }) => {
const {
detail,
currentEpisodeIndex,
currentSourceIndex,
episodes,
status,
isSeeking,
seekPosition,
@@ -30,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";
@@ -164,7 +168,7 @@ const styles = StyleSheet.create({
position: "absolute",
left: 0,
height: 8,
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 4,
},
progressBarTouchable: {

View File

@@ -80,7 +80,7 @@ const styles = StyleSheet.create({
},
seekingBarFilled: {
height: "100%",
backgroundColor: "#ff0000",
backgroundColor: "#fff",
borderRadius: 2.5,
},
});

View File

@@ -1,14 +1,28 @@
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, loadVideo, currentEpisodeIndex, status } = usePlayerStore();
const { searchResults, detail, setDetail } = useDetailStore();
const onSelectSource = (index: number) => {
if (index !== currentSourceIndex) {
switchSource(index);
console.log("onSelectSource", index, searchResults[index].source, detail?.source);
if (searchResults[index].source !== detail?.source) {
const newDetail = searchResults[index];
setDetail(newDetail);
// Reload the video with the new source, preserving current position
const currentPosition = status?.isLoaded ? status.positionMillis : undefined;
loadVideo({
source: newDetail.source,
id: newDetail.id.toString(),
episodeIndex: currentEpisodeIndex,
title: newDetail.title,
position: currentPosition
});
}
setShowSourceModal(false);
};
@@ -23,16 +37,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}
/>
@@ -70,7 +84,9 @@ const styles = StyleSheet.create({
sourceItem: {
paddingVertical: 2,
margin: 4,
width: "31%",
marginLeft: 10,
marginRight: 8,
width: "30%",
},
sourceItemText: {
fontSize: 14,

View File

@@ -1,5 +1,5 @@
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/useAnimation";
@@ -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.primary,
},
focusedButton: {
borderColor: colors.primary,
},
selectedText: {
color: Colors.dark.text,
},
}),
primary: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: colors.primary,
borderColor: colors.background,
},
selectedButton: {
backgroundColor: colors.primary,
},
selectedText: {
color: colors.link,
},
}),
ghost: StyleSheet.create({
button: {
backgroundColor: "transparent",
},
text: {
color: colors.text,
},
focusedButton: {
backgroundColor: "rgba(119, 119, 119, 0.2)",
borderColor: colors.primary,
},
selectedButton: {},
selectedText: {},
}),
};
const styles = StyleSheet.create({
button: {
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

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

View File

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

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface APIConfigSectionProps {
onChanged: () => void;
@@ -75,7 +76,7 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
style={[styles.input, isInputFocused && styles.inputFocused]}
value={apiBaseUrl}
onChangeText={handleUrlChange}
placeholder="输入 API 地址"
placeholder="输入服务器地址"
placeholderTextColor="#888"
autoCapitalize="none"
autoCorrect={false}
@@ -126,8 +127,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface LiveStreamSectionProps {
onChanged: () => void;
@@ -120,8 +121,8 @@ const styles = StyleSheet.create({
borderColor: "transparent",
},
inputFocused: {
borderColor: "#007AFF",
shadowColor: "#007AFF",
borderColor: Colors.dark.primary,
shadowColor: Colors.dark.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 10,

View File

@@ -6,6 +6,7 @@ import { SettingsSection } from "./SettingsSection";
import { useSettingsStore } from "@/stores/settingsStore";
import { useRemoteControlStore } from "@/stores/remoteControlStore";
import { useButtonAnimation } from "@/hooks/useAnimation";
import { Colors } from "@/constants/Colors";
interface RemoteInputSectionProps {
onChanged: () => void;
@@ -59,7 +60,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<Switch
value={remoteInputEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
trackColor={{ false: "#767577", true: "#007AFF" }}
trackColor={{ false: "#767577", true: Colors.dark.primary }}
thumbColor={remoteInputEnabled ? "#ffffff" : "#f4f3f4"}
pointerEvents="none"
/>
@@ -70,7 +71,7 @@ export const RemoteInputSection: React.FC<RemoteInputSectionProps> = ({ onChange
<View style={styles.statusContainer}>
<View style={styles.statusItem}>
<ThemedText style={styles.statusLabel}></ThemedText>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? "#00FF00" : "#FF6B6B" }]}>
<ThemedText style={[styles.statusValue, { color: isServerRunning ? Colors.dark.primary : "#FF6B6B" }]}>
{isServerRunning ? "运行中" : "已停止"}
</ThemedText>
</View>

View File

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

View File

@@ -1,10 +1,10 @@
import React, { useState, useEffect, useCallback } from "react";
import { StyleSheet, View, Switch, ActivityIndicator, FlatList, Pressable, Animated } from "react-native";
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 { api, ApiSite } from "@/services/api";
import { useSettingsStore } from "@/stores/settingsStore";
import useSourceStore, { useSources } from "@/stores/sourceStore";
interface VideoSourceSectionProps {
onChanged: () => void;
@@ -13,56 +13,18 @@ interface VideoSourceSectionProps {
}
export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChanged, onFocus, onBlur }) => {
const [resources, setResources] = useState<ApiSite[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [isSectionFocused, setIsSectionFocused] = useState(false);
const { videoSource, setVideoSource } = useSettingsStore();
const { videoSource } = useSettingsStore();
const resources = useSources();
const { toggleResourceEnabled } = useSourceStore();
useEffect(() => {
fetchResources();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchResources = async () => {
try {
setLoading(true);
const resourcesList = await api.getResources();
setResources(resourcesList);
if (videoSource.enabledAll && Object.keys(videoSource.sources).length === 0) {
const allResourceKeys: { [key: string]: boolean } = {};
for (const resource of resourcesList) {
allResourceKeys[resource.key] = true;
}
setVideoSource({
enabledAll: true,
sources: allResourceKeys,
});
}
} catch (err) {
setError("获取播放源失败");
console.error("Failed to fetch resources:", err);
} finally {
setLoading(false);
}
};
const toggleResourceEnabled = useCallback(
const handleToggle = useCallback(
(resourceKey: string) => {
const isEnabled = videoSource.sources[resourceKey];
const newEnabledSources = { ...videoSource.sources, [resourceKey]: !isEnabled };
setVideoSource({
enabledAll: Object.values(newEnabledSources).every((enabled) => enabled),
sources: newEnabledSources,
});
toggleResourceEnabled(resourceKey);
onChanged();
},
[videoSource.sources, setVideoSource, onChanged]
[onChanged, toggleResourceEnabled]
);
const handleSectionFocus = () => {
@@ -83,20 +45,20 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
if (focusedIndex !== null) {
const resource = resources[focusedIndex];
if (resource) {
toggleResourceEnabled(resource.key);
handleToggle(resource.source);
}
} else if (isSectionFocused) {
setFocusedIndex(0);
}
}
},
[isSectionFocused, focusedIndex, resources, toggleResourceEnabled]
[isSectionFocused, focusedIndex, resources, handleToggle]
);
useTVEventHandler(handleTVEvent);
const renderResourceItem = ({ item, index }: { item: ApiSite; index: number }) => {
const isEnabled = videoSource.enabledAll || videoSource.sources[item.key];
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 (
@@ -107,7 +69,7 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
>
<ThemedText style={styles.resourceName}>{item.name}</ThemedText>
<ThemedText style={styles.resourceName}>{item.source_name}</ThemedText>
<Switch
value={isEnabled}
onValueChange={() => {}} // 禁用Switch的直接交互
@@ -124,20 +86,11 @@ export const VideoSourceSection: React.FC<VideoSourceSectionProps> = ({ onChange
<SettingsSection focusable onFocus={handleSectionFocus} onBlur={handleSectionBlur}>
<ThemedText style={styles.sectionTitle}></ThemedText>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" />
<ThemedText style={styles.loadingText}>...</ThemedText>
</View>
)}
{error && <ThemedText style={styles.errorText}>{error}</ThemedText>}
{!loading && !error && resources.length > 0 && (
{resources.length > 0 && (
<FlatList
data={resources}
renderItem={renderResourceItem}
keyExtractor={(item) => item.key}
keyExtractor={(item) => item.source}
numColumns={3}
columnWrapperStyle={styles.row}
contentContainerStyle={styles.flatListContainer}
@@ -154,22 +107,6 @@ const styles = StyleSheet.create({
fontWeight: "bold",
marginBottom: 16,
},
loadingContainer: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
padding: 16,
},
loadingText: {
marginLeft: 8,
color: "#888",
},
errorText: {
color: "#ff4444",
fontSize: 14,
textAlign: "center",
padding: 16,
},
flatListContainer: {
gap: 12,
},

View File

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

View File

@@ -1,284 +0,0 @@
# 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)

View File

@@ -1,305 +0,0 @@
# 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

@@ -1,136 +0,0 @@
# 手机遥控功能实现方案 (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

@@ -1,131 +0,0 @@
# 设置页面重构方案
## 目标
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,77 +0,0 @@
# StyledButton 组件设计文档
## 1. 目的
为了统一整个应用中的按钮样式和行为,减少代码重复,并提高开发效率和一致性,我们设计了一个通用的 `StyledButton` 组件。
该组件将取代以下位置的自定义 `Pressable``TouchableOpacity` 实现:
- `app/index.tsx` (分类按钮, 头部图标按钮)
- `components/DetailButton.tsx`
- `components/EpisodeSelectionModal.tsx` (剧集分组按钮, 剧集项按钮, 关闭按钮)
- `components/SettingsModal.tsx` (取消和保存按钮)
- `app/search.tsx` (清除按钮)
- `components/MediaButton.tsx` (媒体控制按钮)
- `components/NextEpisodeOverlay.tsx` (取消按钮)
## 2. API 设计
`StyledButton` 组件将基于 React Native 的 `Pressable` 构建,并提供以下 props
```typescript
import { PressableProps, StyleProp, ViewStyle, TextStyle } from "react-native";
interface StyledButtonProps extends PressableProps {
// 按钮的主要内容,可以是文本或图标等 React 节点
children?: React.ReactNode;
// 如果按钮只包含文本,可以使用此 prop 快速设置
text?: string;
// 按钮的视觉变体,用于应用不同的预设样式
// 'default': 默认灰色背景
// 'primary': 主题色背景,用于关键操作
// 'ghost': 透明背景,通常用于图标按钮
variant?: "default" | "primary" | "ghost";
// 按钮是否处于选中状态
isSelected?: boolean;
// 覆盖容器的样式
style?: StyleProp<ViewStyle>;
// 覆盖文本的样式 (当使用 `text` prop 时生效)
textStyle?: StyleProp<TextStyle>;
}
```
## 3. 样式和行为
### 状态样式:
- **默认状态 (`default`)**:
- 背景色: `#333`
- 边框: `transparent`
- **聚焦状态 (`focused`)**:
- 背景色: `#0056b3` (深蓝色)
- 边框: `#fff`
- 阴影/光晕效果
- 轻微放大 (`transform: scale(1.1)`)
- **选中状态 (`isSelected`)**:
- 背景色: `#007AFF` (亮蓝色)
- **主操作 (`primary`)**:
- 默认背景色: `#007AFF`
- **透明背景 (`ghost`)**:
- 默认背景色: `transparent`
### 结构:
组件内部将使用 `Pressable` 作为根元素,并根据 `focused``isSelected` props 动态计算样式。如果 `children``text` prop 都提供了,`children` 将优先被渲染。
## 4. 实现计划
1. **创建 `components/StyledButton.tsx` 文件**
2. **实现上述 API 和样式逻辑**
3. **逐个重构目标文件**,将原有的 `Pressable`/`TouchableOpacity` 替换为新的 `StyledButton` 组件。
4. **删除旧的、不再需要的样式**
5. **测试所有被修改的界面**,确保按钮的功能和视觉效果符合预期。

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.2",
"version": "1.2.9",
"scripts": {
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
@@ -29,12 +29,14 @@
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.3.2",
"@react-native-cookies/cookies": "^6.2.1",
"@react-navigation/native": "^6.0.2",
"expo": "~51.0.13",
"expo-av": "~14.0.7",
"expo-build-properties": "~0.12.3",
"expo-constants": "~16.0.2",
"expo-font": "~12.0.7",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-router": "~3.5.16",
"expo-splash-screen": "~0.27.5",
@@ -78,4 +80,4 @@
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 731 KiB

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 672 KiB

View File

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

View File

@@ -16,23 +16,37 @@ export const parseM3U = (m3uText: string): Channel[] => {
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('#EXTINF:')) {
currentChannelInfo = { id: '', name: '', url: '', logo: '', group: '' };
const commaIndex = trimmedLine.indexOf(',');
currentChannelInfo = {}; // Start a new channel
const commaIndex = trimmedLine.lastIndexOf(',');
if (commaIndex !== -1) {
currentChannelInfo.name = trimmedLine.substring(commaIndex + 1).trim();
const attributesPart = trimmedLine.substring(8, commaIndex);
const logoMatch = attributesPart.match(/tvg-logo="([^"]*)"/i);
if (logoMatch && logoMatch[1]) currentChannelInfo.logo = logoMatch[1];
if (logoMatch && logoMatch[1]) {
currentChannelInfo.logo = logoMatch[1];
}
const groupMatch = attributesPart.match(/group-title="([^"]*)"/i);
if (groupMatch && groupMatch[1]) currentChannelInfo.group = groupMatch[1];
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;
// Ensure all required fields are present, providing defaults if necessary
const finalChannel: Channel = {
id: currentChannelInfo.id,
url: currentChannelInfo.url,
name: currentChannelInfo.name || 'Unknown',
logo: currentChannelInfo.logo || '',
group: currentChannelInfo.group || 'Default',
};
parsedChannels.push(finalChannel);
currentChannelInfo = null; // Reset for the next channel
}
}
return parsedChannels;
@@ -47,7 +61,7 @@ export const fetchAndParseM3u = async (m3uUrl: string): Promise<Channel[]> => {
const m3uText = await response.text();
return parseM3U(m3uText);
} catch (error) {
console.error("Error fetching or parsing M3U:", error);
console.info("Error fetching or parsing M3U:", error);
return []; // Return empty array on error
}
};
@@ -57,14 +71,14 @@ export const getPlayableUrl = (originalUrl: string | null): string | null => {
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)}`;
}
// 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

@@ -1,4 +1,4 @@
import TCPHttpServer from './tcpHttpServer';
import TCPHttpServer from "./tcpHttpServer";
const getRemotePageHTML = () => {
return `
@@ -6,6 +6,7 @@ const getRemotePageHTML = () => {
<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; }
@@ -24,7 +25,7 @@ const getRemotePageHTML = () => {
</div>
<script>
window.addEventListener('DOMContentLoaded', () => {
fetch('/handshake', { method: 'POST' }).catch(console.error);
fetch('/handshake', { method: 'POST' }).catch(console.info);
});
function send() {
const input = document.getElementById("text");
@@ -35,7 +36,7 @@ const getRemotePageHTML = () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: value })
})
.catch(err => console.error(err));
.catch(err => console.info(err));
input.value = '';
}
}
@@ -57,55 +58,55 @@ class RemoteControlService {
private setupRequestHandler() {
this.httpServer.setRequestHandler((request) => {
console.log('[RemoteControl] Received request:', request.method, request.url);
console.log("[RemoteControl] Received request:", request.method, request.url);
try {
if (request.method === 'GET' && request.url === '/') {
if (request.method === "GET" && request.url === "/") {
return {
statusCode: 200,
headers: { 'Content-Type': 'text/html' },
body: getRemotePageHTML()
headers: { "Content-Type": "text/html; charset=utf-8" },
body: getRemotePageHTML(),
};
} else if (request.method === 'POST' && request.url === '/message') {
} else if (request.method === "POST" && request.url === "/message") {
try {
const parsedBody = JSON.parse(request.body || '{}');
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' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} catch (parseError) {
console.error('[RemoteControl] Failed to parse message body:', parseError);
console.info("[RemoteControl] Failed to parse message body:", parseError);
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Invalid JSON' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Invalid JSON" }),
};
}
} else if (request.method === 'POST' && request.url === '/handshake') {
} else if (request.method === "POST" && request.url === "/handshake") {
this.onHandshake();
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'ok' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "ok" }),
};
} else {
return {
statusCode: 404,
headers: { 'Content-Type': 'text/plain' },
body: 'Not Found'
headers: { "Content-Type": "text/plain" },
body: "Not Found",
};
}
} catch (error) {
console.error('[RemoteControl] Request handler error:', error);
console.info("[RemoteControl] Request handler error:", error);
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'Internal Server Error' })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ error: "Internal Server Error" }),
};
}
});
@@ -117,20 +118,20 @@ class RemoteControlService {
}
public async startServer(): Promise<string> {
console.log('[RemoteControl] Attempting to start server...');
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.error('[RemoteControl] Failed to start server:', error);
throw new Error(error instanceof Error ? error.message : 'Failed to start server');
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...');
console.log("[RemoteControl] Stopping server...");
this.httpServer.stop();
}
@@ -139,4 +140,4 @@ class RemoteControlService {
}
}
export const remoteControlService = new RemoteControlService();
export const remoteControlService = new RemoteControlService();

View File

@@ -1,27 +1,27 @@
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 FavoriteItem {
id: string;
source: string;
title: string;
poster: string;
source_name: string;
save_time: number;
export interface PlayerSettings {
introEndTime?: number;
outroStartTime?: number;
}
export interface AppSettings {
@@ -32,59 +32,106 @@ export interface AppSettings {
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);
@@ -96,90 +143,134 @@ 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 = {
@@ -189,15 +280,13 @@ export class SettingsManager {
enabledAll: true,
sources: {},
},
m3uUrl: "https://ghfast.top/https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u",
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;
}
}
@@ -205,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;
},
};

View File

@@ -59,7 +59,7 @@ class TCPHttpServer {
return { method, url, headers, body };
} catch (error) {
console.error('[TCPHttpServer] Error parsing HTTP request:', error);
console.info('[TCPHttpServer] Error parsing HTTP request:', error);
return null;
}
}
@@ -140,7 +140,7 @@ class TCPHttpServer {
socket.write(errorResponse);
}
} catch (error) {
console.error('[TCPHttpServer] Error handling request:', error);
console.info('[TCPHttpServer] Error handling request:', error);
const errorResponse = this.formatHttpResponse({
statusCode: 500,
headers: { 'Content-Type': 'text/plain' },
@@ -155,7 +155,7 @@ class TCPHttpServer {
});
socket.on('error', (error: Error) => {
console.error('[TCPHttpServer] Socket error:', error);
console.info('[TCPHttpServer] Socket error:', error);
});
socket.on('close', () => {
@@ -170,13 +170,13 @@ class TCPHttpServer {
});
this.server.on('error', (error: Error) => {
console.error('[TCPHttpServer] Server error:', error);
console.info('[TCPHttpServer] Server error:', error);
this.isRunning = false;
reject(error);
});
} catch (error) {
console.error('[TCPHttpServer] Failed to start server:', error);
console.info('[TCPHttpServer] Failed to start server:', error);
reject(error);
}
});

66
stores/authStore.ts Normal file
View File

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

186
stores/detailStore.ts Normal file
View File

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

32
stores/favoritesStore.ts Normal file
View File

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

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,18 +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', tags: [ '国产剧', '美剧', '英剧', '韩剧', '日剧', '港剧', '日本动画', '动画'] },
{ title: '电影', type: 'movie', tags: ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '爱情', '科幻', '悬疑', '恐怖'] },
{ title: '综艺', type: 'tv', tag: '综艺' },
{ title: '豆瓣 Top250', type: 'movie', tag: 'top250' },
{ 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 {
@@ -59,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();
},
@@ -72,28 +96,44 @@ 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,
@@ -106,10 +146,10 @@ const useHomeStore = create<HomeState>((set, get) => ({
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: "请点击右上角设置按钮,配置您的服务器地址" });
} else {
set({ error: '加载失败,请重试' });
set({ error: "加载失败,请重试" });
}
} finally {
set({ loading: false, loadingMore: false });
@@ -126,26 +166,41 @@ const useHomeStore = create<HomeState>((set, get) => ({
},
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,13 @@ 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;
@@ -51,17 +47,15 @@ interface PlayerState {
setOutroStartTime: () => void;
reset: () => void;
_seekTimeout?: NodeJS.Timeout;
_isRecordSaveThrottled: boolean;
// Internal helper
_savePlayRecord: (updates?: Partial<PlayRecord>) => void;
_savePlayRecord: (updates?: Partial<PlayRecord>, options?: { immediate?: boolean }) => void;
}
const usePlayerStore = create<PlayerState>((set, get) => ({
videoRef: null,
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
currentEpisodeIndex: -1,
status: null,
isLoading: true,
showControls: false,
@@ -75,75 +69,49 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
introEndTime: undefined,
outroStartTime: undefined,
_seekTimeout: undefined,
_isRecordSaveThrottled: false,
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 +121,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,13 +171,14 @@ 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) {
// Clear the time
set({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined });
get()._savePlayRecord({ introEndTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片头时间",
@@ -203,7 +187,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
// Set the time
const newIntroEndTime = status.positionMillis;
set({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime });
get()._savePlayRecord({ introEndTime: newIntroEndTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
@@ -213,22 +197,24 @@ 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) {
// Clear the time
set({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined });
get()._savePlayRecord({ outroStartTime: undefined }, { immediate: true });
Toast.show({
type: "info",
text1: "已清除片尾时间",
});
} 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 });
get()._savePlayRecord({ outroStartTime: newOutroStartTime }, { immediate: true });
Toast.show({
type: "success",
text1: "设置成功",
@@ -237,22 +223,34 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
}
},
_savePlayRecord: (updates = {}) => {
const { detail, currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
_savePlayRecord: (updates = {}, options = {}) => {
const { immediate = false } = options;
if (!immediate) {
if (get()._isRecordSaveThrottled) {
return;
}
set({ _isRecordSaveThrottled: true });
setTimeout(() => {
set({ _isRecordSaveThrottled: false });
}, 10000); // 10 seconds
}
const { detail } = useDetailStore.getState();
const { currentEpisodeIndex, episodes, status, introEndTime, outroStartTime } = get();
if (detail && status?.isLoaded) {
const { 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 +260,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 +309,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
reset: () => {
set({
detail: null,
episodes: [],
sources: [],
currentSourceIndex: 0,
currentEpisodeIndex: 0,
status: null,
isLoading: true,
@@ -325,3 +325,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

@@ -41,7 +41,7 @@ export const useRemoteControlStore = create<RemoteControlState>((set, get) => ({
set({ isServerRunning: true, serverUrl: url, error: null });
} catch {
const errorMessage = '启动失败,请强制退应用后重试。';
console.error('[RemoteControlStore] Failed to start server:', errorMessage);
console.info('[RemoteControlStore] Failed to start server:', errorMessage);
set({ error: errorMessage });
}
},

View File

@@ -1,8 +1,7 @@
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;
@@ -15,29 +14,32 @@ interface SettingsState {
};
};
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;
setVideoSource: (config: { enabledAll: boolean; sources: { [key: string]: boolean } }) => void;
showModal: () => void;
hideModal: () => void;
}
export const useSettingsStore = create<SettingsState>((set, get) => ({
apiBaseUrl: '',
m3uUrl: 'https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u',
apiBaseUrl: "",
m3uUrl: "",
liveStreamSources: [],
remoteInputEnabled: false,
isModalVisible: false,
serverConfig: null,
videoSource: {
enabledAll: true,
sources: {},
},
loadSettings: async () => {
const settings = await SettingsManager.get();
set({
set({
apiBaseUrl: settings.apiBaseUrl,
m3uUrl: settings.m3uUrl,
remoteInputEnabled: settings.remoteInputEnabled || false,
@@ -47,6 +49,19 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
},
});
api.setBaseUrl(settings.apiBaseUrl);
await get().fetchServerConfig();
},
fetchServerConfig: async () => {
try {
const config = await api.getServerConfig();
if (config) {
storageConfig.setStorageType(config.StorageType);
set({ serverConfig: config });
}
} catch (error) {
set({ serverConfig: null });
console.info("Failed to fetch server config:", error);
}
},
setApiBaseUrl: (url) => set({ apiBaseUrl: url }),
setM3uUrl: (url) => set({ m3uUrl: url }),
@@ -54,16 +69,37 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
setVideoSource: (config) => set({ videoSource: config }),
saveSettings: async () => {
const { apiBaseUrl, m3uUrl, remoteInputEnabled, videoSource } = get();
await SettingsManager.save({
apiBaseUrl,
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(apiBaseUrl);
set({ isModalVisible: false });
useHomeStore.getState().fetchInitialData();
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;

File diff suppressed because it is too large Load Diff

View File

@@ -1762,6 +1762,13 @@
resolved "https://registry.yarnpkg.com/@react-native-community/netinfo/-/netinfo-11.4.1.tgz#a3c247aceab35f75dd0aa4bfa85d2be5a4508688"
integrity sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==
"@react-native-cookies/cookies@^6.2.1":
version "6.2.1"
resolved "https://registry.yarnpkg.com/@react-native-cookies/cookies/-/cookies-6.2.1.tgz#54d50b9496400bbdc19e43c155f70f8f918999e3"
integrity sha512-D17wCA0DXJkGJIxkL74Qs9sZ3sA+c+kCoGmXVknW7bVw/W+Vv1m/7mWTNi9DLBZSRddhzYw8SU0aJapIaM/g5w==
dependencies:
invariant "^2.2.4"
"@react-native-tvos/config-tv@^0.0.10":
version "0.0.10"
resolved "https://registry.yarnpkg.com/@react-native-tvos/config-tv/-/config-tv-0.0.10.tgz#38fe1571e24c6790b43137d130832c68b366c295"
@@ -4580,6 +4587,11 @@ expo-keep-awake@~13.0.2:
resolved "https://registry.yarnpkg.com/expo-keep-awake/-/expo-keep-awake-13.0.2.tgz#5ef31311a339671eec9921b934fdd90ab9652b0e"
integrity sha512-kKiwkVg/bY0AJ5q1Pxnm/GvpeB6hbNJhcFsoOWDh2NlpibhCLaHL826KHUM+WsnJRbVRxJ+K9vbPRHEMvFpVyw==
expo-linear-gradient@~13.0.2:
version "13.0.2"
resolved "https://registry.yarnpkg.com/expo-linear-gradient/-/expo-linear-gradient-13.0.2.tgz#21bd7bc7c71ef4f7c089521daa16db729d2aec5f"
integrity sha512-EDcILUjRKu4P1rtWcwciN6CSyGtH7Bq4ll3oTRV7h3h8oSzSilH1g6z7kTAMlacPBKvMnkkWOGzW6KtgMKEiTg==
expo-linking@~6.3.1:
version "6.3.1"
resolved "https://registry.yarnpkg.com/expo-linking/-/expo-linking-6.3.1.tgz#05aef8a42bd310391d0b00644be40d80ece038d9"
@@ -8797,7 +8809,16 @@ string-length@^5.0.1:
char-regex "^2.0.0"
strip-ansi "^7.0.1"
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -8888,7 +8909,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -8902,6 +8923,13 @@ strip-ansi@^5.0.0, strip-ansi@^5.2.0:
dependencies:
ansi-regex "^4.1.0"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^7.0.1:
version "7.1.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -9733,7 +9761,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -9751,6 +9779,15 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"