mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
1
backend/src/data/favorites.json
Normal file
1
backend/src/data/favorites.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
backend/src/data/playrecords.json
Normal file
1
backend/src/data/playrecords.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
1
backend/src/data/searchhistory.json
Normal file
1
backend/src/data/searchhistory.json
Normal file
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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参数");
|
||||
}
|
||||
|
||||
67
backend/src/routes/favorites.ts
Normal file
67
backend/src/routes/favorites.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
47
backend/src/routes/login.ts
Normal file
47
backend/src/routes/login.ts
Normal 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;
|
||||
59
backend/src/routes/playrecords.ts
Normal file
59
backend/src/routes/playrecords.ts
Normal 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;
|
||||
66
backend/src/routes/searchhistory.ts
Normal file
66
backend/src/routes/searchhistory.ts
Normal 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;
|
||||
27
backend/src/routes/server-config.ts
Normal file
27
backend/src/routes/server-config.ts
Normal 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;
|
||||
@@ -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
100
components/LoginModal.tsx
Normal 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
313
docs/API.md
Normal 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`: 服务器内部错误
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
220
services/api.ts
220
services/api.ts
@@ -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
|
||||
}
|
||||
|
||||
// 默认实例
|
||||
|
||||
@@ -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
15
stores/authStore.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user