Remediate audit findings across runtime and platforms

This commit is contained in:
kuekhaoyang
2026-04-16 20:41:18 +08:00
parent 20c9ae3d56
commit 32791a62a7
122 changed files with 13341 additions and 9985 deletions
+49 -902
View File
@@ -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.
+152 -1044
View File
File diff suppressed because it is too large Load Diff
+37 -7
View File
@@ -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.
+11 -3
View File
@@ -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")
}
+2 -8
View File
@@ -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>
+1 -1
View File
@@ -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';
+6 -2
View File
@@ -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 }) };
}
+6 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 },
);
}
}
+62 -53
View File
@@ -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 },
);
}
}
+15 -3
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
}
+34 -18
View File
@@ -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);
}
+13 -13
View File
@@ -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));
}
+66 -67
View File
@@ -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
View File
@@ -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
View File
@@ -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',
},
});
}
+13 -5
View File
@@ -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(
+17 -5
View File
@@ -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 });
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {}
+9 -1
View File
@@ -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();
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
};
}
+28 -5
View File
@@ -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>
+72 -60
View File
@@ -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 }}>
+30 -16
View File
@@ -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;
}
+5 -9
View File
@@ -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 -4
View File
@@ -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">
+2 -12
View File
@@ -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">
-2
View File
@@ -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)}
/>
+1 -1
View File
@@ -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}
+10 -21
View File
@@ -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 {
+12 -2
View File
@@ -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);
+32 -13
View File
@@ -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);
+2 -4
View File
@@ -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 ${
+83 -27
View File
@@ -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'
}`}
+13 -21
View File
@@ -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);
}
+3 -13
View File
@@ -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>
)}
+37 -26
View File
@@ -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>
)}
+14 -3
View File
@@ -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)]"
/>
)}
+10 -11
View File
@@ -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) => {
+37 -6
View File
@@ -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>
);
}
+37 -6
View File
@@ -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;
+21 -36
View File
@@ -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,
};
+35 -8
View File
@@ -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);
+15 -3
View File
@@ -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
+4 -1
View File
@@ -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;
+9 -16
View File
@@ -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);
};
+3 -6
View File
@@ -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)]">
+4 -13
View File
@@ -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';
+15 -17
View File
@@ -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({
</>
);
});
+3 -11
View File
@@ -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">
+16 -8
View File
@@ -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} />
+5 -5
View File
@@ -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);
+10 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
"伦理"
&quot;&quot;
</p>
<div className="flex gap-2 mb-3">
<input
+14 -16
View File
@@ -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">
+27 -15
View File
@@ -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">
+18 -21
View File
@@ -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();
+7 -10
View File
@@ -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()) {
+25 -24
View File
@@ -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);
+43
View File
@@ -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);
}}
/>
);
}
-2
View File
@@ -1,5 +1,3 @@
version: '3'
services:
kvideo:
container_name: kvideo
+7 -4
View File
@@ -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) {
+6 -1
View File
@@ -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);
};
+22 -5
View File
@@ -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
}
};
+17 -11
View File
@@ -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
View File
@@ -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]);
+12 -21
View File
@@ -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
View File
@@ -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();
+5 -6
View File
@@ -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,
};
}
+9 -13
View File
@@ -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;
+26 -25
View File
@@ -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);
+6 -10
View File
@@ -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
View File
@@ -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,
+11 -22
View File
@@ -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,
};
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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,
};
+79
View File
@@ -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