mirror of
https://github.com/zimplexing/OrionTV.git
synced 2026-02-04 03:36:29 +08:00
@@ -1,5 +0,0 @@
|
||||
# The port the backend server will run on
|
||||
PORT=3001
|
||||
|
||||
# Optional: The password for the login endpoint. If not provided, login is disabled.
|
||||
PASSWORD=
|
||||
@@ -1,5 +0,0 @@
|
||||
# The port the backend server will run on
|
||||
PORT=3001
|
||||
|
||||
# Optional: The password for the login endpoint. If not provided, login is disabled.
|
||||
PASSWORD=
|
||||
@@ -1,38 +0,0 @@
|
||||
# --- Build Stage ---
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and yarn.lock first to leverage Docker cache
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source code
|
||||
COPY . .
|
||||
|
||||
# Compile TypeScript to JavaScript
|
||||
RUN yarn build
|
||||
|
||||
# Prune development dependencies
|
||||
RUN yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
|
||||
# --- Production Stage ---
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy production dependencies and compiled code from the builder stage
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Copy config.json from the project root relative to the Docker build context
|
||||
# IMPORTANT: When building, run `docker build -f backend/Dockerfile .` from the project root.
|
||||
COPY src/config/config.json dist/config/
|
||||
|
||||
# Expose the port the app runs on
|
||||
EXPOSE 3001
|
||||
|
||||
# The command to run the application
|
||||
# You can override the port using -e PORT=... in `docker run`
|
||||
CMD [ "node", "dist/index.docker.js" ]
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "OrionTV-proxy",
|
||||
"version": "1.0.1",
|
||||
"description": "Backend service for MyTV application",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.docker.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"dotenv": "^16.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.2",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"cache_time": 7200,
|
||||
"api_site": {
|
||||
"dyttzy": {
|
||||
"api": "http://caiji.dyttzyapi.com/api.php/provide/vod",
|
||||
"name": "电影天堂资源"
|
||||
},
|
||||
"ruyi": {
|
||||
"api": "https://cj.rycjapi.com/api.php/provide/vod",
|
||||
"name": "如意资源"
|
||||
},
|
||||
"mozhua": {
|
||||
"api": "https://mozhuazy.com/api.php/provide/vod",
|
||||
"name": "魔爪资源"
|
||||
},
|
||||
"heimuer": {
|
||||
"api": "https://json.heimuer.xyz/api.php/provide/vod",
|
||||
"name": "黑木耳"
|
||||
},
|
||||
"bfzy": {
|
||||
"api": "https://bfzyapi.com/api.php/provide/vod",
|
||||
"name": "暴风资源"
|
||||
},
|
||||
"tyyszy": {
|
||||
"api": "https://tyyszy.com/api.php/provide/vod",
|
||||
"name": "天涯资源"
|
||||
},
|
||||
"ffzy": {
|
||||
"api": "http://ffzy5.tv/api.php/provide/vod",
|
||||
"name": "非凡影视"
|
||||
},
|
||||
"zy360": {
|
||||
"api": "https://360zy.com/api.php/provide/vod",
|
||||
"name": "360资源"
|
||||
},
|
||||
"iqiyi": {
|
||||
"api": "https://www.iqiyizyapi.com/api.php/provide/vod",
|
||||
"name": "iqiyi资源"
|
||||
},
|
||||
"wolong": {
|
||||
"api": "https://wolongzyw.com/api.php/provide/vod",
|
||||
"name": "卧龙资源"
|
||||
},
|
||||
"hwba": {
|
||||
"api": "https://cjhwba.com/api.php/provide/vod",
|
||||
"name": "华为吧资源"
|
||||
},
|
||||
"jisu": {
|
||||
"api": "https://jszyapi.com/api.php/provide/vod",
|
||||
"name": "极速资源"
|
||||
},
|
||||
"dbzy": {
|
||||
"api": "https://dbzy.tv/api.php/provide/vod",
|
||||
"name": "豆瓣资源"
|
||||
},
|
||||
"mdzy": {
|
||||
"api": "https://www.mdzyapi.com/api.php/provide/vod",
|
||||
"name": "魔都资源"
|
||||
},
|
||||
"zuid": {
|
||||
"api": "https://api.zuidapi.com/api.php/provide/vod",
|
||||
"name": "最大资源"
|
||||
},
|
||||
"yinghua": {
|
||||
"api": "https://m3u8.apiyhzy.com/api.php/provide/vod",
|
||||
"name": "樱花资源"
|
||||
},
|
||||
"wujin": {
|
||||
"api": "https://api.wujinapi.me/api.php/provide/vod",
|
||||
"name": "无尽资源"
|
||||
},
|
||||
"wwzy": {
|
||||
"api": "https://wwzy.tv/api.php/provide/vod",
|
||||
"name": "旺旺短剧"
|
||||
},
|
||||
"ikun": {
|
||||
"api": "https://ikunzyapi.com/api.php/provide/vod",
|
||||
"name": "iKun资源"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export interface ApiSite {
|
||||
key: string;
|
||||
api: string;
|
||||
name: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: "localstorage" | "database";
|
||||
database?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
cache_time?: number;
|
||||
api_site: {
|
||||
[key: string]: ApiSite;
|
||||
};
|
||||
storage?: StorageConfig;
|
||||
}
|
||||
|
||||
export const API_CONFIG = {
|
||||
search: {
|
||||
path: "?ac=videolist&wd=",
|
||||
pagePath: "?ac=videolist&wd={query}&pg={page}",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
path: "?ac=videolist&ids=",
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Adjust path to read from project root, not from `backend/`
|
||||
const configPath = path.join(__dirname, "config.json");
|
||||
let cachedConfig: Config;
|
||||
|
||||
try {
|
||||
cachedConfig = JSON.parse(fs.readFileSync(configPath, "utf-8")) as Config;
|
||||
} catch (error) {
|
||||
console.info(`Error reading or parsing config.json at ${configPath}`, error);
|
||||
// Provide a default fallback config to prevent crashes
|
||||
cachedConfig = {
|
||||
api_site: {},
|
||||
cache_time: 300,
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfig(): Config {
|
||||
return cachedConfig;
|
||||
}
|
||||
|
||||
export function getCacheTime(): number {
|
||||
const config = getConfig();
|
||||
return config.cache_time || 300; // 默认5分钟缓存
|
||||
}
|
||||
|
||||
export function getApiSites(): ApiSite[] {
|
||||
const config = getConfig();
|
||||
return Object.entries(config.api_site).map(([key, site]) => ({
|
||||
...site,
|
||||
key,
|
||||
}));
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -1,28 +0,0 @@
|
||||
import express, { Express, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT || 3001;
|
||||
|
||||
// Middlewares
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check route
|
||||
app.get("/", (req: Request, res: Response) => {
|
||||
res.send("MyTV Backend Service is running!");
|
||||
});
|
||||
|
||||
import apiRouter from "./routes";
|
||||
|
||||
// API routes
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -1,24 +0,0 @@
|
||||
import express, { Express, Request, Response } from "express";
|
||||
import cors from "cors";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT || 3001;
|
||||
|
||||
// Middlewares
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check route
|
||||
app.get("/", (req: Request, res: Response) => {
|
||||
res.send("MyTV Backend Service is running!");
|
||||
});
|
||||
|
||||
import apiRouter from "./routes";
|
||||
|
||||
// API routes
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
export default app;
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
|
||||
import { VideoDetail } from "../types";
|
||||
import { cleanHtmlTags } from "../utils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Match m3u8 links
|
||||
const M3U8_PATTERN = /(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
|
||||
async function handleSpecialSourceDetail(id: string, apiSite: ApiSite): Promise<VideoDetail> {
|
||||
const detailUrl = `${apiSite.detail}/index.php/vod/detail/id/${id}.html`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情页请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
let matches: string[] = [];
|
||||
|
||||
if (apiSite.key === "ffzy") {
|
||||
const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
|
||||
matches = html.match(ffzyPattern) || [];
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
matches = html.match(generalPattern) || [];
|
||||
}
|
||||
|
||||
matches = Array.from(new Set(matches)).map((link: string) => {
|
||||
link = link.substring(1);
|
||||
const parenIndex = link.indexOf("(");
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
|
||||
const titleText = titleMatch ? titleMatch[1].trim() : "";
|
||||
const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
|
||||
const descText = descMatch ? cleanHtmlTags(descMatch[1]) : "";
|
||||
const coverMatch = html.match(/(https?:\/\/[^"'\s]+?\.jpg)/g);
|
||||
const coverUrl = coverMatch ? coverMatch[0].trim() : "";
|
||||
|
||||
return {
|
||||
id,
|
||||
title: titleText,
|
||||
poster: coverUrl,
|
||||
desc: descText,
|
||||
source_name: apiSite.name,
|
||||
source: apiSite.key,
|
||||
};
|
||||
}
|
||||
|
||||
async function getDetailFromApi(apiSite: ApiSite, id: string): Promise<VideoDetail> {
|
||||
const detailUrl = `${apiSite.api}${API_CONFIG.detail.path}${id}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const response = await fetch(detailUrl, {
|
||||
headers: API_CONFIG.detail.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`详情请求失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
|
||||
throw new Error("获取到的详情内容无效");
|
||||
}
|
||||
|
||||
const videoDetail = data.list[0];
|
||||
let episodes: string[] = [];
|
||||
|
||||
if (videoDetail.vod_play_url) {
|
||||
const playSources = videoDetail.vod_play_url.split("$$$");
|
||||
if (playSources.length > 0) {
|
||||
const mainSource = playSources[0];
|
||||
const episodeList = mainSource.split("#");
|
||||
episodes = episodeList
|
||||
.map((ep: string) => {
|
||||
const parts = ep.split("$");
|
||||
return parts.length > 1 ? parts[1] : "";
|
||||
})
|
||||
.filter((url: string) => url && (url.startsWith("http://") || url.startsWith("https://")));
|
||||
}
|
||||
}
|
||||
|
||||
if (episodes.length === 0 && videoDetail.vod_content) {
|
||||
const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
|
||||
episodes = matches.map((link: string) => link.replace(/^\$/, ""));
|
||||
}
|
||||
|
||||
return {
|
||||
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> {
|
||||
if (!id) {
|
||||
throw new Error("缺少视频ID参数");
|
||||
}
|
||||
if (!/^[\w-]+$/.test(id)) {
|
||||
throw new Error("无效的视频ID格式");
|
||||
}
|
||||
const apiSites = getApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === sourceCode);
|
||||
if (!apiSite) {
|
||||
throw new Error("无效的API来源");
|
||||
}
|
||||
if (apiSite.detail) {
|
||||
return handleSpecialSourceDetail(id, apiSite);
|
||||
}
|
||||
return getDetailFromApi(apiSite, id);
|
||||
}
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const id = req.query.id as string;
|
||||
const sourceCode = req.query.source as string;
|
||||
|
||||
if (!id || !sourceCode) {
|
||||
return res.status(400).json({ error: "缺少必要参数" });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getVideoDetail(id, sourceCode);
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,161 +0,0 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { getCacheTime } from "../config";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Interfaces ---
|
||||
interface DoubanItem {
|
||||
title: string;
|
||||
poster: string;
|
||||
rate: string;
|
||||
}
|
||||
|
||||
interface DoubanResponse {
|
||||
code: number;
|
||||
message: string;
|
||||
list: DoubanItem[];
|
||||
}
|
||||
|
||||
interface DoubanApiResponse {
|
||||
subjects: Array<{
|
||||
title: string;
|
||||
cover: string;
|
||||
rate: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Helper Functions ---
|
||||
|
||||
async function fetchDoubanData(url: string): Promise<DoubanApiResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
Referer: "https://movie.douban.com/",
|
||||
Accept: "application/json, text/plain, */*",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTop250(pageStart: number, res: Response) {
|
||||
const target = `https://movie.douban.com/top250?start=${pageStart}&filter=`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
const fetchOptions = {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
Referer: "https://movie.douban.com/",
|
||||
Accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const fetchResponse = await fetch(target, fetchOptions);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new Error(`HTTP error! Status: ${fetchResponse.status}`);
|
||||
}
|
||||
|
||||
const html = await fetchResponse.text();
|
||||
const moviePattern =
|
||||
/<div class="item">[\s\S]*?<img[^>]+alt="([^"]+)"[^>]*src="([^"]+)"[\s\S]*?<span class="rating_num"[^>]*>([^<]+)<\/span>[\s\S]*?<\/div>/g;
|
||||
const movies: DoubanItem[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = moviePattern.exec(html)) !== null) {
|
||||
const title = match[1];
|
||||
const cover = match[2];
|
||||
const rate = match[3] || "";
|
||||
const processedCover = cover.replace(/^http:/, "https:");
|
||||
movies.push({ title, poster: processedCover, rate });
|
||||
}
|
||||
|
||||
const apiResponse: DoubanResponse = {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
list: movies,
|
||||
};
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json(apiResponse);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
res.status(500).json({
|
||||
error: "获取豆瓣 Top250 数据失败",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Route Handler ---
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const { type, tag } = req.query;
|
||||
const pageSize = parseInt((req.query.pageSize as string) || "16");
|
||||
const pageStart = parseInt((req.query.pageStart as string) || "0");
|
||||
|
||||
if (!type || !tag) {
|
||||
return res.status(400).json({ error: "缺少必要参数: type 或 tag" });
|
||||
}
|
||||
if (typeof type !== "string" || !["tv", "movie"].includes(type)) {
|
||||
return res.status(400).json({ error: "type 参数必须是 tv 或 movie" });
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 100) {
|
||||
return res.status(400).json({ error: "pageSize 必须在 1-100 之间" });
|
||||
}
|
||||
if (pageStart < 0) {
|
||||
return res.status(400).json({ error: "pageStart 不能小于 0" });
|
||||
}
|
||||
|
||||
if (tag === "top250") {
|
||||
return handleTop250(pageStart, res);
|
||||
}
|
||||
|
||||
const target = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${tag}&sort=recommend&page_limit=${pageSize}&page_start=${pageStart}`;
|
||||
|
||||
try {
|
||||
const doubanData = await fetchDoubanData(target);
|
||||
const list: DoubanItem[] = doubanData.subjects.map((item) => ({
|
||||
title: item.title,
|
||||
poster: item.cover,
|
||||
rate: item.rate,
|
||||
}));
|
||||
|
||||
const response: DoubanResponse = {
|
||||
code: 200,
|
||||
message: "获取成功",
|
||||
list: list,
|
||||
};
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
error: "获取豆瓣数据失败",
|
||||
details: (error as Error).message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const imageUrl = req.query.url as string;
|
||||
|
||||
if (!imageUrl) {
|
||||
return res.status(400).send("Missing image URL");
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
headers: {
|
||||
Referer: "https://movie.douban.com/",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return res.status(imageResponse.status).send(imageResponse.statusText);
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get("content-type");
|
||||
if (contentType) {
|
||||
res.setHeader("Content-Type", contentType);
|
||||
}
|
||||
|
||||
if (imageResponse.body) {
|
||||
const nodeStream = Readable.fromWeb(imageResponse.body as any);
|
||||
nodeStream.pipe(res);
|
||||
} else {
|
||||
res.status(500).send("Image response has no body");
|
||||
}
|
||||
} catch (error) {
|
||||
console.info("Image proxy error:", error);
|
||||
res.status(500).send("Error fetching image");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Router } from "express";
|
||||
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);
|
||||
router.use("/image-proxy", imageProxyRouter);
|
||||
|
||||
export default router;
|
||||
@@ -1,58 +0,0 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const router = express.Router();
|
||||
const username = process.env.USERNAME;
|
||||
const password = process.env.PASSWORD;
|
||||
|
||||
/**
|
||||
* @api {post} /api/login User Login
|
||||
* @apiName UserLogin
|
||||
* @apiGroup User
|
||||
*
|
||||
* @apiBody {String} username User's username.
|
||||
* @apiBody {String} password User's password.
|
||||
*
|
||||
* @apiSuccess {Boolean} ok Indicates if the login was successful.
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response:
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "ok": true
|
||||
* }
|
||||
*
|
||||
* @apiError {String} message Error message.
|
||||
*
|
||||
* @apiErrorExample {json} Error-Response:
|
||||
* HTTP/1.1 400 Bad Request
|
||||
* {
|
||||
* "message": "Invalid password"
|
||||
* }
|
||||
*/
|
||||
router.post("/login", (req: Request, res: Response) => {
|
||||
const { username: inputUsername, password: inputPassword } = req.body;
|
||||
|
||||
// Compatibility with old versions, if username is not set, only password is required
|
||||
if (!username || !password) {
|
||||
if (inputPassword === password) {
|
||||
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||
return res.json({ ok: true });
|
||||
} else if (!password) {
|
||||
// If no password is set, login is always successful.
|
||||
return res.json({ ok: true });
|
||||
} else {
|
||||
return res.status(400).json({ message: "Invalid password" });
|
||||
}
|
||||
}
|
||||
|
||||
if (inputUsername === username && inputPassword === password) {
|
||||
res.cookie("auth", "true", { httpOnly: true, maxAge: 24 * 60 * 60 * 1000 });
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.status(400).json({ message: "Invalid username or password" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
@@ -1,270 +0,0 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { API_CONFIG, ApiSite, getApiSites, getCacheTime } from "../config";
|
||||
import { cleanHtmlTags } from "../utils";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 根据环境变量决定最大搜索页数,默认 5
|
||||
const MAX_SEARCH_PAGES: number = Number(process.env.SEARCH_MAX_PAGE) || 5;
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
episodes: string[];
|
||||
source: string;
|
||||
source_name: string;
|
||||
class?: string;
|
||||
year: string;
|
||||
desc?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
interface ApiSearchItem {
|
||||
vod_id: string;
|
||||
vod_name: string;
|
||||
vod_pic: string;
|
||||
vod_remarks?: string;
|
||||
vod_play_url?: string;
|
||||
vod_class?: string;
|
||||
vod_year?: string;
|
||||
vod_content?: string;
|
||||
type_name?: string;
|
||||
}
|
||||
|
||||
async function searchFromApi(
|
||||
apiSite: ApiSite,
|
||||
query: string
|
||||
): Promise<SearchResult[]> {
|
||||
try {
|
||||
const apiBaseUrl = apiSite.api;
|
||||
const apiUrl =
|
||||
apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);
|
||||
const apiName = apiSite.name;
|
||||
|
||||
// 添加超时处理
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
console.log(
|
||||
"apiUrl",
|
||||
apiSite.name,
|
||||
"response status",
|
||||
response.ok,
|
||||
"response data",
|
||||
data.list.length
|
||||
);
|
||||
|
||||
if (
|
||||
!data ||
|
||||
!data.list ||
|
||||
!Array.isArray(data.list) ||
|
||||
data.list.length === 0
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
// 处理第一页结果
|
||||
const results = data.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
|
||||
// 使用正则表达式从 vod_play_url 提取 m3u8 链接
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
// 先用 $$$ 分割
|
||||
const vod_play_url_array = item.vod_play_url.split("$$$");
|
||||
// 对每个分片做匹配,取匹配到最多的作为结果
|
||||
vod_play_url_array.forEach((url: string) => {
|
||||
const matches = url.match(m3u8Regex) || [];
|
||||
if (matches.length > episodes.length) {
|
||||
episodes = matches;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf("(");
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year ? item.vod_year.match(/\d{4}/)?.[0] || "" : "",
|
||||
desc: cleanHtmlTags(item.vod_content || ""),
|
||||
type_name: item.type_name,
|
||||
};
|
||||
});
|
||||
|
||||
// 获取总页数
|
||||
const pageCount = data.pagecount || 1;
|
||||
// 确定需要获取的额外页数
|
||||
const pagesToFetch = Math.min(pageCount - 1, MAX_SEARCH_PAGES - 1);
|
||||
|
||||
// 如果有额外页数,获取更多页的结果
|
||||
if (pagesToFetch > 0) {
|
||||
const additionalPagePromises = [];
|
||||
|
||||
for (let page = 2; page <= pagesToFetch + 1; page++) {
|
||||
const pageUrl =
|
||||
apiBaseUrl +
|
||||
API_CONFIG.search.pagePath
|
||||
.replace("{query}", encodeURIComponent(query))
|
||||
.replace("{page}", page.toString());
|
||||
|
||||
const pagePromise = (async () => {
|
||||
try {
|
||||
const pageController = new AbortController();
|
||||
const pageTimeoutId = setTimeout(
|
||||
() => pageController.abort(),
|
||||
8000
|
||||
);
|
||||
|
||||
const pageResponse = await fetch(pageUrl, {
|
||||
headers: API_CONFIG.search.headers,
|
||||
signal: pageController.signal,
|
||||
});
|
||||
|
||||
clearTimeout(pageTimeoutId);
|
||||
|
||||
if (!pageResponse.ok) return [];
|
||||
|
||||
const pageData = await pageResponse.json();
|
||||
|
||||
if (!pageData || !pageData.list || !Array.isArray(pageData.list))
|
||||
return [];
|
||||
|
||||
return pageData.list.map((item: ApiSearchItem) => {
|
||||
let episodes: string[] = [];
|
||||
|
||||
if (item.vod_play_url) {
|
||||
const m3u8Regex = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
|
||||
episodes = item.vod_play_url.match(m3u8Regex) || [];
|
||||
}
|
||||
|
||||
episodes = Array.from(new Set(episodes)).map((link: string) => {
|
||||
link = link.substring(1); // 去掉开头的 $
|
||||
const parenIndex = link.indexOf("(");
|
||||
return parenIndex > 0 ? link.substring(0, parenIndex) : link;
|
||||
});
|
||||
|
||||
return {
|
||||
id: item.vod_id,
|
||||
title: item.vod_name,
|
||||
poster: item.vod_pic,
|
||||
episodes,
|
||||
source: apiSite.key,
|
||||
source_name: apiName,
|
||||
class: item.vod_class,
|
||||
year: item.vod_year
|
||||
? item.vod_year.match(/\d{4}/)?.[0] || ""
|
||||
: "",
|
||||
desc: cleanHtmlTags(item.vod_content || ""),
|
||||
type_name: item.type_name,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
|
||||
additionalPagePromises.push(pagePromise);
|
||||
}
|
||||
|
||||
const additionalResults = await Promise.all(additionalPagePromises);
|
||||
|
||||
additionalResults.forEach((pageResults) => {
|
||||
if (pageResults.length > 0) {
|
||||
results.push(...pageResults);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const query = req.query.q as string;
|
||||
|
||||
if (!query) {
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
return res.json({ results: [] });
|
||||
}
|
||||
|
||||
const apiSites = getApiSites();
|
||||
const searchPromises = apiSites.map((site) => searchFromApi(site, query));
|
||||
|
||||
try {
|
||||
const results = await Promise.all(searchPromises);
|
||||
const flattenedResults = results.flat();
|
||||
const cacheTime = getCacheTime();
|
||||
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json({ results: flattenedResults });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "搜索失败" });
|
||||
}
|
||||
});
|
||||
|
||||
// 按资源 url 单个获取数据
|
||||
router.get("/one", async (req: Request, res: Response) => {
|
||||
const { resourceId, q } = req.query;
|
||||
|
||||
if (!resourceId || !q) {
|
||||
return res.status(400).json({ error: "resourceId and q are required" });
|
||||
}
|
||||
|
||||
const apiSites = getApiSites();
|
||||
const apiSite = apiSites.find((site) => site.key === (resourceId as string));
|
||||
|
||||
if (!apiSite) {
|
||||
return res.status(404).json({ error: "Resource not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await searchFromApi(apiSite, q as string);
|
||||
const result = results.filter((r) => r.title === (q as string));
|
||||
|
||||
if (results) {
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json({results: result});
|
||||
} else {
|
||||
res.status(404).json({ error: "Resource not found with the given query" });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "Failed to fetch resource details" });
|
||||
}
|
||||
});
|
||||
|
||||
// 获取所有可用的资源列表
|
||||
router.get("/resources", async (req: Request, res: Response) => {
|
||||
const apiSites = getApiSites();
|
||||
const cacheTime = getCacheTime();
|
||||
res.setHeader("Cache-Control", `public, max-age=${cacheTime}`);
|
||||
res.json(apiSites);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,66 +0,0 @@
|
||||
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;
|
||||
@@ -1,38 +0,0 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { getConfig } from "../config";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* @api {get} /api/server-config Get Server Configuration
|
||||
* @apiName GetServerConfig
|
||||
* @apiGroup Server
|
||||
*
|
||||
* @apiSuccess {String} SiteName The name of the site.
|
||||
* @apiSuccess {String} StorageType The storage type used by the server ("localstorage" or "database").
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response (LocalStorage):
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "SiteName": "OrionTV-Local",
|
||||
* "StorageType": "localstorage"
|
||||
* }
|
||||
*
|
||||
* @apiSuccessExample {json} Success-Response (Database):
|
||||
* HTTP/1.1 200 OK
|
||||
* {
|
||||
* "SiteName": "OrionTV-Cloud",
|
||||
* "StorageType": "database"
|
||||
* }
|
||||
*/
|
||||
router.get("/server-config", (req: Request, res: Response) => {
|
||||
const config = getConfig();
|
||||
const storageType = config.storage?.type || "database"; // Default to 'database' if not specified
|
||||
|
||||
res.json({
|
||||
SiteName: storageType === "localstorage" ? "OrionTV-Local" : "OrionTV-Cloud",
|
||||
StorageType: storageType,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,28 +0,0 @@
|
||||
// Data structure for play records
|
||||
export interface PlayRecord {
|
||||
title: string;
|
||||
source_name: string;
|
||||
poster: string;
|
||||
index: number; // Episode number
|
||||
total_episodes: number; // Total number of episodes
|
||||
play_time: number; // Play progress in seconds
|
||||
total_time: number; // Total duration in seconds
|
||||
save_time: number; // Timestamp of when the record was saved
|
||||
user_id: number; // User ID, always 0 in this version
|
||||
}
|
||||
|
||||
// You can add other shared types here
|
||||
export interface VideoDetail {
|
||||
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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export function cleanHtmlTags(text: string): string {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/<[^>]+>/g, "\n") // 将 HTML 标签替换为换行
|
||||
.replace(/\n+/g, "\n") // 将多个连续换行合并为一个
|
||||
.replace(/[ \t]+/g, " ") // 将多个连续空格和制表符合并为一个空格,但保留换行符
|
||||
.replace(/^\n+|\n+$/g, "") // 去掉首尾换行
|
||||
.replace(/ /g, " ") // 将 替换为空格
|
||||
.trim(); // 去掉首尾空格
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{
|
||||
"src": "src/index.ts",
|
||||
"use": "@vercel/node",
|
||||
"config": {
|
||||
"includeFiles": ["./config.json"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "src/index.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
1025
backend/yarn.lock
1025
backend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { View, StyleSheet, ScrollView, Dimensions, ActivityIndicator } from "react-native";
|
||||
import { ThemedText } from "@/components/ThemedText";
|
||||
|
||||
@@ -91,7 +91,7 @@ const CustomScrollView: React.FC<CustomScrollViewProps> = ({
|
||||
<>
|
||||
{/* Render content in a grid layout */}
|
||||
{Array.from({ length: Math.ceil(data.length / numColumns) }).map((_, rowIndex) => (
|
||||
<View key={rowIndex} style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
||||
<View key={rowIndex} style={styles.rowContainer}>
|
||||
{data.slice(rowIndex * numColumns, (rowIndex + 1) * numColumns).map((item, index) => (
|
||||
<View key={index} style={[styles.itemContainer, { width: ITEM_WIDTH }]}>
|
||||
{renderItem({ item, index: rowIndex * numColumns + index })}
|
||||
@@ -121,6 +121,11 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
rowContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
itemContainer: {
|
||||
margin: 8,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Modal, View, TextInput, StyleSheet, ActivityIndicator } from "react-native";
|
||||
import { Modal, View, TextInput, StyleSheet, ActivityIndicator, Alert } from "react-native";
|
||||
import { usePathname } from "expo-router";
|
||||
import Toast from "react-native-toast-message";
|
||||
import useAuthStore from "@/stores/authStore";
|
||||
@@ -55,6 +55,13 @@ const LoginModal = () => {
|
||||
hideLoginModal();
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
|
||||
// Show disclaimer alert after successful login
|
||||
Alert.alert(
|
||||
"免责声明",
|
||||
"本网站仅提供影视信息搜索服务,所有内容均来自第三方网站。本站不存储任何视频资源,不对任何内容的准确性、合法性、完整性负责。",
|
||||
[{ text: "确定" }]
|
||||
);
|
||||
} catch {
|
||||
Toast.show({ type: "error", text1: "登录失败", text2: "用户名或密码错误" });
|
||||
} finally {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const APIConfigSection = forwardRef<APIConfigSectionRef, APIConfigSection
|
||||
style={[styles.input, isInputFocused && styles.inputFocused]}
|
||||
value={apiBaseUrl}
|
||||
onChangeText={handleUrlChange}
|
||||
placeholder="输入 API 地址"
|
||||
placeholder="输入服务器地址"
|
||||
placeholderTextColor="#888"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
|
||||
@@ -1,284 +0,0 @@
|
||||
# OrionTV Android 5.0 兼容性分析报告
|
||||
|
||||
## 项目概述
|
||||
|
||||
OrionTV是一个基于React Native TVOS和Expo SDK的电视端视频流媒体应用,专为Apple TV和Android TV平台设计。本文档分析了将项目降级到支持Android 5.0 (API Level 21)的兼容性风险和实施方案。
|
||||
|
||||
## 当前技术栈
|
||||
|
||||
### 核心框架版本
|
||||
- **React Native**: `npm:react-native-tvos@~0.74.2-0`
|
||||
- **Expo SDK**: `~51.0.13`
|
||||
- **React**: `18.2.0`
|
||||
- **TypeScript**: `~5.3.3`
|
||||
- **最小Android API级别**: 23 (Android 6.0)
|
||||
- **目标Android API级别**: 34 (Android 14)
|
||||
|
||||
### 关键依赖
|
||||
- `expo-av`: `~14.0.7` (视频播放)
|
||||
- `expo-router`: `~3.5.16` (路由导航)
|
||||
- `react-native-reanimated`: `~3.10.1` (动画)
|
||||
- `react-native-tcp-socket`: `^6.0.6` (网络服务)
|
||||
- `zustand`: `^5.0.6` (状态管理)
|
||||
|
||||
## 兼容性限制分析
|
||||
|
||||
### React Native 0.74 限制
|
||||
根据官方文档,React Native 0.74已将最低Android API级别要求提升到23 (Android 6.0),不再支持Android 5.0 (API Level 21)。
|
||||
|
||||
### Expo SDK 51 限制
|
||||
Expo SDK 51基于React Native 0.74,同样不支持Android 5.0。
|
||||
|
||||
## 降级方案
|
||||
|
||||
### 推荐的版本组合
|
||||
|
||||
#### 方案A: 保持TV功能的最新兼容版本
|
||||
```json
|
||||
{
|
||||
"react-native": "npm:react-native-tvos@~0.73.8-0",
|
||||
"expo": "~50.0.0",
|
||||
"expo-av": "~13.10.x",
|
||||
"expo-router": "~3.4.x",
|
||||
"react-native-reanimated": "~3.8.x"
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案B: 最大向后兼容版本
|
||||
```json
|
||||
{
|
||||
"react-native": "npm:react-native-tvos@~0.72.12-0",
|
||||
"expo": "~49.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
### Android配置修改
|
||||
```gradle
|
||||
// android/build.gradle
|
||||
android {
|
||||
minSdkVersion = 21 // 支持Android 5.0
|
||||
targetSdkVersion = 30 // 降级到Android 11
|
||||
compileSdkVersion = 33 // 对应的编译SDK版本
|
||||
}
|
||||
```
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 🔴 高风险组件
|
||||
|
||||
#### 1. 视频播放功能 (expo-av)
|
||||
- **影响文件**: `hooks/usePlaybackManager.ts`, `app/play.tsx`, `stores/playerStore.ts`
|
||||
- **风险**: API变化可能影响播放控制
|
||||
- **关键代码**:
|
||||
```typescript
|
||||
import { Video, AVPlaybackStatus } from "expo-av";
|
||||
// 可能受影响的API调用
|
||||
videoRef?.current?.replayAsync();
|
||||
videoRef?.current?.pauseAsync();
|
||||
videoRef?.current?.playAsync();
|
||||
```
|
||||
|
||||
#### 2. TV遥控器功能 (react-native-tvos)
|
||||
- **影响文件**: `hooks/useTVRemoteHandler.ts` + 7个组件文件
|
||||
- **风险**: 遥控器事件处理可能有变化
|
||||
- **关键代码**:
|
||||
```typescript
|
||||
import { useTVEventHandler, HWEvent } from "react-native";
|
||||
// 长按事件处理可能需要调整
|
||||
case "longRight":
|
||||
case "longLeft":
|
||||
```
|
||||
|
||||
#### 3. 路由导航 (expo-router)
|
||||
- **影响文件**: 9个页面文件
|
||||
- **风险**: 路由配置和参数传递可能有变化
|
||||
- **关键代码**:
|
||||
```typescript
|
||||
import { useRouter, useLocalSearchParams } from "expo-router";
|
||||
```
|
||||
|
||||
### 🟡 中等风险组件
|
||||
|
||||
#### 1. 远程控制服务 (react-native-tcp-socket)
|
||||
- **影响文件**: `services/tcpHttpServer.ts`
|
||||
- **风险**: 网络API兼容性问题
|
||||
- **关键代码**:
|
||||
```typescript
|
||||
import TcpSocket from 'react-native-tcp-socket';
|
||||
this.server = TcpSocket.createServer((socket: TcpSocket.Socket) => {});
|
||||
```
|
||||
|
||||
#### 2. 动画效果 (react-native-reanimated)
|
||||
- **影响文件**: `components/VideoCard.tv.tsx`
|
||||
- **风险**: 动画性能可能下降
|
||||
- **关键代码**:
|
||||
```typescript
|
||||
import Animated, { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
|
||||
```
|
||||
|
||||
### 🟢 低风险组件
|
||||
|
||||
#### 1. 状态管理 (zustand)
|
||||
- **影响**: 与React Native版本无直接关系
|
||||
- **风险**: 极低
|
||||
|
||||
#### 2. 数据存储 (AsyncStorage)
|
||||
- **影响文件**: `services/storage.ts`
|
||||
- **风险**: 极低,API稳定
|
||||
|
||||
## 平台特定风险
|
||||
|
||||
### Android API 23 → 21 降级影响
|
||||
|
||||
#### 1. 运行时权限模型
|
||||
- **API 23+**: 需要运行时权限请求
|
||||
- **API 21-22**: 安装时权限模型
|
||||
- **影响**: 网络权限处理可能需要调整
|
||||
|
||||
#### 2. 网络安全配置
|
||||
- **风险**: HTTP cleartext流量处理
|
||||
- **当前配置**: `android.usesCleartextTraffic = true`
|
||||
- **建议**: 保持当前配置确保向后兼容
|
||||
|
||||
#### 3. 后台服务限制
|
||||
- **API 23+**: 更严格的后台服务限制
|
||||
- **API 21-22**: 相对宽松的后台服务策略
|
||||
- **影响**: 远程控制服务可能表现不同
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 准备阶段
|
||||
```bash
|
||||
# 1. 备份当前项目
|
||||
git checkout -b android-5-compatibility
|
||||
|
||||
# 2. 清理现有依赖
|
||||
rm -rf node_modules
|
||||
rm yarn.lock
|
||||
```
|
||||
|
||||
### 2. 版本降级
|
||||
```bash
|
||||
# 3. 修改package.json依赖版本
|
||||
# 4. 重新安装依赖
|
||||
yarn install
|
||||
|
||||
# 5. 清理原生代码
|
||||
yarn prebuild-tv --clean
|
||||
```
|
||||
|
||||
### 3. 配置修改
|
||||
```bash
|
||||
# 6. 修改android/build.gradle
|
||||
# 7. 更新app.json配置
|
||||
# 8. 复制TV相关配置
|
||||
yarn copy-config
|
||||
```
|
||||
|
||||
### 4. 测试构建
|
||||
```bash
|
||||
# 9. 本地构建测试
|
||||
yarn build-local
|
||||
|
||||
# 10. 运行测试
|
||||
yarn test
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 核心功能测试
|
||||
- [ ] 视频播放、暂停、进度控制
|
||||
- [ ] 遥控器所有按键响应(上下左右、选择、菜单、返回)
|
||||
- [ ] 长按快进/快退功能
|
||||
- [ ] 页面导航和参数传递
|
||||
- [ ] 焦点管理和视觉反馈
|
||||
|
||||
### TV特定功能测试
|
||||
- [ ] 控制条自动显示/隐藏
|
||||
- [ ] 剧集切换功能
|
||||
- [ ] 远程控制HTTP服务
|
||||
- [ ] 设置页面各项配置
|
||||
- [ ] 搜索功能
|
||||
|
||||
### 兼容性测试
|
||||
- [ ] Android 5.0真机测试
|
||||
- [ ] Android TV模拟器测试
|
||||
- [ ] Apple TV模拟器测试
|
||||
- [ ] 不同屏幕尺寸适配
|
||||
- [ ] 内存使用情况
|
||||
- [ ] 启动性能测试
|
||||
|
||||
## 依赖版本对照表
|
||||
|
||||
| 组件 | 当前版本 | 目标版本 | 风险等级 | 备注 |
|
||||
|------|----------|----------|----------|------|
|
||||
| react-native-tvos | ~0.74.2-0 | ~0.73.8-0 | 🔴 高 | TV功能核心 |
|
||||
| expo | ~51.0.13 | ~50.0.0 | 🔴 高 | 框架基础 |
|
||||
| expo-av | ~14.0.7 | ~13.10.x | 🔴 高 | 视频播放 |
|
||||
| expo-router | ~3.5.16 | ~3.4.x | 🔴 高 | 路由导航 |
|
||||
| react-native-reanimated | ~3.10.1 | ~3.8.x | 🟡 中 | 动画效果 |
|
||||
| react-native-tcp-socket | ^6.0.6 | ^6.0.4 | 🟡 中 | 网络服务 |
|
||||
| zustand | ^5.0.6 | ^5.0.6 | 🟢 低 | 状态管理 |
|
||||
| @react-native-async-storage/async-storage | ^2.2.0 | ^2.1.x | 🟢 低 | 数据存储 |
|
||||
|
||||
## 潜在问题和解决方案
|
||||
|
||||
### 1. 视频播放问题
|
||||
**问题**: expo-av版本降级可能导致某些视频格式不支持
|
||||
**解决方案**:
|
||||
- 测试主要视频格式(MP4, M3U8)
|
||||
- 必要时实现格式转换
|
||||
- 提供播放失败的友好提示
|
||||
|
||||
### 2. 遥控器响应问题
|
||||
**问题**: TV事件处理可能有差异
|
||||
**解决方案**:
|
||||
- 仔细测试所有遥控器按键
|
||||
- 调整事件处理逻辑
|
||||
- 增加兼容性检查
|
||||
|
||||
### 3. 路由导航问题
|
||||
**问题**: 页面跳转参数传递可能有变化
|
||||
**解决方案**:
|
||||
- 测试所有页面跳转
|
||||
- 验证参数正确传递
|
||||
- 必要时调整路由配置
|
||||
|
||||
### 4. 动画性能问题
|
||||
**问题**: 动画可能在低端设备上表现不佳
|
||||
**解决方案**:
|
||||
- 简化动画效果
|
||||
- 增加性能检测
|
||||
- 提供动画开关选项
|
||||
|
||||
## 建议与结论
|
||||
|
||||
### 风险总结
|
||||
- **总体风险等级**: 🔴 **高等风险**
|
||||
- **主要风险点**: 视频播放、遥控器功能、路由导航
|
||||
- **预计工作量**: 2-3周开发 + 1-2周测试
|
||||
|
||||
### 成本效益分析
|
||||
- **开发成本**: 高(需要大量测试和调试)
|
||||
- **维护成本**: 高(使用较旧版本,安全更新有限)
|
||||
- **用户覆盖**: 低(Android 5用户占比通常<2%)
|
||||
|
||||
### 最终建议
|
||||
**不建议进行降级**,原因如下:
|
||||
1. 技术风险高,可能影响核心功能稳定性
|
||||
2. 维护成本高,需要长期支持多个版本
|
||||
3. 用户收益有限,Android 5用户占比极低
|
||||
4. 与业界趋势不符,各大平台都在提升最低版本要求
|
||||
|
||||
### 替代方案
|
||||
1. **统计用户分布**: 收集实际用户设备数据,确认Android 5用户占比
|
||||
2. **渐进式升级**: 引导用户升级设备,提供升级指南
|
||||
3. **精简版本**: 为老设备提供功能精简的独立版本
|
||||
4. **Web版本**: 提供Web端访问方式作为补充
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [React Native 0.74 Release Notes](https://reactnative.dev/blog/2024/04/22/release-0.74)
|
||||
- [Expo SDK 51 Changelog](https://expo.dev/changelog/2024-05-07-sdk-51)
|
||||
- [React Native TV OS Documentation](https://github.com/react-native-tvos/react-native-tvos)
|
||||
- [Android API Level Distribution](https://developer.android.com/about/dashboards)
|
||||
313
docs/API.md
313
docs/API.md
@@ -1,313 +0,0 @@
|
||||
### 服务器配置
|
||||
|
||||
- **接口地址**: `/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,305 +0,0 @@
|
||||
# OrionTV Native HTTP Server Implementation Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
OrionTV implements a sophisticated native HTTP server solution that enables remote control functionality for the TV application. This implementation uses TCP sockets to create a custom HTTP server directly within the React Native application, providing a web-based remote control interface accessible from mobile devices.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. TCPHttpServer (`/services/tcpHttpServer.ts`)
|
||||
|
||||
A custom HTTP server implementation built on top of `react-native-tcp-socket` that handles raw TCP connections and implements HTTP protocol parsing and response formatting.
|
||||
|
||||
**Key Features:**
|
||||
- Custom HTTP request/response parsing
|
||||
- Fixed port configuration (12346)
|
||||
- Automatic IP address detection via `@react-native-community/netinfo`
|
||||
- Support for GET and POST methods
|
||||
- Error handling and connection management
|
||||
|
||||
**Class Structure:**
|
||||
```typescript
|
||||
class TCPHttpServer {
|
||||
private server: TcpSocket.Server | null = null;
|
||||
private isRunning = boolean;
|
||||
private requestHandler: RequestHandler | null = null;
|
||||
}
|
||||
```
|
||||
|
||||
**Core Methods:**
|
||||
- `start()`: Initializes server and binds to `0.0.0.0:12346`
|
||||
- `stop()`: Gracefully shuts down the server
|
||||
- `setRequestHandler()`: Sets the request handling logic
|
||||
- `parseHttpRequest()`: Parses raw HTTP request data
|
||||
- `formatHttpResponse()`: Formats HTTP responses
|
||||
|
||||
#### 2. RemoteControlService (`/services/remoteControlService.ts`)
|
||||
|
||||
A service layer that wraps the TCPHttpServer and provides the remote control functionality with predefined routes and HTML interface.
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /` - Serves HTML remote control interface
|
||||
- `POST /message` - Receives messages from mobile devices
|
||||
- `POST /handshake` - Connection handshake for mobile clients
|
||||
|
||||
**Features:**
|
||||
- Built-in HTML interface generation
|
||||
- JSON message parsing
|
||||
- Callback-based event handling
|
||||
- Error handling and validation
|
||||
|
||||
#### 3. RemoteControlStore (`/stores/remoteControlStore.ts`)
|
||||
|
||||
Zustand store that manages the remote control server state and provides React component integration.
|
||||
|
||||
**State Management:**
|
||||
```typescript
|
||||
interface RemoteControlState {
|
||||
isServerRunning: boolean;
|
||||
serverUrl: string | null;
|
||||
error: string | null;
|
||||
isModalVisible: boolean;
|
||||
lastMessage: string | null;
|
||||
startServer: () => Promise<void>;
|
||||
stopServer: () => void;
|
||||
showModal: () => void;
|
||||
hideModal: () => void;
|
||||
setMessage: (message: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### HTTP Protocol Implementation
|
||||
|
||||
#### Request Parsing
|
||||
The server implements custom HTTP request parsing that handles:
|
||||
- HTTP method and URL extraction
|
||||
- Header parsing with case-insensitive keys
|
||||
- Body content extraction
|
||||
- Malformed request detection
|
||||
|
||||
#### Response Formatting
|
||||
Responses are formatted according to HTTP/1.1 specification:
|
||||
- Status line with appropriate status codes (200, 400, 404, 500)
|
||||
- Content-Length header calculation
|
||||
- Connection: close header for stateless operation
|
||||
- Proper CRLF line endings
|
||||
|
||||
### Network Configuration
|
||||
|
||||
#### IP Address Detection
|
||||
The server automatically detects the device's IP address using `@react-native-community/netinfo`:
|
||||
- Supports WiFi and Ethernet connections
|
||||
- Validates network connectivity before starting
|
||||
- Provides clear error messages for network issues
|
||||
|
||||
#### Server Binding
|
||||
- Binds to `0.0.0.0:12346` for universal access
|
||||
- Fixed port configuration for consistency
|
||||
- Supports all network interfaces on the device
|
||||
|
||||
### Security Considerations
|
||||
|
||||
#### Current Implementation
|
||||
- No authentication mechanism
|
||||
- Open access on local network
|
||||
- Basic request validation
|
||||
- Error handling prevents information disclosure
|
||||
|
||||
#### Limitations
|
||||
- Suitable only for local network use
|
||||
- No HTTPS/TLS encryption
|
||||
- No rate limiting or DDoS protection
|
||||
- Assumes trusted network environment
|
||||
|
||||
## Web Interface
|
||||
|
||||
### HTML Template
|
||||
The service provides a responsive web interface optimized for mobile devices:
|
||||
- Dark theme design matching TV app aesthetics
|
||||
- Touch-friendly controls with large buttons
|
||||
- Real-time message sending capability
|
||||
- Automatic handshake on page load
|
||||
|
||||
### JavaScript Functionality
|
||||
- Automatic handshake POST request on page load
|
||||
- Message submission via JSON POST requests
|
||||
- Input field clearing after submission
|
||||
- Error handling for network issues
|
||||
|
||||
## Integration with React Native App
|
||||
|
||||
### App Initialization
|
||||
The server is automatically started when the app launches (`/app/_layout.tsx`):
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const { setMessage, hideModal } = useRemoteControlStore.getState();
|
||||
remoteControlService.init({
|
||||
onMessage: setMessage,
|
||||
onHandshake: hideModal,
|
||||
});
|
||||
useRemoteControlStore.getState().startServer();
|
||||
|
||||
return () => {
|
||||
useRemoteControlStore.getState().stopServer();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Message Handling
|
||||
Messages received from mobile devices are processed and displayed as Toast notifications in the TV app, providing visual feedback for remote interactions.
|
||||
|
||||
### QR Code Integration
|
||||
The app generates QR codes containing the server URL (`http://{device_ip}:12346`) for easy mobile device connection via `RemoteControlModal.tsx`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required Packages
|
||||
- `react-native-tcp-socket@^6.0.6` - TCP socket implementation
|
||||
- `@react-native-community/netinfo@^11.3.2` - Network interface information
|
||||
- `react-native-qrcode-svg@^6.3.1` - QR code generation for UI
|
||||
|
||||
### Platform Compatibility
|
||||
- iOS (Apple TV)
|
||||
- Android (Android TV)
|
||||
- Requires network connectivity (WiFi or Ethernet)
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Connection Handling
|
||||
- Single-threaded event-driven architecture
|
||||
- Stateless HTTP connections with immediate closure
|
||||
- Memory-efficient request buffering
|
||||
- Graceful error recovery
|
||||
|
||||
### Resource Usage
|
||||
- Minimal CPU overhead for HTTP parsing
|
||||
- Low memory footprint
|
||||
- Network I/O bound operations
|
||||
- Automatic connection cleanup
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Server Level
|
||||
- Network binding failures with descriptive messages
|
||||
- Socket error handling and logging
|
||||
- Graceful server shutdown procedures
|
||||
- IP address detection error handling
|
||||
|
||||
### Request Level
|
||||
- Malformed HTTP request detection
|
||||
- JSON parsing error handling
|
||||
- 400/404/500 status code responses
|
||||
- Request timeout and connection cleanup
|
||||
|
||||
## Debugging and Monitoring
|
||||
|
||||
### Logging
|
||||
Comprehensive logging throughout the system:
|
||||
- Server startup/shutdown events
|
||||
- Client connection/disconnection
|
||||
- Request processing details
|
||||
- Error conditions and stack traces
|
||||
|
||||
### Console Output Format
|
||||
```
|
||||
[TCPHttpServer] Server listening on 192.168.1.100:12346
|
||||
[RemoteControl] Received request: POST /message
|
||||
[RemoteControlStore] Server started, URL: http://192.168.1.100:12346
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Starting the Server
|
||||
```typescript
|
||||
// Automatic startup via store
|
||||
const { startServer } = useRemoteControlStore();
|
||||
await startServer();
|
||||
|
||||
// Manual service usage
|
||||
await remoteControlService.startServer();
|
||||
```
|
||||
|
||||
### Stopping the Server
|
||||
```typescript
|
||||
// Via store
|
||||
const { stopServer } = useRemoteControlStore();
|
||||
stopServer();
|
||||
|
||||
// Direct service call
|
||||
remoteControlService.stopServer();
|
||||
```
|
||||
|
||||
### Mobile Device Access
|
||||
1. Ensure mobile device is on the same network as TV
|
||||
2. Scan QR code displayed in TV app
|
||||
3. Access web interface at `http://{tv_ip}:12346`
|
||||
4. Send messages that appear as notifications on TV
|
||||
|
||||
## Comparison with Alternatives
|
||||
|
||||
### vs react-native-http-bridge
|
||||
- **Advantages**: More control over HTTP implementation, custom error handling
|
||||
- **Disadvantages**: More complex implementation, requires manual HTTP parsing
|
||||
|
||||
### vs External Backend Server
|
||||
- **Advantages**: No additional infrastructure, embedded in app
|
||||
- **Disadvantages**: Limited scalability, single device constraint
|
||||
|
||||
## Future Enhancement Opportunities
|
||||
|
||||
### Security
|
||||
- Authentication token implementation
|
||||
- HTTPS/TLS encryption support
|
||||
- Request rate limiting
|
||||
- CORS configuration
|
||||
|
||||
### Functionality
|
||||
- Multi-device support
|
||||
- WebSocket integration for real-time communication
|
||||
- File upload/download capabilities
|
||||
- Advanced remote control commands
|
||||
|
||||
### Performance
|
||||
- Connection pooling
|
||||
- Request caching
|
||||
- Compression support
|
||||
- IPv6 compatibility
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### "Unable to get IP address" Error
|
||||
- Verify WiFi/Ethernet connection
|
||||
- Check network interface availability
|
||||
- Restart network services
|
||||
|
||||
#### Server Won't Start
|
||||
- Check if port 12346 is already in use
|
||||
- Verify network permissions
|
||||
- Restart the application
|
||||
|
||||
#### Mobile Device Can't Connect
|
||||
- Confirm both devices on same network
|
||||
- Verify firewall settings
|
||||
- Check IP address in QR code
|
||||
|
||||
### Diagnostic Commands
|
||||
```bash
|
||||
# Check network connectivity
|
||||
yarn react-native log-ios # View iOS logs
|
||||
yarn react-native log-android # View Android logs
|
||||
|
||||
# Network debugging
|
||||
netstat -an | grep 12346 # Check port binding (debugging environment)
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The OrionTV native HTTP server implementation provides a robust, embedded solution for remote control functionality without requiring external infrastructure. The custom TCP-based approach offers flexibility and control while maintaining simplicity and performance suitable for TV applications.
|
||||
|
||||
The implementation demonstrates sophisticated understanding of HTTP protocol handling, React Native integration, and TV-specific user experience requirements, making it an effective solution for cross-device interaction in smart TV environments.
|
||||
@@ -1,136 +0,0 @@
|
||||
# 手机遥控功能实现方案 (V2)
|
||||
|
||||
本文档详细描述了在 OrionTV 应用中集成一个基于 **HTTP 请求** 的手机遥控功能的完整方案。
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心功能与流程
|
||||
|
||||
该功能允许用户通过手机浏览器向 TV 端发送文本消息,TV 端接收后以 Toast 形式进行展示。服务将在应用启动时自动开启,用户可在设置中找到入口以显示连接二维码。
|
||||
|
||||
### 流程图
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as App 启动
|
||||
participant RemoteControlStore as 状态管理 (TV)
|
||||
participant RemoteControlService as 遥控服务 (TV)
|
||||
participant User as 用户
|
||||
participant SettingsUI as 设置界面 (TV)
|
||||
participant PhoneBrowser as 手机浏览器
|
||||
|
||||
App->>RemoteControlStore: App 启动, 触发 startHttpServer()
|
||||
RemoteControlStore->>RemoteControlService: 启动 HTTP 服务
|
||||
RemoteControlService-->>RemoteControlStore: 更新服务状态 (IP, Port)
|
||||
|
||||
User->>SettingsUI: 打开设置, 点击“手机遥控”按钮
|
||||
SettingsUI->>RemoteControlStore: 获取服务 URL
|
||||
RemoteControlStore-->>SettingsUI: 返回 serverUrl
|
||||
SettingsUI-->>User: 显示二维码弹窗
|
||||
|
||||
User->>PhoneBrowser: 扫描二维码
|
||||
PhoneBrowser->>RemoteControlService: (HTTP GET) 请求网页
|
||||
RemoteControlService-->>PhoneBrowser: 返回 HTML 页面
|
||||
User->>PhoneBrowser: 输入文本并发送
|
||||
PhoneBrowser->>RemoteControlService: (HTTP POST /message) 发送消息
|
||||
RemoteControlService->>RemoteControlStore: 处理消息 (显示 Toast)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
* **HTTP 服务**: `react-native-http-bridge`
|
||||
* **二维码生成**: `react-native-qrcode-svg`
|
||||
* **网络信息 (IP 地址)**: `@react-native-community/netinfo`
|
||||
* **状态管理**: `zustand` (项目已集成)
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目结构变更
|
||||
|
||||
### 新增文件
|
||||
|
||||
* `services/remoteControlService.ts`: 封装 HTTP 服务的核心逻辑。
|
||||
* `stores/remoteControlStore.ts`: 使用 Zustand 管理远程控制服务的状态。
|
||||
* `components/RemoteControlModal.tsx`: 显示二维码和连接信息的弹窗组件。
|
||||
* `types/react-native-http-bridge.d.ts`: `react-native-http-bridge` 的 TypeScript 类型定义。
|
||||
|
||||
### 修改文件
|
||||
|
||||
* `app/_layout.tsx`: 在应用根组件中调用服务启动逻辑。
|
||||
* `components/SettingsModal.tsx`: 添加“手机遥控”按钮,用于触发二维码弹窗。
|
||||
* `package.json`: 添加新依赖。
|
||||
|
||||
---
|
||||
|
||||
## 4. 实现细节
|
||||
|
||||
### a. 状态管理 (`stores/remoteControlStore.ts`)
|
||||
|
||||
创建一个 Zustand store 来管理遥控服务的状态。
|
||||
|
||||
* **State**:
|
||||
* `isServerRunning`: `boolean` - 服务是否正在运行。
|
||||
* `serverUrl`: `string | null` - 完整的 HTTP 服务 URL (e.g., `http://192.168.1.5:12346`)。
|
||||
* `error`: `string | null` - 错误信息。
|
||||
* **Actions**:
|
||||
* `startServer()`: 异步 action,调用 `remoteControlService.startServer` 并更新 state。
|
||||
* `stopServer()`: 调用 `remoteControlService.stopServer` 并更新 state。
|
||||
|
||||
### b. 服务层 (`services/remoteControlService.ts`)
|
||||
|
||||
实现服务的启动、停止和消息处理。
|
||||
|
||||
* **`startServer()`**:
|
||||
1. 使用 `@react-native-community/netinfo` 获取 IP 地址。
|
||||
2. 定义一个包含 `fetch` API 调用逻辑的 HTML 字符串。
|
||||
3. 使用 `react-native-http-bridge` 在固定端口(如 `12346`)启动 HTTP 服务。
|
||||
4. 配置 `GET /` 路由以返回 HTML 页面。
|
||||
5. 配置 `POST /message` 路由来接收手机端发送的消息,并使用 `Toast` 显示。
|
||||
6. 返回服务器 URL。
|
||||
* **`stopServer()`**:
|
||||
1. 调用 `httpBridge.stop()`。
|
||||
|
||||
### c. UI 集成
|
||||
|
||||
* **`app/_layout.tsx`**:
|
||||
* 在根组件 `useEffect` 中调用 `useRemoteControlStore.getState().startServer()`,实现服务自启。
|
||||
* **`components/SettingsModal.tsx`**:
|
||||
* 添加一个 `<StyledButton text="手机遥控" />`。
|
||||
* 点击按钮时,触发 `RemoteControlModal` 的显示。
|
||||
* **`components/RemoteControlModal.tsx`**:
|
||||
* 从 `remoteControlStore` 中获取 `serverUrl`。
|
||||
* 如果 `serverUrl` 存在,则使用 `react-native-qrcode-svg` 的 `<QRCode />` 组件显示二维码。
|
||||
* 如果不存在,则显示加载中或错误信息。
|
||||
|
||||
### d. 网页内容 (HTML)
|
||||
|
||||
一个简单的 HTML 页面,包含一个输入框和一个按钮。
|
||||
|
||||
```html
|
||||
<html>
|
||||
<head>
|
||||
<title>OrionTV Remote</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style> /* ... some basic styles ... */ </style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>发送消息到 TV</h3>
|
||||
<input id="text" />
|
||||
<button onclick="send()">发送</button>
|
||||
<script>
|
||||
function send() {
|
||||
const val = document.getElementById("text").value;
|
||||
if (val) {
|
||||
fetch("/message", {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: val })
|
||||
});
|
||||
document.getElementById("text").value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,131 +0,0 @@
|
||||
# 设置页面重构方案
|
||||
|
||||
## 目标
|
||||
1. 将设置从弹窗模式改为独立页面
|
||||
2. 新增直播源配置功能
|
||||
3. 新增远程输入开关配置
|
||||
4. 新增播放源启用配置
|
||||
|
||||
## 现有架构分析
|
||||
|
||||
### 当前设置相关文件:
|
||||
- `stores/settingsStore.ts` - 设置状态管理,目前只有API地址配置
|
||||
- `components/SettingsModal.tsx` - 设置弹窗组件
|
||||
- `stores/remoteControlStore.ts` - 远程控制状态管理
|
||||
|
||||
### 现有功能:
|
||||
- API基础地址配置
|
||||
- 远程控制服务器(但未集成到设置中)
|
||||
|
||||
## 重构方案
|
||||
|
||||
### 1. 创建独立设置页面
|
||||
- 新建 `app/settings.tsx` 页面
|
||||
- 使用 Expo Router 的文件路由系统
|
||||
- 删除现有的 `SettingsModal.tsx` 组件
|
||||
|
||||
### 2. 扩展设置Store
|
||||
在 `settingsStore.ts` 中新增以下配置项:
|
||||
```typescript
|
||||
interface SettingsState {
|
||||
// 现有配置
|
||||
apiBaseUrl: string;
|
||||
|
||||
// 新增配置项
|
||||
liveStreamSources: LiveStreamSource[]; // 直播源配置
|
||||
remoteInputEnabled: boolean; // 远程输入开关
|
||||
videoSourceConfig: VideoSourceConfig; // 播放源配置
|
||||
}
|
||||
|
||||
interface LiveStreamSource {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface VideoSourceConfig {
|
||||
primarySource: string;
|
||||
fallbackSources: string[];
|
||||
enabledSources: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 设置页面UI结构
|
||||
```
|
||||
设置页面 (app/settings.tsx)
|
||||
├── API 配置区域
|
||||
│ └── API 基础地址输入框
|
||||
├── 直播源配置区域
|
||||
│ ├── 直播源列表
|
||||
│ ├── 添加直播源按钮
|
||||
│ └── 编辑/删除直播源功能
|
||||
├── 远程输入配置区域
|
||||
│ └── 远程输入开关
|
||||
└── 播放源配置区域
|
||||
├── 主播放源选择
|
||||
├── 备用播放源配置
|
||||
└── 启用的播放源选择
|
||||
```
|
||||
|
||||
### 4. 组件设计
|
||||
- 使用 TV 适配的组件和样式
|
||||
- 实现焦点管理和遥控器导航
|
||||
- 遵循现有的设计规范(ThemedView, ThemedText, StyledButton)
|
||||
|
||||
### 5. 导航集成
|
||||
- 在主页面添加设置入口
|
||||
- 使用 Expo Router 的 router.push('/settings') 进行导航
|
||||
|
||||
## 实施步骤
|
||||
|
||||
1. **扩展 settingsStore.ts**
|
||||
- 添加新的状态接口
|
||||
- 实现新配置项的增删改查方法
|
||||
- 集成本地存储
|
||||
|
||||
2. **创建设置页面**
|
||||
- 新建 `app/settings.tsx`
|
||||
- 实现基础页面结构和导航
|
||||
|
||||
3. **实现配置组件**
|
||||
- API 配置组件(复用现有逻辑)
|
||||
- 直播源配置组件
|
||||
- 远程输入开关组件
|
||||
- 播放源配置组件
|
||||
|
||||
4. **集成远程控制**
|
||||
- 将远程控制功能集成到设置页面
|
||||
- 统一管理所有设置项
|
||||
|
||||
5. **更新导航**
|
||||
- 在主页面添加设置入口
|
||||
- 移除现有的设置弹窗触发逻辑
|
||||
|
||||
6. **测试验证**
|
||||
- 测试所有配置项的保存和加载
|
||||
- 验证TV平台的交互体验
|
||||
- 确保配置项生效
|
||||
|
||||
## 技术考虑
|
||||
|
||||
### TV平台适配
|
||||
- 使用 `useTVRemoteHandler` 处理遥控器事件
|
||||
- 实现合适的焦点管理
|
||||
- 确保所有交互元素可通过遥控器操作
|
||||
|
||||
### 数据持久化
|
||||
- 使用现有的 `SettingsManager` 进行本地存储
|
||||
- 确保新配置项能正确保存和恢复
|
||||
|
||||
### 向后兼容
|
||||
- 保持现有API配置功能不变
|
||||
- 为新配置项提供默认值
|
||||
- 处理旧版本设置数据的迁移
|
||||
|
||||
## 预期收益
|
||||
|
||||
1. **更好的用户体验**:独立页面提供更多空间展示配置选项
|
||||
2. **功能扩展性**:为未来添加更多配置项提供良好基础
|
||||
3. **代码组织**:将设置相关功能集中管理
|
||||
4. **TV平台适配**:更好的遥控器交互体验
|
||||
@@ -1,77 +0,0 @@
|
||||
# StyledButton 组件设计文档
|
||||
|
||||
## 1. 目的
|
||||
|
||||
为了统一整个应用中的按钮样式和行为,减少代码重复,并提高开发效率和一致性,我们设计了一个通用的 `StyledButton` 组件。
|
||||
|
||||
该组件将取代以下位置的自定义 `Pressable` 和 `TouchableOpacity` 实现:
|
||||
|
||||
- `app/index.tsx` (分类按钮, 头部图标按钮)
|
||||
- `components/DetailButton.tsx`
|
||||
- `components/EpisodeSelectionModal.tsx` (剧集分组按钮, 剧集项按钮, 关闭按钮)
|
||||
- `components/SettingsModal.tsx` (取消和保存按钮)
|
||||
- `app/search.tsx` (清除按钮)
|
||||
- `components/MediaButton.tsx` (媒体控制按钮)
|
||||
- `components/NextEpisodeOverlay.tsx` (取消按钮)
|
||||
|
||||
## 2. API 设计
|
||||
|
||||
`StyledButton` 组件将基于 React Native 的 `Pressable` 构建,并提供以下 props:
|
||||
|
||||
```typescript
|
||||
import { PressableProps, StyleProp, ViewStyle, TextStyle } from "react-native";
|
||||
|
||||
interface StyledButtonProps extends PressableProps {
|
||||
// 按钮的主要内容,可以是文本或图标等 React 节点
|
||||
children?: React.ReactNode;
|
||||
|
||||
// 如果按钮只包含文本,可以使用此 prop 快速设置
|
||||
text?: string;
|
||||
|
||||
// 按钮的视觉变体,用于应用不同的预设样式
|
||||
// 'default': 默认灰色背景
|
||||
// 'primary': 主题色背景,用于关键操作
|
||||
// 'ghost': 透明背景,通常用于图标按钮
|
||||
variant?: "default" | "primary" | "ghost";
|
||||
|
||||
// 按钮是否处于选中状态
|
||||
isSelected?: boolean;
|
||||
|
||||
// 覆盖容器的样式
|
||||
style?: StyleProp<ViewStyle>;
|
||||
|
||||
// 覆盖文本的样式 (当使用 `text` prop 时生效)
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 样式和行为
|
||||
|
||||
### 状态样式:
|
||||
|
||||
- **默认状态 (`default`)**:
|
||||
- 背景色: `#333`
|
||||
- 边框: `transparent`
|
||||
- **聚焦状态 (`focused`)**:
|
||||
- 背景色: `#0056b3` (深蓝色)
|
||||
- 边框: `#fff`
|
||||
- 阴影/光晕效果
|
||||
- 轻微放大 (`transform: scale(1.1)`)
|
||||
- **选中状态 (`isSelected`)**:
|
||||
- 背景色: `#007AFF` (亮蓝色)
|
||||
- **主操作 (`primary`)**:
|
||||
- 默认背景色: `#007AFF`
|
||||
- **透明背景 (`ghost`)**:
|
||||
- 默认背景色: `transparent`
|
||||
|
||||
### 结构:
|
||||
|
||||
组件内部将使用 `Pressable` 作为根元素,并根据 `focused` 和 `isSelected` props 动态计算样式。如果 `children` 和 `text` prop 都提供了,`children` 将优先被渲染。
|
||||
|
||||
## 4. 实现计划
|
||||
|
||||
1. **创建 `components/StyledButton.tsx` 文件**。
|
||||
2. **实现上述 API 和样式逻辑**。
|
||||
3. **逐个重构目标文件**,将原有的 `Pressable`/`TouchableOpacity` 替换为新的 `StyledButton` 组件。
|
||||
4. **删除旧的、不再需要的样式**。
|
||||
5. **测试所有被修改的界面**,确保按钮的功能和视觉效果符合预期。
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "OrionTV",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"scripts": {
|
||||
"start": "EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
"start-tv": "EXPO_TV=1 EXPO_USE_METRO_WORKSPACE_ROOT=1 expo start",
|
||||
|
||||
@@ -26,7 +26,7 @@ const useAuthStore = create<AuthState>((set) => ({
|
||||
try {
|
||||
const serverConfig = useSettingsStore.getState().serverConfig;
|
||||
if (!serverConfig?.StorageType) {
|
||||
Toast.show({ type: "error", text1: "请检查网络或者 API 地址是否可用" });
|
||||
Toast.show({ type: "error", text1: "请检查网络或者服务器地址是否可用" });
|
||||
return
|
||||
}
|
||||
const cookies = await Cookies.get(api.baseURL);
|
||||
|
||||
@@ -147,7 +147,7 @@ const useHomeStore = create<HomeState>((set, get) => ({
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.message === "API_URL_NOT_SET") {
|
||||
set({ error: "请点击右上角设置按钮,配置您的 API 地址" });
|
||||
set({ error: "请点击右上角设置按钮,配置您的服务器地址" });
|
||||
} else {
|
||||
set({ error: "加载失败,请重试" });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user