mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-17 01:33:43 +08:00
Remediate audit findings across runtime and platforms
This commit is contained in:
+49
-902
@@ -1,930 +1,77 @@
|
||||
# 贡献指南 (Contributing Guide)
|
||||
# Contributing
|
||||
|
||||
欢迎来到 **KVideo** 项目!我们非常感谢你愿意为这个项目做出贡献。无论是修复 Bug、添加新功能、改进文档,还是提出建议,你的每一份贡献都将让这个项目变得更好。
|
||||
## Baseline
|
||||
|
||||
为了确保协作顺畅、代码质量一致,请在提交贡献前仔细阅读本指南。
|
||||
Contributions are expected to preserve the post-audit behavior of this repository:
|
||||
|
||||
## 📋 目录
|
||||
- secure outbound request policy
|
||||
- private-by-default relay endpoints
|
||||
- explicit auth secret requirements
|
||||
- Workers/OpenNext Cloudflare path
|
||||
- Android TV-only wrapper scope
|
||||
- Apple TV unsupported
|
||||
|
||||
- [行为准则](#行为准则)
|
||||
- [快速开始](#快速开始)
|
||||
- [开发环境设置](#开发环境设置)
|
||||
- [代码规范](#代码规范)
|
||||
- [Git 工作流程](#git-工作流程)
|
||||
- [提交规范](#提交规范)
|
||||
- [Pull Request 指南](#pull-request-指南)
|
||||
- [设计系统规范](#设计系统规范)
|
||||
- [测试要求](#测试要求)
|
||||
- [常见问题](#常见问题)
|
||||
Do not reintroduce permissive relay behavior, wildcard CORS, cookie forwarding, TLS verification bypasses, or public private-network fetches.
|
||||
|
||||
## 🤝 行为准则
|
||||
## Prerequisites
|
||||
|
||||
我们致力于构建一个开放、友好、包容的社区环境。请在参与项目时:
|
||||
- Node.js 22+
|
||||
- npm 10+
|
||||
- Java 17 for Android builds
|
||||
- Android SDK for Android TV validation
|
||||
- Docker if you need to run the image/build checks locally
|
||||
|
||||
- ✅ 保持尊重和礼貌
|
||||
- ✅ 欢迎不同的观点和经验
|
||||
- ✅ 接受建设性的批评
|
||||
- ✅ 专注于对社区最有利的事情
|
||||
- ❌ 不要使用性别化的语言或图像
|
||||
- ❌ 不要进行人身攻击或政治攻击
|
||||
- ❌ 不要骚扰或歧视他人
|
||||
|
||||
详细的行为准则请参阅 [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 我能贡献什么?
|
||||
|
||||
以下是一些你可以做出贡献的方式:
|
||||
|
||||
1. **🐛 报告 Bug**:发现了问题?请提交 Issue
|
||||
2. **💡 提出新功能**:有好想法?在 Discussions 或 Issues 中分享
|
||||
3. **📝 改进文档**:发现文档不清晰或有错误?帮助我们改进
|
||||
4. **🎨 优化 UI/UX**:让界面更美观、更易用
|
||||
5. **⚡ 性能优化**:让应用运行得更快
|
||||
6. **🔧 修复 Bug**:解决现有的问题
|
||||
7. **✨ 添加功能**:实现新的特性
|
||||
|
||||
### 第一次贡献?
|
||||
|
||||
如果这是你第一次为开源项目做贡献,我们推荐:
|
||||
|
||||
1. 浏览 [GitHub Issues](https://github.com/KuekHaoYang/KVideo/issues)
|
||||
2. 寻找标记为 `good first issue` 的问题
|
||||
3. 在 Issue 中评论,表明你想要解决这个问题
|
||||
4. 按照本指南进行开发和提交
|
||||
|
||||
## 🛠 开发环境设置
|
||||
|
||||
### 系统要求
|
||||
|
||||
确保你的开发环境满足以下要求:
|
||||
|
||||
| 工具 | 最低版本 | 推荐版本 | 检查命令 |
|
||||
|------|----------|----------|----------|
|
||||
| **Node.js** | 20.0.0 | 22.x LTS | `node --version` |
|
||||
| **npm** | 9.0.0 | 10.x | `npm --version` |
|
||||
| **Git** | 2.30.0 | 最新版本 | `git --version` |
|
||||
|
||||
### 详细设置步骤
|
||||
|
||||
#### 1. Fork 仓库
|
||||
|
||||
点击 GitHub 页面右上角的 "Fork" 按钮,将项目 Fork 到你的账号下。
|
||||
|
||||
#### 2. 克隆仓库
|
||||
|
||||
```bash
|
||||
# 克隆你 Fork 的仓库
|
||||
git clone https://github.com/YOUR_USERNAME/KVideo.git
|
||||
cd KVideo
|
||||
|
||||
# 添加上游仓库
|
||||
git remote add upstream https://github.com/KuekHaoYang/KVideo.git
|
||||
```
|
||||
|
||||
#### 3. 安装依赖
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
#### 4. 启动开发服务器
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:3000` 查看应用。
|
||||
|
||||
#### 5. 验证环境
|
||||
|
||||
确保以下命令都能正常运行:
|
||||
|
||||
```bash
|
||||
# 代码检查
|
||||
npm run lint
|
||||
|
||||
# 构建测试
|
||||
npm test
|
||||
npm run test:e2e
|
||||
npm run build
|
||||
npm run cf:build
|
||||
docker compose config
|
||||
docker build -t kvideo .
|
||||
cd android-tv && ./gradlew --no-daemon lint test assembleDebug assembleRelease
|
||||
```
|
||||
|
||||
## 📏 代码规范
|
||||
## Required Checks Before a PR
|
||||
|
||||
### 核心规范
|
||||
At minimum, run the checks relevant to the code you changed. For broad or infrastructure-facing work, run the full matrix:
|
||||
|
||||
#### 1. 文件长度限制 ⚠️
|
||||
- `npm run lint`
|
||||
- `npm test`
|
||||
- `npm run build`
|
||||
- `npm run cf:build`
|
||||
- `npm audit --omit=dev`
|
||||
- `docker compose config`
|
||||
- `docker build -t kvideo .`
|
||||
- `cd android-tv && ./gradlew --no-daemon lint test assembleDebug assembleRelease`
|
||||
|
||||
> [!CAUTION]
|
||||
> **这是项目的硬性规则!所有项目文件必须保持在 150 行以内(除系统文件外)。**
|
||||
If you touch user flows, add or update Playwright smoke coverage in [`playwright`](/Users/haoyangkuek/development/KVideo/playwright).
|
||||
|
||||
**检查命令:**
|
||||
## Style Expectations
|
||||
|
||||
```bash
|
||||
find . -type f -not -path "*/node_modules/*" -not -path "*/.next/*" -not -path "*/.git/*" -not -name "package-lock.json" -not -name "*.png" -not -name "*.md" | xargs wc -l | awk '$1 > 150 && $2 != "total" {print $2 " - " $1 "行"}'
|
||||
```
|
||||
- Keep changes scoped and intentional.
|
||||
- Prefer testable extraction over speculative abstraction.
|
||||
- Do not add fake compatibility aliases for insecure legacy behavior.
|
||||
- Do not depend on arbitrary file-length limits. CI-backed quality gates matter; line counts do not.
|
||||
- Keep documentation accurate to the actual runtime behavior of the branch.
|
||||
|
||||
**如果命令有输出,说明有文件超过 150 行,必须重构!**
|
||||
## Pull Requests
|
||||
|
||||
**重构策略:**
|
||||
Each PR should include:
|
||||
|
||||
如果文件超过 150 行,请使用以下方法重构:
|
||||
- what changed
|
||||
- why it changed
|
||||
- risk areas
|
||||
- validation performed
|
||||
- any deployment/env var changes
|
||||
|
||||
##### A. 提取组件
|
||||
|
||||
**问题:** 一个组件太长,包含太多 JSX
|
||||
|
||||
**解决方案:** 将大组件拆分为多个小组件
|
||||
|
||||
```typescript
|
||||
// ❌ 不好:一个 200 行的大组件
|
||||
export function VideoPlayer() {
|
||||
// 150+ 行代码
|
||||
return (
|
||||
<div>
|
||||
{/* 大量 JSX */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ 好:拆分为多个小组件
|
||||
export function VideoPlayer() {
|
||||
return (
|
||||
<div>
|
||||
<PlayerControls />
|
||||
<ProgressBar />
|
||||
<VolumeControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PlayerControls.tsx (单独文件)
|
||||
export function PlayerControls() { /* ... */ }
|
||||
|
||||
// ProgressBar.tsx (单独文件)
|
||||
export function ProgressBar() { /* ... */ }
|
||||
|
||||
// VolumeControl.tsx (单独文件)
|
||||
export function VolumeControl() { /* ... */ }
|
||||
```
|
||||
|
||||
##### B. 提取自定义 Hook
|
||||
|
||||
**问题:** 组件包含大量状态逻辑
|
||||
|
||||
**解决方案:** 将逻辑提取到自定义 Hook
|
||||
|
||||
```typescript
|
||||
// ❌ 不好:组件内有大量状态逻辑
|
||||
export function SearchPage() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// ... 大量逻辑
|
||||
|
||||
const handleSearch = async () => {
|
||||
// ... 50+ 行逻辑
|
||||
};
|
||||
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
// ✅ 好:提取到自定义 Hook
|
||||
export function SearchPage() {
|
||||
const { query, results, loading, handleSearch } = useSearch();
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
// useSearch.ts (单独文件)
|
||||
export function useSearch() {
|
||||
// ... 所有状态逻辑
|
||||
return { query, results, loading, handleSearch };
|
||||
}
|
||||
```
|
||||
|
||||
##### C. 提取工具函数
|
||||
|
||||
**问题:** 文件包含大量辅助函数
|
||||
|
||||
**解决方案:** 将工具函数移到 `lib/utils/`
|
||||
|
||||
```typescript
|
||||
// ❌ 不好:组件文件包含工具函数
|
||||
export function VideoCard() {
|
||||
const formatDuration = (seconds: number) => {
|
||||
// ... 格式化逻辑
|
||||
};
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
// ... 格式化逻辑
|
||||
};
|
||||
|
||||
// ... 更多工具函数
|
||||
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
// ✅ 好:提取到工具文件
|
||||
import { formatDuration, formatDate } from '@/lib/utils/format-utils';
|
||||
|
||||
export function VideoCard() {
|
||||
return <div>{/* JSX */}</div>;
|
||||
}
|
||||
|
||||
// lib/utils/format-utils.ts
|
||||
export function formatDuration(seconds: number) { /* ... */ }
|
||||
export function formatDate(date: Date) { /* ... */ }
|
||||
```
|
||||
|
||||
##### D. 模块化
|
||||
|
||||
**问题:** 单个文件处理多个相关功能
|
||||
|
||||
**解决方案:** 按功能拆分文件并使用桶文件(barrel exports)
|
||||
|
||||
```typescript
|
||||
// ❌ 不好:player-utils.ts 包含 200 行
|
||||
export function parseHLS() { /* ... */ }
|
||||
export function handlePlayback() { /* ... */ }
|
||||
export function manageQuality() { /* ... */ }
|
||||
// ... 更多函数
|
||||
|
||||
// ✅ 好:拆分为多个文件
|
||||
// lib/utils/player/index.ts
|
||||
export * from './hls-parser';
|
||||
export * from './playback-manager';
|
||||
export * from './quality-manager';
|
||||
|
||||
// lib/utils/player/hls-parser.ts
|
||||
export function parseHLS() { /* ... */ }
|
||||
|
||||
// lib/utils/player/playback-manager.ts
|
||||
export function handlePlayback() { /* ... */ }
|
||||
|
||||
// lib/utils/player/quality-manager.ts
|
||||
export function manageQuality() { /* ... */ }
|
||||
```
|
||||
|
||||
#### 2. TypeScript 规范
|
||||
|
||||
**类型安全**
|
||||
|
||||
```typescript
|
||||
// ❌ 避免使用 any
|
||||
function processData(data: any) {
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// ✅ 使用具体类型
|
||||
interface VideoData {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
function processData(data: VideoData) {
|
||||
return data.title;
|
||||
}
|
||||
|
||||
// ✅ 或使用 unknown(需要类型检查)
|
||||
function processData(data: unknown) {
|
||||
if (typeof data === 'object' && data !== null && 'value' in data) {
|
||||
return (data as { value: string }).value;
|
||||
}
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
```
|
||||
|
||||
**函数返回类型**
|
||||
|
||||
```typescript
|
||||
// ❌ 缺少返回类型
|
||||
function calculateTotal(items) {
|
||||
return items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
|
||||
// ✅ 明确返回类型
|
||||
function calculateTotal(items: Item[]): number {
|
||||
return items.reduce((sum, item) => sum + item.price, 0);
|
||||
}
|
||||
```
|
||||
|
||||
**接口定义**
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 interface 定义对象类型
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
onPlay: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ✅ 使用 type 定义联合类型
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
```
|
||||
|
||||
#### 3. React 组件规范
|
||||
|
||||
**函数组件**
|
||||
|
||||
```typescript
|
||||
// ✅ 标准函数组件结构
|
||||
interface ButtonProps {
|
||||
variant?: 'primary' | 'secondary';
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', children, onClick }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-${variant}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**组件文件组织**
|
||||
|
||||
```typescript
|
||||
// 1. 导入
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
// 2. 类型定义
|
||||
interface ComponentProps {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 3. 组件定义
|
||||
export function Component({ prop1, prop2 }: ComponentProps) {
|
||||
// 4. Hooks
|
||||
const [state, setState] = useState();
|
||||
const router = useRouter();
|
||||
|
||||
// 5. 事件处理函数
|
||||
const handleClick = () => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// 6. 渲染
|
||||
return (
|
||||
<div>{/* JSX */}</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**单一职责原则**
|
||||
|
||||
```typescript
|
||||
// ❌ 组件做太多事情
|
||||
export function VideoSection() {
|
||||
// 获取数据
|
||||
// 处理搜索
|
||||
// 渲染列表
|
||||
// 处理分页
|
||||
// 处理过滤
|
||||
}
|
||||
|
||||
// ✅ 拆分为专注的组件
|
||||
export function VideoSection() {
|
||||
const videos = useVideos();
|
||||
return (
|
||||
<div>
|
||||
<SearchBar />
|
||||
<FilterPanel />
|
||||
<VideoList videos={videos} />
|
||||
<Pagination />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 样式规范
|
||||
|
||||
**Tailwind CSS 优先**
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 Tailwind 类名
|
||||
export function Card({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl glass p-6 hover:shadow-lg transition-shadow">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**遵循 Liquid Glass 设计系统**
|
||||
|
||||
```typescript
|
||||
// ✅ 正确使用圆角
|
||||
<div className="rounded-2xl"> {/* 容器:大圆角 */}
|
||||
<div className="rounded-full"> {/* 小元素:完全圆形 */}
|
||||
|
||||
// ❌ 不要使用其他圆角值
|
||||
<div className="rounded-lg"> {/* 错误! */}
|
||||
<div className="rounded-xl"> {/* 错误! */}
|
||||
```
|
||||
|
||||
**响应式设计**
|
||||
|
||||
```typescript
|
||||
// ✅ 移动优先的响应式设计
|
||||
<div className="
|
||||
flex flex-col {/* 移动端:垂直布局 */}
|
||||
md:flex-row {/* 平板及以上:水平布局 */}
|
||||
gap-4 md:gap-6 {/* 响应式间距 */}
|
||||
">
|
||||
```
|
||||
|
||||
#### 5. 命名规范
|
||||
|
||||
**文件命名**
|
||||
|
||||
- 组件文件:`PascalCase.tsx`(例如:`VideoCard.tsx`)
|
||||
- Hook 文件:`camelCase.ts`(例如:`useVideoPlayer.ts`)
|
||||
- 工具文件:`kebab-case.ts`(例如:`format-utils.ts`)
|
||||
- 类型文件:`kebab-case.ts`(例如:`video-types.ts`)
|
||||
|
||||
**变量命名**
|
||||
|
||||
```typescript
|
||||
// ✅ 清晰的命名
|
||||
const videoList = [...];
|
||||
const isLoading = false;
|
||||
const handleSubmit = () => {};
|
||||
|
||||
// ❌ 模糊的命名
|
||||
const data = [...];
|
||||
const flag = false;
|
||||
const fn = () => {};
|
||||
```
|
||||
|
||||
**常量命名**
|
||||
|
||||
```typescript
|
||||
// ✅ 全大写 + 下划线
|
||||
const MAX_VIDEO_DURATION = 7200;
|
||||
const API_BASE_URL = 'https://api.example.com';
|
||||
```
|
||||
|
||||
#### 6. 导入顺序
|
||||
|
||||
```typescript
|
||||
// 1. React 和 Next.js
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// 2. 第三方库
|
||||
import { create } from 'zustand';
|
||||
|
||||
// 3. 项目别名导入
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { formatDate } from '@/lib/utils/date-utils';
|
||||
|
||||
// 4. 相对路径导入
|
||||
import { LocalComponent } from './LocalComponent';
|
||||
|
||||
// 5. 类型导入
|
||||
import type { Video } from '@/lib/types/video';
|
||||
```
|
||||
|
||||
## 🔄 Git 工作流程
|
||||
|
||||
### 分支策略
|
||||
|
||||
**主分支**
|
||||
|
||||
- `main`:稳定的生产分支,只接受 PR 合并
|
||||
|
||||
**功能分支命名**
|
||||
|
||||
遵循以下命名规范:
|
||||
|
||||
- `feat/功能名称`:新功能(例如:`feat/add-playlist`)
|
||||
- `fix/问题描述`:错误修复(例如:`fix/search-crash`)
|
||||
- `docs/文档修改`:文档更新(例如:`docs/update-readme`)
|
||||
- `refactor/重构名称`:代码重构(例如:`refactor/player-controls`)
|
||||
- `perf/优化内容`:性能优化(例如:`perf/image-loading`)
|
||||
- `style/样式修改`:样式调整(例如:`style/button-spacing`)
|
||||
- `test/测试内容`:测试相关(例如:`test/add-unit-tests`)
|
||||
- `chore/其他修改`:构建或工具变动(例如:`chore/update-deps`)
|
||||
|
||||
### 开发流程
|
||||
|
||||
#### 1. 同步上游仓库
|
||||
|
||||
在开始新工作前,先同步最新的代码:
|
||||
|
||||
```bash
|
||||
# 获取上游更新
|
||||
git fetch upstream
|
||||
|
||||
# 切换到主分支
|
||||
git checkout main
|
||||
|
||||
# 合并上游更新
|
||||
git merge upstream/main
|
||||
|
||||
# 推送到你的 Fork
|
||||
git push origin main
|
||||
```
|
||||
|
||||
#### 2. 创建功能分支
|
||||
|
||||
```bash
|
||||
# 从 main 创建新分支
|
||||
git checkout -b feat/your-feature-name
|
||||
|
||||
# 确认当前分支
|
||||
git branch
|
||||
```
|
||||
|
||||
#### 3. 进行开发
|
||||
|
||||
在开发过程中:
|
||||
|
||||
- 频繁提交小的、原子性的改动
|
||||
- 编写清晰的提交信息
|
||||
- 定期运行 `npm run lint` 检查代码
|
||||
|
||||
#### 4. 提交前检查
|
||||
|
||||
**必须通过的检查:**
|
||||
|
||||
```bash
|
||||
# 1. 代码规范检查
|
||||
npm run lint
|
||||
|
||||
# 2. 文件长度检查
|
||||
find . -type f -not -path "*/node_modules/*" -not -path "*/.next/*" -not -path "*/.git/*" -not -name "package-lock.json" -not -name "*.png" -not -name "*.md" | xargs wc -l | awk '$1 > 150 && $2 != "total" {print $2 " - " $1 "行"}'
|
||||
|
||||
# 3. 构建测试
|
||||
npm run build
|
||||
```
|
||||
|
||||
**如果任何检查失败,必须先修复!**
|
||||
|
||||
#### 5. 推送分支
|
||||
|
||||
```bash
|
||||
# 推送到你的 Fork
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
## 📝 提交规范
|
||||
|
||||
### Conventional Commits
|
||||
|
||||
我们使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
**Type 类型:**
|
||||
|
||||
- `feat`:新功能
|
||||
- `fix`:错误修复
|
||||
- `docs`:文档变更
|
||||
- `style`:代码格式(不影响代码运行)
|
||||
- `refactor`:重构
|
||||
- `perf`:性能优化
|
||||
- `test`:测试相关
|
||||
- `chore`:构建过程或辅助工具的变动
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 简单提交
|
||||
git commit -m "feat: 添加视频播放列表功能"
|
||||
|
||||
# 详细提交
|
||||
git commit -m "feat(player): 添加倍速播放功能
|
||||
|
||||
- 支持 0.5x 到 2x 的播放速度
|
||||
- 添加速度选择器 UI
|
||||
- 保存用户的速度偏好
|
||||
|
||||
Closes #123"
|
||||
```
|
||||
|
||||
**提交信息最佳实践:**
|
||||
|
||||
- ✅ 使用中文或英文(保持一致)
|
||||
- ✅ 使用祈使句("添加功能" 而不是 "添加了功能")
|
||||
- ✅ 第一行不超过 50 个字符
|
||||
- ✅ 正文每行不超过 72 个字符
|
||||
- ✅ 说明 "做了什么" 和 "为什么",而不仅是 "怎么做"
|
||||
|
||||
## 🔍 Pull Request 指南
|
||||
|
||||
### 创建 PR
|
||||
|
||||
1. **推送分支到你的 Fork**
|
||||
|
||||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
2. **在 GitHub 上创建 PR**
|
||||
|
||||
- 访问你的 Fork 页面
|
||||
- 点击 "Compare & pull request"
|
||||
- 选择目标分支:`KuekHaoYang/KVideo:main`
|
||||
|
||||
### PR 描述模板
|
||||
|
||||
```markdown
|
||||
## 📝 变更说明
|
||||
|
||||
简要描述这个 PR 做了什么。
|
||||
|
||||
## 🎯 相关 Issue
|
||||
|
||||
Closes #123
|
||||
Fixes #456
|
||||
|
||||
## 📸 截图(如果是 UI 变更)
|
||||
|
||||
[如果有 UI 变更,添加截图或 GIF]
|
||||
|
||||
## ✅ 检查清单
|
||||
|
||||
- [ ] 代码已通过 `npm run lint`
|
||||
- [ ] 所有文件都在 150 行以内
|
||||
- [ ] 构建成功(`npm run build`)
|
||||
- [ ] 已在本地测试所有变更
|
||||
- [ ] 遵循 Liquid Glass 设计系统
|
||||
- [ ] 提交信息符合规范
|
||||
- [ ] 已更新相关文档
|
||||
|
||||
## 🧪 测试步骤
|
||||
|
||||
1. 第一步
|
||||
2. 第二步
|
||||
3. 预期结果
|
||||
|
||||
## 📌 额外说明
|
||||
|
||||
[任何其他需要 reviewer 知道的信息]
|
||||
```
|
||||
|
||||
### PR 审查流程
|
||||
|
||||
1. **自动检查**:GitHub Actions 会自动运行检查
|
||||
2. **代码审查**:维护者会审查你的代码
|
||||
3. **修改请求**:如果需要修改,会留下评论
|
||||
4. **批准和合并**:审查通过后会被合并
|
||||
|
||||
### 回应审查意见
|
||||
|
||||
```bash
|
||||
# 进行修改后
|
||||
git add .
|
||||
git commit -m "refactor: 根据审查意见调整代码"
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
|
||||
PR 会自动更新。
|
||||
|
||||
## 🎨 设计系统规范
|
||||
|
||||
### Liquid Glass 原则
|
||||
|
||||
在编写 UI 代码时,必须遵循 Liquid Glass 设计系统:
|
||||
|
||||
#### 1. 圆角规范
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **只使用两种圆角:`rounded-2xl` 和 `rounded-full`**
|
||||
|
||||
```typescript
|
||||
// ✅ 正确
|
||||
<div className="rounded-2xl"> {/* 容器、卡片、按钮、输入框 */}
|
||||
<div className="rounded-full"> {/* 头像、徽章、药丸形状 */}
|
||||
|
||||
// ❌ 错误
|
||||
<div className="rounded-lg">
|
||||
<div className="rounded-xl">
|
||||
<div className="rounded-md">
|
||||
```
|
||||
|
||||
#### 2. 玻璃效果
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 glass 类或 backdrop-filter
|
||||
<div className="glass">
|
||||
{/* 内容 */}
|
||||
</div>
|
||||
|
||||
// 或自定义玻璃效果
|
||||
<div className="
|
||||
backdrop-blur-xl
|
||||
backdrop-saturate-180
|
||||
backdrop-brightness-110
|
||||
bg-white/10
|
||||
border border-white/20
|
||||
">
|
||||
```
|
||||
|
||||
#### 3. 动画过渡
|
||||
|
||||
```typescript
|
||||
// ✅ 使用标准过渡曲线
|
||||
<button className="
|
||||
transition-all
|
||||
duration-300
|
||||
ease-out
|
||||
hover:scale-105
|
||||
">
|
||||
```
|
||||
|
||||
#### 4. 颜色系统
|
||||
|
||||
```typescript
|
||||
// ✅ 使用 CSS 变量
|
||||
<div className="bg-glass text-glass-text border-glass-border">
|
||||
|
||||
// 或 Tailwind 的语义化颜色
|
||||
<div className="bg-primary text-primary-foreground">
|
||||
```
|
||||
|
||||
### 组件复用
|
||||
|
||||
优先复用 `components/ui/` 下的基础组件:
|
||||
|
||||
```typescript
|
||||
// ✅ 好:复用基础组件
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
|
||||
export function Feature() {
|
||||
return (
|
||||
<Modal>
|
||||
<Button variant="primary">确定</Button>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ❌ 不好:重新实现基础组件
|
||||
export function Feature() {
|
||||
return (
|
||||
<div className="modal">
|
||||
<button className="btn">确定</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 🧪 测试要求
|
||||
|
||||
### 手动测试
|
||||
|
||||
在提交 PR 前,请手动测试以下内容:
|
||||
|
||||
#### 功能测试
|
||||
|
||||
- [ ] 新功能按预期工作
|
||||
- [ ] 没有破坏现有功能
|
||||
- [ ] 边界情况处理正确
|
||||
|
||||
#### 浏览器测试
|
||||
|
||||
在以下浏览器中测试:
|
||||
|
||||
- [ ] Chrome/Edge(最新版)
|
||||
- [ ] Firefox(最新版)
|
||||
- [ ] Safari(最新版)
|
||||
|
||||
#### 响应式测试
|
||||
|
||||
在以下设备尺寸测试:
|
||||
|
||||
- [ ] 移动端(375px - 428px)
|
||||
- [ ] 平板端(768px - 1024px)
|
||||
- [ ] 桌面端(1280px+)
|
||||
|
||||
#### 无障碍测试
|
||||
|
||||
- [ ] 键盘导航正常工作
|
||||
- [ ] 焦点状态清晰可见
|
||||
- [ ] 屏幕阅读器友好
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
# 运行 ESLint
|
||||
npm run lint
|
||||
|
||||
# 检查文件长度
|
||||
find . -type f -not -path "*/node_modules/*" -not -path "*/.next/*" -not -path "*/.git/*" -not -name "package-lock.json" -not -name "*.png" -not -name "*.md" | xargs wc -l | awk '$1 > 150 && $2 != "total" {print $2 " - " $1 "行"}'
|
||||
```
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q1: 我应该从哪里开始?
|
||||
|
||||
**A:** 查看标记为 `good first issue` 的 Issues,这些通常比较简单,适合新手。
|
||||
|
||||
### Q2: 如何让文件保持在 150 行以内?
|
||||
|
||||
**A:** 参考 [文件长度限制](#1-文件长度限制-️) 部分的重构策略。关键是:
|
||||
- 提取组件
|
||||
- 提取 Hook
|
||||
- 提取工具函数
|
||||
- 模块化
|
||||
|
||||
注:系统文件(如 README.md、CONTRIBUTING.md 等文档)不受此限制。
|
||||
|
||||
### Q3: 我的 PR 多久会被审查?
|
||||
|
||||
**A:** 通常在 1-3 个工作日内。如果超过一周没有回应,可以在 PR 中添加评论提醒。
|
||||
|
||||
### Q4: 可以同时提交多个 PR 吗?
|
||||
|
||||
**A:** 可以,但建议每个 PR 专注于一个功能或修复。避免在一个 PR 中做太多不相关的改动。
|
||||
|
||||
### Q5: 如何解决合并冲突?
|
||||
|
||||
```bash
|
||||
# 1. 同步上游
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
|
||||
# 2. 切换到功能分支并 rebase
|
||||
git checkout feat/your-feature
|
||||
git rebase main
|
||||
|
||||
# 3. 解决冲突后
|
||||
git add .
|
||||
git rebase --continue
|
||||
|
||||
# 4. 强制推送(因为 rebase 改变了历史)
|
||||
git push origin feat/your-feature --force
|
||||
```
|
||||
|
||||
### Q6: 我的提交信息写错了怎么办?
|
||||
|
||||
```bash
|
||||
# 修改最后一次提交
|
||||
git commit --amend -m "新的提交信息"
|
||||
|
||||
# 如果已经推送了
|
||||
git push origin feat/your-feature --force
|
||||
```
|
||||
|
||||
### Q7: 如何测试我的改动?
|
||||
|
||||
1. 启动开发服务器:`npm run dev`
|
||||
2. 在浏览器中手动测试功能
|
||||
3. 测试不同的设备尺寸
|
||||
4. 运行 `npm run build` 确保生产构建成功
|
||||
|
||||
### Q8: Liquid Glass 设计系统在哪里定义?
|
||||
|
||||
在 `app/styles/glass.css` 文件中。所有组件都应该基于这个设计系统。
|
||||
|
||||
### Q9: 我需要更新文档吗?
|
||||
|
||||
如果你的 PR 包含以下内容,请更新相应文档:
|
||||
|
||||
- 新功能:更新 README.md
|
||||
- API 变化:更新相关注释和文档
|
||||
- 配置变化:更新配置说明
|
||||
|
||||
### Q10: 如何报告安全漏洞?
|
||||
|
||||
请查看 [SECURITY.md](SECURITY.md) 了解安全漏洞报告流程。不要在公开 Issue 中讨论安全问题。
|
||||
|
||||
## 📞 需要帮助?
|
||||
|
||||
如果你有任何问题:
|
||||
|
||||
1. **查看文档**:README.md 和本指南
|
||||
2. **搜索 Issues**:可能已经有人问过相同的问题
|
||||
3. **提出问题**:在 Discussions 或 Issues 中提问
|
||||
4. **联系维护者**:[@KuekHaoYang](https://github.com/KuekHaoYang)
|
||||
|
||||
## 🎉 感谢你的贡献!
|
||||
|
||||
感谢你花时间阅读本指南,并为 KVideo 做出贡献。每一个贡献,无论大小,都让这个项目变得更好。
|
||||
|
||||
我们期待看到你的 Pull Request!
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<strong>让我们一起打造更好的 KVideo!</strong>
|
||||
</div>
|
||||
If a change affects relay, auth, sync, IPTV, PWA behavior, Workers deployment, or Android TV, say so explicitly in the PR body.
|
||||
|
||||
+37
-7
@@ -1,10 +1,40 @@
|
||||
# 安全策略 (Security Policy)
|
||||
# Security Policy
|
||||
|
||||
## 支持的版本
|
||||
## Supported Versions
|
||||
|
||||
目前我们仅对项目的最新主分支 (`main`) 提供安全更新支持。
|
||||
Security fixes are applied to the current `main` branch only.
|
||||
|
||||
| 版本 | 支持状态 |
|
||||
| :--- | :--- |
|
||||
| `main` (最新版) | ✅ 支持 |
|
||||
| 历史版本 | ❌ 不支持 |
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| `main` | Yes |
|
||||
| Historical releases / stale forks | No |
|
||||
|
||||
## Reporting
|
||||
|
||||
For sensitive vulnerabilities, use GitHub Security Advisories / private reporting on the repository instead of opening a public issue first.
|
||||
|
||||
For non-sensitive hardening bugs or follow-up cleanup, open a normal issue with:
|
||||
|
||||
- affected route or component
|
||||
- exact deployment mode
|
||||
- reproduction steps
|
||||
- expected behavior
|
||||
- actual behavior
|
||||
|
||||
## Current Security Posture
|
||||
|
||||
This repository intentionally hardens several surfaces that were previously too permissive:
|
||||
|
||||
- outbound requests are restricted to `http` / `https`
|
||||
- loopback, private, link-local, metadata, and reserved targets are blocked by default
|
||||
- redirects are revalidated before follow
|
||||
- raw relay routes are not public by default
|
||||
- relay forwarding excludes cookies and spoofed forwarding headers
|
||||
- auth-enabled deployments require `AUTH_SECRET`
|
||||
- login attempts are throttled and may return `429`
|
||||
|
||||
## Deployment Guidance
|
||||
|
||||
Use self-hosted Node.js or Docker when you need the full relay / IPTV surface.
|
||||
|
||||
Cloudflare Workers via OpenNext is supported, but this codebase intentionally applies managed-platform restrictions there. Do not assume parity with unrestricted self-hosted Node deployments.
|
||||
|
||||
@@ -22,11 +22,19 @@ android {
|
||||
versionName = "1.0.0"
|
||||
|
||||
buildConfigField("String", "DEFAULT_KVIDEO_URL", "\"$defaultKVideoUrl\"")
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "false")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "true")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
buildConfigField("boolean", "ALLOW_CLEARTEXT", "false")
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt")
|
||||
)
|
||||
@@ -48,7 +56,7 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.activity:activity-ktx:1.8.2")
|
||||
implementation("androidx.webkit:webkit:1.9.0")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.activity:activity-ktx:1.9.3")
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
android:required="true" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
@@ -21,7 +21,7 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
@@ -37,12 +37,6 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Standard launcher fallback -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
@@ -16,8 +17,11 @@ import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebChromeClient.CustomViewCallback
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
@@ -100,11 +104,55 @@ class MainActivity : ComponentActivity() {
|
||||
loadWithOverviewMode = true
|
||||
useWideViewPort = true
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
databaseEnabled = true
|
||||
allowFileAccess = false
|
||||
allowContentAccess = false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
safeBrowsingEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
webViewClient = WebViewClient()
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?
|
||||
): Boolean {
|
||||
val targetUrl = request?.url?.toString() ?: return true
|
||||
if (!request.isForMainFrame) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (targetUrl == "about:blank" || isAllowedNavigationTarget(targetUrl)) {
|
||||
return false
|
||||
}
|
||||
|
||||
showStatus(getString(R.string.status_blocked_navigation))
|
||||
Log.w(TAG, "Blocked navigation to $targetUrl")
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.cancel()
|
||||
showStatus(getString(R.string.status_ssl_error))
|
||||
Log.w(TAG, "Blocked page due to SSL error: ${error?.primaryError}")
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request?.isForMainFrame == true) {
|
||||
showSetup(getString(R.string.status_load_failed))
|
||||
}
|
||||
}
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
|
||||
if (view == null || callback == null) {
|
||||
@@ -240,8 +288,15 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
private fun loadConfiguredUrl(url: String) {
|
||||
if (!isValidUrl(url)) {
|
||||
showSetup(getString(R.string.status_invalid_url))
|
||||
return
|
||||
}
|
||||
|
||||
setupContainer.visibility = View.GONE
|
||||
webView.visibility = View.VISIBLE
|
||||
statusText.text = getString(R.string.status_ready)
|
||||
webView.stopLoading()
|
||||
webView.loadUrl(url)
|
||||
}
|
||||
|
||||
@@ -306,7 +361,49 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
val uri = Uri.parse(url)
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
return (scheme == "http" || scheme == "https") && !uri.host.isNullOrBlank()
|
||||
val isHttp = scheme == "http"
|
||||
val isHttps = scheme == "https"
|
||||
|
||||
if (!BuildConfig.ALLOW_CLEARTEXT && !isHttps) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (BuildConfig.ALLOW_CLEARTEXT && !isHttp && !isHttps) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !uri.host.isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun normalizedPort(uri: Uri): Int {
|
||||
return when {
|
||||
uri.port != -1 -> uri.port
|
||||
uri.scheme.equals("https", ignoreCase = true) -> 443
|
||||
uri.scheme.equals("http", ignoreCase = true) -> 80
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAllowedNavigationTarget(url: String): Boolean {
|
||||
if (!isValidUrl(url)) {
|
||||
return false
|
||||
}
|
||||
|
||||
val configuredUrl = getConfiguredUrl()
|
||||
if (configuredUrl.isBlank()) {
|
||||
return false
|
||||
}
|
||||
|
||||
val configuredUri = Uri.parse(configuredUrl)
|
||||
val targetUri = Uri.parse(url)
|
||||
|
||||
return configuredUri.scheme.equals(targetUri.scheme, ignoreCase = true) &&
|
||||
configuredUri.host.equals(targetUri.host, ignoreCase = true) &&
|
||||
normalizedPort(configuredUri) == normalizedPort(targetUri)
|
||||
}
|
||||
|
||||
private fun showStatus(message: String) {
|
||||
statusText.text = message
|
||||
}
|
||||
|
||||
private fun applyImmersiveMode() {
|
||||
|
||||
@@ -16,16 +16,15 @@
|
||||
android:background="#E6101115"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="32dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="#F31A1C22"
|
||||
android:elevation="12dp"
|
||||
android:maxWidth="680dp"
|
||||
@@ -48,6 +47,14 @@
|
||||
android:textColor="#D7DBE5"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="@string/setup_security_note"
|
||||
android:textColor="#9FC0FF"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/url_input"
|
||||
android:layout_width="match_parent"
|
||||
@@ -110,7 +117,7 @@
|
||||
android:textColor="#9EA7BA"
|
||||
android:textSize="15sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</ScrollView>
|
||||
|
||||
<FrameLayout
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<resources>
|
||||
<string name="app_name">KVideo</string>
|
||||
<string name="app_name">KVideo TV</string>
|
||||
<string name="setup_title">连接你的 KVideo</string>
|
||||
<string name="setup_description">首次使用时输入你的 KVideo 部署地址。保存后,APK 会记住这个地址,后续可直接打开。</string>
|
||||
<string name="setup_description">这是一个仅面向 Android TV 的轻量 WebView 壳。首次使用时输入你的 KVideo 部署地址,保存后可直接从电视主页打开。</string>
|
||||
<string name="setup_security_note">发布版默认只接受 HTTPS,并且应用只会继续停留在你配置的同一站点域名内。</string>
|
||||
<string name="setup_hint">https://example.com</string>
|
||||
<string name="setup_footer">提示:如果 APK 已打开站点,按返回直到页面根目录即可回到这里;部分遥控器也可直接按菜单键打开此设置页。</string>
|
||||
<string name="setup_footer">提示:如果 APK 已打开站点,按菜单键可随时返回这个设置页;发布版不会加载 HTTP 地址或跳转到其他域名。</string>
|
||||
<string name="button_open_saved">打开已保存地址</string>
|
||||
<string name="button_save">保存并重载</string>
|
||||
<string name="button_save">保存并打开</string>
|
||||
<string name="button_exit">退出</string>
|
||||
<string name="status_ready">已准备就绪</string>
|
||||
<string name="status_first_launch">首次使用请先填写可访问的 KVideo 地址</string>
|
||||
<string name="status_invalid_url">地址无效。请输入完整的 http:// 或 https:// 地址,或输入域名后让应用自动补全为 https://。</string>
|
||||
<string name="status_invalid_url">地址无效。发布版仅允许 HTTPS;调试版可选 HTTP 或 HTTPS。输入域名时应用会自动补全为 https://。</string>
|
||||
<string name="status_settings_hint">你可以在这里修改服务器地址,然后重新打开 KVideo。</string>
|
||||
<string name="status_blocked_navigation">已拦截跨站跳转。Android TV 壳仅允许当前已配置站点继续导航。</string>
|
||||
<string name="status_ssl_error">TLS 证书校验失败,已阻止当前加载。</string>
|
||||
<string name="status_load_failed">页面加载失败,请检查地址、证书和网络连通性后重试。</string>
|
||||
</resources>
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
AppUpdateResponse,
|
||||
} from '@/lib/types/app-update';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MANIFEST_PATH = 'app-release.json';
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
updateManagedAccount,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function requireManagedSuperAdmin(request: NextRequest) {
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.authError) {
|
||||
return { error: NextResponse.json({ error: config.authError }, { status: 503 }) };
|
||||
}
|
||||
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
@@ -19,7 +24,6 @@ async function requireManagedSuperAdmin(request: NextRequest) {
|
||||
return { error: NextResponse.json({ error: 'Super admin required' }, { status: 403 }) };
|
||||
}
|
||||
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.loginMode !== 'managed') {
|
||||
return { error: NextResponse.json({ error: 'Managed account mode is not enabled' }, { status: 400 }) };
|
||||
}
|
||||
|
||||
@@ -7,9 +7,14 @@ import {
|
||||
listAccountInfo,
|
||||
} from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function requireSuperAdmin(request: NextRequest) {
|
||||
const config = await getPublicAuthConfig();
|
||||
if (config.authError) {
|
||||
return { error: NextResponse.json({ error: config.authError }, { status: 503 }) };
|
||||
}
|
||||
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return { error: NextResponse.json({ error: 'Authentication required' }, { status: 401 }) };
|
||||
|
||||
+39
-4
@@ -5,8 +5,13 @@ import {
|
||||
getPublicAuthConfig,
|
||||
validatePremiumAccess,
|
||||
} from '@/lib/server/auth';
|
||||
import {
|
||||
clearAuthFailures,
|
||||
getAuthThrottleStatus,
|
||||
recordAuthFailure,
|
||||
} from '@/lib/server/auth-rate-limit';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await getPublicAuthConfig());
|
||||
@@ -14,23 +19,53 @@ export async function GET() {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { username, password, type } = body || {};
|
||||
const body = (await request.json()) as {
|
||||
username?: unknown;
|
||||
password?: unknown;
|
||||
type?: unknown;
|
||||
};
|
||||
const username = typeof body.username === 'string' ? body.username : undefined;
|
||||
const password = typeof body.password === 'string' ? body.password : undefined;
|
||||
const type = body.type === 'premium' ? 'premium' : 'login';
|
||||
const throttle = await getAuthThrottleStatus(request, username, type);
|
||||
|
||||
if (throttle.blocked) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
valid: false,
|
||||
message: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: throttle.retryAfterSeconds,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'Retry-After': String(throttle.retryAfterSeconds),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'premium') {
|
||||
const valid = await validatePremiumAccess(request, { username, password });
|
||||
if (valid) {
|
||||
await clearAuthFailures(request, username, type);
|
||||
} else {
|
||||
await recordAuthFailure(request, username, type);
|
||||
}
|
||||
return NextResponse.json({ valid });
|
||||
}
|
||||
|
||||
if (!password || typeof password !== 'string') {
|
||||
if (!password) {
|
||||
return NextResponse.json({ valid: false, message: 'Password required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const session = await authenticateLogin({ username, password });
|
||||
if (!session) {
|
||||
await recordAuthFailure(request, username, type);
|
||||
return NextResponse.json({ valid: false });
|
||||
}
|
||||
|
||||
await clearAuthFailures(request, username, type);
|
||||
return createLoginResponse(session);
|
||||
} catch {
|
||||
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { createSessionStatusResponse, logoutResponse } from '@/lib/server/auth';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return createSessionStatusResponse(request);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const SUBSCRIPTION_SOURCES = process.env.SUBSCRIPTION_SOURCES || process.env.NEXT_PUBLIC_SUBSCRIPTION_SOURCES || '';
|
||||
|
||||
|
||||
+42
-47
@@ -1,63 +1,58 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildSameOriginOptionsResponse, requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
|
||||
import { fetchWithPolicy, OutboundPolicyError, assertOutboundUrlAllowed } from '@/lib/server/outbound-policy';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
function buildDanmakuTarget(baseUrl: URL, action: 'search' | 'comments', keyword?: string, episodeId?: string): URL {
|
||||
const normalizedBase = new URL(baseUrl.toString().replace(/\/+$/, '/'));
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, { headers: CORS_HEADERS });
|
||||
if (action === 'search') {
|
||||
normalizedBase.pathname = `${normalizedBase.pathname.replace(/\/$/, '')}/api/v2/search/episodes`;
|
||||
normalizedBase.search = `anime=${encodeURIComponent(keyword || '')}`;
|
||||
return normalizedBase;
|
||||
}
|
||||
|
||||
normalizedBase.pathname = `${normalizedBase.pathname.replace(/\/$/, '')}/api/v2/comment/${encodeURIComponent(episodeId || '')}`;
|
||||
normalizedBase.search = 'withRelated=true';
|
||||
return normalizedBase;
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const { searchParams } = request.nextUrl;
|
||||
const action = searchParams.get('action');
|
||||
const apiUrl = searchParams.get('apiUrl');
|
||||
|
||||
if (!action || !apiUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing action or apiUrl parameter' },
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
if (!action || !apiUrl || (action !== 'search' && action !== 'comments')) {
|
||||
return NextResponse.json({ error: 'Missing or invalid action/apiUrl parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize base URL (remove trailing slash)
|
||||
const baseUrl = apiUrl.replace(/\/+$/, '');
|
||||
|
||||
try {
|
||||
let targetUrl: string;
|
||||
const baseUrl = await assertOutboundUrlAllowed(apiUrl);
|
||||
const keyword = searchParams.get('keyword') || undefined;
|
||||
const episodeId = searchParams.get('episodeId') || undefined;
|
||||
|
||||
if (action === 'search') {
|
||||
const keyword = searchParams.get('keyword');
|
||||
if (!keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing keyword parameter' },
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
targetUrl = `${baseUrl}/api/v2/search/episodes?anime=${encodeURIComponent(keyword)}`;
|
||||
} else if (action === 'comments') {
|
||||
const episodeId = searchParams.get('episodeId');
|
||||
if (!episodeId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing episodeId parameter' },
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
}
|
||||
targetUrl = `${baseUrl}/api/v2/comment/${encodeURIComponent(episodeId)}?withRelated=true`;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid action. Use "search" or "comments".' },
|
||||
{ status: 400, headers: CORS_HEADERS }
|
||||
);
|
||||
if (action === 'search' && !keyword) {
|
||||
return NextResponse.json({ error: 'Missing keyword parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, {
|
||||
if (action === 'comments' && !episodeId) {
|
||||
return NextResponse.json({ error: 'Missing episodeId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const targetUrl = buildDanmakuTarget(baseUrl, action, keyword, episodeId);
|
||||
const response = await fetchWithPolicy(targetUrl, {
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'KVideo/1.0',
|
||||
},
|
||||
});
|
||||
@@ -65,21 +60,21 @@ export async function GET(request: NextRequest) {
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream API returned ${response.status}` },
|
||||
{ status: response.status, headers: CORS_HEADERS }
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
...CORS_HEADERS,
|
||||
'Cache-Control': 'public, max-age=3600', // Cache danmaku for 1 hour
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof OutboundPolicyError ? error.status : 502;
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch from danmaku API' },
|
||||
{ status: 502, headers: CORS_HEADERS }
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch from danmaku API' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+34
-50
@@ -1,112 +1,96 @@
|
||||
/**
|
||||
* Detail API Route
|
||||
* Fetches video details including episodes and M3U8 URLs with automatic source validation
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getVideoDetail } from '@/lib/api/client';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
|
||||
import { normalizeSourceConfig } from '@/lib/server/source-validation';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* Shared handler for fetching video details
|
||||
*/
|
||||
async function handleDetailRequest(id: string | null, source: string | null, method: string) {
|
||||
// Validate input
|
||||
async function resolveSourceConfig(source: unknown): Promise<VideoSource | null> {
|
||||
if (typeof source === 'string') {
|
||||
const builtInSource = getSourceById(source);
|
||||
return builtInSource ? normalizeSourceConfig(builtInSource) : null;
|
||||
}
|
||||
|
||||
return normalizeSourceConfig(source);
|
||||
}
|
||||
|
||||
async function handleDetailRequest(id: string | null, source: unknown) {
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing video ID parameter' },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate source
|
||||
if (!source) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing source parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
let sourceConfig;
|
||||
|
||||
// If source is an object (from POST), use it
|
||||
if (typeof source === 'object') {
|
||||
sourceConfig = source;
|
||||
} else {
|
||||
// If source is a string ID (from GET), try to look it up
|
||||
sourceConfig = getSourceById(source);
|
||||
}
|
||||
|
||||
const sourceConfig = await resolveSourceConfig(source);
|
||||
if (!sourceConfig) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid source configuration' },
|
||||
{ status: 400 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch video detail without validation (already validated during search)
|
||||
try {
|
||||
const videoDetail = await getVideoDetail(id, sourceConfig);
|
||||
|
||||
// Skip validation - videos are already checked during search
|
||||
// Just return the episodes as-is
|
||||
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: videoDetail,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Detail API error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch video detail',
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const id = searchParams.get('id');
|
||||
const source = searchParams.get('source');
|
||||
|
||||
return await handleDetailRequest(id, source, 'GET');
|
||||
return handleDetailRequest(id, source);
|
||||
} catch (error) {
|
||||
console.error('Detail API error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Support POST method for complex requests
|
||||
export async function POST(request: NextRequest) {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { id, source } = body;
|
||||
const body = (await request.json()) as { id?: unknown; source?: unknown };
|
||||
const id = typeof body.id === 'string' || typeof body.id === 'number' ? String(body.id) : null;
|
||||
|
||||
return await handleDetailRequest(id, source, 'POST');
|
||||
return handleDetailRequest(id, body.source);
|
||||
} catch (error) {
|
||||
console.error('Detail API error:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,69 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
|
||||
import { assertOutboundUrlAllowed, fetchWithPolicy, OutboundPolicyError } from '@/lib/server/outbound-policy';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const imageUrl = searchParams.get('url');
|
||||
function isAllowedDoubanImageHost(hostname: string): boolean {
|
||||
return hostname === 'doubanio.com' || hostname.endsWith('.doubanio.com');
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const imageUrl = request.nextUrl.searchParams.get('url');
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const targetUrl = await assertOutboundUrlAllowed(imageUrl);
|
||||
if (!isAllowedDoubanImageHost(targetUrl.hostname)) {
|
||||
return NextResponse.json({ error: 'Only Douban image hosts are allowed' }, { status: 403 });
|
||||
}
|
||||
|
||||
const imageResponse = await fetchWithPolicy(targetUrl, {
|
||||
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',
|
||||
Accept: 'image/avif,image/webp,image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status },
|
||||
);
|
||||
}
|
||||
|
||||
if (!imageUrl) {
|
||||
return NextResponse.json({ error: 'Missing image URL' }, { status: 400 });
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const imageResponse = await fetch(imageUrl, {
|
||||
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',
|
||||
Accept: 'image/jpeg,image/png,image/gif,*/*;q=0.8',
|
||||
Referer: 'https://movie.douban.com/',
|
||||
},
|
||||
});
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: imageResponse.statusText },
|
||||
{ status: imageResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
|
||||
if (!imageResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Image response has no body' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// 创建响应头
|
||||
const headers = new Headers();
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
|
||||
// 设置缓存头
|
||||
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
|
||||
|
||||
// 直接返回图片流
|
||||
// @ts-ignore
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Error fetching image' },
|
||||
{ status: 500 }
|
||||
);
|
||||
const headers = new Headers();
|
||||
const contentType = imageResponse.headers.get('content-type');
|
||||
if (contentType) {
|
||||
headers.set('Content-Type', contentType);
|
||||
}
|
||||
headers.set('Cache-Control', 'public, max-age=15720000, s-maxage=15720000');
|
||||
|
||||
return new Response(imageResponse.body, {
|
||||
status: 200,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof OutboundPolicyError ? error.status : 500;
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Error fetching image' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
interface DoubanSubject {
|
||||
id: string;
|
||||
title: string;
|
||||
cover?: string;
|
||||
rate?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface DoubanRecommendResponse {
|
||||
subjects?: DoubanSubject[];
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
@@ -24,11 +36,11 @@ export async function GET(request: Request) {
|
||||
throw new Error(`Douban API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DoubanRecommendResponse;
|
||||
|
||||
// 转换图片链接使用代理
|
||||
if (data.subjects && Array.isArray(data.subjects)) {
|
||||
data.subjects = data.subjects.map((item: any) => ({
|
||||
data.subjects = data.subjects.map((item) => ({
|
||||
...item,
|
||||
cover: item.cover ? `/api/douban/image?url=${encodeURIComponent(item.cover)}` : item.cover,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
+29
-29
@@ -1,60 +1,60 @@
|
||||
/**
|
||||
* IPTV Proxy API Route
|
||||
* Fetches M3U playlist files to avoid CORS issues
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import {
|
||||
fetchWithPolicy,
|
||||
OutboundPolicyError,
|
||||
sanitizeReferer,
|
||||
sanitizeUserAgent,
|
||||
} from '@/lib/server/outbound-policy';
|
||||
import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const customUa = request.nextUrl.searchParams.get('ua');
|
||||
const customReferer = request.nextUrl.searchParams.get('referer');
|
||||
const access = await requireRelayAccess(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
let refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
||||
if (customReferer) {
|
||||
try {
|
||||
refererOrigin = new URL(customReferer).origin;
|
||||
} catch {
|
||||
refererOrigin = `${parsedUrl.protocol}//${parsedUrl.host}`;
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
const customUa = sanitizeUserAgent(request.nextUrl.searchParams.get('ua'));
|
||||
const customReferer = await sanitizeReferer(request.nextUrl.searchParams.get('referer'));
|
||||
const response = await fetchWithPolicy(url, {
|
||||
headers: {
|
||||
'User-Agent': customUa || 'Mozilla/5.0 (compatible; KVideo/1.0)',
|
||||
...(customReferer ? { 'Referer': customReferer } : {}),
|
||||
'Origin': refererOrigin,
|
||||
'Accept': 'text/plain, application/vnd.apple.mpegurl, application/x-mpegurl;q=0.9, */*;q=0.8',
|
||||
...(customUa ? { 'User-Agent': customUa } : {}),
|
||||
...(customReferer ? { Referer: customReferer } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to fetch: ${response.status}` },
|
||||
{ status: response.status }
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'public, max-age=300', // Cache for 5 minutes
|
||||
'Cache-Control': 'public, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
const status = error instanceof OutboundPolicyError ? error.status : 500;
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch M3U playlist' },
|
||||
{ status: 500 }
|
||||
{ error: error instanceof Error ? error.message : 'Failed to fetch M3U playlist' },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
|
||||
}
|
||||
|
||||
+96
-111
@@ -1,60 +1,62 @@
|
||||
/**
|
||||
* IPTV Stream Proxy API Route
|
||||
* Proxies HLS manifests and media segments to avoid CORS issues.
|
||||
* For .m3u8/.m3u manifests, rewrites URLs to also route through this proxy.
|
||||
* Supports HLS, MPEG-TS, and other stream formats with automatic content detection.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
|
||||
import {
|
||||
fetchWithPolicy,
|
||||
OutboundPolicyError,
|
||||
sanitizeReferer,
|
||||
sanitizeUserAgent,
|
||||
} from '@/lib/server/outbound-policy';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const STREAM_TIMEOUT_MS = 20000;
|
||||
const REALISTIC_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
const DEFAULT_USER_AGENT =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
||||
|
||||
function resolveUrl(base: string, relative: string): string {
|
||||
if (relative.startsWith('http://') || relative.startsWith('https://')) {
|
||||
return relative;
|
||||
}
|
||||
try {
|
||||
return new URL(relative, base).href;
|
||||
} catch {
|
||||
// Fallback: manual resolution
|
||||
const baseUrl = base.substring(0, base.lastIndexOf('/') + 1);
|
||||
return baseUrl + relative;
|
||||
}
|
||||
|
||||
return new URL(relative, base).href;
|
||||
}
|
||||
|
||||
function buildProxyBase(customUa?: string | null, customReferer?: string | null): string {
|
||||
let base = '/api/iptv/stream?';
|
||||
if (customUa) base += `ua=${encodeURIComponent(customUa)}&`;
|
||||
if (customReferer) base += `referer=${encodeURIComponent(customReferer)}&`;
|
||||
base += 'url=';
|
||||
return base;
|
||||
function buildProxyBase(customUa?: string, customReferer?: string): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (customUa) {
|
||||
searchParams.set('ua', customUa);
|
||||
}
|
||||
if (customReferer) {
|
||||
searchParams.set('referer', customReferer);
|
||||
}
|
||||
searchParams.set('url', '');
|
||||
return `/api/iptv/stream?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
function rewriteM3u8(content: string, baseUrl: string, proxyBase: string): string {
|
||||
return content.split('\n').map(line => {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines
|
||||
if (!trimmed) return line;
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// Rewrite URI="..." in EXT-X-KEY, EXT-X-MAP, etc.
|
||||
if (trimmed.startsWith('#') && trimmed.includes('URI="')) {
|
||||
return line.replace(/URI="([^"]+)"/g, (_match, uri) => {
|
||||
const absoluteUri = resolveUrl(baseUrl, uri);
|
||||
return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`;
|
||||
});
|
||||
}
|
||||
if (trimmed.startsWith('#') && trimmed.includes('URI="')) {
|
||||
return line.replace(/URI="([^"]+)"/g, (_match, uri) => {
|
||||
const absoluteUri = resolveUrl(baseUrl, uri);
|
||||
return `URI="${proxyBase}${encodeURIComponent(absoluteUri)}"`;
|
||||
});
|
||||
}
|
||||
|
||||
// Skip other comment lines
|
||||
if (trimmed.startsWith('#')) return line;
|
||||
if (trimmed.startsWith('#')) {
|
||||
return line;
|
||||
}
|
||||
|
||||
// This is a segment/playlist URL line - rewrite it
|
||||
const absoluteUrl = resolveUrl(baseUrl, trimmed);
|
||||
return `${proxyBase}${encodeURIComponent(absoluteUrl)}`;
|
||||
}).join('\n');
|
||||
return `${proxyBase}${encodeURIComponent(resolveUrl(baseUrl, trimmed))}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function isM3u8Url(url: string): boolean {
|
||||
@@ -71,7 +73,10 @@ function isM3u8ContentType(contentType: string): boolean {
|
||||
}
|
||||
|
||||
function isAmbiguousContentType(contentType: string): boolean {
|
||||
if (!contentType) return true;
|
||||
if (!contentType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const lower = contentType.toLowerCase();
|
||||
return lower.includes('text/plain') ||
|
||||
lower.includes('application/octet-stream') ||
|
||||
@@ -84,56 +89,65 @@ function isM3u8Content(text: string): boolean {
|
||||
return trimmed.startsWith('#EXTM3U') || trimmed.startsWith('#EXT-X-');
|
||||
}
|
||||
|
||||
const CORS_HEADERS = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
|
||||
'Access-Control-Allow-Headers': '*',
|
||||
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Accept-Ranges',
|
||||
};
|
||||
function buildResponseHeaders(response: Response, fallbackContentType: string): Headers {
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('content-type') || fallbackContentType);
|
||||
headers.set('Cache-Control', 'public, max-age=60');
|
||||
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) {
|
||||
headers.set('Content-Range', contentRange);
|
||||
}
|
||||
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
if (acceptRanges) {
|
||||
headers.set('Accept-Ranges', acceptRanges);
|
||||
}
|
||||
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
headers.set('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.iptvEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'IPTV relay is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const customUa = request.nextUrl.searchParams.get('ua');
|
||||
const customReferer = request.nextUrl.searchParams.get('referer');
|
||||
const access = await requireRelayAccess(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
const fetchHeaders: Record<string, string> = {
|
||||
'User-Agent': customUa || REALISTIC_USER_AGENT,
|
||||
'Accept': '*/*',
|
||||
'Referer': customReferer || `${parsedUrl.protocol}//${parsedUrl.host}/`,
|
||||
'Origin': `${parsedUrl.protocol}//${parsedUrl.host}`,
|
||||
'Connection': 'keep-alive',
|
||||
};
|
||||
|
||||
// Forward Range header for partial content requests
|
||||
const customUa = sanitizeUserAgent(request.nextUrl.searchParams.get('ua')) || DEFAULT_USER_AGENT;
|
||||
const customReferer = await sanitizeReferer(request.nextUrl.searchParams.get('referer'));
|
||||
const rangeHeader = request.headers.get('range');
|
||||
if (rangeHeader) {
|
||||
fetchHeaders['Range'] = rangeHeader;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), STREAM_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: fetchHeaders,
|
||||
redirect: 'follow',
|
||||
const response = await fetchWithPolicy(url, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
...(customUa ? { 'User-Agent': customUa } : {}),
|
||||
...(customReferer ? { Referer: customReferer } : {}),
|
||||
...(rangeHeader ? { Range: rangeHeader } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
@@ -141,7 +155,7 @@ export async function GET(request: NextRequest) {
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream returned ${response.status}` },
|
||||
{ status: response.status }
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -149,14 +163,14 @@ export async function GET(request: NextRequest) {
|
||||
let isM3u8 = isM3u8Url(url) || isM3u8ContentType(contentType);
|
||||
const proxyBase = buildProxyBase(customUa, customReferer);
|
||||
|
||||
// If content-type is ambiguous, check the response body for M3U header
|
||||
if (!isM3u8 && isAmbiguousContentType(contentType)) {
|
||||
const cloned = response.clone();
|
||||
// Read first 1KB to check for M3U8 header without consuming too much
|
||||
const reader = cloned.body?.getReader();
|
||||
|
||||
if (reader) {
|
||||
const { value } = await reader.read();
|
||||
reader.releaseLock();
|
||||
|
||||
if (value) {
|
||||
const text = new TextDecoder().decode(value.slice(0, 1024));
|
||||
if (isM3u8Content(text)) {
|
||||
@@ -166,7 +180,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (isM3u8) {
|
||||
// Re-read the full body for M3U8 rewriting
|
||||
const fullText = await response.text();
|
||||
const rewritten = rewriteM3u8(fullText, url, proxyBase);
|
||||
return new NextResponse(rewritten, {
|
||||
@@ -174,72 +187,44 @@ export async function GET(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Not M3U8 — stream original binary body directly
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'video/mp2t',
|
||||
'Cache-Control': 'no-cache',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
headers: buildResponseHeaders(response, 'video/mp2t'),
|
||||
});
|
||||
}
|
||||
|
||||
if (isM3u8) {
|
||||
// Parse and rewrite manifest
|
||||
const text = await response.text();
|
||||
const rewritten = rewriteM3u8(text, url, proxyBase);
|
||||
|
||||
return new NextResponse(rewritten, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-cache, no-store',
|
||||
...CORS_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Non-M3U8 media content — pipe through directly
|
||||
const body = response.body;
|
||||
const forwardContentType = contentType || 'video/mp2t';
|
||||
|
||||
const responseHeaders: Record<string, string> = {
|
||||
'Content-Type': forwardContentType,
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
...CORS_HEADERS,
|
||||
};
|
||||
|
||||
// Forward range-related headers
|
||||
const contentRange = response.headers.get('content-range');
|
||||
if (contentRange) responseHeaders['Content-Range'] = contentRange;
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
if (acceptRanges) responseHeaders['Accept-Ranges'] = acceptRanges;
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength) responseHeaders['Content-Length'] = contentLength;
|
||||
|
||||
return new NextResponse(body, {
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders,
|
||||
headers: buildResponseHeaders(response, 'video/mp2t'),
|
||||
});
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isTimeout = message.includes('abort');
|
||||
const status = error instanceof OutboundPolicyError ? error.status : isTimeout ? 504 : 502;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: isTimeout ? 'Stream request timed out' : 'Failed to proxy stream' },
|
||||
{ status: isTimeout ? 504 : 502 }
|
||||
{ error: isTimeout ? 'Stream request timed out' : message },
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: CORS_HEADERS,
|
||||
});
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return buildSameOriginOptionsResponse(request, 'GET, HEAD, OPTIONS');
|
||||
}
|
||||
|
||||
+65
-67
@@ -1,75 +1,73 @@
|
||||
/**
|
||||
* Ping API Route - Measures latency to video sources
|
||||
* Returns response time for real-time latency display
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildSameOriginOptionsResponse, requireRelayAccess } from '@/lib/server/api-access';
|
||||
import { fetchWithPolicy, OutboundPolicyError } from '@/lib/server/outbound-policy';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function pingUrl(url: string, method: 'HEAD' | 'GET'): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await fetchWithPolicy(url, {
|
||||
method,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { url } = body;
|
||||
const access = await requireRelayAccess(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const body = (await request.json()) as { url?: unknown };
|
||||
const url = typeof body.url === 'string' ? body.url : '';
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
// Use HEAD request for faster ping (less data transfer)
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors', // Allow cross-origin requests
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch (fetchError) {
|
||||
// If HEAD fails, try GET with timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
return NextResponse.json({ latency, success: true });
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
// Still return latency even on error (timeout = slow)
|
||||
return NextResponse.json({ latency, success: false, timeout: true });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ping error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
try {
|
||||
await pingUrl(url, 'HEAD');
|
||||
return NextResponse.json({
|
||||
latency: Math.round(performance.now() - startTime),
|
||||
success: true,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await pingUrl(url, 'GET');
|
||||
return NextResponse.json({
|
||||
latency: Math.round(performance.now() - startTime),
|
||||
success: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error instanceof OutboundPolicyError ? error.status : 200;
|
||||
return NextResponse.json(
|
||||
{
|
||||
latency: Math.round(performance.now() - startTime),
|
||||
success: false,
|
||||
timeout: !(error instanceof OutboundPolicyError),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return buildSameOriginOptionsResponse(request, 'POST, OPTIONS');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { fetchWithPolicy } from '@/lib/server/outbound-policy';
|
||||
import { normalizeSourceConfigList } from '@/lib/server/source-validation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
// We still import this type but won't rely on the empty array
|
||||
export const runtime = 'nodejs';
|
||||
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
|
||||
|
||||
/**
|
||||
* Shared handler for fetching content
|
||||
*/
|
||||
interface PremiumCategoryVideo {
|
||||
vod_id: string | number;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_remarks?: string;
|
||||
type_name?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface PremiumCategoryResponse {
|
||||
list?: Array<{
|
||||
vod_id: string | number;
|
||||
vod_name: string;
|
||||
vod_pic?: string;
|
||||
vod_remarks?: string;
|
||||
type_name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
async function handleCategoryRequest(
|
||||
sourceList: any[],
|
||||
sourceList: VideoSource[],
|
||||
categoryParam: string,
|
||||
page: number,
|
||||
limit: number
|
||||
@@ -43,11 +61,12 @@ async function handleCategoryRequest(
|
||||
return NextResponse.json({ videos: [], error: 'No enabled sources provided or found' }, { status: 500 });
|
||||
}
|
||||
|
||||
const fetchPromises = targetSources.map(async (source: any) => {
|
||||
const fetchPromises = targetSources.map(async (source): Promise<PremiumCategoryVideo[]> => {
|
||||
try {
|
||||
const url = new URL(source.baseUrl);
|
||||
url.searchParams.set('ac', 'detail');
|
||||
url.searchParams.set('pg', page.toString());
|
||||
url.searchParams.set('limit', limit.toString());
|
||||
|
||||
if (sourceMap.has(source.id)) {
|
||||
url.searchParams.set('t', sourceMap.get(source.id)!);
|
||||
@@ -56,20 +75,19 @@ async function handleCategoryRequest(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await fetchWithPolicy(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
next: { revalidate: 1800 },
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) return [];
|
||||
|
||||
const data = await response.json();
|
||||
return (data.list || []).map((item: any) => ({
|
||||
const data = (await response.json()) as PremiumCategoryResponse;
|
||||
return (data.list || []).map((item) => ({
|
||||
vod_id: item.vod_id,
|
||||
vod_name: item.vod_name,
|
||||
vod_pic: item.vod_pic,
|
||||
@@ -85,7 +103,7 @@ async function handleCategoryRequest(
|
||||
|
||||
const results = await Promise.all(fetchPromises);
|
||||
|
||||
const interleavedVideos = [];
|
||||
const interleavedVideos: PremiumCategoryVideo[] = [];
|
||||
const maxLen = Math.max(...results.map(r => r.length));
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
@@ -111,26 +129,24 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { sources, category, page, limit } = body;
|
||||
const normalizedSources = await normalizeSourceConfigList(sources);
|
||||
|
||||
// Use provided sources
|
||||
return await handleCategoryRequest(
|
||||
sources || [],
|
||||
normalizedSources,
|
||||
category || '',
|
||||
parseInt(page || '1'),
|
||||
parseInt(limit || '20')
|
||||
);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
// Legacy GET support - currently BROKEN since ADULT_SOURCES is empty
|
||||
// But kept for structure. It will likely return 500 "No enabled sources"
|
||||
const { searchParams } = new URL(request.url);
|
||||
const categoryParam = searchParams.get('category') || '';
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
|
||||
return await handleCategoryRequest(PREMIUM_SOURCES, categoryParam, page, limit);
|
||||
return await handleCategoryRequest(await normalizeSourceConfigList(PREMIUM_SOURCES), categoryParam, page, limit);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
import { PREMIUM_SOURCES } from '@/lib/api/premium-sources';
|
||||
import { fetchWithPolicy } from '@/lib/server/outbound-policy';
|
||||
import { normalizeSourceConfigList } from '@/lib/server/source-validation';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export const revalidate = 3600; // Cache for 1 hour
|
||||
|
||||
@@ -10,19 +13,17 @@ interface Category {
|
||||
type_name: string;
|
||||
}
|
||||
|
||||
interface SourceCategories {
|
||||
sourceId: string;
|
||||
sourceName: string;
|
||||
categories: Category[];
|
||||
interface PremiumTypesResponse {
|
||||
class?: Category[];
|
||||
}
|
||||
|
||||
// Shared handler
|
||||
async function handleTypesRequest(sourceList: any[]) {
|
||||
async function handleTypesRequest(sourceList: VideoSource[]) {
|
||||
try {
|
||||
const enabledSources = sourceList.filter(s => s.enabled);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
enabledSources.map(async (source: any) => {
|
||||
enabledSources.map(async (source) => {
|
||||
try {
|
||||
const url = new URL(source.baseUrl);
|
||||
url.searchParams.set('ac', 'list');
|
||||
@@ -30,12 +31,11 @@ async function handleTypesRequest(sourceList: any[]) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
const response = await fetchWithPolicy(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||
},
|
||||
next: { revalidate: 3600 }
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
@@ -44,7 +44,7 @@ async function handleTypesRequest(sourceList: any[]) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as PremiumTypesResponse;
|
||||
return {
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
@@ -164,12 +164,12 @@ export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { sources } = body;
|
||||
return await handleTypesRequest(sources || []);
|
||||
} catch (error) {
|
||||
return await handleTypesRequest(await normalizeSourceConfigList(sources));
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return await handleTypesRequest(PREMIUM_SOURCES);
|
||||
return await handleTypesRequest(await normalizeSourceConfigList(PREMIUM_SOURCES));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
/**
|
||||
* Probe Resolution API
|
||||
* Fetches actual video resolution by parsing m3u8 manifests.
|
||||
* Accepts a batch of videos and streams results back via SSE.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getSourceById } from '@/lib/api/video-sources';
|
||||
import { getVideoDetail } from '@/lib/api/detail-api';
|
||||
@@ -14,9 +8,11 @@ import {
|
||||
parseResolutionFromManifest,
|
||||
type ResolutionProbeLabel,
|
||||
} from '@/lib/player/resolution-probe-utils';
|
||||
import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
|
||||
import { buildSourceConfigMap, normalizeSourceConfig } from '@/lib/server/source-validation';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
interface ProbeRequest {
|
||||
id: string | number;
|
||||
@@ -24,45 +20,39 @@ interface ProbeRequest {
|
||||
episodeIndex?: number;
|
||||
}
|
||||
|
||||
function isValidSourceConfig(value: unknown): value is VideoSource {
|
||||
interface ProbeRequestBody {
|
||||
videos?: unknown;
|
||||
sourceConfigs?: unknown;
|
||||
}
|
||||
|
||||
function isProbeRequest(value: unknown): value is ProbeRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const source = value as Partial<VideoSource>;
|
||||
return typeof source.id === 'string' &&
|
||||
typeof source.name === 'string' &&
|
||||
typeof source.baseUrl === 'string' &&
|
||||
typeof source.searchPath === 'string' &&
|
||||
typeof source.detailPath === 'string';
|
||||
}
|
||||
|
||||
function buildSourceConfigMap(rawConfigs: unknown): Map<string, VideoSource> {
|
||||
const configs = new Map<string, VideoSource>();
|
||||
if (!Array.isArray(rawConfigs)) {
|
||||
return configs;
|
||||
}
|
||||
|
||||
for (const config of rawConfigs) {
|
||||
if (isValidSourceConfig(config)) {
|
||||
configs.set(config.id, config);
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
const request = value as Partial<ProbeRequest>;
|
||||
return (
|
||||
(typeof request.id === 'string' || typeof request.id === 'number') &&
|
||||
typeof request.source === 'string' &&
|
||||
(typeof request.episodeIndex === 'undefined' || typeof request.episodeIndex === 'number')
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchManifestText(url: string, timeoutMs: number): Promise<string> {
|
||||
const response = await fetchWithTimeout(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
}, timeoutMs);
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
},
|
||||
timeoutMs,
|
||||
);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function probeManifestResolution(
|
||||
targetUrl: string,
|
||||
m3u8Content: string,
|
||||
detailHint: ResolutionProbeLabel | null
|
||||
detailHint: ResolutionProbeLabel | null,
|
||||
): Promise<{ resolution: ResolutionProbeLabel | null; origin: 'manifest' | 'hint' }> {
|
||||
const directResolution = parseResolutionFromManifest(m3u8Content, targetUrl);
|
||||
if (directResolution) {
|
||||
@@ -94,23 +84,26 @@ async function probeManifestResolution(
|
||||
};
|
||||
}
|
||||
|
||||
async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>): Promise<{
|
||||
id: string | number;
|
||||
source: string;
|
||||
episodeIndex?: number;
|
||||
resolution: ResolutionProbeLabel | null;
|
||||
resolutionOrigin: 'manifest' | 'hint';
|
||||
}> {
|
||||
async function resolveSourceConfig(sourceId: string, providedConfigs: Map<string, VideoSource>): Promise<VideoSource | null> {
|
||||
const providedSource = providedConfigs.get(sourceId);
|
||||
if (providedSource) {
|
||||
return providedSource;
|
||||
}
|
||||
|
||||
const builtInSource = getSourceById(sourceId);
|
||||
return builtInSource ? normalizeSourceConfig(builtInSource) : null;
|
||||
}
|
||||
|
||||
async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoSource>) {
|
||||
try {
|
||||
const sourceConfig = providedConfigs.get(video.source) || getSourceById(video.source);
|
||||
const sourceConfig = await resolveSourceConfig(video.source, providedConfigs);
|
||||
if (!sourceConfig) {
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' as const };
|
||||
}
|
||||
|
||||
// 1. Get detail to find first episode URL
|
||||
const detail = await getVideoDetail(video.id, sourceConfig);
|
||||
if (!detail.episodes || detail.episodes.length === 0) {
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
return { id: video.id, source: video.source, episodeIndex: video.episodeIndex, resolution: null, resolutionOrigin: 'manifest' as const };
|
||||
}
|
||||
|
||||
const episodeIndex = typeof video.episodeIndex === 'number'
|
||||
@@ -118,45 +111,47 @@ async function probeOne(video: ProbeRequest, providedConfigs: Map<string, VideoS
|
||||
: 0;
|
||||
const targetUrl = detail.episodes[episodeIndex]?.url || detail.episodes[0]?.url;
|
||||
if (!targetUrl) {
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null, resolutionOrigin: 'manifest' };
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: null, resolutionOrigin: 'manifest' as const };
|
||||
}
|
||||
|
||||
const detailHint = extractResolutionHint(detail.vod_remarks, targetUrl);
|
||||
|
||||
// 2. Fetch the m3u8 manifest
|
||||
let m3u8Content: string;
|
||||
try {
|
||||
m3u8Content = await fetchManifestText(targetUrl, 8000);
|
||||
const m3u8Content = await fetchManifestText(targetUrl, 8000);
|
||||
const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint);
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin };
|
||||
} catch {
|
||||
return {
|
||||
id: video.id,
|
||||
source: video.source,
|
||||
episodeIndex,
|
||||
resolution: detailHint,
|
||||
resolutionOrigin: detailHint ? 'hint' : 'manifest',
|
||||
resolutionOrigin: detailHint ? 'hint' as const : 'manifest' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const probed = await probeManifestResolution(targetUrl, m3u8Content, detailHint);
|
||||
return { id: video.id, source: video.source, episodeIndex, resolution: probed.resolution, resolutionOrigin: probed.origin };
|
||||
} catch {
|
||||
return {
|
||||
id: video.id,
|
||||
source: video.source,
|
||||
episodeIndex: video.episodeIndex,
|
||||
resolution: null,
|
||||
resolutionOrigin: 'manifest',
|
||||
resolutionOrigin: 'manifest' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const videos: ProbeRequest[] = body.videos;
|
||||
const sourceConfigs = buildSourceConfigMap(body.sourceConfigs);
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
if (!Array.isArray(videos) || videos.length === 0) {
|
||||
try {
|
||||
const body = (await request.json()) as ProbeRequestBody;
|
||||
const videos = Array.isArray(body.videos) ? body.videos.filter(isProbeRequest) : [];
|
||||
const sourceConfigs = await buildSourceConfigMap(body.sourceConfigs, 50);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return new Response(JSON.stringify({ error: 'Missing videos array' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -164,12 +159,11 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
const batch = videos.slice(0, 100);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Process in parallel with concurrency limit
|
||||
const CONCURRENCY = 6;
|
||||
const concurrency = 6;
|
||||
let index = 0;
|
||||
|
||||
async function processNext(): Promise<void> {
|
||||
@@ -177,17 +171,22 @@ export async function POST(request: NextRequest) {
|
||||
const current = batch[index++];
|
||||
try {
|
||||
const result = await probeOne(current, sourceConfigs);
|
||||
const line = `data: ${JSON.stringify(result)}\n\n`;
|
||||
controller.enqueue(encoder.encode(line));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(result)}\n\n`));
|
||||
} catch {
|
||||
const fallback = { id: current.id, source: current.source, resolution: null, resolutionOrigin: 'manifest' };
|
||||
const fallback = {
|
||||
id: current.id,
|
||||
source: current.source,
|
||||
resolution: null,
|
||||
resolutionOrigin: 'manifest',
|
||||
};
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(fallback)}\n\n`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, batch.length) }, () => processNext());
|
||||
await Promise.all(workers);
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, batch.length) }, () => processNext()),
|
||||
);
|
||||
controller.enqueue(encoder.encode('data: {"done":true}\n\n'));
|
||||
controller.close();
|
||||
},
|
||||
@@ -197,7 +196,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
|
||||
+109
-123
@@ -1,142 +1,128 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { processM3u8Content } from '@/lib/utils/proxy-utils';
|
||||
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
|
||||
import { requireRelayAccess, buildSameOriginOptionsResponse } from '@/lib/server/api-access';
|
||||
import { OutboundPolicyError, getRelayForwardHeaders } from '@/lib/server/outbound-policy';
|
||||
import { getRuntimeFeatures } from '@/lib/server/runtime-features';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// Disable SSL verification for video sources with invalid certificates
|
||||
// Note: This is not supported in Cloudflare Workers/Edge Runtime.
|
||||
// process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
function buildPassThroughHeaders(response: Response): Headers {
|
||||
const headers = new Headers();
|
||||
|
||||
response.headers.forEach((value, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (
|
||||
[
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-methods',
|
||||
'access-control-allow-headers',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'set-cookie',
|
||||
'transfer-encoding',
|
||||
].includes(lowerKey)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
headers.set(key, value);
|
||||
});
|
||||
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
|
||||
if (!runtimeFeatures.mediaProxyEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'External media proxy is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
if (!runtimeFeatures.mediaProxyEnabled) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'External media proxy is disabled on this deployment',
|
||||
message: runtimeFeatures.restrictionSummary,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const access = await requireRelayAccess(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
if (!url) {
|
||||
return new NextResponse('Missing URL parameter', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithRetry({
|
||||
url,
|
||||
headers: Object.fromEntries(getRelayForwardHeaders(request)),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText || `Upstream error: ${response.status}`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'text/plain',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const url = request.nextUrl.searchParams.get('url');
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
const isM3u8ByHeader = contentType.includes('application/vnd.apple.mpegurl') ||
|
||||
contentType.includes('application/x-mpegurl') ||
|
||||
url.endsWith('.m3u8');
|
||||
|
||||
if (!url) {
|
||||
return new NextResponse('Missing URL parameter', { status: 400 });
|
||||
if (isM3u8ByHeader || url.includes('.m3u8')) {
|
||||
const text = await response.text();
|
||||
|
||||
if (text.trim().startsWith('#EXTM3U') || text.trim().startsWith('#EXT-X-')) {
|
||||
const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
|
||||
return new NextResponse(modifiedText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'text/plain',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract headers to forward (only essential ones)
|
||||
const requestHeaders: Record<string, string> = {};
|
||||
const forwardHeaders = ['cookie', 'range'];
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: buildPassThroughHeaders(response),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
const status = error instanceof OutboundPolicyError ? error.status : 500;
|
||||
|
||||
forwardHeaders.forEach(key => {
|
||||
const value = request.headers.get(key);
|
||||
if (value) requestHeaders[key] = value;
|
||||
});
|
||||
|
||||
const response = await fetchWithRetry({ url, request, headers: requestHeaders });
|
||||
|
||||
// If upstream returned an error, pass it through with CORS headers
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText || `Upstream error: ${response.status}`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('Content-Type') || 'text/plain',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
|
||||
// Better M3U8 detection: check both content-type and actual content
|
||||
const isM3u8ByHeader = contentType &&
|
||||
(contentType.includes('application/vnd.apple.mpegurl') ||
|
||||
contentType.includes('application/x-mpegurl')) ||
|
||||
url.endsWith('.m3u8');
|
||||
|
||||
// For potential M3U8 files, check content
|
||||
if (isM3u8ByHeader || url.includes('.m3u8')) {
|
||||
const text = await response.text();
|
||||
|
||||
// Verify it's actually M3U8 content (starts with #EXTM3U or #EXT-X-)
|
||||
if (text.trim().startsWith('#EXTM3U') || text.trim().startsWith('#EXT-X-')) {
|
||||
const modifiedText = await processM3u8Content(text, url, request.nextUrl.origin);
|
||||
|
||||
return new NextResponse(modifiedText, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Not M3U8 content, return as-is
|
||||
return new NextResponse(text, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'text/plain',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// For non-m3u8 content
|
||||
const headers = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (!['content-encoding', 'content-length', 'transfer-encoding'].includes(lowerKey)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
headers.set('Access-Control-Allow-Origin', '*');
|
||||
headers.set('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: 'Proxy request failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
url: url
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Proxy request failed',
|
||||
message,
|
||||
url,
|
||||
},
|
||||
{ status },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: NextRequest) {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
},
|
||||
});
|
||||
return buildSameOriginOptionsResponse(request, 'GET, OPTIONS');
|
||||
}
|
||||
|
||||
+126
-141
@@ -1,200 +1,185 @@
|
||||
/**
|
||||
* Parallel Streaming Search API Route
|
||||
* Searches all sources in parallel and streams results immediately as they arrive.
|
||||
* Supports abort via request.signal when clients disconnect.
|
||||
* Caps results per source and total to prevent OOM.
|
||||
*/
|
||||
|
||||
import { NextRequest } from 'next/server';
|
||||
import { searchVideos } from '@/lib/api/client';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
import { traditionalToSimplified } from '@/lib/utils/chinese-convert';
|
||||
import { requireAuthenticatedRequestIfConfigured } from '@/lib/server/api-access';
|
||||
import { normalizeSourceConfigList } from '@/lib/server/source-validation';
|
||||
import type { VideoItem, VideoSource } from '@/lib/types';
|
||||
|
||||
export const runtime = 'edge';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
const MAX_TOTAL_VIDEOS = 2000;
|
||||
const MAX_PAGES_PER_SOURCE = 3;
|
||||
const PER_SOURCE_TIMEOUT_MS = 20000;
|
||||
|
||||
interface SearchRequestBody {
|
||||
query?: unknown;
|
||||
sources?: unknown;
|
||||
}
|
||||
|
||||
function withPresentationFields(videos: VideoItem[], source: VideoSource, latency: number) {
|
||||
return videos.map((video) => ({
|
||||
...video,
|
||||
sourceDisplayName: getSourceName(source.id),
|
||||
latency,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access.error;
|
||||
}
|
||||
|
||||
const body = (await request.json()) as SearchRequestBody;
|
||||
const query = typeof body.query === 'string' ? body.query.trim() : '';
|
||||
const sources = await normalizeSourceConfigList(body.sources, 50);
|
||||
|
||||
if (!query) {
|
||||
return Response.json({ error: 'Invalid query' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (sources.length === 0) {
|
||||
return Response.json({ error: 'No valid sources provided' }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedQuery = traditionalToSimplified(query);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Use the request signal for abort detection
|
||||
const signal = request.signal;
|
||||
|
||||
const safeSend = (data: object) => {
|
||||
if (signal.aborted) return;
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
// Controller may be closed
|
||||
// Ignore closed controller errors.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { query, sources: sourceConfigs } = body;
|
||||
safeSend({ type: 'start', totalSources: sources.length });
|
||||
|
||||
if (!query || typeof query !== 'string' || query.trim().length === 0) {
|
||||
safeSend({ type: 'error', message: 'Invalid query' });
|
||||
controller.close();
|
||||
let completedSources = 0;
|
||||
let totalVideosFound = 0;
|
||||
|
||||
const searchPromises = sources.map(async (source) => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedQuery = traditionalToSimplified(query.trim());
|
||||
const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
|
||||
? sourceConfigs
|
||||
: [];
|
||||
const startTime = performance.now();
|
||||
const sourceController = new AbortController();
|
||||
const sourceTimeout = setTimeout(() => sourceController.abort(), PER_SOURCE_TIMEOUT_MS);
|
||||
const onRequestAbort = () => sourceController.abort();
|
||||
signal.addEventListener('abort', onRequestAbort, { once: true });
|
||||
|
||||
if (sources.length === 0) {
|
||||
safeSend({ type: 'error', message: 'No valid sources provided' });
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
|
||||
safeSend({ type: 'start', totalSources: sources.length });
|
||||
|
||||
let completedSources = 0;
|
||||
let totalVideosFound = 0;
|
||||
|
||||
const searchPromises = sources.map(async (source: any) => {
|
||||
if (signal.aborted) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
// Per-source timeout via AbortController
|
||||
const sourceController = new AbortController();
|
||||
const sourceTimeout = setTimeout(
|
||||
() => sourceController.abort(),
|
||||
PER_SOURCE_TIMEOUT_MS
|
||||
try {
|
||||
const [initialResult] = await searchVideos(
|
||||
normalizedQuery,
|
||||
[source],
|
||||
1,
|
||||
sourceController.signal,
|
||||
);
|
||||
|
||||
// Cascade request abort to source controller
|
||||
const onRequestAbort = () => sourceController.abort();
|
||||
signal.addEventListener('abort', onRequestAbort, { once: true });
|
||||
const latency = Math.round(performance.now() - startTime);
|
||||
const videos = initialResult?.results || [];
|
||||
const pagecount = initialResult?.pagecount ?? 1;
|
||||
|
||||
try {
|
||||
const result = await searchVideos(
|
||||
normalizedQuery, [source], 1, sourceController.signal
|
||||
);
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
const videos = result[0]?.results || [];
|
||||
const pagecount = result[0]?.pagecount ?? 1;
|
||||
|
||||
completedSources++;
|
||||
totalVideosFound += videos.length;
|
||||
|
||||
if (videos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'videos',
|
||||
videos: videos.map((video: any) => ({
|
||||
...video,
|
||||
sourceDisplayName: getSourceName(source.id),
|
||||
latency,
|
||||
})),
|
||||
source: source.id,
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
latency,
|
||||
});
|
||||
}
|
||||
completedSources += 1;
|
||||
totalVideosFound += videos.length;
|
||||
|
||||
if (videos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
type: 'videos',
|
||||
videos: withPresentationFields(videos, source, latency),
|
||||
source: source.id,
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound,
|
||||
latency,
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-fetch remaining pages (capped)
|
||||
if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
|
||||
const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
|
||||
const remainingPages = Array.from(
|
||||
{ length: maxPages - 1 }, (_, i) => i + 2
|
||||
);
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound,
|
||||
});
|
||||
|
||||
for (const pg of remainingPages) {
|
||||
if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) break;
|
||||
if (pagecount > 1 && totalVideosFound < MAX_TOTAL_VIDEOS && !signal.aborted) {
|
||||
const maxPages = Math.min(pagecount, MAX_PAGES_PER_SOURCE);
|
||||
|
||||
try {
|
||||
const pageResult = await searchVideos(
|
||||
normalizedQuery, [source], pg, sourceController.signal
|
||||
);
|
||||
const pageVideos = pageResult[0]?.results || [];
|
||||
totalVideosFound += pageVideos.length;
|
||||
for (let page = 2; page <= maxPages; page += 1) {
|
||||
if (signal.aborted || totalVideosFound >= MAX_TOTAL_VIDEOS) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (pageVideos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'videos',
|
||||
videos: pageVideos.map((video: any) => ({
|
||||
...video,
|
||||
sourceDisplayName: getSourceName(source.id),
|
||||
latency,
|
||||
})),
|
||||
source: source.id,
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
latency,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const [pageResult] = await searchVideos(
|
||||
normalizedQuery,
|
||||
[source],
|
||||
page,
|
||||
sourceController.signal,
|
||||
);
|
||||
const pageVideos = pageResult?.results || [];
|
||||
totalVideosFound += pageVideos.length;
|
||||
|
||||
if (pageVideos.length > 0 && !signal.aborted) {
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
type: 'videos',
|
||||
videos: withPresentationFields(pageVideos, source, latency),
|
||||
source: source.id,
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound,
|
||||
latency,
|
||||
});
|
||||
} catch {
|
||||
// Page fetch failed, continue
|
||||
}
|
||||
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound,
|
||||
});
|
||||
} catch {
|
||||
// Ignore failed page fetches and continue.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
const latency = Math.round(endTime - startTime);
|
||||
console.error(
|
||||
`[Search] Source ${source.id} failed after ${latency}ms:`,
|
||||
error
|
||||
);
|
||||
completedSources++;
|
||||
|
||||
safeSend({
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
totalVideosFound,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(sourceTimeout);
|
||||
signal.removeEventListener('abort', onRequestAbort);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const latency = Math.round(performance.now() - startTime);
|
||||
console.error(`[Search] Source ${source.id} failed after ${latency}ms:`, error);
|
||||
completedSources += 1;
|
||||
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
if (!signal.aborted) {
|
||||
safeSend({
|
||||
type: 'complete',
|
||||
totalVideosFound,
|
||||
type: 'progress',
|
||||
completedSources,
|
||||
totalSources: sources.length,
|
||||
maxPageCount: MAX_PAGES_PER_SOURCE,
|
||||
totalVideosFound,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(sourceTimeout);
|
||||
signal.removeEventListener('abort', onRequestAbort);
|
||||
}
|
||||
});
|
||||
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
if (!signal.aborted) {
|
||||
console.error('Search error:', error);
|
||||
safeSend({
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
await Promise.all(searchPromises);
|
||||
|
||||
if (!signal.aborted) {
|
||||
safeSend({
|
||||
type: 'complete',
|
||||
totalVideosFound,
|
||||
totalSources: sources.length,
|
||||
maxPageCount: MAX_PAGES_PER_SOURCE,
|
||||
});
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -202,7 +187,7 @@ export async function POST(request: NextRequest) {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,13 +5,11 @@
|
||||
* so they persist across browsers, devices, and PWA installs.
|
||||
*/
|
||||
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
import { getRedisClient } from '@/lib/server/redis-client';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
const redis = Redis.fromEnv();
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
function redisKey(profileId: string): string {
|
||||
const safe = profileId.replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
@@ -26,6 +24,11 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return NextResponse.json({ success: true, data: null, synced: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await redis.get(redisKey(profileId));
|
||||
return NextResponse.json({ success: true, data: data || null });
|
||||
@@ -46,6 +49,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return NextResponse.json({ success: true, synced: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const key = redisKey(profileId);
|
||||
@@ -56,7 +64,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
await redis.set(key, merged);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, synced: true });
|
||||
} catch (error) {
|
||||
console.error('Config write error:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getServerSession } from '@/lib/server/auth';
|
||||
import { getRedisClient } from '@/lib/server/redis-client';
|
||||
|
||||
// 确保这行代码在整个文件中只出现一次
|
||||
export const runtime = 'edge';
|
||||
|
||||
const redis = Redis.fromEnv();
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const session = await getServerSession(request);
|
||||
@@ -15,6 +13,15 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { history: [], favorites: [] },
|
||||
synced: false,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await redis.get(`user:sync:${profileId}`);
|
||||
return NextResponse.json({
|
||||
@@ -35,13 +42,18 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing profileId' }, { status: 400 });
|
||||
}
|
||||
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
return NextResponse.json({ success: true, synced: false });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { history, favorites } = body;
|
||||
|
||||
await redis.set(`user:sync:${profileId}`, { history, favorites });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true, synced: true });
|
||||
} catch (error) {
|
||||
console.error('Redis Set Error:', error);
|
||||
return NextResponse.json({ error: 'Failed to save sync data' }, { status: 500 });
|
||||
|
||||
@@ -27,3 +27,18 @@
|
||||
.no-spinner {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -21,9 +21,6 @@ import { resolveSiteIconSrc } from '@/lib/server/site-icon';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DEFAULT_VIDEOTOGETHER_SCRIPT_URL =
|
||||
'https://fastly.jsdelivr.net/gh/VideoTogether/VideoTogether@latest/release/extension.website.user.js';
|
||||
|
||||
// Server Component specifically for reading env/file (async for best practices)
|
||||
async function AdKeywordsWrapper() {
|
||||
let keywords: string[] = [];
|
||||
@@ -82,10 +79,9 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const siteIconSrc = await resolveSiteIconSrc();
|
||||
const runtimeFeatures = getRuntimeFeatures();
|
||||
const videoTogetherScriptUrl =
|
||||
process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || DEFAULT_VIDEOTOGETHER_SCRIPT_URL;
|
||||
const videoTogetherScriptUrl = process.env.VIDEOTOGETHER_SCRIPT_URL?.trim() || '';
|
||||
const videoTogetherSettingUrl = process.env.VIDEOTOGETHER_SETTING_URL?.trim();
|
||||
const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED !== 'false';
|
||||
const videoTogetherEnvEnabled = process.env.VIDEOTOGETHER_ENABLED === 'true';
|
||||
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
|
||||
+42
-17
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { Suspense, useMemo, useSyncExternalStore } from 'react';
|
||||
import { SearchForm } from '@/components/search/SearchForm';
|
||||
import { NoResults } from '@/components/search/NoResults';
|
||||
import { PopularFeatures } from '@/components/home/PopularFeatures';
|
||||
@@ -9,6 +9,31 @@ import { Navbar } from '@/components/layout/Navbar';
|
||||
import { SearchResults } from '@/components/home/SearchResults';
|
||||
import { useHomePage } from '@/lib/hooks/useHomePage';
|
||||
import { useLatencyPing } from '@/lib/hooks/useLatencyPing';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { userSourcesStore } from '@/lib/store/user-sources-store';
|
||||
import { buildLatencySourceUrls } from '@/lib/utils/latency-source-map';
|
||||
|
||||
function subscribeToConfiguredSources(listener: () => void) {
|
||||
const unsubscribeSettings = settingsStore.subscribe(listener);
|
||||
const unsubscribeUserSources = userSourcesStore.subscribe(listener);
|
||||
|
||||
return () => {
|
||||
unsubscribeSettings();
|
||||
unsubscribeUserSources();
|
||||
};
|
||||
}
|
||||
|
||||
function getConfiguredSourcesSnapshot() {
|
||||
const settings = settingsStore.getSettings();
|
||||
const configuredSources = [...settings.sources, ...userSourcesStore.getSources()]
|
||||
.filter((source) => source.enabled !== false)
|
||||
.map((source) => ({
|
||||
id: source.id,
|
||||
baseUrl: source.baseUrl,
|
||||
}));
|
||||
|
||||
return JSON.stringify(configuredSources);
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
const {
|
||||
@@ -24,12 +49,21 @@ function HomePage() {
|
||||
handleCancelSearch,
|
||||
} = useHomePage();
|
||||
|
||||
// Real-time latency pinging
|
||||
const sourceUrls = useMemo(() =>
|
||||
availableSources.map(s => ({ id: s.id, baseUrl: s.id })), // Using id as baseUrl if not available elsewhere
|
||||
[availableSources]
|
||||
const configuredSourcesSnapshot = useSyncExternalStore(
|
||||
subscribeToConfiguredSources,
|
||||
getConfiguredSourcesSnapshot,
|
||||
() => '[]',
|
||||
);
|
||||
|
||||
const sourceUrls = useMemo(() => {
|
||||
const configuredSources = JSON.parse(configuredSourcesSnapshot) as Array<{
|
||||
id: string;
|
||||
baseUrl: string;
|
||||
}>;
|
||||
|
||||
return buildLatencySourceUrls(availableSources, configuredSources);
|
||||
}, [availableSources, configuredSourcesSnapshot]);
|
||||
|
||||
const { latencies } = useLatencyPing({
|
||||
sourceUrls,
|
||||
enabled: hasSearched && results.length > 0,
|
||||
@@ -37,11 +71,9 @@ function HomePage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Glass Navbar */}
|
||||
<Navbar onReset={handleReset} />
|
||||
|
||||
{/* Search Form - Separate from navbar */}
|
||||
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 mt-3 sm:mt-5 mb-5 sm:mb-7 relative" style={{
|
||||
transform: 'translate3d(0, 0, 0)',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
@@ -57,9 +89,7 @@ function HomePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||
{/* Results Section */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16 sm:pb-20">
|
||||
{(results.length >= 1 || (!loading && results.length > 0)) && (
|
||||
<SearchResults
|
||||
results={results}
|
||||
@@ -69,20 +99,15 @@ function HomePage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Popular Features - Homepage */}
|
||||
{!loading && !hasSearched && (
|
||||
<>
|
||||
<PopularFeatures onSearch={handleSearch} />
|
||||
</>
|
||||
<PopularFeatures onSearch={handleSearch} />
|
||||
)}
|
||||
|
||||
{/* No Results */}
|
||||
{!loading && hasSearched && results.length === 0 && (
|
||||
<NoResults onReset={handleReset} />
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Favorites Sidebar - Left */}
|
||||
<FavoritesSidebar />
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
-3
@@ -146,11 +146,11 @@ function PlayerContent() {
|
||||
let sources: SourceInfo[] = [];
|
||||
|
||||
if (gsKey) {
|
||||
const cached = retrieveGroupedSources(gsKey);
|
||||
const cached = retrieveGroupedSources<SourceInfo[]>(gsKey);
|
||||
if (cached) sources = cached;
|
||||
} else if (groupedSourcesParam) {
|
||||
try {
|
||||
sources = JSON.parse(groupedSourcesParam);
|
||||
sources = JSON.parse(groupedSourcesParam) as SourceInfo[];
|
||||
} catch {
|
||||
sources = [];
|
||||
}
|
||||
@@ -200,7 +200,7 @@ function PlayerContent() {
|
||||
// Check if existing grouped sources already have full info (pic + latency)
|
||||
let existingSources: SourceInfo[] = [];
|
||||
if (gsKey) {
|
||||
const cached = retrieveGroupedSources(gsKey);
|
||||
const cached = retrieveGroupedSources<SourceInfo[]>(gsKey);
|
||||
if (cached) existingSources = cached;
|
||||
} else if (groupedSourcesParam) {
|
||||
try { existingSources = JSON.parse(groupedSourcesParam); } catch {}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
parseSourcesFromJson,
|
||||
fetchSourcesFromUrl
|
||||
} from '@/lib/utils/source-import-utils';
|
||||
import { clearSession } from '@/lib/store/auth-store';
|
||||
|
||||
export function useSettingsPage() {
|
||||
const [sources, setSources] = useState<VideoSource[]>([]);
|
||||
@@ -360,7 +361,14 @@ export function useSettingsPage() {
|
||||
setIsRestoreDefaultsDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const handleResetAll = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/session', { method: 'DELETE' });
|
||||
} catch {
|
||||
// Clear local state even if the server-side logout request fails.
|
||||
}
|
||||
|
||||
clearSession();
|
||||
settingsStore.resetToDefaults();
|
||||
setIsResetDialogOpen(false);
|
||||
window.location.reload();
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function SettingsPage() {
|
||||
<ConfirmDialog
|
||||
isOpen={isResetDialogOpen}
|
||||
title="清除所有数据"
|
||||
message="这将删除所有设置、历史记录、Cookie 和缓存。此操作不可撤销。是否继续?"
|
||||
message="这将删除本地设置、历史记录、缓存,并退出当前登录会话。此操作不可撤销。是否继续?"
|
||||
confirmText="清除"
|
||||
cancelText="取消"
|
||||
onConfirm={handleResetAll}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
/// Change this to your deployed KVideo instance URL
|
||||
let kvideoURL = "https://kvideo.example.com"
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
WebView(url: URL(string: kvideoURL)!)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
struct WebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.allowsInlineMediaPlayback = true
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
|
||||
let preferences = WKWebpagePreferences()
|
||||
preferences.allowsContentJavaScript = true
|
||||
config.defaultWebpagePreferences = preferences
|
||||
|
||||
let webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .black
|
||||
webView.scrollView.backgroundColor = .black
|
||||
|
||||
// Allow back navigation via Menu button
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
|
||||
webView.load(URLRequest(url: url))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, WKNavigationDelegate {
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
// Inject JS to signal TV mode
|
||||
webView.evaluateJavaScript("""
|
||||
document.body.classList.add('tv-mode');
|
||||
""")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct KVideoTVApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-37
@@ -1,40 +1,7 @@
|
||||
# KVideo Apple TV App
|
||||
# Apple TV
|
||||
|
||||
A lightweight tvOS WebView wrapper for KVideo.
|
||||
Apple TV is not supported in this repository.
|
||||
|
||||
## Requirements
|
||||
The previous tvOS sample WebView shell has been removed because it was not a complete or supported product path.
|
||||
|
||||
- macOS with Xcode 15+
|
||||
- Apple Developer account (free is fine for personal device sideloading)
|
||||
|
||||
## Setup
|
||||
|
||||
1. Open Xcode → **File → New → Project**
|
||||
2. Select **tvOS → App**, click Next
|
||||
3. Set:
|
||||
- Product Name: `KVideoTV`
|
||||
- Interface: **SwiftUI**
|
||||
- Language: **Swift**
|
||||
4. Choose a save location, click Create
|
||||
5. **Replace** the generated `KVideoTVApp.swift` with the one in this directory
|
||||
6. **Replace** the generated `ContentView.swift` with the one in this directory
|
||||
7. In `ContentView.swift`, change `kvideoURL` to your deployed KVideo instance URL:
|
||||
```swift
|
||||
let kvideoURL = "https://your-kvideo-instance.com"
|
||||
```
|
||||
8. Set deployment target to **tvOS 16.0** or later
|
||||
9. Connect your Apple TV (or use the tvOS Simulator)
|
||||
10. Build and run (Cmd+R)
|
||||
|
||||
## How it works
|
||||
|
||||
- The app is a fullscreen `WKWebView` that loads your KVideo URL
|
||||
- On page load, it injects `tv-mode` CSS class to activate TV-optimized styles
|
||||
- The Apple TV remote's swipe gestures map to scroll, and click maps to tap/focus
|
||||
- Back navigation uses `allowsBackForwardNavigationGestures`
|
||||
|
||||
## Notes
|
||||
|
||||
- Apple TV apps **cannot** be published to the App Store if they're just web wrappers
|
||||
- This is intended for personal sideloading only
|
||||
- For AirPlay: you can also just AirPlay from iPhone/iPad/Mac without needing this app
|
||||
If you need TV playback on Apple hardware today, use AirPlay or another browser/device casting path from an already supported KVideo deployment.
|
||||
|
||||
+11
-6
@@ -7,12 +7,17 @@ import { useCloudSync } from '@/lib/hooks/useCloudSync';
|
||||
import { useConfigSync } from '@/lib/hooks/useConfigSync';
|
||||
import { getSession } from '@/lib/store/auth-store';
|
||||
|
||||
// 防抖函数,防止频繁请求
|
||||
function debounce(fn: Function, delay: number) {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
return (...args: any[]) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
type VoidCallback = () => void;
|
||||
|
||||
function debounce(fn: VoidCallback, delay: number): VoidCallback {
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => fn(), delay);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ export function PasswordGate({
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [persistSession, setPersistSession] = useState(true);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
@@ -145,6 +146,7 @@ export function PasswordGate({
|
||||
|
||||
setPersistSession(config.persistSession);
|
||||
setLoginMode(config.loginMode || 'none');
|
||||
setAuthError(typeof config.authError === 'string' ? config.authError : '');
|
||||
applyRuntimeConfig(config);
|
||||
|
||||
if (sessionStatus.authenticated && sessionStatus.session) {
|
||||
@@ -171,6 +173,13 @@ export function PasswordGate({
|
||||
return;
|
||||
}
|
||||
|
||||
if (config.authError) {
|
||||
setError(config.authError);
|
||||
setIsLocked(true);
|
||||
setIsClient(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLocked(!!config.hasAuth);
|
||||
setIsClient(true);
|
||||
} catch {
|
||||
@@ -189,8 +198,14 @@ export function PasswordGate({
|
||||
|
||||
const handleUnlock = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (authError) {
|
||||
setError(authError);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidating(true);
|
||||
setError('');
|
||||
let nextError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth', {
|
||||
@@ -208,11 +223,15 @@ export function PasswordGate({
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.message === 'string' && data.message) {
|
||||
nextError = data.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore network errors and show the same message as invalid credentials.
|
||||
}
|
||||
|
||||
setError(loginMode === 'managed' ? '用户名或密码错误' : '密码错误');
|
||||
setError(nextError || (loginMode === 'managed' ? '用户名或密码错误' : '密码错误'));
|
||||
setIsValidating(false);
|
||||
const form = document.getElementById('password-form');
|
||||
form?.classList.add('animate-shake');
|
||||
@@ -256,7 +275,9 @@ export function PasswordGate({
|
||||
value={username}
|
||||
onChange={(event) => {
|
||||
setUsername(event.target.value);
|
||||
setError('');
|
||||
if (!authError) {
|
||||
setError('');
|
||||
}
|
||||
}}
|
||||
placeholder="输入用户名..."
|
||||
className="w-full pl-11 pr-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border border-[var(--glass-border)] focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]"
|
||||
@@ -273,7 +294,9 @@ export function PasswordGate({
|
||||
value={password}
|
||||
onChange={(event) => {
|
||||
setPassword(event.target.value);
|
||||
setError('');
|
||||
if (!authError) {
|
||||
setError('');
|
||||
}
|
||||
}}
|
||||
placeholder={showManagedFields ? '输入密码...' : '输入密码...'}
|
||||
className={`w-full px-4 py-3 rounded-[var(--radius-2xl)] bg-[var(--glass-bg)] border ${error ? 'border-red-500' : 'border-[var(--glass-border)]'} focus:outline-none focus:border-[var(--accent-color)] focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)] transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1) text-[var(--text-color)] placeholder-[var(--text-color-secondary)]`}
|
||||
@@ -289,10 +312,10 @@ export function PasswordGate({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isValidating}
|
||||
disabled={isValidating || !!authError}
|
||||
className="w-full py-3 px-4 bg-[var(--accent-color)] text-white font-bold rounded-[var(--radius-2xl)] hover:translate-y-[-2px] hover:brightness-110 shadow-[var(--shadow-sm)] hover:shadow-[0_4px_8px_var(--shadow-color)] active:translate-y-0 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? '验证中...' : '登录'}
|
||||
{authError ? '配置错误' : isValidating ? '验证中...' : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
@@ -12,73 +12,97 @@ interface ThemeContextType {
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
interface ViewTransitionHandle {
|
||||
finished: Promise<void>;
|
||||
skipTransition?: () => void;
|
||||
}
|
||||
|
||||
type DocumentWithViewTransition = Document & {
|
||||
startViewTransition?: (callback: () => void) => ViewTransitionHandle;
|
||||
};
|
||||
|
||||
function isTheme(value: string | null): value is Theme {
|
||||
return value === 'light' || value === 'dark' || value === 'system';
|
||||
}
|
||||
|
||||
function subscribeToSystemTheme(listener: () => void) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}
|
||||
|
||||
function getSystemThemeSnapshot() {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [actualTheme, setActualTheme] = useState<'light' | 'dark'>('dark');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const transitionRef = React.useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
// Load saved theme
|
||||
const saved = localStorage.getItem('theme') as Theme;
|
||||
if (saved) {
|
||||
setTheme(saved);
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'system';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
return isTheme(savedTheme) ? savedTheme : 'system';
|
||||
});
|
||||
const transitionRef = useRef<ViewTransitionHandle | null>(null);
|
||||
const prefersDark = useSyncExternalStore(
|
||||
subscribeToSystemTheme,
|
||||
getSystemThemeSnapshot,
|
||||
() => true,
|
||||
);
|
||||
const actualTheme = useMemo<'light' | 'dark'>(() => {
|
||||
if (theme === 'system') {
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return theme;
|
||||
}, [prefersDark, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const applyTheme = (newTheme?: 'light' | 'dark') => {
|
||||
const themeToApply = newTheme || (theme === 'system'
|
||||
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
: theme);
|
||||
|
||||
setActualTheme(themeToApply);
|
||||
document.documentElement.classList.toggle('dark', themeToApply === 'dark');
|
||||
const applyTheme = () => {
|
||||
document.documentElement.classList.toggle('dark', actualTheme === 'dark');
|
||||
};
|
||||
|
||||
const applyThemeWithTransition = () => {
|
||||
// Abort previous transition if it exists
|
||||
if (transitionRef.current) {
|
||||
try {
|
||||
transitionRef.current.skipTransition();
|
||||
} catch (e) {
|
||||
// Ignore if transition already finished
|
||||
transitionRef.current.skipTransition?.();
|
||||
} catch {
|
||||
// Ignore if the previous transition already finished.
|
||||
}
|
||||
}
|
||||
|
||||
// Check if document is visible - skip transition if hidden
|
||||
if (document.hidden) {
|
||||
applyTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if View Transition API is supported
|
||||
// @ts-ignore - View Transition API is experimental
|
||||
if (typeof document.startViewTransition === 'function') {
|
||||
const transitionDocument = document as DocumentWithViewTransition;
|
||||
if (typeof transitionDocument.startViewTransition === 'function') {
|
||||
try {
|
||||
// @ts-ignore
|
||||
transitionRef.current = document.startViewTransition(() => {
|
||||
transitionRef.current = transitionDocument.startViewTransition(() => {
|
||||
applyTheme();
|
||||
});
|
||||
|
||||
// Clear ref after transition completes or fails
|
||||
|
||||
if (transitionRef.current) {
|
||||
transitionRef.current.finished
|
||||
.then(() => { transitionRef.current = null; })
|
||||
.catch((error: Error) => {
|
||||
// Silently handle transition errors (visibility changes, etc.)
|
||||
.catch(() => {
|
||||
transitionRef.current = null;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback if transition fails to start
|
||||
} catch {
|
||||
applyTheme();
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers that don't support View Transition API
|
||||
applyTheme();
|
||||
}
|
||||
};
|
||||
@@ -86,42 +110,30 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
applyThemeWithTransition();
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
// Listen for system theme changes
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemThemeChange = () => {
|
||||
if (theme === 'system') {
|
||||
applyThemeWithTransition();
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for visibility changes to abort transitions
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden && transitionRef.current) {
|
||||
try {
|
||||
transitionRef.current.skipTransition();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
transitionRef.current.skipTransition?.();
|
||||
} catch {
|
||||
// Ignore transition cleanup failures.
|
||||
}
|
||||
transitionRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
// Abort any pending transition on unmount
|
||||
if (transitionRef.current) {
|
||||
try {
|
||||
transitionRef.current.skipTransition();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
transitionRef.current.skipTransition?.();
|
||||
} catch {
|
||||
// Ignore transition cleanup failures.
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [theme, mounted]);
|
||||
}, [actualTheme, theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
@@ -30,6 +30,19 @@ function isSupportedRoute(pathname: string | null): boolean {
|
||||
return pathname?.startsWith('/player') === true || pathname?.startsWith('/iptv') === true;
|
||||
}
|
||||
|
||||
function normalizeHttpsUrl(rawUrl?: string): string | null {
|
||||
if (!rawUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(rawUrl);
|
||||
return parsedUrl.protocol === 'https:' ? parsedUrl.toString() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMinimizedDefaults(forceCurrentPageMinimized: boolean) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -82,19 +95,20 @@ export function VideoTogetherController({
|
||||
settingUrl,
|
||||
}: VideoTogetherControllerProps) {
|
||||
const pathname = usePathname();
|
||||
const [videoTogetherEnabled, setVideoTogetherEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setVideoTogetherEnabled(settingsStore.getSettings().videoTogetherEnabled);
|
||||
};
|
||||
|
||||
sync();
|
||||
return settingsStore.subscribe(sync);
|
||||
}, []);
|
||||
const videoTogetherEnabled = useSyncExternalStore(
|
||||
(listener) => settingsStore.subscribe(listener),
|
||||
() => settingsStore.getSettings().videoTogetherEnabled,
|
||||
() => false,
|
||||
);
|
||||
const normalizedScriptUrl = useMemo(() => normalizeHttpsUrl(scriptUrl), [scriptUrl]);
|
||||
const normalizedSettingUrl = useMemo(() => normalizeHttpsUrl(settingUrl), [settingUrl]);
|
||||
|
||||
const supportedRoute = isSupportedRoute(pathname);
|
||||
const shouldActivate = envEnabled && videoTogetherEnabled && supportedRoute;
|
||||
const shouldActivate =
|
||||
envEnabled &&
|
||||
Boolean(normalizedScriptUrl) &&
|
||||
videoTogetherEnabled &&
|
||||
supportedRoute;
|
||||
|
||||
useEffect(() => {
|
||||
if (!envEnabled || !videoTogetherEnabled) {
|
||||
@@ -114,8 +128,8 @@ export function VideoTogetherController({
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingUrl) {
|
||||
window.videoTogetherWebsiteSettingUrl = settingUrl;
|
||||
if (normalizedSettingUrl) {
|
||||
window.videoTogetherWebsiteSettingUrl = normalizedSettingUrl;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -130,11 +144,11 @@ export function VideoTogetherController({
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.id = SCRIPT_ID;
|
||||
script.src = scriptUrl;
|
||||
script.src = normalizedScriptUrl!;
|
||||
script.async = true;
|
||||
|
||||
document.body.appendChild(script);
|
||||
}, [scriptUrl, settingUrl, shouldActivate]);
|
||||
}, [normalizedScriptUrl, normalizedSettingUrl, shouldActivate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import { formatDate } from '@/lib/utils/format-utils';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache';
|
||||
@@ -40,7 +41,7 @@ export function FavoritesItem({ item, onRemove, isPremium = false }: FavoritesIt
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Middle mouse or Ctrl/Cmd+click opens in new tab
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
@@ -55,27 +56,22 @@ export function FavoritesItem({ item, onRemove, isPremium = false }: FavoritesIt
|
||||
href={getVideoUrl()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClick(e as any);
|
||||
handleClick(e);
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
window.location.href = getVideoUrl();
|
||||
}
|
||||
}}
|
||||
onAuxClick={(e) => handleClick(e as any)}
|
||||
onAuxClick={handleClick}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
{/* Poster - Same size as HistoryItem */}
|
||||
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{item.poster ? (
|
||||
<img
|
||||
<RemotePosterImage
|
||||
src={item.poster}
|
||||
alt={item.title}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* Fallback icon */}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* Displays video thumbnail, title, episode, progress, and delete button
|
||||
*/
|
||||
|
||||
import Image from 'next/image';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { formatTime, formatDate } from '@/lib/utils/format-utils';
|
||||
import { PosterImage } from './PosterImage';
|
||||
@@ -45,7 +44,7 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
|
||||
return `/player?${params.toString()}`;
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
const handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// Middle mouse or Ctrl/Cmd+click opens in new tab
|
||||
if (event.button === 1 || event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
@@ -65,12 +64,12 @@ export function HistoryItem({ item, onRemove, isPremium = false }: HistoryItemPr
|
||||
href={getVideoUrl()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClick(e as any);
|
||||
handleClick(e);
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
window.location.href = getVideoUrl();
|
||||
}
|
||||
}}
|
||||
onAuxClick={(e) => handleClick(e as any)}
|
||||
onAuxClick={handleClick}
|
||||
className="block"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
|
||||
interface PosterImageProps {
|
||||
poster?: string;
|
||||
@@ -15,21 +16,10 @@ export function PosterImage({ poster, title, progress }: PosterImageProps) {
|
||||
return (
|
||||
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{poster ? (
|
||||
<img
|
||||
<RemotePosterImage
|
||||
src={poster}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const parent = target.parentElement;
|
||||
if (parent) {
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'w-full h-full flex items-center justify-center';
|
||||
fallback.innerHTML = '<svg class="text-[var(--text-color-secondary)] opacity-30" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect><line x1="7" y1="2" x2="7" y2="22"></line><line x1="17" y1="2" x2="17" y2="22"></line><line x1="2" y1="12" x2="22" y2="12"></line><line x1="2" y1="7" x2="7" y2="7"></line><line x1="2" y1="17" x2="7" y2="17"></line><line x1="17" y1="17" x2="22" y2="17"></line><line x1="17" y1="7" x2="22" y2="7"></line></svg>';
|
||||
parent.appendChild(fallback);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
|
||||
@@ -55,8 +55,6 @@ export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieC
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105 rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||
loading="eager"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function MovieGrid({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 gap-3 md:gap-4">
|
||||
{movies.map((movie) => (
|
||||
<MovieCard
|
||||
key={movie.id}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { TagManager } from './TagManager';
|
||||
import { MovieGrid } from './MovieGrid';
|
||||
import { useTagManager } from './hooks/useTagManager';
|
||||
@@ -46,19 +46,8 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
loadMoreRef: recommendLoadMoreRef,
|
||||
} = usePersonalizedRecommendations(false);
|
||||
|
||||
// Track whether the recommendation tab is active
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
|
||||
|
||||
// Sync selection when hasHistory changes after Zustand hydration from localStorage.
|
||||
// On first render the store is empty (hasHistory=false), so useState captures false.
|
||||
// Once hydration completes and hasHistory becomes true, auto-select the recommendation tab.
|
||||
useEffect(() => {
|
||||
if (hasHistory) {
|
||||
setIsRecommendSelected(true);
|
||||
}
|
||||
}, [hasHistory]);
|
||||
|
||||
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
|
||||
const [selectionMode, setSelectionMode] = useState<'recommend' | 'tag'>('recommend');
|
||||
const effectiveRecommendSelected = hasHistory && selectionMode === 'recommend';
|
||||
|
||||
const {
|
||||
movies,
|
||||
@@ -72,14 +61,14 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
contentType
|
||||
);
|
||||
|
||||
const handleMovieClick = (movie: any) => {
|
||||
const handleMovieClick = (movie: { title: string }) => {
|
||||
if (onSearch) {
|
||||
onSearch(movie.title);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecommendSelect = () => {
|
||||
setIsRecommendSelected(true);
|
||||
setSelectionMode('recommend');
|
||||
};
|
||||
|
||||
const handleRegularTagSelect = (tagId: string) => {
|
||||
@@ -87,7 +76,7 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
window.location.href = '/premium';
|
||||
return;
|
||||
}
|
||||
setIsRecommendSelected(false);
|
||||
setSelectionMode('tag');
|
||||
setSelectedTag(tagId);
|
||||
};
|
||||
|
||||
@@ -95,8 +84,8 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
<div className="animate-fade-in">
|
||||
{/* Content Type Toggle (Capsule Liquid Glass - Fixed & Centered) */}
|
||||
{!effectiveRecommendSelected && (
|
||||
<div className="mb-10 flex justify-center">
|
||||
<div className="relative w-80 p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||
<div className="mb-6 sm:mb-8 flex justify-center">
|
||||
<div className="relative w-full max-w-xs p-1 bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-full grid grid-cols-2 backdrop-blur-2xl shadow-lg ring-1 ring-white/10 overflow-hidden">
|
||||
{/* Sliding Indicator */}
|
||||
<div
|
||||
className="absolute top-1 bottom-1 w-[calc(50%-4px)] bg-[var(--accent-color)] rounded-full transition-transform duration-400 cubic-bezier(0.4, 0, 0.2, 1) shadow-[0_0_15px_rgba(0,122,255,0.4)]"
|
||||
@@ -107,14 +96,14 @@ export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
|
||||
|
||||
<button
|
||||
onClick={() => setContentType('movie')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
className={`relative z-10 py-2 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'movie' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电影
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setContentType('tv')}
|
||||
className={`relative z-10 py-2.5 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
className={`relative z-10 py-2 text-sm font-bold transition-colors duration-300 cursor-pointer flex justify-center items-center ${contentType === 'tv' ? 'text-white' : 'text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'
|
||||
}`}
|
||||
>
|
||||
电视剧
|
||||
|
||||
@@ -37,6 +37,10 @@ interface InterleavedMovie extends DoubanMovie {
|
||||
sourceLabel: string;
|
||||
}
|
||||
|
||||
interface DoubanRecommendResponse {
|
||||
subjects?: Array<Partial<DoubanMovie>>;
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
|
||||
const ITEMS_PER_PAGE = 18; // How many to fetch per query per page
|
||||
const MAX_ROUNDS = 8; // Max times to regenerate queries before giving up
|
||||
@@ -75,13 +79,13 @@ export function usePersonalizedRecommendations(isPremium = false) {
|
||||
`/api/douban/recommend?tag=${encodeURIComponent(query.tag)}&type=${query.type}&page_limit=${ITEMS_PER_PAGE}&page_start=${offset}`
|
||||
);
|
||||
if (!res.ok) return { label: query.label, movies: [] as DoubanMovie[] };
|
||||
const data = await res.json();
|
||||
const movies: DoubanMovie[] = (data.subjects || []).map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
cover: s.cover,
|
||||
rate: s.rate,
|
||||
url: s.url,
|
||||
const data = (await res.json()) as DoubanRecommendResponse;
|
||||
const movies: DoubanMovie[] = (data.subjects || []).map((subject) => ({
|
||||
id: subject.id || '',
|
||||
title: subject.title || '',
|
||||
cover: subject.cover || '',
|
||||
rate: subject.rate || '',
|
||||
url: subject.url || '',
|
||||
}));
|
||||
return { label: query.label, movies };
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
|
||||
|
||||
interface HomeTag {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DoubanMovie {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -9,9 +15,13 @@ interface DoubanMovie {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface DoubanRecommendResponse {
|
||||
subjects?: DoubanMovie[];
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
export function usePopularMovies(selectedTag: string, tags: any[], contentType: 'movie' | 'tv' = 'movie') {
|
||||
export function usePopularMovies(selectedTag: string, tags: HomeTag[], contentType: 'movie' | 'tv' = 'movie') {
|
||||
const [movies, setMovies] = useState<DoubanMovie[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
@@ -29,7 +39,7 @@ export function usePopularMovies(selectedTag: string, tags: any[], contentType:
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DoubanRecommendResponse;
|
||||
const newMovies = data.subjects || [];
|
||||
|
||||
setMovies(prev => append ? [...prev, ...newMovies] : newMovies);
|
||||
|
||||
@@ -2,7 +2,17 @@ import { useState, useEffect } from 'react';
|
||||
import { DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
|
||||
const DEFAULT_TAG = { id: 'popular', label: '热门', value: '热门' };
|
||||
export interface HomeTag {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface DoubanTagsResponse {
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_TAG: HomeTag = { id: 'popular', label: '热门', value: '热门' };
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'kvideo_custom_tags_';
|
||||
|
||||
@@ -13,7 +23,7 @@ export function useTagManager() {
|
||||
return saved === 'tv' ? 'tv' : 'movie';
|
||||
});
|
||||
const [selectedTag, setSelectedTag] = useState(DEFAULT_TAG.value);
|
||||
const [tags, setTags] = useState<any[]>([]);
|
||||
const [tags, setTags] = useState<HomeTag[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState(false);
|
||||
const [newTagInput, setNewTagInput] = useState('');
|
||||
const [showTagManager, setShowTagManager] = useState(false);
|
||||
@@ -32,10 +42,21 @@ export function useTagManager() {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
if (saved) {
|
||||
try {
|
||||
setTags(JSON.parse(saved));
|
||||
const parsed = JSON.parse(saved) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
setTags(parsed.filter((tag): tag is HomeTag => {
|
||||
return Boolean(
|
||||
tag &&
|
||||
typeof tag === 'object' &&
|
||||
typeof (tag as HomeTag).id === 'string' &&
|
||||
typeof (tag as HomeTag).label === 'string' &&
|
||||
typeof (tag as HomeTag).value === 'string'
|
||||
);
|
||||
}));
|
||||
}
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse saved tags', e);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse saved tags', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +64,7 @@ export function useTagManager() {
|
||||
setIsLoadingTags(true);
|
||||
try {
|
||||
const response = await fetch(`/api/douban/tags?type=${contentType}`);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DoubanTagsResponse;
|
||||
if (data.tags && Array.isArray(data.tags)) {
|
||||
const mappedTags = data.tags.map((label: string) => ({
|
||||
id: label === '热门' ? 'popular' : `tag_${label}`,
|
||||
@@ -52,13 +73,11 @@ export function useTagManager() {
|
||||
}));
|
||||
|
||||
// If "热门" isn't in the list, add it to the front
|
||||
if (!mappedTags.some((t: any) => t.value === '热门')) {
|
||||
if (!mappedTags.some((tag) => tag.value === '热门')) {
|
||||
mappedTags.unshift(DEFAULT_TAG);
|
||||
}
|
||||
|
||||
setTags(mappedTags);
|
||||
// Also save to localStorage to avoid repeated fetches if desired
|
||||
// Actually, let's just keep them in memory for now unless they customize
|
||||
} else {
|
||||
setTags([DEFAULT_TAG]);
|
||||
}
|
||||
@@ -74,7 +93,7 @@ export function useTagManager() {
|
||||
setSelectedTag(DEFAULT_TAG.value);
|
||||
}, [contentType, storageKey]);
|
||||
|
||||
const saveTags = (newTags: any[]) => {
|
||||
const saveTags = (newTags: HomeTag[]) => {
|
||||
setTags(newTags);
|
||||
localStorage.setItem(storageKey, JSON.stringify(newTags));
|
||||
};
|
||||
@@ -104,21 +123,21 @@ export function useTagManager() {
|
||||
setIsLoadingTags(true);
|
||||
try {
|
||||
const response = await fetch(`/api/douban/tags?type=${contentType}`);
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as DoubanTagsResponse;
|
||||
if (data.tags && Array.isArray(data.tags)) {
|
||||
const mappedTags = data.tags.map((label: string) => ({
|
||||
id: label === '热门' ? 'popular' : `tag_${label}`,
|
||||
label,
|
||||
value: label,
|
||||
}));
|
||||
if (!mappedTags.some((t: any) => t.value === '热门')) {
|
||||
if (!mappedTags.some((tag) => tag.value === '热门')) {
|
||||
mappedTags.unshift(DEFAULT_TAG);
|
||||
}
|
||||
setTags(mappedTags);
|
||||
} else {
|
||||
setTags([DEFAULT_TAG]);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setTags([DEFAULT_TAG]);
|
||||
} finally {
|
||||
setIsLoadingTags(false);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import type { M3UChannel } from '@/lib/utils/m3u-parser';
|
||||
import type { IPTVSource } from '@/lib/store/iptv-store';
|
||||
|
||||
@@ -191,13 +192,10 @@ export function IPTVChannelGrid({ channels, groups, onSelect, activeChannel, cha
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{channel.logo ? (
|
||||
<img
|
||||
<RemotePosterImage
|
||||
src={channel.logo}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-contain bg-black/10 flex-shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-8 h-8 rounded flex items-center justify-center flex-shrink-0 ${
|
||||
|
||||
@@ -38,6 +38,14 @@ interface IPTVPlayerProps {
|
||||
sources?: IPTVSource[];
|
||||
}
|
||||
|
||||
interface RouteUiState {
|
||||
channelKey: string;
|
||||
currentRouteIndex: number;
|
||||
showAllRoutes: boolean;
|
||||
}
|
||||
|
||||
type RouteIndexUpdater = number | ((prev: number) => number);
|
||||
|
||||
function getProxiedUrl(url: string, ua?: string, referer?: string): string {
|
||||
let proxyUrl = `/api/iptv/stream?`;
|
||||
if (ua) proxyUrl += `ua=${encodeURIComponent(ua)}&`;
|
||||
@@ -95,10 +103,14 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const [volume, setVolume] = useState(1);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||
const [currentRouteIndex, setCurrentRouteIndex] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [showAllRoutes, setShowAllRoutes] = useState(false);
|
||||
const [seekStepSeconds, setSeekStepSeconds] = useState(DEFAULT_SEEK_STEP_SECONDS);
|
||||
const channelKey = `${channel.sourceId ?? ''}::${channel.name}::${channel.url}`;
|
||||
const [routeUiState, setRouteUiState] = useState<RouteUiState>(() => ({
|
||||
channelKey,
|
||||
currentRouteIndex: 0,
|
||||
showAllRoutes: false,
|
||||
}));
|
||||
|
||||
// Multi-level sidebar state
|
||||
const [expandedSources, setExpandedSources] = useState<Set<string>>(new Set());
|
||||
@@ -112,6 +124,22 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
() => (activeSourceId && sources ? sources.find((source) => source.id === activeSourceId) || null : null),
|
||||
[activeSourceId, sources]
|
||||
);
|
||||
const visibleExpandedSources = useMemo(() => {
|
||||
const next = new Set(expandedSources);
|
||||
if (activeSourceId) {
|
||||
next.add(activeSourceId);
|
||||
}
|
||||
return next;
|
||||
}, [expandedSources, activeSourceId]);
|
||||
const visibleExpandedGroups = useMemo(() => {
|
||||
const next = new Set(expandedGroups);
|
||||
if (activeGroupKey) {
|
||||
next.add(activeGroupKey);
|
||||
}
|
||||
return next;
|
||||
}, [expandedGroups, activeGroupKey]);
|
||||
const currentRouteIndex = routeUiState.channelKey === channelKey ? routeUiState.currentRouteIndex : 0;
|
||||
const showAllRoutes = routeUiState.channelKey === channelKey ? routeUiState.showAllRoutes : false;
|
||||
|
||||
// Get current route URL
|
||||
const routes = channel.routes || [channel.url];
|
||||
@@ -138,16 +166,6 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
return () => unsubscribe();
|
||||
}, []);
|
||||
|
||||
// Auto-expand the source/group containing the active channel
|
||||
useEffect(() => {
|
||||
if (channel.sourceId) {
|
||||
setExpandedSources(prev => new Set(prev).add(channel.sourceId!));
|
||||
if (channel.group) {
|
||||
setExpandedGroups(prev => new Set(prev).add(`${channel.sourceId}::${channel.group}`));
|
||||
}
|
||||
}
|
||||
}, [channel.sourceId, channel.group]);
|
||||
|
||||
// Track fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
@@ -433,8 +451,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
|
||||
// Load on channel/route change
|
||||
useEffect(() => {
|
||||
loadChannel(currentUrl);
|
||||
const loadTimer = window.setTimeout(() => {
|
||||
loadChannel(currentUrl);
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(loadTimer);
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
@@ -446,12 +467,6 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
};
|
||||
}, [currentUrl, loadChannel]);
|
||||
|
||||
// Reset route index when channel changes
|
||||
useEffect(() => {
|
||||
setCurrentRouteIndex(0);
|
||||
setShowAllRoutes(false);
|
||||
}, [channel.name, channel.url]);
|
||||
|
||||
// Playback controls
|
||||
const togglePlay = () => {
|
||||
const video = videoRef.current;
|
||||
@@ -582,6 +597,43 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
}, [sidebarSearch, channels]);
|
||||
|
||||
const isSearchMode = sidebarSearch.trim().length > 0;
|
||||
const getCurrentRouteUiState = useCallback((state: RouteUiState): RouteUiState => {
|
||||
if (state.channelKey === channelKey) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
channelKey,
|
||||
currentRouteIndex: 0,
|
||||
showAllRoutes: false,
|
||||
};
|
||||
}, [channelKey]);
|
||||
|
||||
const handleRouteIndexChange = useCallback((nextIndex: RouteIndexUpdater) => {
|
||||
setRouteUiState((previousState) => {
|
||||
const baseState = getCurrentRouteUiState(previousState);
|
||||
const resolvedIndex = typeof nextIndex === 'function'
|
||||
? nextIndex(baseState.currentRouteIndex)
|
||||
: nextIndex;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
channelKey,
|
||||
currentRouteIndex: resolvedIndex,
|
||||
};
|
||||
});
|
||||
}, [channelKey, getCurrentRouteUiState]);
|
||||
|
||||
const toggleRouteVisibility = useCallback(() => {
|
||||
setRouteUiState((previousState) => {
|
||||
const baseState = getCurrentRouteUiState(previousState);
|
||||
return {
|
||||
...baseState,
|
||||
channelKey,
|
||||
showAllRoutes: !baseState.showAllRoutes,
|
||||
};
|
||||
});
|
||||
}, [channelKey, getCurrentRouteUiState]);
|
||||
|
||||
// Toggle source expansion
|
||||
const toggleSource = useCallback((sourceId: string) => {
|
||||
@@ -610,7 +662,11 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
|
||||
const toggleActiveGroup = useCallback(() => {
|
||||
if (!activeSourceId || !channel.group) return;
|
||||
setExpandedSources(prev => new Set(prev).add(activeSourceId));
|
||||
setExpandedSources(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(activeSourceId);
|
||||
return next;
|
||||
});
|
||||
toggleGroup(`${activeSourceId}::${channel.group}`);
|
||||
}, [activeSourceId, channel.group, toggleGroup]);
|
||||
|
||||
@@ -664,7 +720,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
const sourceData = channelsBySource[source.id];
|
||||
if (!sourceData || sourceData.channels.length === 0) return null;
|
||||
|
||||
const isExpanded = expandedSources.has(source.id);
|
||||
const isExpanded = visibleExpandedSources.has(source.id);
|
||||
const isActiveSource = source.id === activeSourceId;
|
||||
const orderedGroups = isActiveSource && channel.group
|
||||
? [channel.group, ...sourceData.groups.filter((group) => group !== channel.group)]
|
||||
@@ -699,7 +755,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
// Has groups — show group-level
|
||||
orderedGroups.map(group => {
|
||||
const groupKey = `${source.id}::${group}`;
|
||||
const groupExpanded = expandedGroups.has(groupKey);
|
||||
const groupExpanded = visibleExpandedGroups.has(groupKey);
|
||||
const groupChannels = sourceData.channels.filter(ch => ch.group === group);
|
||||
const isActiveGroup = groupKey === activeGroupKey;
|
||||
|
||||
@@ -821,7 +877,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
</button>
|
||||
{routes.length > 1 && currentRouteIndex < routes.length - 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentRouteIndex(prev => prev + 1); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleRouteIndexChange(prev => prev + 1); }}
|
||||
className="px-4 py-2 bg-blue-600/80 hover:bg-blue-600 rounded-lg text-white text-sm transition-colors cursor-pointer"
|
||||
>
|
||||
切换线路
|
||||
@@ -906,7 +962,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
{visibleRoutes.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); setCurrentRouteIndex(i); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleRouteIndexChange(i); }}
|
||||
className={`px-2 py-0.5 text-[10px] rounded transition-colors cursor-pointer ${
|
||||
i === currentRouteIndex
|
||||
? 'bg-[var(--accent-color)] text-white'
|
||||
@@ -918,7 +974,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
))}
|
||||
{hasMoreRoutes && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowAllRoutes(!showAllRoutes); }}
|
||||
onClick={(e) => { e.stopPropagation(); toggleRouteVisibility(); }}
|
||||
className="px-2 py-0.5 text-[10px] rounded bg-white/5 text-white/40 hover:bg-white/10 hover:text-white/60 transition-colors cursor-pointer"
|
||||
>
|
||||
{showAllRoutes ? '收起' : `+${routes.length - MAX_VISIBLE_ROUTES}`}
|
||||
@@ -1016,7 +1072,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
toggleActiveSource();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
activeSourceId && expandedSources.has(activeSourceId)
|
||||
activeSourceId && visibleExpandedSources.has(activeSourceId)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
@@ -1031,7 +1087,7 @@ export function IPTVPlayer({ channel, onClose, channels, onChannelChange, channe
|
||||
toggleActiveGroup();
|
||||
}}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border transition-colors cursor-pointer ${
|
||||
expandedGroups.has(activeGroupKey)
|
||||
visibleExpandedGroups.has(activeGroupKey)
|
||||
? 'bg-[var(--accent-color)] border-[var(--accent-color)] text-white'
|
||||
: 'bg-white/5 border-white/10 text-white/80 hover:bg-white/10'
|
||||
}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, useEffect, useCallback } from 'react';
|
||||
import React, { useRef, useEffect, useCallback, useSyncExternalStore } from 'react';
|
||||
import type { DanmakuComment } from '@/lib/types/danmaku';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
@@ -18,6 +18,7 @@ interface ActiveDanmaku {
|
||||
speed: number;
|
||||
width: number;
|
||||
lane: number;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
const SCROLL_DURATION = 8; // seconds for a comment to cross the screen
|
||||
@@ -34,24 +35,14 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
const lastSpawnTimeRef = useRef(-1);
|
||||
const laneSlotsRef = useRef<number[]>(new Array(MAX_LANES).fill(0)); // tracks when each lane becomes free
|
||||
|
||||
// Settings (read reactively)
|
||||
const [opacity, setOpacity] = React.useState(0.7);
|
||||
const [fontSize, setFontSize] = React.useState(20);
|
||||
const [displayArea, setDisplayArea] = React.useState(0.5);
|
||||
|
||||
useEffect(() => {
|
||||
const s = settingsStore.getSettings();
|
||||
setOpacity(s.danmakuOpacity);
|
||||
setFontSize(s.danmakuFontSize);
|
||||
setDisplayArea(s.danmakuDisplayArea);
|
||||
const unsub = settingsStore.subscribe(() => {
|
||||
const ns = settingsStore.getSettings();
|
||||
setOpacity(ns.danmakuOpacity);
|
||||
setFontSize(ns.danmakuFontSize);
|
||||
setDisplayArea(ns.danmakuDisplayArea);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
const settings = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
settingsStore.getSettings,
|
||||
settingsStore.getSettings
|
||||
);
|
||||
const opacity = settings.danmakuOpacity;
|
||||
const fontSize = settings.danmakuFontSize;
|
||||
const displayArea = settings.danmakuDisplayArea;
|
||||
|
||||
// Handle canvas resize
|
||||
useEffect(() => {
|
||||
@@ -164,12 +155,13 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
: effectiveHeight - bestLane * laneHeight - fontSize * 0.4;
|
||||
|
||||
activeRef.current.push({
|
||||
comment: { ...c, _expiry: time + TOP_BOTTOM_DURATION } as any,
|
||||
comment: c,
|
||||
x: (canvasWidth - textWidth) / 2,
|
||||
y,
|
||||
speed: 0,
|
||||
width: textWidth,
|
||||
lane: bestLane,
|
||||
expiresAt: time + TOP_BOTTOM_DURATION,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -235,7 +227,7 @@ export function DanmakuCanvas({ comments, currentTime, isPlaying, duration }: Da
|
||||
}
|
||||
} else {
|
||||
// Top/bottom: remove when expired
|
||||
const expiry = (d.comment as any)._expiry || 0;
|
||||
const expiry = d.expiresAt ?? 0;
|
||||
if (currentTime < expiry) {
|
||||
newActive.push(d);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import type { VideoResolutionInfo } from './hooks/useVideoResolution';
|
||||
@@ -401,17 +401,12 @@ export function EpisodeList({
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -480,17 +475,12 @@ export function EpisodeList({
|
||||
>
|
||||
{source.pic && (
|
||||
<div className="w-10 h-14 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={40}
|
||||
height={56}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,13 +5,14 @@
|
||||
* Following Liquid Glass design system
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
export interface SourceInfo {
|
||||
id: string | number;
|
||||
@@ -36,15 +37,37 @@ export function SourceSelector({
|
||||
}: SourceSelectorProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [latencies, setLatencies] = useState<Record<string, number>>({});
|
||||
const initialLatencies = useMemo(() => {
|
||||
return sources.reduce<Record<string, number>>((accumulator, source) => {
|
||||
if (source.latency !== undefined) {
|
||||
accumulator[source.source] = source.latency;
|
||||
}
|
||||
return accumulator;
|
||||
}, {});
|
||||
}, [sources]);
|
||||
const mergedLatencies = useMemo(() => ({
|
||||
...initialLatencies,
|
||||
...latencies,
|
||||
}), [initialLatencies, latencies]);
|
||||
|
||||
const getSourcePingUrl = useCallback((sourceId: string): string | null => {
|
||||
const settings = settingsStore.getSettings();
|
||||
const allConfigs = [
|
||||
...settings.sources,
|
||||
...settings.premiumSources,
|
||||
];
|
||||
const config = allConfigs.find((source) => source.id === sourceId);
|
||||
return config?.baseUrl || null;
|
||||
}, []);
|
||||
|
||||
// Sort sources by latency
|
||||
const sortedSources = useMemo(() => {
|
||||
return [...sources].sort((a, b) => {
|
||||
const latA = latencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = latencies[b.source] ?? b.latency ?? Infinity;
|
||||
const latA = mergedLatencies[a.source] ?? a.latency ?? Infinity;
|
||||
const latB = mergedLatencies[b.source] ?? b.latency ?? Infinity;
|
||||
return latA - latB;
|
||||
});
|
||||
}, [sources, latencies]);
|
||||
}, [sources, mergedLatencies]);
|
||||
|
||||
// Refresh latency for all sources
|
||||
const refreshLatencies = useCallback(async () => {
|
||||
@@ -53,12 +76,16 @@ export function SourceSelector({
|
||||
const results = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
try {
|
||||
// Use the stored baseUrl or extract from source
|
||||
const pingUrl = getSourcePingUrl(source.source);
|
||||
if (!pingUrl) {
|
||||
return { source: source.source, latency: undefined };
|
||||
}
|
||||
|
||||
const response = await fetch('/api/ping', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url: source.source, // This should be the baseUrl ideally
|
||||
url: pingUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -82,18 +109,7 @@ export function SourceSelector({
|
||||
|
||||
setLatencies(newLatencies);
|
||||
setIsLoading(false);
|
||||
}, [sources]);
|
||||
|
||||
// Initialize latencies from sources
|
||||
useEffect(() => {
|
||||
const initial: Record<string, number> = {};
|
||||
sources.forEach(s => {
|
||||
if (s.latency !== undefined) {
|
||||
initial[s.source] = s.latency;
|
||||
}
|
||||
});
|
||||
setLatencies(initial);
|
||||
}, [sources]);
|
||||
}, [sources, getSourcePingUrl]);
|
||||
|
||||
if (sources.length <= 1) {
|
||||
return null;
|
||||
@@ -121,7 +137,7 @@ export function SourceSelector({
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{sortedSources.map((source, index) => {
|
||||
const isCurrent = source.source === currentSource;
|
||||
const latency = latencies[source.source] ?? source.latency;
|
||||
const latency = mergedLatencies[source.source] ?? source.latency;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -140,17 +156,12 @@ export function SourceSelector({
|
||||
{/* Thumbnail */}
|
||||
{source.pic && (
|
||||
<div className="w-12 h-16 rounded-[var(--radius-2xl)] overflow-hidden flex-shrink-0 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)]">
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={source.pic}
|
||||
alt=""
|
||||
width={48}
|
||||
height={64}
|
||||
className="w-full h-full object-cover"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import { getSourceName } from '@/lib/utils/source-names';
|
||||
|
||||
/**
|
||||
@@ -15,7 +16,17 @@ function splitPersonNames(str: string): string[] {
|
||||
}
|
||||
|
||||
interface VideoMetadataProps {
|
||||
videoData: any;
|
||||
videoData: {
|
||||
vod_pic?: string;
|
||||
vod_name?: string;
|
||||
vod_content?: string;
|
||||
vod_actor?: string;
|
||||
vod_director?: string;
|
||||
vod_year?: string;
|
||||
vod_area?: string;
|
||||
vod_lang?: string;
|
||||
type_name?: string;
|
||||
} | null;
|
||||
source: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
@@ -25,9 +36,9 @@ export function VideoMetadata({ videoData, source, title }: VideoMetadataProps)
|
||||
<Card hover={false}>
|
||||
<div className="flex flex-col sm:flex-row items-start gap-4">
|
||||
{videoData?.vod_pic && (
|
||||
<img
|
||||
<RemotePosterImage
|
||||
src={videoData.vod_pic}
|
||||
alt={videoData.vod_name}
|
||||
alt={videoData.vod_name || title || '视频海报'}
|
||||
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useHistory } from '@/lib/store/history-store';
|
||||
import { CustomVideoPlayer } from './CustomVideoPlayer';
|
||||
@@ -46,7 +46,7 @@ export function VideoPlayer({
|
||||
const [shouldAutoPlay, setShouldAutoPlay] = useState(true);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const MAX_MANUAL_RETRIES = 20;
|
||||
const lastSaveTimeRef = useRef(0);
|
||||
const lastSavedPlaybackTimeRef = useRef(0);
|
||||
const currentTimeRef = useRef(0);
|
||||
const durationRef = useRef(0);
|
||||
const SAVE_INTERVAL = 5000; // 5 seconds throttle
|
||||
@@ -86,7 +86,7 @@ export function VideoPlayer({
|
||||
};
|
||||
|
||||
// Save progress function (used by throttle and beforeunload)
|
||||
const saveProgress = useCallback((currentTime: number, duration: number) => {
|
||||
const saveProgress = (currentTime: number, duration: number) => {
|
||||
if (!videoId || !playUrl || duration === 0 || currentTime <= 1) return;
|
||||
addToHistory(
|
||||
videoId,
|
||||
@@ -99,10 +99,10 @@ export function VideoPlayer({
|
||||
undefined,
|
||||
[]
|
||||
);
|
||||
}, [videoId, playUrl, title, currentEpisode, source, addToHistory]);
|
||||
};
|
||||
|
||||
// Handle time updates and save progress (throttled to every 5 seconds)
|
||||
const handleTimeUpdate = useCallback((currentTime: number, duration: number) => {
|
||||
const handleTimeUpdate = (currentTime: number, duration: number) => {
|
||||
// Always track current time for beforeunload
|
||||
currentTimeRef.current = currentTime;
|
||||
durationRef.current = duration;
|
||||
@@ -111,13 +111,12 @@ export function VideoPlayer({
|
||||
|
||||
if (!videoId || !playUrl || duration === 0) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Only save if enough time has passed since last save
|
||||
if (currentTime > 1 && now - lastSaveTimeRef.current >= SAVE_INTERVAL) {
|
||||
lastSaveTimeRef.current = now;
|
||||
// Only save if enough playback time has passed since the last persisted checkpoint.
|
||||
if (currentTime > 1 && currentTime - lastSavedPlaybackTimeRef.current >= SAVE_INTERVAL / 1000) {
|
||||
lastSavedPlaybackTimeRef.current = currentTime;
|
||||
saveProgress(currentTime, duration);
|
||||
}
|
||||
}, [videoId, playUrl, saveProgress]);
|
||||
};
|
||||
|
||||
// Save on page leave/refresh
|
||||
useEffect(() => {
|
||||
@@ -130,7 +129,7 @@ export function VideoPlayer({
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
}, [saveProgress]);
|
||||
}, [addToHistory, currentEpisode, playUrl, source, title, videoId]);
|
||||
|
||||
// Handle video errors
|
||||
const handleVideoError = (error: string) => {
|
||||
|
||||
@@ -21,6 +21,15 @@ interface DesktopMoreMenuProps {
|
||||
isRotated?: boolean;
|
||||
}
|
||||
|
||||
interface MenuPositionState {
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: string;
|
||||
openUpward: boolean;
|
||||
align: 'left' | 'right';
|
||||
triggerWidth: number;
|
||||
}
|
||||
|
||||
export function DesktopMoreMenu({
|
||||
showMoreMenu,
|
||||
isPremium = false,
|
||||
@@ -66,8 +75,16 @@ export function DesktopMoreMenu({
|
||||
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = React.useState({ top: 0, left: 0, maxHeight: 'none', openUpward: false, align: 'right' as 'left' | 'right' });
|
||||
const [menuPosition, setMenuPosition] = React.useState<MenuPositionState>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
maxHeight: 'none',
|
||||
openUpward: false,
|
||||
align: 'right',
|
||||
triggerWidth: 0,
|
||||
});
|
||||
const [isAdFilterOpen, setAdFilterOpen] = React.useState(false);
|
||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
const AD_FILTER_LABELS: Record<string, string> = {
|
||||
off: '关闭',
|
||||
@@ -100,6 +117,17 @@ export function DesktopMoreMenu({
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPortalTarget = ((isRotated || isFullscreen) && containerRef.current)
|
||||
? containerRef.current
|
||||
: document.body;
|
||||
setPortalTarget(nextPortalTarget);
|
||||
}, [containerRef, isRotated, isFullscreen, showMoreMenu]);
|
||||
|
||||
// Dual Positioning Strategy
|
||||
const calculateMenuPosition = React.useCallback(() => {
|
||||
if (!buttonRef.current || !containerRef.current) return;
|
||||
@@ -145,7 +173,8 @@ export function DesktopMoreMenu({
|
||||
left: left,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonRect.width,
|
||||
});
|
||||
} else if (isFullscreen && !isRotated) {
|
||||
// Fullscreen Mode (not rotated): Use container-relative coordinates
|
||||
@@ -186,7 +215,8 @@ export function DesktopMoreMenu({
|
||||
left: isLeftHalf ? left : left + buttonWidth,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonWidth,
|
||||
});
|
||||
} else {
|
||||
// Rotated Mode: Use Container Coordinates (offset loop) and Portal to Container
|
||||
@@ -234,7 +264,8 @@ export function DesktopMoreMenu({
|
||||
left: left, // Fixed vertical container coordinate
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonWidth,
|
||||
});
|
||||
}
|
||||
}, [containerRef, isRotated, isFullscreen]);
|
||||
@@ -283,7 +314,7 @@ export function DesktopMoreMenu({
|
||||
right: `calc(100% - ${menuPosition.left}px + 10px)`,
|
||||
left: 'auto'
|
||||
} : {
|
||||
left: `${menuPosition.left + buttonRef.current?.offsetWidth! + 10}px`,
|
||||
left: `${menuPosition.left + menuPosition.triggerWidth + 10}px`,
|
||||
right: 'auto'
|
||||
}),
|
||||
|
||||
@@ -649,7 +680,7 @@ export function DesktopMoreMenu({
|
||||
|
||||
{/* More Menu Dropdown (Portal) */}
|
||||
{/* More Menu Dropdown (Portal) */}
|
||||
{showMoreMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)}
|
||||
{showMoreMenu && portalTarget && createPortal(MenuContent, portalTarget)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ interface DesktopSpeedMenuProps {
|
||||
isRotated?: boolean;
|
||||
}
|
||||
|
||||
interface MenuPositionState {
|
||||
top: number;
|
||||
left: number;
|
||||
maxHeight: string;
|
||||
openUpward: boolean;
|
||||
align: 'left' | 'right';
|
||||
triggerWidth: number;
|
||||
}
|
||||
|
||||
export function DesktopSpeedMenu({
|
||||
showSpeedMenu,
|
||||
playbackRate,
|
||||
@@ -26,7 +35,15 @@ export function DesktopSpeedMenu({
|
||||
}: DesktopSpeedMenuProps) {
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const menuRef = React.useRef<HTMLDivElement>(null);
|
||||
const [menuPosition, setMenuPosition] = React.useState({ top: 0, left: 0, maxHeight: 'none', openUpward: false, align: 'right' as 'left' | 'right' });
|
||||
const [menuPosition, setMenuPosition] = React.useState<MenuPositionState>({
|
||||
top: 0,
|
||||
left: 0,
|
||||
maxHeight: 'none',
|
||||
openUpward: false,
|
||||
align: 'right',
|
||||
triggerWidth: 0,
|
||||
});
|
||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
|
||||
@@ -47,6 +64,17 @@ export function DesktopSpeedMenu({
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPortalTarget = ((isRotated || isFullscreen) && containerRef.current)
|
||||
? containerRef.current
|
||||
: document.body;
|
||||
setPortalTarget(nextPortalTarget);
|
||||
}, [containerRef, isRotated, isFullscreen, showSpeedMenu]);
|
||||
|
||||
// Dual Positioning Strategy
|
||||
const calculateMenuPosition = React.useCallback(() => {
|
||||
if (!buttonRef.current || !containerRef.current) return;
|
||||
@@ -92,7 +120,8 @@ export function DesktopSpeedMenu({
|
||||
left: left,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonRect.width,
|
||||
});
|
||||
} else if (isFullscreen && !isRotated) {
|
||||
// Fullscreen Mode (not rotated): Use container-relative coordinates
|
||||
@@ -132,7 +161,8 @@ export function DesktopSpeedMenu({
|
||||
left: isLeftHalf ? left : left + buttonWidth,
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonWidth,
|
||||
});
|
||||
} else {
|
||||
// Rotated Mode: Fullscreen/Landscape forced
|
||||
@@ -183,7 +213,8 @@ export function DesktopSpeedMenu({
|
||||
left: left, // Fixed vertical container coordinate
|
||||
maxHeight: `${maxHeight}px`,
|
||||
openUpward: openUpward,
|
||||
align: align
|
||||
align: align,
|
||||
triggerWidth: buttonWidth,
|
||||
});
|
||||
}
|
||||
}, [containerRef, isRotated, isFullscreen]);
|
||||
@@ -232,7 +263,7 @@ export function DesktopSpeedMenu({
|
||||
right: `calc(100% - ${menuPosition.left}px + 10px)`,
|
||||
left: 'auto'
|
||||
} : {
|
||||
left: `${menuPosition.left + buttonRef.current?.offsetWidth! + 10}px`,
|
||||
left: `${menuPosition.left + menuPosition.triggerWidth + 10}px`,
|
||||
right: 'auto'
|
||||
}),
|
||||
|
||||
@@ -295,7 +326,7 @@ export function DesktopSpeedMenu({
|
||||
So portaling to containerRef is SAFE and CORRECT.
|
||||
*/}
|
||||
{/* Speed Menu (Portal) */}
|
||||
{showSpeedMenu && typeof document !== 'undefined' && createPortal(MenuContent, ((isRotated || isFullscreen) && containerRef.current) ? containerRef.current : document.body)}
|
||||
{showSpeedMenu && portalTarget && createPortal(MenuContent, portalTarget)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,67 @@ interface UseCastControlsProps {
|
||||
setIsCasting: (casting: boolean) => void;
|
||||
}
|
||||
|
||||
interface CastMediaInfo {
|
||||
contentType: string;
|
||||
}
|
||||
|
||||
interface CastLoadRequest {
|
||||
currentTime?: number;
|
||||
}
|
||||
|
||||
interface CastMediaNamespace {
|
||||
DEFAULT_MEDIA_RECEIVER_APP_ID: string;
|
||||
MediaInfo: new (src: string, contentType: string) => CastMediaInfo;
|
||||
LoadRequest: new (mediaInfo: CastMediaInfo) => CastLoadRequest;
|
||||
}
|
||||
|
||||
interface CastSession {
|
||||
loadMedia(request: CastLoadRequest): Promise<void>;
|
||||
}
|
||||
|
||||
interface CastContextEvent {
|
||||
sessionState: string;
|
||||
}
|
||||
|
||||
interface CastContext {
|
||||
getCurrentSession(): CastSession | null;
|
||||
setOptions(options: {
|
||||
receiverApplicationId: string;
|
||||
autoJoinPolicy: string;
|
||||
}): void;
|
||||
addEventListener(eventType: string, listener: (event: CastContextEvent) => void): void;
|
||||
removeEventListener(eventType: string, listener: (event: CastContextEvent) => void): void;
|
||||
requestSession(): void;
|
||||
}
|
||||
|
||||
interface CastFrameworkNamespace {
|
||||
CastContext: {
|
||||
getInstance(): CastContext;
|
||||
};
|
||||
CastContextEventType: {
|
||||
SESSION_STATE_CHANGED: string;
|
||||
};
|
||||
SessionState: {
|
||||
SESSION_STARTED: string;
|
||||
SESSION_RESUMED: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ChromeCastNamespace {
|
||||
media?: CastMediaNamespace;
|
||||
AutoJoinPolicy?: {
|
||||
ORIGIN_SCOPED: string;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
chrome: any;
|
||||
cast: any;
|
||||
chrome?: {
|
||||
cast?: ChromeCastNamespace;
|
||||
};
|
||||
cast?: {
|
||||
framework?: CastFrameworkNamespace;
|
||||
};
|
||||
__onGCastApiAvailable?: (isAvailable: boolean) => void;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +80,7 @@ export function useCastControls({
|
||||
setIsCastAvailable,
|
||||
setIsCasting
|
||||
}: UseCastControlsProps) {
|
||||
const castContextRef = useRef<any>(null);
|
||||
const castContextRef = useRef<CastContext | null>(null);
|
||||
const loadMediaRef = useRef<() => void>(() => {});
|
||||
|
||||
const isCastSdkReady = useCallback(() => {
|
||||
@@ -63,7 +120,7 @@ export function useCastControls({
|
||||
|
||||
session.loadMedia(request).then(
|
||||
() => console.log('Cast: Media loaded successfully'),
|
||||
(error: any) => console.error('Cast: Media load failed', error)
|
||||
(error: unknown) => console.error('Cast: Media load failed', error)
|
||||
);
|
||||
}, [src, videoRef]);
|
||||
|
||||
@@ -72,7 +129,7 @@ export function useCastControls({
|
||||
}, [loadMedia]);
|
||||
|
||||
useEffect(() => {
|
||||
let sessionStateListener: ((event: any) => void) | null = null;
|
||||
let sessionStateListener: ((event: CastContextEvent) => void) | null = null;
|
||||
let onGCastApiAvailable: ((isAvailable: boolean) => void) | null = null;
|
||||
|
||||
const markCastUnavailable = () => {
|
||||
@@ -89,12 +146,21 @@ export function useCastControls({
|
||||
}
|
||||
|
||||
try {
|
||||
const castContext = window.cast.framework.CastContext.getInstance();
|
||||
const castFramework = window.cast?.framework;
|
||||
const chromeCast = window.chrome?.cast;
|
||||
const castMedia = chromeCast?.media;
|
||||
const autoJoinPolicy = chromeCast?.AutoJoinPolicy?.ORIGIN_SCOPED;
|
||||
if (!castFramework || !castMedia || !autoJoinPolicy) {
|
||||
markCastUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
const castContext = castFramework.CastContext.getInstance();
|
||||
castContextRef.current = castContext;
|
||||
|
||||
castContext.setOptions({
|
||||
receiverApplicationId: window.chrome.cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy: window.chrome.cast.AutoJoinPolicy.ORIGIN_SCOPED
|
||||
receiverApplicationId: castMedia.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
autoJoinPolicy
|
||||
});
|
||||
|
||||
// SDK loaded — show cast button immediately.
|
||||
@@ -102,10 +168,10 @@ export function useCastControls({
|
||||
setIsCastAvailable(true);
|
||||
|
||||
// Monitor session state
|
||||
sessionStateListener = (event: any) => {
|
||||
sessionStateListener = (event: CastContextEvent) => {
|
||||
const sessionState = event.sessionState;
|
||||
const isSessionActive = sessionState === window.cast.framework.SessionState.SESSION_STARTED ||
|
||||
sessionState === window.cast.framework.SessionState.SESSION_RESUMED;
|
||||
const isSessionActive = sessionState === castFramework.SessionState.SESSION_STARTED ||
|
||||
sessionState === castFramework.SessionState.SESSION_RESUMED;
|
||||
|
||||
setIsCasting(isSessionActive);
|
||||
|
||||
@@ -116,10 +182,10 @@ export function useCastControls({
|
||||
};
|
||||
|
||||
castContext.addEventListener(
|
||||
window.cast.framework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
castFramework.CastContextEventType.SESSION_STATE_CHANGED,
|
||||
sessionStateListener
|
||||
);
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.warn('Cast SDK is not usable in this browser context.', error);
|
||||
markCastUnavailable();
|
||||
}
|
||||
@@ -163,7 +229,12 @@ export function useCastControls({
|
||||
if (!isCastSdkReady()) return;
|
||||
|
||||
try {
|
||||
window.cast.framework.CastContext.getInstance().requestSession();
|
||||
const castFramework = window.cast?.framework;
|
||||
if (!castFramework) {
|
||||
setIsCastAvailable(false);
|
||||
return;
|
||||
}
|
||||
castFramework.CastContext.getInstance().requestSession();
|
||||
} catch (error) {
|
||||
console.warn('Cast session request failed.', error);
|
||||
setIsCastAvailable(false);
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
|
||||
type ProgressInteractionEvent =
|
||||
| MouseEvent
|
||||
| TouchEvent
|
||||
| React.MouseEvent<HTMLDivElement>
|
||||
| React.TouchEvent<HTMLDivElement>;
|
||||
|
||||
function getClientPosition(event: ProgressInteractionEvent) {
|
||||
if ('touches' in event) {
|
||||
const touch = event.touches[0] ?? event.changedTouches[0];
|
||||
return {
|
||||
x: touch?.clientX ?? 0,
|
||||
y: touch?.clientY ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseProgressControlsProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
progressBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -19,10 +40,8 @@ export function useProgressControls({
|
||||
}: UseProgressControlsProps) {
|
||||
const lastDragTimeRef = useRef<number>(0);
|
||||
|
||||
const getEventPos = useCallback((e: any, rect: DOMRect) => {
|
||||
// Handle both mouse and touch events
|
||||
const clientX = e.clientX || (e.touches && e.touches[0]?.clientX) || 0;
|
||||
const clientY = e.clientY || (e.touches && e.touches[0]?.clientY) || 0;
|
||||
const getEventPos = useCallback((event: ProgressInteractionEvent, rect: DOMRect) => {
|
||||
const { x: clientX, y: clientY } = getClientPosition(event);
|
||||
|
||||
if (isRotated) {
|
||||
// When rotated 90deg, visual left->right is physical top->bottom
|
||||
@@ -33,26 +52,26 @@ export function useProgressControls({
|
||||
}
|
||||
}, [isRotated]);
|
||||
|
||||
const handleProgressClick = useCallback((e: any) => {
|
||||
const handleProgressClick = useCallback((event: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!videoRef.current || !progressBarRef.current) return;
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const pos = getEventPos(e, rect);
|
||||
const pos = getEventPos(event, rect);
|
||||
const newTime = pos * duration;
|
||||
videoRef.current.currentTime = newTime;
|
||||
lastDragTimeRef.current = newTime; // Update ref to prevent snap-back on mouseup
|
||||
setCurrentTime(newTime);
|
||||
}, [videoRef, progressBarRef, duration, setCurrentTime, getEventPos]);
|
||||
|
||||
const handleProgressMouseDown = useCallback((e: any) => {
|
||||
e.preventDefault();
|
||||
const handleProgressMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
isDraggingProgressRef.current = true;
|
||||
handleProgressClick(e);
|
||||
handleProgressClick(event);
|
||||
}, [isDraggingProgressRef, handleProgressClick]);
|
||||
|
||||
const handleProgressTouchStart = useCallback((e: any) => {
|
||||
e.preventDefault();
|
||||
const handleProgressTouchStart = useCallback((event: React.TouchEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
isDraggingProgressRef.current = true;
|
||||
handleProgressClick(e);
|
||||
handleProgressClick(event);
|
||||
}, [isDraggingProgressRef, handleProgressClick]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
type VolumeInteractionEvent = MouseEvent | React.MouseEvent<HTMLDivElement>;
|
||||
|
||||
function getVolumeRatio(event: VolumeInteractionEvent, rect: DOMRect) {
|
||||
return Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||
}
|
||||
|
||||
interface UseVolumeControlsProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
volumeBarRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -47,10 +53,10 @@ export function useVolumeControls({
|
||||
}, 1000);
|
||||
}, [setShowVolumeBar, volumeBarTimeoutRef]);
|
||||
|
||||
const handleVolumeChange = useCallback((e: any) => {
|
||||
const handleVolumeChange = useCallback((event: VolumeInteractionEvent) => {
|
||||
if (!videoRef.current || !volumeBarRef.current) return;
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const pos = getVolumeRatio(event, rect);
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
videoRef.current.muted = pos === 0;
|
||||
@@ -59,10 +65,10 @@ export function useVolumeControls({
|
||||
localStorage.setItem('kvideo-muted', String(pos === 0));
|
||||
}, [videoRef, volumeBarRef, setVolume, setIsMuted]);
|
||||
|
||||
const handleVolumeMouseDown = useCallback((e: any) => {
|
||||
e.preventDefault();
|
||||
const handleVolumeMouseDown = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
isDraggingVolumeRef.current = true;
|
||||
handleVolumeChange(e);
|
||||
handleVolumeChange(event);
|
||||
}, [isDraggingVolumeRef, handleVolumeChange]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,7 +76,7 @@ export function useVolumeControls({
|
||||
if (!isDraggingVolumeRef.current || !volumeBarRef.current || !videoRef.current) return;
|
||||
e.preventDefault();
|
||||
const rect = volumeBarRef.current.getBoundingClientRect();
|
||||
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const pos = getVolumeRatio(e, rect);
|
||||
setVolume(pos);
|
||||
videoRef.current.volume = pos;
|
||||
videoRef.current.muted = pos === 0;
|
||||
|
||||
@@ -49,8 +49,6 @@ export function useAutoSkip({
|
||||
const lastHandledSrcRef = useRef<string>('');
|
||||
// Track if we've triggered outro skip to prevent multiple triggers within the same video session
|
||||
const hasTriggeredOutroSkipRef = useRef(false);
|
||||
// Track if we're currently in the outro zone for UI purposes
|
||||
const [isOutroActive, setIsOutroActive] = useState(false);
|
||||
// Track if we're transitioning to next episode (for custom loading indicator)
|
||||
const [isTransitioningToNextEpisode, setIsTransitioningToNextEpisode] = useState(false);
|
||||
|
||||
@@ -58,7 +56,6 @@ export function useAutoSkip({
|
||||
useEffect(() => {
|
||||
hasSkippedIntroRef.current = false;
|
||||
hasTriggeredOutroSkipRef.current = false;
|
||||
setIsOutroActive(false);
|
||||
// Note: isTransitioningToNextEpisode is NOT reset here immediately
|
||||
// because we want it to persist while the next episode is loading.
|
||||
// It will be reset via the 'canplay' event below.
|
||||
@@ -85,27 +82,18 @@ export function useAutoSkip({
|
||||
const canAdvanceToNext = useCallback(() => {
|
||||
if (totalEpisodes <= 1) return false;
|
||||
|
||||
const nextEpisodeFn = onNextEpisodeRef.current;
|
||||
|
||||
if (!isReversed) {
|
||||
// Normal order: next is index + 1
|
||||
return currentEpisodeIndex < totalEpisodes - 1 && !!nextEpisodeFn;
|
||||
return currentEpisodeIndex < totalEpisodes - 1 && !!onNextEpisode;
|
||||
} else {
|
||||
// Reversed order: next is index - 1 (since we're going backwards)
|
||||
return currentEpisodeIndex > 0 && !!nextEpisodeFn;
|
||||
return currentEpisodeIndex > 0 && !!onNextEpisode;
|
||||
}
|
||||
}, [totalEpisodes, currentEpisodeIndex, isReversed]);
|
||||
|
||||
|
||||
// Keep a stable ref to onNextEpisode to avoid effect re-runs
|
||||
const onNextEpisodeRef = useRef(onNextEpisode);
|
||||
useEffect(() => {
|
||||
onNextEpisodeRef.current = onNextEpisode;
|
||||
}, [onNextEpisode]);
|
||||
}, [totalEpisodes, currentEpisodeIndex, isReversed, onNextEpisode]);
|
||||
|
||||
// Helper to trigger next episode exactly once per source
|
||||
const triggerNextEpisode = useCallback((reason: string) => {
|
||||
if (!onNextEpisodeRef.current) return;
|
||||
if (!onNextEpisode) return;
|
||||
|
||||
// Prevent double trigger for the same source URL
|
||||
if (lastHandledSrcRef.current === src) {
|
||||
@@ -125,8 +113,8 @@ export function useAutoSkip({
|
||||
lastHandledSrcRef.current = src;
|
||||
// Set transitioning state for custom loading indicator
|
||||
setIsTransitioningToNextEpisode(true);
|
||||
onNextEpisodeRef.current();
|
||||
}, [src, isTransitioningToNextEpisode]);
|
||||
onNextEpisode();
|
||||
}, [src, isTransitioningToNextEpisode, onNextEpisode]);
|
||||
|
||||
// Validate that duration is ready (not 0, NaN, or Infinity)
|
||||
const isDurationValid = useCallback(() => {
|
||||
@@ -137,6 +125,14 @@ export function useAutoSkip({
|
||||
const isTimeValid = useCallback(() => {
|
||||
return !isNaN(currentTime) && isFinite(currentTime);
|
||||
}, [currentTime]);
|
||||
const remainingTime = duration - currentTime;
|
||||
const isOutroActive = autoSkipOutro &&
|
||||
skipOutroSeconds > 0 &&
|
||||
isDurationValid() &&
|
||||
isTimeValid() &&
|
||||
remainingTime > 0 &&
|
||||
remainingTime <= skipOutroSeconds &&
|
||||
currentTime > 0;
|
||||
|
||||
// Handle intro skip
|
||||
const attemptIntroSkip = useCallback(() => {
|
||||
@@ -183,21 +179,11 @@ export function useAutoSkip({
|
||||
|
||||
// Handle outro skip (based on remaining time)
|
||||
useEffect(() => {
|
||||
if (!autoSkipOutro || skipOutroSeconds <= 0) {
|
||||
setIsOutroActive(false);
|
||||
return;
|
||||
}
|
||||
if (!autoSkipOutro || skipOutroSeconds <= 0) return;
|
||||
if (!isDurationValid() || !isTimeValid()) return;
|
||||
if (hasTriggeredOutroSkipRef.current) return;
|
||||
|
||||
const remainingTime = duration - currentTime;
|
||||
|
||||
// Check if we're in the outro zone
|
||||
const inOutroZone = remainingTime > 0 && remainingTime <= skipOutroSeconds && currentTime > 0;
|
||||
|
||||
if (inOutroZone) {
|
||||
setIsOutroActive(true);
|
||||
|
||||
if (isOutroActive) {
|
||||
// Only auto-trigger if video is actually playing
|
||||
if (isPlaying) {
|
||||
console.log(`[AutoSkip] Outro detected: ${remainingTime.toFixed(1)}s remaining`);
|
||||
@@ -205,7 +191,10 @@ export function useAutoSkip({
|
||||
|
||||
// If we can advance to next episode, do it
|
||||
if (autoNextEpisode && canAdvanceToNext()) {
|
||||
triggerNextEpisode('outro-timer');
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
triggerNextEpisode('outro-timer');
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
} else {
|
||||
// Otherwise just seek to end to trigger ended event
|
||||
const video = videoRef.current;
|
||||
@@ -215,10 +204,8 @@ export function useAutoSkip({
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setIsOutroActive(false);
|
||||
}
|
||||
}, [autoSkipOutro, skipOutroSeconds, currentTime, duration, isPlaying, isDurationValid, isTimeValid, autoNextEpisode, canAdvanceToNext, triggerNextEpisode, videoRef]);
|
||||
}, [autoSkipOutro, skipOutroSeconds, duration, isPlaying, isDurationValid, isTimeValid, autoNextEpisode, canAdvanceToNext, triggerNextEpisode, videoRef, isOutroActive, remainingTime]);
|
||||
|
||||
// Handle video ended event for auto-next
|
||||
const handleVideoEnded = useCallback(() => {
|
||||
@@ -243,8 +230,6 @@ export function useAutoSkip({
|
||||
}, [videoRef, handleVideoEnded]);
|
||||
|
||||
return {
|
||||
hasSkippedIntro: hasSkippedIntroRef.current,
|
||||
hasTriggeredOutroSkip: hasTriggeredOutroSkipRef.current,
|
||||
isOutroActive,
|
||||
isTransitioningToNextEpisode,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,34 @@ interface UseHlsPlayerProps {
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
interface PlaylistLoaderContext {
|
||||
type: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PlaylistLoaderResponse {
|
||||
data?: string;
|
||||
}
|
||||
|
||||
interface PlaylistLoaderCallbacks {
|
||||
onSuccess: (
|
||||
response: PlaylistLoaderResponse,
|
||||
stats: unknown,
|
||||
context: PlaylistLoaderContext,
|
||||
networkDetails: unknown
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface PlaylistLoaderInstance {
|
||||
load(
|
||||
context: PlaylistLoaderContext,
|
||||
config: unknown,
|
||||
callbacks: PlaylistLoaderCallbacks
|
||||
): void;
|
||||
}
|
||||
|
||||
type PlaylistLoaderConstructor = new (config: unknown) => PlaylistLoaderInstance;
|
||||
|
||||
export function useHlsPlayer({
|
||||
videoRef,
|
||||
src,
|
||||
@@ -48,23 +76,22 @@ export function useHlsPlayer({
|
||||
if (isMSESupported) {
|
||||
|
||||
// Define custom loader class to intercept manifest loading
|
||||
// We use 'any' cast because default loader type might not be strictly exposed in all typings
|
||||
const DefaultLoader = (Hls as any).DefaultConfig.loader;
|
||||
const DefaultLoader = Hls.DefaultConfig.loader as unknown as PlaylistLoaderConstructor;
|
||||
|
||||
class AdFilterLoader extends DefaultLoader {
|
||||
load(context: any, config: any, callbacks: any) {
|
||||
load(context: PlaylistLoaderContext, config: unknown, callbacks: PlaylistLoaderCallbacks) {
|
||||
if (isAdFilterEnabled && (context.type === 'manifest' || context.type === 'level')) {
|
||||
const originalOnSuccess = callbacks.onSuccess;
|
||||
callbacks.onSuccess = (response: any, stats: any, context: any, networkDetails: any) => {
|
||||
callbacks.onSuccess = (response, stats, requestContext, networkDetails) => {
|
||||
if (typeof response.data === 'string') {
|
||||
try {
|
||||
// Filter the content
|
||||
response.data = filterM3u8Ad(response.data, context.url, adFilterMode, adKeywords);
|
||||
response.data = filterM3u8Ad(response.data, requestContext.url, adFilterMode, adKeywords);
|
||||
} catch (e) {
|
||||
console.warn('[HLS] Ad filter error:', e);
|
||||
}
|
||||
}
|
||||
originalOnSuccess(response, stats, context, networkDetails);
|
||||
originalOnSuccess(response, stats, requestContext, networkDetails);
|
||||
};
|
||||
}
|
||||
super.load(context, config, callbacks);
|
||||
@@ -76,7 +103,7 @@ export function useHlsPlayer({
|
||||
// Exceptions might exist for iOS where MSE is strictly not available, check Hls.isSupported() result carefully.
|
||||
// Hls.isSupported() is false on iOS Safari usually, so this block won't run there.
|
||||
|
||||
const config: any = {
|
||||
const config: Partial<Hls['config']> = {
|
||||
// Worker & Performance
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
@@ -121,7 +148,7 @@ export function useHlsPlayer({
|
||||
|
||||
// Use custom loader if ad filtering is enabled
|
||||
if (isAdFilterEnabled) {
|
||||
config.loader = AdFilterLoader;
|
||||
config.loader = AdFilterLoader as unknown as typeof Hls.DefaultConfig.loader;
|
||||
}
|
||||
|
||||
hls = new Hls(config);
|
||||
|
||||
@@ -29,13 +29,25 @@ export function useResolutionBadge(resolution: VideoResolutionInfo | null) {
|
||||
|
||||
// Show badge when resolution first detected or changes
|
||||
useEffect(() => {
|
||||
let frameId: number | null = null;
|
||||
|
||||
if (resolution) {
|
||||
setVisible(true);
|
||||
startHideTimer();
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
setVisible(true);
|
||||
startHideTimer();
|
||||
});
|
||||
} else {
|
||||
setVisible(false);
|
||||
clearTimer();
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
setVisible(false);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (frameId !== null) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [resolution, startHideTimer, clearTimer]);
|
||||
|
||||
// Briefly show badge on user interaction
|
||||
|
||||
@@ -22,12 +22,15 @@ export function useStallDetection({
|
||||
isTransitioningToNextEpisode
|
||||
}: UseStallDetectionProps) {
|
||||
const lastTimeRef = useRef<number>(0);
|
||||
const lastUpdateTimeRef = useRef<number>(Date.now());
|
||||
const lastUpdateTimeRef = useRef<number>(0);
|
||||
const isStalledByMeRef = useRef<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
lastTimeRef.current = videoRef.current.currentTime;
|
||||
lastUpdateTimeRef.current = Date.now();
|
||||
|
||||
const checkStall = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { TagManager } from '@/components/home/TagManager';
|
||||
import { MovieGrid } from '@/components/home/MovieGrid';
|
||||
import { PremiumContentGrid } from './PremiumContentGrid';
|
||||
@@ -38,16 +38,8 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
||||
loadMoreRef: recommendLoadMoreRef,
|
||||
} = usePersonalizedRecommendations(true);
|
||||
|
||||
// Track whether the recommendation tab is active
|
||||
const [isRecommendSelected, setIsRecommendSelected] = useState(hasHistory);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHistory) {
|
||||
setIsRecommendSelected(true);
|
||||
}
|
||||
}, [hasHistory]);
|
||||
|
||||
const effectiveRecommendSelected = hasHistory && isRecommendSelected;
|
||||
const [selectionMode, setSelectionMode] = useState<'recommend' | 'tag'>('recommend');
|
||||
const effectiveRecommendSelected = hasHistory && selectionMode === 'recommend';
|
||||
|
||||
// Get the category value from selected tag
|
||||
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
|
||||
@@ -60,18 +52,19 @@ export function PremiumContent({ onSearch }: PremiumContentProps) {
|
||||
loadMoreRef,
|
||||
} = usePremiumContent(effectiveRecommendSelected ? '' : categoryValue);
|
||||
|
||||
const handleVideoClick = (video: any) => {
|
||||
if (onSearch) {
|
||||
onSearch(video.vod_name || video.title);
|
||||
const handleVideoClick = (video: { vod_name?: string; title?: string }) => {
|
||||
const keyword = video.vod_name || video.title;
|
||||
if (onSearch && keyword) {
|
||||
onSearch(keyword);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecommendSelect = () => {
|
||||
setIsRecommendSelected(true);
|
||||
setSelectionMode('recommend');
|
||||
};
|
||||
|
||||
const handleRegularTagSelect = (tagId: string) => {
|
||||
setIsRecommendSelected(false);
|
||||
setSelectionMode('tag');
|
||||
setSelectedTag(tagId);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Video } from '@/lib/types';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
|
||||
interface PremiumContentGridProps {
|
||||
videos: Video[];
|
||||
@@ -54,14 +54,11 @@ export function PremiumContentGrid({
|
||||
<Card hover={false} className="p-0 h-full shadow-[0_2px_8px_var(--shadow-color)] hover:shadow-[0_8px_24px_var(--shadow-color)] transition-shadow duration-200 ease-out" blur={false}>
|
||||
<div className="relative aspect-[2/3] bg-[var(--glass-bg)] rounded-[var(--radius-2xl)]">
|
||||
{video.vod_pic ? (
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
||||
absoluteFill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105 rounded-[var(--radius-2xl)]"
|
||||
loading="eager"
|
||||
unoptimized
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[var(--text-color-secondary)]">
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { memo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
|
||||
import { Video } from '@/lib/types';
|
||||
import { parseVideoTitle } from '@/lib/utils/video';
|
||||
@@ -65,21 +65,13 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
}}
|
||||
>
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{video.vod_pic ? (
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={video.vod_pic}
|
||||
alt={video.vod_name}
|
||||
fill
|
||||
absoluteFill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
loading="eager"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@@ -196,4 +188,3 @@ export const VideoCard = memo<VideoCardProps>(({
|
||||
});
|
||||
|
||||
VideoCard.displayName = 'VideoCard';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo, memo, useEffect, useSyncExternalStore } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { VideoCard } from './VideoCard';
|
||||
import { VideoGroupCard, GroupedVideo } from './VideoGroupCard';
|
||||
@@ -23,16 +23,18 @@ export const VideoGrid = memo(function VideoGrid({
|
||||
}: VideoGridProps) {
|
||||
const [activeCardId, setActiveCardId] = useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(24);
|
||||
const [displayMode, setDisplayMode] = useState<'normal' | 'grouped'>('normal');
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const displayMode = useSyncExternalStore(
|
||||
settingsStore.subscribe,
|
||||
() => settingsStore.getSettings().searchDisplayMode,
|
||||
() => settingsStore.getSettings().searchDisplayMode
|
||||
);
|
||||
|
||||
// Load display mode from settings
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setDisplayMode(settings.searchDisplayMode);
|
||||
|
||||
// Initial load: Check for saved scroll position to ensure we render enough items
|
||||
const params = searchParams.toString();
|
||||
@@ -57,23 +59,16 @@ export const VideoGrid = memo(function VideoGrid({
|
||||
const neededCount = Math.min(videos.length, estimatedRowsNeeded * itemsPerRow);
|
||||
|
||||
if (neededCount > 24) {
|
||||
setVisibleCount(Math.ceil(neededCount / 24) * 24);
|
||||
const nextVisibleCount = Math.ceil(neededCount / 24) * 24;
|
||||
const frameId = window.requestAnimationFrame(() => {
|
||||
setVisibleCount(nextVisibleCount);
|
||||
});
|
||||
return () => window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
const newSettings = settingsStore.getSettings();
|
||||
setDisplayMode(newSettings.searchDisplayMode);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [pathname, searchParams, videos.length]);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build stable list of videos to probe for resolution
|
||||
const videosToProbe = useMemo(() => {
|
||||
if (displayMode === 'grouped') {
|
||||
@@ -187,6 +182,10 @@ export const VideoGrid = memo(function VideoGrid({
|
||||
|
||||
const totalItems = displayMode === 'grouped' ? groupItems.length : videoItems.length;
|
||||
|
||||
if (videos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -246,4 +245,3 @@ export const VideoGrid = memo(function VideoGrid({
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { LatencyBadge } from '@/components/ui/LatencyBadge';
|
||||
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
|
||||
import { RemotePosterImage } from '@/components/ui/RemotePosterImage';
|
||||
import { Video } from '@/lib/types';
|
||||
import { parseVideoTitle } from '@/lib/utils/video';
|
||||
import { storeGroupedSources } from '@/lib/utils/grouped-sources-cache';
|
||||
@@ -118,19 +118,11 @@ export const VideoGroupCard = memo<VideoGroupCardProps>(({
|
||||
{/* Poster */}
|
||||
<div className="relative aspect-[2/3] bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] overflow-hidden">
|
||||
{representative.vod_pic ? (
|
||||
<Image
|
||||
<RemotePosterImage
|
||||
src={representative.vod_pic}
|
||||
alt={name}
|
||||
fill
|
||||
absoluteFill
|
||||
className="object-cover rounded-[var(--radius-2xl)]"
|
||||
sizes="(max-width: 640px) 33vw, (max-width: 1024px) 20vw, 16vw"
|
||||
loading="eager"
|
||||
unoptimized
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget as HTMLImageElement;
|
||||
target.style.opacity = '0';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
|
||||
@@ -25,26 +25,34 @@ interface AddSourceModalProps {
|
||||
}
|
||||
|
||||
export function AddSourceModal({ isOpen, onClose, onAdd, existingIds, initialValues }: AddSourceModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<AddSourceModalContent
|
||||
key={initialValues?.id ?? 'new-source'}
|
||||
onClose={onClose}
|
||||
onAdd={onAdd}
|
||||
existingIds={existingIds}
|
||||
initialValues={initialValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AddSourceModalContent({ onClose, onAdd, existingIds, initialValues }: Omit<AddSourceModalProps, 'isOpen'>) {
|
||||
const { name, setName, customId, setCustomId, url, setUrl, error, handleSubmit, isEditing } = useAddSourceForm({
|
||||
isOpen,
|
||||
existingIds,
|
||||
onAdd,
|
||||
onClose,
|
||||
initialValues,
|
||||
});
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBackdrop isOpen={isOpen} onClose={onClose} />
|
||||
<ModalBackdrop isOpen={true} onClose={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 opacity-100 -translate-y-1/2 scale-100"
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<ModalHeader title={initialValues ? "编辑视频源" : "添加自定义源"} onClose={onClose} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useEffectEvent, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import type { AppReleaseEntry, AppUpdateResponse } from '@/lib/types/app-update';
|
||||
@@ -109,7 +109,7 @@ export function AppVersionSettings() {
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const fetchUpdateInfo = useEffectEvent(async (manual: boolean = false) => {
|
||||
const fetchUpdateInfo = useCallback(async (manual: boolean = false) => {
|
||||
if (manual) {
|
||||
setIsRefreshing(true);
|
||||
}
|
||||
@@ -147,13 +147,13 @@ export function AppVersionSettings() {
|
||||
} finally {
|
||||
if (manual) {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUpdateInfo(false);
|
||||
}, []);
|
||||
}, [fetchUpdateInfo]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
void fetchUpdateInfo(true);
|
||||
|
||||
@@ -28,10 +28,19 @@ export function DataSettings({ onExport, onImport, onReset }: DataSettingsProps)
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[var(--radius-2xl)] border border-red-200/80 bg-red-50/80 p-4 dark:border-red-900/60 dark:bg-red-950/20">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-semibold text-red-700 dark:text-red-300">危险操作</h3>
|
||||
<p className="mt-1 text-sm text-red-700/80 dark:text-red-300/80">
|
||||
这会删除本地设置、历史、收藏,并同时请求服务端清除当前登录会话。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-all duration-200 flex items-center justify-between cursor-pointer"
|
||||
className="w-full px-6 py-4 rounded-[var(--radius-2xl)] bg-red-600 border border-red-700 text-white font-medium hover:bg-red-700 transition-all duration-200 flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span>清除所有数据</span>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
|
||||
@@ -151,7 +151,7 @@ export function DisplaySettings({
|
||||
<div className="mt-6">
|
||||
<h3 className="font-medium text-[var(--text-color)] mb-2">内容类目过滤</h3>
|
||||
<p className="text-sm text-[var(--text-color-secondary)] mb-4">
|
||||
添加要从搜索结果中隐藏的类目关键词(如"伦理"),匹配的视频将不会显示
|
||||
添加要从搜索结果中隐藏的类目关键词(如 "伦理"),匹配的视频将不会显示
|
||||
</p>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ExportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,38 +9,36 @@ interface ExportModalProps {
|
||||
}
|
||||
|
||||
export function ExportModal({ isOpen, onClose, onExport }: ExportModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ExportModalContent
|
||||
onClose={onClose}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportModalContent({ onClose, onExport }: Omit<ExportModalProps, 'isOpen'>) {
|
||||
const [includeSearchHistory, setIncludeSearchHistory] = useState(true);
|
||||
const [includeWatchHistory, setIncludeWatchHistory] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIncludeSearchHistory(true);
|
||||
setIncludeWatchHistory(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleExport = () => {
|
||||
onExport(includeSearchHistory, includeWatchHistory);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
className="fixed inset-0 z-[9998] bg-black/30 backdrop-blur-md transition-opacity duration-300 opacity-100"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 opacity-100 -translate-y-1/2 scale-100"
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { ImportModalTabs } from './import/ImportModalTabs';
|
||||
import { FileImportTab } from './import/FileImportTab';
|
||||
import { LinkImportTab } from './import/LinkImportTab';
|
||||
@@ -34,27 +34,39 @@ export function ImportModal({
|
||||
onRemoveSubscription,
|
||||
onRefreshSubscription
|
||||
}: ImportModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
|
||||
|
||||
// Reset tab on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setActiveTab('file');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ImportModalContent
|
||||
onClose={onClose}
|
||||
onImportFile={onImportFile}
|
||||
onImportLink={onImportLink}
|
||||
subscriptions={subscriptions}
|
||||
onAddSubscription={onAddSubscription}
|
||||
onRemoveSubscription={onRemoveSubscription}
|
||||
onRefreshSubscription={onRefreshSubscription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportModalContent({
|
||||
onClose,
|
||||
onImportFile,
|
||||
onImportLink,
|
||||
subscriptions,
|
||||
onAddSubscription,
|
||||
onRemoveSubscription,
|
||||
onRefreshSubscription
|
||||
}: Omit<ImportModalProps, 'isOpen'>) {
|
||||
const [activeTab, setActiveTab] = useState<'file' | 'link' | 'subscription' | 'json'>('file');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalBackdrop isOpen={isOpen} onClose={onClose} />
|
||||
<ModalBackdrop isOpen={true} onClose={onClose} />
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 ${isOpen
|
||||
? 'opacity-100 -translate-y-1/2 scale-100'
|
||||
: 'opacity-0 -translate-y-[40%] scale-95 pointer-events-none'
|
||||
}`}
|
||||
className="fixed top-1/2 left-1/2 z-[9999] w-[90%] max-w-md -translate-x-1/2 transition-all duration-300 opacity-100 -translate-y-1/2 scale-100"
|
||||
>
|
||||
<div className="bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-6 flex flex-col max-h-[85vh]">
|
||||
<div className="flex items-center justify-between mb-6 shrink-0">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { userSourcesStore, type DanmakuApiEntry } from '@/lib/store/user-sources-store';
|
||||
@@ -8,29 +8,26 @@ import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { hasPermission } from '@/lib/store/auth-store';
|
||||
|
||||
export function UserDanmakuSettings() {
|
||||
const [apis, setApis] = useState<DanmakuApiEntry[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [systemApiUrl, setSystemApiUrl] = useState('');
|
||||
const danmakuSnapshot = useSyncExternalStore(
|
||||
(listener) => userSourcesStore.subscribe(listener),
|
||||
() => JSON.stringify(userSourcesStore.getState()),
|
||||
() => JSON.stringify({ danmakuApis: [], activeDanmakuApiId: null }),
|
||||
);
|
||||
const settingsSnapshot = useSyncExternalStore(
|
||||
(listener) => settingsStore.subscribe(listener),
|
||||
() => settingsStore.getSettings().danmakuApiUrl,
|
||||
() => '',
|
||||
);
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const state = userSourcesStore.getState();
|
||||
setApis(state.danmakuApis);
|
||||
setActiveId(state.activeDanmakuApiId);
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
|
||||
const unsub = userSourcesStore.subscribe(() => {
|
||||
const s = userSourcesStore.getState();
|
||||
setApis(s.danmakuApis);
|
||||
setActiveId(s.activeDanmakuApiId);
|
||||
});
|
||||
const unsub2 = settingsStore.subscribe(() => {
|
||||
setSystemApiUrl(settingsStore.getSettings().danmakuApiUrl);
|
||||
});
|
||||
return () => { unsub(); unsub2(); };
|
||||
}, []);
|
||||
const parsedDanmakuState = JSON.parse(danmakuSnapshot) as {
|
||||
danmakuApis: DanmakuApiEntry[];
|
||||
activeDanmakuApiId: string | null;
|
||||
};
|
||||
const apis = parsedDanmakuState.danmakuApis;
|
||||
const activeId = parsedDanmakuState.activeDanmakuApiId;
|
||||
const systemApiUrl = settingsSnapshot;
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { Icons } from '@/components/ui/Icon';
|
||||
import { userSourcesStore } from '@/lib/store/user-sources-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
export function UserSourceSettings() {
|
||||
const [sources, setSources] = useState<VideoSource[]>([]);
|
||||
const sourcesSnapshot = useSyncExternalStore(
|
||||
(listener) => userSourcesStore.subscribe(listener),
|
||||
() => JSON.stringify(userSourcesStore.getSources()),
|
||||
() => '[]',
|
||||
);
|
||||
const sources = JSON.parse(sourcesSnapshot) as VideoSource[];
|
||||
const [name, setName] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSources(userSourcesStore.getSources());
|
||||
const unsub = userSourcesStore.subscribe(() => {
|
||||
setSources(userSourcesStore.getSources());
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
const handleAdd = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !baseUrl.trim()) {
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
interface UseAddSourceFormProps {
|
||||
isOpen: boolean;
|
||||
existingIds: string[];
|
||||
onAdd: (source: VideoSource) => void;
|
||||
onClose: () => void;
|
||||
@@ -20,29 +19,31 @@ function generateIdFromName(name: string): string {
|
||||
return slug || `custom-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export function useAddSourceForm({ isOpen, existingIds, onAdd, onClose, initialValues }: UseAddSourceFormProps) {
|
||||
const [name, setName] = useState('');
|
||||
const [customId, setCustomId] = useState('');
|
||||
const [idManuallyEdited, setIdManuallyEdited] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
function getInitialFormState(initialValues?: VideoSource | null) {
|
||||
if (initialValues) {
|
||||
return {
|
||||
name: initialValues.name,
|
||||
customId: initialValues.id,
|
||||
idManuallyEdited: true,
|
||||
url: initialValues.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (initialValues) {
|
||||
setName(initialValues.name);
|
||||
setCustomId(initialValues.id);
|
||||
setUrl(initialValues.baseUrl);
|
||||
setIdManuallyEdited(true);
|
||||
} else {
|
||||
setName('');
|
||||
setCustomId('');
|
||||
setUrl('');
|
||||
setIdManuallyEdited(false);
|
||||
}
|
||||
setError('');
|
||||
}
|
||||
}, [isOpen, initialValues]);
|
||||
return {
|
||||
name: '',
|
||||
customId: '',
|
||||
idManuallyEdited: false,
|
||||
url: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function useAddSourceForm({ existingIds, onAdd, onClose, initialValues }: UseAddSourceFormProps) {
|
||||
const initialFormState = getInitialFormState(initialValues);
|
||||
const [name, setName] = useState(initialFormState.name);
|
||||
const [customId, setCustomId] = useState(initialFormState.customId);
|
||||
const [idManuallyEdited, setIdManuallyEdited] = useState(initialFormState.idManuallyEdited);
|
||||
const [url, setUrl] = useState(initialFormState.url);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleNameChange = (newName: string) => {
|
||||
setName(newName);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
|
||||
interface RemotePosterImageProps
|
||||
extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'alt' | 'src'> {
|
||||
src: string;
|
||||
alt: string;
|
||||
absoluteFill?: boolean;
|
||||
}
|
||||
|
||||
export function RemotePosterImage({
|
||||
src,
|
||||
alt,
|
||||
absoluteFill = false,
|
||||
className,
|
||||
loading = 'lazy',
|
||||
referrerPolicy = 'no-referrer',
|
||||
decoding = 'async',
|
||||
onError,
|
||||
...rest
|
||||
}: RemotePosterImageProps) {
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
decoding={decoding}
|
||||
referrerPolicy={referrerPolicy}
|
||||
className={[
|
||||
absoluteFill ? 'absolute inset-0 w-full h-full' : '',
|
||||
className || '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none';
|
||||
onError?.(event);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
kvideo:
|
||||
container_name: kvideo
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
* Handles timeouts and retries
|
||||
*/
|
||||
|
||||
// Disable SSL verification for video sources with invalid certificates
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
import { fetchWithPolicy } from '@/lib/server/outbound-policy';
|
||||
|
||||
const REQUEST_TIMEOUT = 15000;
|
||||
const MAX_RETRIES = 3;
|
||||
@@ -35,10 +34,14 @@ export async function fetchWithTimeout(
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
const requestOptions = {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
};
|
||||
|
||||
const response = /^https?:\/\//i.test(url)
|
||||
? await fetchWithPolicy(url, requestOptions)
|
||||
: await fetch(url, requestOptions);
|
||||
clearTimeout(timeoutId);
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type IOSWindow = Window & {
|
||||
MSStream?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to detect if the device is mobile
|
||||
*/
|
||||
@@ -31,7 +35,8 @@ export function useIsIOS() {
|
||||
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream;
|
||||
const iosWindow = window as IOSWindow;
|
||||
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) && !iosWindow.MSStream;
|
||||
setIsIOS(ios);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type ScreenOrientationLock =
|
||||
| 'any'
|
||||
| 'natural'
|
||||
| 'landscape'
|
||||
| 'portrait'
|
||||
| 'portrait-primary'
|
||||
| 'portrait-secondary'
|
||||
| 'landscape-primary'
|
||||
| 'landscape-secondary';
|
||||
|
||||
type LockableScreen = Screen & {
|
||||
orientation?: {
|
||||
lock?: (orientation: ScreenOrientationLock) => Promise<void>;
|
||||
unlock?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for managing screen orientation on mobile devices
|
||||
* Auto-rotates to landscape on fullscreen, portrait on exit
|
||||
@@ -10,12 +27,12 @@ export function useScreenOrientation(isFullscreen: boolean) {
|
||||
|
||||
const handleOrientation = async () => {
|
||||
try {
|
||||
const screen = window.screen as any;
|
||||
const screen = window.screen as LockableScreen;
|
||||
|
||||
if (isFullscreen) {
|
||||
// Fullscreen: Lock to landscape
|
||||
if (screen.orientation?.lock) {
|
||||
await screen.orientation.lock('landscape').catch((err: any) => {
|
||||
await screen.orientation.lock('landscape').catch((err: unknown) => {
|
||||
console.warn('Could not lock orientation:', err);
|
||||
});
|
||||
}
|
||||
@@ -25,7 +42,7 @@ export function useScreenOrientation(isFullscreen: boolean) {
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
console.warn('Orientation API not supported:', error);
|
||||
}
|
||||
};
|
||||
@@ -35,11 +52,11 @@ export function useScreenOrientation(isFullscreen: boolean) {
|
||||
// Cleanup: Always unlock on unmount
|
||||
return () => {
|
||||
try {
|
||||
const screen = window.screen as any;
|
||||
const screen = window.screen as LockableScreen;
|
||||
if (screen.orientation?.unlock) {
|
||||
screen.orientation.unlock();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ export function useFloatingButtonPosition({
|
||||
const dragStateRef = useRef<DragState>(INITIAL_DRAG_STATE);
|
||||
const positionRef = useRef<FloatingButtonPosition | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const listenerAbortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const clampPosition = useCallback((x: number, y: number, width: number, height: number) => ({
|
||||
x: clamp(x, margin, Math.max(margin, width - buttonSize - margin)),
|
||||
@@ -192,18 +193,16 @@ export function useFloatingButtonPosition({
|
||||
}
|
||||
|
||||
finishDrag();
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
}, [finishDrag, handlePointerMove]);
|
||||
listenerAbortControllerRef.current?.abort();
|
||||
listenerAbortControllerRef.current = null;
|
||||
}, [finishDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
listenerAbortControllerRef.current?.abort();
|
||||
listenerAbortControllerRef.current = null;
|
||||
};
|
||||
}, [handlePointerMove, handlePointerUp]);
|
||||
}, []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
@@ -221,9 +220,16 @@ export function useFloatingButtonPosition({
|
||||
offsetY: event.clientY - rect.top,
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', handlePointerUp);
|
||||
window.addEventListener('pointercancel', handlePointerUp);
|
||||
listenerAbortControllerRef.current?.abort();
|
||||
const abortController = new AbortController();
|
||||
listenerAbortControllerRef.current = abortController;
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove, {
|
||||
passive: false,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
window.addEventListener('pointerup', handlePointerUp, { signal: abortController.signal });
|
||||
window.addEventListener('pointercancel', handlePointerUp, { signal: abortController.signal });
|
||||
}, [handlePointerMove, handlePointerUp]);
|
||||
|
||||
const consumeSyntheticClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
|
||||
+15
-12
@@ -10,12 +10,14 @@ export function useHomePage() {
|
||||
useSubscriptionSync();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialUrlQuery = searchParams.get('q') ?? '';
|
||||
const { loadFromCache, saveToCache } = useSearchCache();
|
||||
const hasLoadedCache = useRef(false);
|
||||
const hasSearchedWithSourcesRef = useRef(false);
|
||||
const isInitialCacheLoad = useRef(false);
|
||||
const initialUrlQueryRef = useRef(initialUrlQuery);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [query, setQuery] = useState(initialUrlQuery);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [currentSortBy, setCurrentSortBy] = useState<SortOption>('default');
|
||||
|
||||
@@ -130,21 +132,22 @@ export function useHomePage() {
|
||||
if (hasLoadedCache.current) return;
|
||||
hasLoadedCache.current = true;
|
||||
|
||||
const urlQuery = searchParams.get('q');
|
||||
const urlQuery = initialUrlQueryRef.current;
|
||||
const cached = loadFromCache();
|
||||
|
||||
if (urlQuery) {
|
||||
setQuery(urlQuery);
|
||||
if (cached && cached.query === urlQuery && cached.results.length > 0) {
|
||||
isInitialCacheLoad.current = true;
|
||||
setHasSearched(true);
|
||||
loadCachedResults(cached.results, cached.availableSources);
|
||||
hasSearchedWithSourcesRef.current = true;
|
||||
} else {
|
||||
handleSearch(urlQuery);
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
if (cached && cached.query === urlQuery && cached.results.length > 0) {
|
||||
isInitialCacheLoad.current = true;
|
||||
setHasSearched(true);
|
||||
loadCachedResults(cached.results, cached.availableSources);
|
||||
hasSearchedWithSourcesRef.current = true;
|
||||
} else {
|
||||
handleSearch(urlQuery);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [searchParams, loadFromCache, loadCachedResults, handleSearch]);
|
||||
}, [loadFromCache, loadCachedResults, handleSearch]);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { LanguageBadge } from '@/lib/types';
|
||||
|
||||
/**
|
||||
@@ -26,16 +26,23 @@ export function useLanguageBadges<T extends { vod_lang?: string }>(videos: T[])
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [videos]);
|
||||
|
||||
const effectiveSelectedLangs = useMemo(() => {
|
||||
const availableLangs = new Set(languageBadges.map((badge) => badge.lang));
|
||||
return new Set(
|
||||
Array.from(selectedLangs).filter((lang) => availableLangs.has(lang))
|
||||
);
|
||||
}, [languageBadges, selectedLangs]);
|
||||
|
||||
// Filter videos by selected languages
|
||||
const filteredVideos = useMemo(() => {
|
||||
if (selectedLangs.size === 0) {
|
||||
if (effectiveSelectedLangs.size === 0) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
return videos.filter(video =>
|
||||
video.vod_lang && selectedLangs.has(video.vod_lang.trim())
|
||||
video.vod_lang && effectiveSelectedLangs.has(video.vod_lang.trim())
|
||||
);
|
||||
}, [videos, selectedLangs]);
|
||||
}, [videos, effectiveSelectedLangs]);
|
||||
|
||||
// Toggle language selection
|
||||
const toggleLang = useCallback((lang: string) => {
|
||||
@@ -50,25 +57,9 @@ export function useLanguageBadges<T extends { vod_lang?: string }>(videos: T[])
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-cleanup: remove selected langs that no longer exist in badges
|
||||
useEffect(() => {
|
||||
const availableLangs = new Set(languageBadges.map(b => b.lang));
|
||||
|
||||
setSelectedLangs(prev => {
|
||||
const filtered = new Set(
|
||||
Array.from(prev).filter(lang => availableLangs.has(lang))
|
||||
);
|
||||
|
||||
if (filtered.size !== prev.size) {
|
||||
return filtered;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [languageBadges]);
|
||||
|
||||
return {
|
||||
languageBadges,
|
||||
selectedLangs,
|
||||
selectedLangs: effectiveSelectedLangs,
|
||||
filteredVideos,
|
||||
toggleLang,
|
||||
};
|
||||
|
||||
+23
-36
@@ -3,7 +3,7 @@
|
||||
* Periodically pings video sources when enabled
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, useSyncExternalStore } from 'react';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
|
||||
interface LatencyState {
|
||||
@@ -25,30 +25,11 @@ export function useLatencyPing({
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Check if real-time latency is enabled in settings
|
||||
const [realtimeEnabled, setRealtimeEnabled] = useState(false);
|
||||
|
||||
// Stabilize sourceUrls to prevent unnecessary effect re-runs if parent passes new array
|
||||
const stableSourceUrls = useMemo(() => sourceUrls, [
|
||||
// Create a unique key for the sources array
|
||||
sourceUrls.map(s => `${s.id}|${s.baseUrl}`).join(',')
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = settingsStore.getSettings();
|
||||
setRealtimeEnabled(settings.realtimeLatency);
|
||||
|
||||
// Subscribe to settings changes
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
const newSettings = settingsStore.getSettings();
|
||||
setRealtimeEnabled(newSettings.realtimeLatency);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
const realtimeEnabled = useSyncExternalStore(
|
||||
(listener) => settingsStore.subscribe(listener),
|
||||
() => settingsStore.getSettings().realtimeLatency,
|
||||
() => false,
|
||||
);
|
||||
|
||||
const pingSource = useCallback(async (sourceId: string, baseUrl: string): Promise<number | null> => {
|
||||
try {
|
||||
@@ -69,12 +50,12 @@ export function useLatencyPing({
|
||||
}, []);
|
||||
|
||||
const pingAllSources = useCallback(async () => {
|
||||
if (!mountedRef.current || stableSourceUrls.length === 0) return;
|
||||
if (!mountedRef.current || sourceUrls.length === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const results = await Promise.all(
|
||||
stableSourceUrls.map(async ({ id, baseUrl }) => {
|
||||
sourceUrls.map(async ({ id, baseUrl }) => {
|
||||
const latency = await pingSource(id, baseUrl);
|
||||
return { id, latency };
|
||||
})
|
||||
@@ -92,33 +73,39 @@ export function useLatencyPing({
|
||||
});
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [stableSourceUrls, pingSource]);
|
||||
}, [sourceUrls, pingSource]);
|
||||
|
||||
// Start/stop polling based on enabled state
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
let initialTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
const shouldPoll = enabled && realtimeEnabled && stableSourceUrls.length > 0;
|
||||
const shouldPoll = enabled && realtimeEnabled && sourceUrls.length > 0;
|
||||
|
||||
if (shouldPoll) {
|
||||
// Initial ping
|
||||
pingAllSources();
|
||||
initialTimeout = setTimeout(() => {
|
||||
void pingAllSources();
|
||||
}, 0);
|
||||
|
||||
// Set up interval
|
||||
intervalRef.current = setInterval(pingAllSources, intervalMs);
|
||||
intervalRef.current = setInterval(() => {
|
||||
void pingAllSources();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (initialTimeout) {
|
||||
clearTimeout(initialTimeout);
|
||||
}
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, realtimeEnabled, stableSourceUrls, intervalMs, pingAllSources]);
|
||||
}, [enabled, realtimeEnabled, sourceUrls, intervalMs, pingAllSources]);
|
||||
|
||||
const refreshLatency = useCallback((sourceId: string) => {
|
||||
const source = stableSourceUrls.find(s => s.id === sourceId);
|
||||
const source = sourceUrls.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
pingSource(sourceId, source.baseUrl).then(latency => {
|
||||
if (latency !== null && mountedRef.current) {
|
||||
@@ -126,7 +113,7 @@ export function useLatencyPing({
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [stableSourceUrls, pingSource]);
|
||||
}, [sourceUrls, pingSource]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
pingAllSources();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
import type { Video, SourceBadge } from '@/lib/types';
|
||||
import type { Video, SourceBadge, VideoSource } from '@/lib/types';
|
||||
import { useSearchState } from './useSearchState';
|
||||
import { useSearchAction } from './useSearchAction';
|
||||
|
||||
@@ -14,10 +14,10 @@ interface ParallelSearchResult {
|
||||
completedSources: number;
|
||||
totalSources: number;
|
||||
totalVideosFound: number;
|
||||
performSearch: (query: string, sources?: any[], sortBy?: SortOption) => Promise<void>;
|
||||
performSearch: (query: string, sources?: VideoSource[], sortBy?: SortOption) => Promise<void>;
|
||||
resetSearch: () => void;
|
||||
cancelSearch: () => void;
|
||||
loadCachedResults: (results: Video[], sources: any[]) => void;
|
||||
loadCachedResults: (results: Video[], sources: SourceBadge[]) => void;
|
||||
applySorting: (sortBy: SortOption) => void;
|
||||
loadMore: () => Promise<void>;
|
||||
hasMore: boolean;
|
||||
@@ -25,7 +25,7 @@ interface ParallelSearchResult {
|
||||
}
|
||||
|
||||
export function useParallelSearch(
|
||||
onCacheUpdate: (query: string, results: any[], sources: any[]) => void,
|
||||
onCacheUpdate: (query: string, results: Video[], sources: SourceBadge[]) => void,
|
||||
onUrlUpdate: (query: string) => void
|
||||
): ParallelSearchResult {
|
||||
const state = useSearchState();
|
||||
@@ -64,7 +64,7 @@ export function useParallelSearch(
|
||||
/**
|
||||
* Load cached results
|
||||
*/
|
||||
const loadCachedResults = useCallback((cachedResults: Video[], cachedSources: any[]) => {
|
||||
const loadCachedResults = useCallback((cachedResults: Video[], cachedSources: SourceBadge[]) => {
|
||||
setResults(cachedResults);
|
||||
setAvailableSources(cachedSources);
|
||||
setTotalVideosFound(cachedResults.length);
|
||||
@@ -94,4 +94,3 @@ export function useParallelSearch(
|
||||
loadingMore,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import type { VideoSource } from '@/lib/types';
|
||||
|
||||
interface PremiumVideo {
|
||||
vod_id: string | number;
|
||||
@@ -11,6 +12,10 @@ interface PremiumVideo {
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface PremiumCategoryResponse {
|
||||
videos?: PremiumVideo[];
|
||||
}
|
||||
|
||||
const PAGE_LIMIT = 20;
|
||||
|
||||
export function usePremiumContent(categoryValue: string) {
|
||||
@@ -29,13 +34,7 @@ export function usePremiumContent(categoryValue: string) {
|
||||
try {
|
||||
// Get sources from settings
|
||||
const settings = settingsStore.getSettings();
|
||||
// Resolve all relevant sources (premium sources + subscriptions that might be premium)
|
||||
// For simplicity, we send all enabled premium sources.
|
||||
const premiumSources = [
|
||||
...settings.premiumSources,
|
||||
// Check if any subscription sources are marked as premium
|
||||
...settings.subscriptions.filter(s => (s as any).group === 'premium')
|
||||
].filter(s => (s as any).enabled !== false);
|
||||
const premiumSources: VideoSource[] = settings.premiumSources.filter((source) => source.enabled !== false);
|
||||
|
||||
if (premiumSources.length === 0) {
|
||||
setLoading(false);
|
||||
@@ -59,7 +58,7 @@ export function usePremiumContent(categoryValue: string) {
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch');
|
||||
|
||||
const data = await response.json();
|
||||
const data = (await response.json()) as PremiumCategoryResponse;
|
||||
const newVideos = data.videos || [];
|
||||
|
||||
setVideos(prev => append ? [...prev, ...newVideos] : newVideos);
|
||||
@@ -80,7 +79,7 @@ export function usePremiumContent(categoryValue: string) {
|
||||
|
||||
// Initial check for sources
|
||||
const settings = settingsStore.getSettings();
|
||||
const sourcesCount = settings.premiumSources.length + settings.subscriptions.length;
|
||||
const sourcesCount = settings.premiumSources.length;
|
||||
sourceCountRef.current = sourcesCount;
|
||||
|
||||
loadVideos(1, false);
|
||||
@@ -90,10 +89,7 @@ export function usePremiumContent(categoryValue: string) {
|
||||
useEffect(() => {
|
||||
const handleSettingsUpdate = () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
const premiumSources = [
|
||||
...settings.premiumSources,
|
||||
...settings.subscriptions.filter(s => (s as any).group === 'premium')
|
||||
].filter(s => (s as any).enabled !== false);
|
||||
const premiumSources = settings.premiumSources.filter((source) => source.enabled !== false);
|
||||
|
||||
const currentSourceCount = premiumSources.length;
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSearchCache } from '@/lib/hooks/useSearchCache';
|
||||
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
|
||||
import { useSubscriptionSync } from '@/lib/hooks/useSubscriptionSync';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { settingsStore, type SortOption } from '@/lib/store/settings-store';
|
||||
import { VideoSource } from '@/lib/types';
|
||||
|
||||
export function usePremiumHomePage() {
|
||||
useSubscriptionSync();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { loadFromCache, saveToCache } = useSearchCache();
|
||||
const initialUrlQuery = searchParams.get('q') ?? '';
|
||||
const { saveToCache } = useSearchCache();
|
||||
const hasLoadedCache = useRef(false);
|
||||
const hasSearchedWithSourcesRef = useRef(false);
|
||||
const initialUrlQueryRef = useRef(initialUrlQuery);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [query, setQuery] = useState(initialUrlQuery);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [currentSortBy, setCurrentSortBy] = useState('default');
|
||||
const [currentSortBy, setCurrentSortBy] = useState<SortOption>('default');
|
||||
|
||||
// Use state for sources to trigger re-renders when they update
|
||||
const [enabledPremiumSources, setEnabledPremiumSources] = useState<VideoSource[]>([]);
|
||||
@@ -53,15 +55,23 @@ export function usePremiumHomePage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
performSearch(searchQuery, sources, currentSortBy as any);
|
||||
performSearch(searchQuery, sources, currentSortBy);
|
||||
hasSearchedWithSourcesRef.current = true;
|
||||
return true;
|
||||
}, [performSearch, currentSortBy]);
|
||||
|
||||
const handleSearch = useCallback((searchQuery: string) => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setQuery(searchQuery);
|
||||
setHasSearched(true);
|
||||
executeSearch(searchQuery, enabledPremiumSources);
|
||||
}, [enabledPremiumSources, executeSearch]);
|
||||
|
||||
// Re-sort results when sort preference changes
|
||||
useEffect(() => {
|
||||
if (hasSearched && results.length > 0) {
|
||||
applySorting(currentSortBy as any);
|
||||
applySorting(currentSortBy);
|
||||
}
|
||||
}, [currentSortBy, applySorting, hasSearched, results.length]);
|
||||
|
||||
@@ -103,28 +113,19 @@ export function usePremiumHomePage() {
|
||||
if (hasLoadedCache.current) return;
|
||||
hasLoadedCache.current = true;
|
||||
|
||||
const urlQuery = searchParams.get('q');
|
||||
const urlQuery = initialUrlQueryRef.current;
|
||||
|
||||
if (urlQuery) {
|
||||
setQuery(urlQuery);
|
||||
// We need to wait for sources to be available, which is handled by the subscription effect
|
||||
// But if sources are already available (e.g. navigation), execute immediately
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
const currentSources = currentSettings.premiumSources.filter(s => s.enabled);
|
||||
queueMicrotask(() => {
|
||||
const currentSettings = settingsStore.getSettings();
|
||||
const currentSources = currentSettings.premiumSources.filter(s => s.enabled);
|
||||
|
||||
if (currentSources.length > 0) {
|
||||
handleSearch(urlQuery);
|
||||
}
|
||||
// If no sources yet, the useEffect above will catch it when they load
|
||||
if (currentSources.length > 0) {
|
||||
handleSearch(urlQuery);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSearch = (searchQuery: string) => {
|
||||
setQuery(searchQuery);
|
||||
setHasSearched(true);
|
||||
// Use current state of sources
|
||||
executeSearch(searchQuery, enabledPremiumSources);
|
||||
};
|
||||
}, [handleSearch]);
|
||||
|
||||
const handleReset = () => {
|
||||
setHasSearched(false);
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { SOURCE_IDS } from '@/lib/utils/source-names';
|
||||
import { sortVideos } from '@/lib/utils/sort';
|
||||
import { binaryInsertVideos } from '@/lib/utils/sorted-insert';
|
||||
import { processSearchStream } from '@/lib/utils/search-stream';
|
||||
import type { SortOption } from '@/lib/store/settings-store';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import type { Video } from '@/lib/types';
|
||||
import type { SourceBadge, Video, VideoSource } from '@/lib/types';
|
||||
import { useSearchState } from './useSearchState';
|
||||
|
||||
type SearchState = ReturnType<typeof useSearchState>;
|
||||
|
||||
interface UseSearchActionProps {
|
||||
state: SearchState;
|
||||
onCacheUpdate: (query: string, results: any[], sources: any[]) => void;
|
||||
onCacheUpdate: (query: string, results: Video[], sources: SourceBadge[]) => void;
|
||||
onUrlUpdate: (query: string) => void;
|
||||
}
|
||||
|
||||
@@ -34,19 +33,16 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
// Keep track of the last search params so loadMore can re-use them
|
||||
const lastSearchParamsRef = useRef<{ query: string; sources: any[]; sortBy: SortOption } | null>(null);
|
||||
const lastSearchParamsRef = useRef<{ query: string; sources: VideoSource[]; sortBy: SortOption } | null>(null);
|
||||
|
||||
const performSearch = useCallback(async (searchQuery: string, sources: any[] = [], sortBy: SortOption = 'default') => {
|
||||
const performSearch = useCallback(async (searchQuery: string, sources: VideoSource[] = [], sortBy: SortOption = 'default') => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
// Resolve sources if not provided
|
||||
let targetSources = sources;
|
||||
if (!targetSources || targetSources.length === 0) {
|
||||
const settings = settingsStore.getSettings();
|
||||
targetSources = [
|
||||
...settings.sources,
|
||||
...settings.subscriptions.filter(s => (s as any).enabled !== false), // Include valid subscriptions
|
||||
].filter(s => (s as any).enabled !== false);
|
||||
targetSources = settings.sources.filter((source) => source.enabled !== false);
|
||||
}
|
||||
|
||||
// Abort any ongoing search
|
||||
@@ -113,7 +109,7 @@ export function useSearchAction({ state, onCacheUpdate, onUrlUpdate }: UseSearch
|
||||
id: id,
|
||||
name: info.name,
|
||||
count: info.count,
|
||||
}));
|
||||
})) satisfies SourceBadge[];
|
||||
setAvailableSources(sources);
|
||||
|
||||
// Apply final sorting after all results are received
|
||||
|
||||
+19
-15
@@ -1,9 +1,10 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import type { SourceBadge, Video } from '@/lib/types';
|
||||
|
||||
interface SearchCache {
|
||||
query: string;
|
||||
results: any[];
|
||||
availableSources: any[];
|
||||
results: Video[];
|
||||
availableSources: SourceBadge[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -15,16 +16,13 @@ const MAX_CACHED_RESULTS = 300;
|
||||
/**
|
||||
* Strip unnecessary large fields before caching to save LocalStorage space
|
||||
*/
|
||||
const stripVideoData = (results: any[]) => {
|
||||
const stripVideoData = (results: Video[]) => {
|
||||
return results.slice(0, MAX_CACHED_RESULTS).map(video => {
|
||||
// Remove large text fields that are only needed for the detail page
|
||||
const {
|
||||
vod_content,
|
||||
vod_actor,
|
||||
vod_director,
|
||||
...rest
|
||||
} = video;
|
||||
return rest;
|
||||
const strippedVideo = { ...video };
|
||||
delete strippedVideo.vod_content;
|
||||
delete strippedVideo.vod_actor;
|
||||
delete strippedVideo.vod_director;
|
||||
return strippedVideo;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,8 +34,8 @@ export function useSearchCache() {
|
||||
|
||||
const saveToCache = useCallback((
|
||||
query: string,
|
||||
results: any[],
|
||||
sources: any[]
|
||||
results: Video[],
|
||||
sources: SourceBadge[]
|
||||
) => {
|
||||
try {
|
||||
const strippedResults = stripVideoData(results);
|
||||
@@ -58,7 +56,13 @@ export function useSearchCache() {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
// Try saving only top 100 results if quota exceeded
|
||||
const reducedResults = results.slice(0, 100).map(({ vod_content, vod_actor, vod_director, ...rest }: any) => rest);
|
||||
const reducedResults = results.slice(0, 100).map((video) => {
|
||||
const strippedVideo = { ...video };
|
||||
delete strippedVideo.vod_content;
|
||||
delete strippedVideo.vod_actor;
|
||||
delete strippedVideo.vod_director;
|
||||
return strippedVideo;
|
||||
});
|
||||
const reducedCache = {
|
||||
query,
|
||||
results: reducedResults,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { SourceBadge } from '@/lib/types';
|
||||
|
||||
/**
|
||||
@@ -16,17 +16,23 @@ export function useSourceBadges<T extends { source?: string; sourceName?: string
|
||||
availableSources: SourceBadge[]
|
||||
) {
|
||||
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
|
||||
const effectiveSelectedSources = useMemo(() => {
|
||||
const availableSourceIds = new Set(availableSources.map((source) => source.id));
|
||||
return new Set(
|
||||
Array.from(selectedSources).filter((sourceId) => availableSourceIds.has(sourceId))
|
||||
);
|
||||
}, [availableSources, selectedSources]);
|
||||
|
||||
// Filter videos by selected sources
|
||||
const filteredVideos = useMemo(() => {
|
||||
if (selectedSources.size === 0) {
|
||||
if (effectiveSelectedSources.size === 0) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
return videos.filter(video =>
|
||||
video.source && selectedSources.has(video.source)
|
||||
video.source && effectiveSelectedSources.has(video.source)
|
||||
);
|
||||
}, [videos, selectedSources]);
|
||||
}, [videos, effectiveSelectedSources]);
|
||||
|
||||
// Toggle source selection
|
||||
const toggleSource = useCallback((sourceId: string) => {
|
||||
@@ -41,25 +47,8 @@ export function useSourceBadges<T extends { source?: string; sourceName?: string
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-cleanup: remove selected sources that no longer exist
|
||||
useEffect(() => {
|
||||
const availableSourceIds = new Set(availableSources.map(s => s.id));
|
||||
|
||||
setSelectedSources(prev => {
|
||||
const filtered = new Set(
|
||||
Array.from(prev).filter(sourceId => availableSourceIds.has(sourceId))
|
||||
);
|
||||
|
||||
// Only update if changed
|
||||
if (filtered.size !== prev.size) {
|
||||
return filtered;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [availableSources]);
|
||||
|
||||
return {
|
||||
selectedSources,
|
||||
selectedSources: effectiveSelectedSources,
|
||||
filteredVideos,
|
||||
toggleSource,
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export function useSubscriptionSync() {
|
||||
let anyChanged = false;
|
||||
let currentSources = [...settings.sources];
|
||||
let currentPremiumSources = [...settings.premiumSources];
|
||||
let updatedSubscriptions = [...settings.subscriptions];
|
||||
const updatedSubscriptions = [...settings.subscriptions];
|
||||
const now = Date.now();
|
||||
|
||||
// Filter out subscriptions that were synced recently (within cooldown period)
|
||||
|
||||
+40
-20
@@ -3,7 +3,7 @@
|
||||
* Detects if the user is on a TV/set-top-box browser.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
const TV_USER_AGENT_PATTERNS = [
|
||||
/smarttv/i,
|
||||
@@ -21,29 +21,49 @@ const TV_USER_AGENT_PATTERNS = [
|
||||
/hbbtv/i,
|
||||
];
|
||||
|
||||
export function useTVDetection(): boolean {
|
||||
const [isTV, setIsTV] = useState(false);
|
||||
function computeIsTV(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const ua = navigator.userAgent;
|
||||
const ua = navigator.userAgent;
|
||||
const uaMatch = TV_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua));
|
||||
if (uaMatch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check UA for TV indicators
|
||||
const uaMatch = TV_USER_AGENT_PATTERNS.some(pattern => pattern.test(ua));
|
||||
const maxDimension = Math.max(window.screen.width, window.screen.height);
|
||||
const minDimension = Math.min(window.screen.width, window.screen.height);
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)').matches;
|
||||
const noHover =
|
||||
window.matchMedia('(hover: none)').matches &&
|
||||
window.matchMedia('(any-hover: none)').matches;
|
||||
const noTouch = navigator.maxTouchPoints === 0;
|
||||
const largeScreen = maxDimension >= 1280 && minDimension >= 720;
|
||||
|
||||
if (uaMatch) {
|
||||
setIsTV(true);
|
||||
return;
|
||||
}
|
||||
return largeScreen && coarsePointer && noHover && noTouch;
|
||||
}
|
||||
|
||||
// Fallback heuristic: large screen + no touch + low pixel density
|
||||
const isLargeScreen = window.innerWidth >= 1280;
|
||||
const hasNoTouch = !('ontouchstart' in window) && navigator.maxTouchPoints === 0;
|
||||
const lowDensity = window.devicePixelRatio <= 1.5;
|
||||
function subscribeToTVSignals(listener: () => void) {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (isLargeScreen && hasNoTouch && lowDensity) {
|
||||
setIsTV(true);
|
||||
}
|
||||
}, []);
|
||||
const mediaQueries = [
|
||||
window.matchMedia('(pointer: coarse)'),
|
||||
window.matchMedia('(hover: none)'),
|
||||
window.matchMedia('(any-hover: none)'),
|
||||
];
|
||||
|
||||
return isTV;
|
||||
window.addEventListener('resize', listener);
|
||||
mediaQueries.forEach((mediaQuery) => mediaQuery.addEventListener('change', listener));
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', listener);
|
||||
mediaQueries.forEach((mediaQuery) => mediaQuery.removeEventListener('change', listener));
|
||||
};
|
||||
}
|
||||
|
||||
export function useTVDetection(): boolean {
|
||||
return useSyncExternalStore(subscribeToTVSignals, computeIsTV, () => false);
|
||||
}
|
||||
|
||||
+12
-22
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import type { TypeBadge } from '@/lib/types';
|
||||
|
||||
/**
|
||||
@@ -61,21 +61,28 @@ export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [videos]);
|
||||
|
||||
const effectiveSelectedTypes = useMemo(() => {
|
||||
const availableTypes = new Set(typeBadges.map((badge) => badge.type));
|
||||
return new Set(
|
||||
Array.from(selectedTypes).filter((type) => availableTypes.has(type))
|
||||
);
|
||||
}, [selectedTypes, typeBadges]);
|
||||
|
||||
// Filter videos by selected types
|
||||
const filteredVideos = useMemo(() => {
|
||||
if (selectedTypes.size === 0) {
|
||||
if (effectiveSelectedTypes.size === 0) {
|
||||
return videos;
|
||||
}
|
||||
|
||||
// Build a set of normalized selected types
|
||||
const normalizedSelected = new Set(
|
||||
Array.from(selectedTypes).map(normalizeTypeName)
|
||||
Array.from(effectiveSelectedTypes).map(normalizeTypeName)
|
||||
);
|
||||
|
||||
return videos.filter(video =>
|
||||
video.type_name && normalizedSelected.has(normalizeTypeName(video.type_name.trim()))
|
||||
);
|
||||
}, [videos, selectedTypes]);
|
||||
}, [videos, effectiveSelectedTypes]);
|
||||
|
||||
// Toggle type selection - useCallback to prevent re-creation
|
||||
const toggleType = useCallback((type: string) => {
|
||||
@@ -91,26 +98,9 @@ export function useTypeBadges<T extends { type_name?: string }>(videos: T[]) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-cleanup: remove selected types that no longer exist in badges
|
||||
useEffect(() => {
|
||||
const availableTypes = new Set(typeBadges.map(b => b.type));
|
||||
|
||||
setSelectedTypes(prev => {
|
||||
const filtered = new Set(
|
||||
Array.from(prev).filter(type => availableTypes.has(type))
|
||||
);
|
||||
|
||||
// Only update if changed
|
||||
if (filtered.size !== prev.size) {
|
||||
return filtered;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [typeBadges]);
|
||||
|
||||
return {
|
||||
typeBadges,
|
||||
selectedTypes,
|
||||
selectedTypes: effectiveSelectedTypes,
|
||||
filteredVideos,
|
||||
toggleType,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'server-only';
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getPublicAuthConfig, getServerSession, type ServerAuthSession } from '@/lib/server/auth';
|
||||
|
||||
function relayDisabledResponse(message: string, status: number): NextResponse {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
function isPublicRelayEnabled(): boolean {
|
||||
return process.env.KVIDEO_PUBLIC_RELAY_ENABLED === 'true';
|
||||
}
|
||||
|
||||
interface AccessResult {
|
||||
config: Awaited<ReturnType<typeof getPublicAuthConfig>>;
|
||||
session: ServerAuthSession | null;
|
||||
error?: NextResponse;
|
||||
}
|
||||
|
||||
export async function requireAuthenticatedRequestIfConfigured(request: NextRequest): Promise<AccessResult> {
|
||||
const config = await getPublicAuthConfig();
|
||||
|
||||
if (config.authError) {
|
||||
return {
|
||||
config,
|
||||
session: null,
|
||||
error: relayDisabledResponse(config.authError, 503),
|
||||
};
|
||||
}
|
||||
|
||||
if (!config.hasAuth) {
|
||||
return { config, session: null };
|
||||
}
|
||||
|
||||
const session = await getServerSession(request);
|
||||
if (!session) {
|
||||
return {
|
||||
config,
|
||||
session: null,
|
||||
error: relayDisabledResponse('Authentication required', 401),
|
||||
};
|
||||
}
|
||||
|
||||
return { config, session };
|
||||
}
|
||||
|
||||
export async function requireRelayAccess(request: NextRequest): Promise<AccessResult> {
|
||||
const access = await requireAuthenticatedRequestIfConfigured(request);
|
||||
if (access.error) {
|
||||
return access;
|
||||
}
|
||||
|
||||
if (!access.config.hasAuth && !isPublicRelayEnabled()) {
|
||||
return {
|
||||
...access,
|
||||
error: relayDisabledResponse(
|
||||
'Public relay routes are disabled unless KVIDEO_PUBLIC_RELAY_ENABLED=true',
|
||||
403,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return access;
|
||||
}
|
||||
|
||||
export function buildSameOriginOptionsResponse(request: NextRequest, methods: string): NextResponse {
|
||||
const origin = request.headers.get('origin');
|
||||
const allowOrigin = origin === request.nextUrl.origin ? origin : request.nextUrl.origin;
|
||||
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': methods,
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
Vary: 'Origin',
|
||||
},
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user