]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/
- );
+ const descMatch = html.match(/
]*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
{
+async function getDetailFromApi(apiSite: ApiSite, id: string): Promise {
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 {
+async function getVideoDetail(id: string, sourceCode: string): Promise {
if (!id) {
throw new Error("缺少视频ID参数");
}
diff --git a/backend/src/routes/favorites.ts b/backend/src/routes/favorites.ts
new file mode 100644
index 0000000..c7fa2fe
--- /dev/null
+++ b/backend/src/routes/favorites.ts
@@ -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;
diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts
index 2ffb2f9..b0b455f 100644
--- a/backend/src/routes/index.ts
+++ b/backend/src/routes/index.ts
@@ -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);
diff --git a/backend/src/routes/login.ts b/backend/src/routes/login.ts
new file mode 100644
index 0000000..21f10f3
--- /dev/null
+++ b/backend/src/routes/login.ts
@@ -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;
diff --git a/backend/src/routes/playrecords.ts b/backend/src/routes/playrecords.ts
new file mode 100644
index 0000000..43f6255
--- /dev/null
+++ b/backend/src/routes/playrecords.ts
@@ -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;
diff --git a/backend/src/routes/searchhistory.ts b/backend/src/routes/searchhistory.ts
new file mode 100644
index 0000000..f814018
--- /dev/null
+++ b/backend/src/routes/searchhistory.ts
@@ -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 => {
+ 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;
diff --git a/backend/src/routes/server-config.ts b/backend/src/routes/server-config.ts
new file mode 100644
index 0000000..ac04294
--- /dev/null
+++ b/backend/src/routes/server-config.ts
@@ -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;
diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts
index 984e431..4d67937 100644
--- a/backend/src/types/index.ts
+++ b/backend/src/types/index.ts
@@ -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;
}
diff --git a/components/LoginModal.tsx b/components/LoginModal.tsx
new file mode 100644
index 0000000..e03fdeb
--- /dev/null
+++ b/components/LoginModal.tsx
@@ -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 (
+
+
+
+ 需要登录
+ 服务器需要验证您的身份
+
+
+ {isLoading && }
+
+
+
+
+ );
+};
+
+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;
diff --git a/docs/API.md b/docs/API.md
new file mode 100644
index 0000000..c3c1994
--- /dev/null
+++ b/docs/API.md
@@ -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`: 服务器内部错误
diff --git a/hooks/usePlaybackManager.ts b/hooks/usePlaybackManager.ts
deleted file mode 100644
index 1b2cfba..0000000
--- a/hooks/usePlaybackManager.ts
+++ /dev/null
@@ -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