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.
This commit is contained in:
zimplexing
2025-07-14 16:21:28 +08:00
parent f06b10feec
commit 0452bfe21f
20 changed files with 941 additions and 497 deletions

View File

@@ -8,7 +8,7 @@ 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";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
@@ -58,6 +58,7 @@ export default function RootLayout() {
<Stack.Screen name="+not-found" />
</Stack>
<Toast />
<LoginModal />
</ThemeProvider>
);
}

View File

@@ -98,7 +98,7 @@ export default function PlayScreen() {
style={styles.videoPlayer}
source={{ uri: currentEpisode?.url }}
usePoster
posterSource={{ uri: detail?.videoInfo.cover ?? "" }}
posterSource={{ uri: detail?.videoInfo.poster ?? "" }}
resizeMode={ResizeMode.CONTAIN}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
onLoad={() => {

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,7 @@ const router = Router();
// Match m3u8 links
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
async function handleSpecialSourceDetail(
id: string,
apiSite: ApiSite
): Promise<VideoDetail> {
async function handleSpecialSourceDetail(id: string, apiSite: ApiSite): Promise<VideoDetail> {
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -30,8 +27,7 @@ async function handleSpecialSourceDetail(
let matches: string[] = [];
if (apiSite.key === "ffzy") {
const ffzyPattern =
/\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
matches = html.match(ffzyPattern) || [];
}
@@ -48,32 +44,22 @@ async function handleSpecialSourceDetail(
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : "";
const descMatch = html.match(
/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
);
const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
const coverUrl = coverMatch ? coverMatch[0].trim() : "";
return {
code: 200,
episodes: matches,
detailUrl,
videoInfo: {
title: titleText,
cover: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
id,
},
id,
title: titleText,
poster: coverUrl,
desc: descText,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getDetailFromApi(
apiSite: ApiSite,
id: string
): Promise<VideoDetail> {
async function getDetailFromApi(apiSite: ApiSite, id: string): Promise<VideoDetail> {
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
@@ -89,12 +75,7 @@ async function getDetailFromApi(
}
const data = await response.json();
if (
!data ||
!data.list ||
!Array.isArray(data.list) ||
data.list.length === 0
) {
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
throw new Error("获取到的详情内容无效");
}
@@ -111,10 +92,7 @@ async function getDetailFromApi(
const parts = ep.split("$");
return parts.length > 1 ? parts[1] : "";
})
.filter(
(url: string) =>
url && (url.startsWith("http://") || url.startsWith("https://"))
);
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
}
}
@@ -124,30 +102,22 @@ async function getDetailFromApi(
}
return {
code: 200,
episodes,
detailUrl,
videoInfo: {
title: videoDetail.vod_name,
cover: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
id,
},
id,
title: videoDetail.vod_name,
poster: videoDetail.vod_pic,
desc: cleanHtmlTags(videoDetail.vod_content),
type: videoDetail.type_name,
year: videoDetail.vod_year?.match(/\d{4}/)?.[0] || "",
area: videoDetail.vod_area,
director: videoDetail.vod_director,
actor: videoDetail.vod_actor,
remarks: videoDetail.vod_remarks,
source_name: apiSite.name,
source: apiSite.key,
};
}
async function getVideoDetail(
id: string,
sourceCode: string
): Promise<VideoDetail> {
async function getVideoDetail(id: string, sourceCode: string): Promise<VideoDetail> {
if (!id) {
throw new Error("缺少视频ID参数");
}

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import express, { Request, Response } from "express";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const password = process.env.PASSWORD;
/**
* @api {post} /api/login User Login
* @apiName UserLogin
* @apiGroup User
*
* @apiBody {String} password User's password.
*
* @apiSuccess {Boolean} ok Indicates if the login was successful.
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "ok": true
* }
*
* @apiError {String} message Error message.
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 400 Bad Request
* {
* "message": "Invalid password"
* }
*/
router.post("/login", (req: Request, res: Response) => {
if (!password) {
// If no password is set, login is always successful.
return res.json({ ok: true });
}
const { password: inputPassword } = req.body;
if (inputPassword === password) {
res.json({ ok: true });
} else {
res.status(400).json({ message: "Invalid password" });
}
});
export default router;

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import express, { Request, Response } from "express";
const router = express.Router();
/**
* @api {get} /api/server-config Get Server Configuration
* @apiName GetServerConfig
* @apiGroup Server
*
* @apiSuccess {String} SiteName The name of the site.
* @apiSuccess {String} StorageType The storage type used by the server ("localstorage").
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "SiteName": "OrionTV-Local",
* "StorageType": "localstorage"
* }
*/
router.get("/server-config", (req: Request, res: Response) => {
res.json({
SiteName: "OrionTV-Local",
StorageType: "localstorage",
});
});
export default router;

View File

@@ -2,7 +2,7 @@
export interface PlayRecord {
title: string;
source_name: string;
cover: string;
poster: string;
index: number; // Episode number
total_episodes: number; // Total number of episodes
play_time: number; // Play progress in seconds
@@ -13,21 +13,16 @@ export interface PlayRecord {
// You can add other shared types here
export interface VideoDetail {
code: number;
episodes: string[];
detailUrl: string;
videoInfo: {
title: string;
cover: string;
desc: string;
source_name: string;
source: string;
id: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
};
id: string;
title: string;
poster: string;
source: string;
source_name: string;
desc?: string;
type?: string;
year?: string;
area?: string;
director?: string;
actor?: string;
remarks?: string;
}

100
components/LoginModal.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React, { useState } from "react";
import { Modal, View, Text, TextInput, StyleSheet, ActivityIndicator } from "react-native";
import Toast from "react-native-toast-message";
import useAuthStore from "@/stores/authStore";
import { api } from "@/services/api";
import { ThemedView } from "./ThemedView";
import { ThemedText } from "./ThemedText";
import { StyledButton } from "./StyledButton";
const LoginModal = () => {
const { isLoginModalVisible, hideLoginModal } = useAuthStore();
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async () => {
if (!password) {
Toast.show({ type: "error", text1: "请输入密码" });
return;
}
setIsLoading(true);
try {
await api.login(password);
Toast.show({ type: "success", text1: "登录成功" });
hideLoginModal();
setPassword("");
} catch (error) {
Toast.show({ type: "error", text1: "登录失败", text2: "密码错误或服务器无法连接" });
} finally {
setIsLoading(false);
}
};
return (
<Modal transparent={true} visible={isLoginModalVisible} animationType="fade" onRequestClose={hideLoginModal}>
<View style={styles.overlay}>
<ThemedView style={styles.container}>
<ThemedText style={styles.title}></ThemedText>
<ThemedText style={styles.subtitle}></ThemedText>
<TextInput
style={styles.input}
placeholder="请输入密码"
placeholderTextColor="#888"
secureTextEntry
value={password}
onChangeText={setPassword}
autoFocus
/>
<StyledButton text={isLoading ? "" : "登录"} onPress={handleLogin} disabled={isLoading} style={styles.button}>
{isLoading && <ActivityIndicator color="#fff" />}
</StyledButton>
</ThemedView>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
justifyContent: "center",
alignItems: "center",
},
container: {
width: "80%",
maxWidth: 400,
padding: 24,
borderRadius: 12,
alignItems: "center",
},
title: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 8,
},
subtitle: {
fontSize: 16,
color: "#ccc",
marginBottom: 20,
textAlign: "center",
},
input: {
width: "100%",
height: 50,
backgroundColor: "#333",
borderRadius: 8,
paddingHorizontal: 16,
color: "#fff",
fontSize: 16,
marginBottom: 20,
borderWidth: 1,
borderColor: "#555",
},
button: {
width: "100%",
height: 50,
},
});
export default LoginModal;

313
docs/API.md Normal file
View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { SettingsManager } from "./storage";
import useAuthStore from "@/stores/authStore";
// region: --- Interface Definitions ---
export interface DoubanItem {
title: string;
poster: string;
@@ -13,23 +15,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 +42,22 @@ export interface SearchResult {
type_name?: string;
}
// Data structure for play records
export interface Favorite {
title: string;
poster: string;
source_name: 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
poster: string;
index: number;
total_episodes: number;
play_time: number;
total_time: number;
save_time: number;
}
export interface ApiSite {
@@ -65,6 +67,12 @@ export interface ApiSite {
detail?: string;
}
export interface ServerConfig {
SiteName: string;
StorageType: "localstorage" | "redis" | string;
}
// endregion
export class API {
public baseURL: string = "";
@@ -78,84 +86,142 @@ 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) {
useAuthStore.getState().showLoginModal();
throw new Error("UNAUTHORIZED");
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response;
}
// region: --- New API Methods ---
async getServerConfig(): Promise<ServerConfig> {
const response = await this._fetch("/api/server-config");
return response.json();
}
async login(password: string): Promise<{ ok: boolean }> {
const response = await this._fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
return response.json();
}
async getFavorites(key?: string): Promise<Record<string, Favorite> | Favorite | null> {
const url = key ? `/api/favorites?key=${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=${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=${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();
}
// endregion
// region: --- Existing API Methods (Refactored) ---
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();
}
// endregion
}
// 默认实例

View File

@@ -1,28 +1,18 @@
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";
// --- Storage Keys ---
const STORAGE_KEYS = {
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 interface FavoriteItem {
id: string;
source: string;
title: string;
poster: string;
source_name: string;
save_time: number;
}
};
export type Favorite = ApiFavorite;
export interface AppSettings {
apiBaseUrl: string;
@@ -32,59 +22,36 @@ export interface AppSettings {
sources: {
[key: string]: boolean;
};
},
};
m3uUrl: string;
}
// --- Helper ---
const generateKey = (source: string, id: string) => `${source}+${id}`;
// --- FavoriteManager ---
// --- FavoriteManager (Refactored to use API) ---
export class FavoriteManager {
static async getAll(): Promise<Record<string, FavoriteItem>> {
try {
const data = await AsyncStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : {};
} catch (error) {
console.error("Failed to get favorites:", error);
return {};
}
static async getAll(): Promise<Record<string, Favorite>> {
return (await api.getFavorites()) as Record<string, Favorite>;
}
static async save(
source: string,
id: string,
item: Omit<FavoriteItem, "id" | "source" | "save_time">
): Promise<void> {
const favorites = await this.getAll();
static async save(source: string, id: string, item: Omit<Favorite, "save_time">): Promise<void> {
const key = generateKey(source, id);
favorites[key] = { ...item, id, source, save_time: Date.now() };
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
await api.addFavorite(key, item);
}
static async remove(source: string, id: string): Promise<void> {
const favorites = await this.getAll();
const key = generateKey(source, id);
delete favorites[key];
await AsyncStorage.setItem(
STORAGE_KEYS.FAVORITES,
JSON.stringify(favorites)
);
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);
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: Omit<Favorite, "save_time">): Promise<boolean> {
const isFav = await this.isFavorited(source, id);
if (isFav) {
await this.remove(source, id);
@@ -96,34 +63,20 @@ export class FavoriteManager {
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.FAVORITES);
await api.deleteFavorite();
}
}
// --- PlayRecordManager ---
// --- PlayRecordManager (Refactored to use API) ---
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 {};
}
return (await api.getPlayRecords()) as Record<string, PlayRecord>;
}
static async save(
source: string,
id: string,
record: Omit<PlayRecord, "user_id" | "save_time">
): Promise<void> {
const records = await this.getAll();
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)
);
// The API will handle setting the save_time
await api.savePlayRecord(key, record);
}
static async get(source: string, id: string): Promise<PlayRecord | null> {
@@ -132,54 +85,33 @@ export class PlayRecordManager {
}
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 api.deletePlayRecord(key);
}
static async clearAll(): Promise<void> {
await AsyncStorage.removeItem(STORAGE_KEYS.PLAY_RECORDS);
await api.deletePlayRecord();
}
}
// --- SearchHistoryManager ---
const SEARCH_HISTORY_LIMIT = 20;
// --- SearchHistoryManager (Refactored to use API) ---
export class SearchHistoryManager {
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 [];
}
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;
}
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);
await api.deleteSearchHistory();
}
}
// --- SettingsManager ---
// --- SettingsManager (Remains unchanged, uses AsyncStorage) ---
export class SettingsManager {
static async get(): Promise<AppSettings> {
const defaultSettings: AppSettings = {
@@ -189,13 +121,12 @@ 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:
"https://ghfast.top/https://raw.githubusercontent.com/sjnhnp/adblock/refs/heads/main/filtered_http_only_valid.m3u",
};
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);
return defaultSettings;
@@ -205,10 +136,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> {

15
stores/authStore.ts Normal file
View File

@@ -0,0 +1,15 @@
import { create } from "zustand";
interface AuthState {
isLoginModalVisible: boolean;
showLoginModal: () => void;
hideLoginModal: () => void;
}
const useAuthStore = create<AuthState>((set) => ({
isLoginModalVisible: false,
showLoginModal: () => set({ isLoginModalVisible: true }),
hideLoginModal: () => set({ isLoginModalVisible: false }),
}));
export default useAuthStore;

View File

@@ -11,7 +11,7 @@ interface Episode {
}
interface VideoDetail {
videoInfo: ApiVideoDetail["videoInfo"];
videoInfo: ApiVideoDetail;
episodes: Episode[];
sources: SearchResult[];
}
@@ -89,15 +89,16 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
});
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 [{ results: sources }, resources] = await Promise.all([
api.searchVideo(videoDetail.title, source),
api.getResources(),
]);
const currentSourceIndex = resources.findIndex((s) => s.key === source);
const episodes = sources.map((ep, index) => ({ url: ep.episodes[index], title: `${index + 1}` }));
const playRecord = await PlayRecordManager.get(source, id);
set({
detail: { videoInfo: videoDetail.videoInfo, episodes, sources },
detail: { videoInfo: videoDetail, episodes, sources },
episodes,
sources,
currentSourceIndex: currentSourceIndex !== -1 ? currentSourceIndex : 0,
@@ -123,12 +124,17 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
try {
const videoDetail = await api.getVideoDetail(newSource.source, newSource.id.toString());
const episodes = videoDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
const searchResults = await api.searchVideo(videoDetail.title, newSource.source);
if (!searchResults.results || searchResults.results.length === 0) {
throw new Error("No episodes found for this source.");
}
const sourceDetail = searchResults.results[0];
const episodes = sourceDetail.episodes.map((ep, index) => ({ url: ep, title: `${index + 1}` }));
set({
detail: {
...detail,
videoInfo: videoDetail.videoInfo,
videoInfo: videoDetail,
episodes,
},
episodes,
@@ -248,7 +254,7 @@ const usePlayerStore = create<PlayerState>((set, get) => ({
};
PlayRecordManager.save(videoInfo.source, videoInfo.id, {
title: videoInfo.title,
cover: videoInfo.cover || "",
poster: videoInfo.poster || "",
index: currentEpisodeIndex,
total_episodes: episodes.length,
play_time: status.positionMillis,