This commit is contained in:
kuekhaoyang
2025-12-24 23:53:24 +08:00
commit 43a35e3f47
229 changed files with 28007 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
Dockerfile
.dockerignore
node_modules
npm-debug.log
README.md
.next
.git

51
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: 🐛 错误报告 (Bug Report)
description: 创建一个报告以帮助我们改进 KVideo
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
感谢您花时间填写此错误报告!
- type: checkboxes
id: duplicates
attributes:
label: 重复检查
description: 请确保您已搜索过现有的 Issue以避免重复报告。
options:
- label: 我已搜索过现有的 Issue没有发现类似的问题。
required: true
- type: textarea
id: description
attributes:
label: 问题描述
description: 请清晰简洁地描述发生了什么问题。
placeholder: 比如:点击播放按钮时,播放器没有反应...
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: 复现步骤
description: 请提供复现该问题的步骤。
placeholder: |
1. 打开首页
2. 搜索 "Matrix"
3. 点击第一个结果
4. ...
validations:
required: true
- type: input
id: environment
attributes:
label: 环境信息
description: 请提供您的浏览器版本、操作系统等信息。
placeholder: Chrome 120, macOS 14.2
validations:
required: true
- type: textarea
id: logs
attributes:
label: 相关日志
description: 如果有控制台报错或服务器日志,请粘贴在这里。
render: shell

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: 提问 / Questions
url: https://github.com/KuekHaoYang/KVideo/discussions/new?category=q-a
about: 如果你有关于项目的问题,请在这里提问。
- name: 安全漏洞 / Security Vulnerability
url: https://github.com/KuekHaoYang/KVideo/security/advisories/new
about: 报告安全漏洞。

View File

@@ -0,0 +1,45 @@
name: 🚀 功能请求 (Feature Request)
description: 建议一个新功能或改进
title: "[Feat]: "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
感谢您建议新功能!
- type: checkboxes
id: duplicates
attributes:
label: 重复检查
description: 请确保您已搜索过现有的 Issue以避免重复建议。
options:
- label: 我已搜索过现有的 Issue没有发现类似的建议。
required: true
- type: textarea
id: problem
attributes:
label: 问题描述
description: 这个功能是为了解决什么问题?
placeholder: 比如:我总是很难找到我上次看到的视频...
validations:
required: true
- type: textarea
id: solution
attributes:
label: 建议的解决方案
description: 您希望如何解决这个问题?
placeholder: 比如:添加一个"观看历史"的侧边栏...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: 替代方案
description: 您是否考虑过其他替代方案?
validations:
required: false
- type: textarea
id: context
attributes:
label: 其他上下文
description: 任何其他有助于理解该功能的截图或上下文。

35
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,35 @@
# 描述 (Description)
请简要描述您的更改。说明您解决的问题或添加的功能。
## 关联 Issue (Related Issue)
如果有相关的 Issue请在这里链接 (例如: Fixes #123)。
## 更改类型 (Type of Change)
请删除不适用的选项:
- [ ] 🐛 Bug 修复 (Bug fix)
- [ ] ✨ 新功能 (New feature)
- [ ] 📝 文档更新 (Documentation update)
- [ ] 💄 样式/UI 更新 (Style/UI update)
- [ ] ♻️ 代码重构 (Refactoring)
- [ ] ⚡️ 性能优化 (Performance improvement)
- [ ] 🔧 配置更改 (Configuration change)
## 检查清单 (Checklist)
在提交 PR 之前,请确保您已完成以下检查:
- [ ] 我已阅读并遵守 [贡献指南](../CONTRIBUTING.md)
- [ ] 我的代码遵循项目的代码风格
- [ ] 我已对自己更改的代码进行了自我审查
- [ ] 我已注释了难以理解的代码部分
- [ ] 我已更新了相应的文档 (如果适用)
- [ ] 我的更改没有产生新的警告或错误
- [ ] 我已测试了我的更改,确保其按预期工作
## 截图/录屏 (Screenshots/Recordings)
如果您的更改涉及 UI/UX请提供截图或录屏

44
.gitignore vendored Normal file
View File

@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# unused/generated files
contrast-test-results.json

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

46
CODE_OF_CONDUCT.md Normal file
View File

@@ -0,0 +1,46 @@
# 行为准则 (Code of Conduct)
## 誓言
为了营造一个开放和热情的环境,作为贡献者和维护者,我们承诺让每一位参与者,无论其年龄、体型、残疾、种族、性别特征、性别认同和表达、经验水平、教育程度、社会地位、国籍、个人形象、种族、宗教或性取向如何,都能在我们的项目中获得无骚扰的体验。
## 标准
有助于创造积极环境的行为包括:
* 使用欢迎和包容的语言
* 尊重不同的观点和经验
* 优雅地接受建设性的批评
* 关注什么对社区最有利
* 对其他社区成员表现出同理心
不可接受的行为包括:
* 使用性语言或图像,以及不受欢迎的性关注或挑逗
* 挑衅、侮辱/贬损性评论以及个人或政治攻击
* 公开或私下骚扰
* 未经明确许可发布他人的私人信息,例如物理地址或电子邮箱地址
* 其他在职业环境中被认为不适当的行为
## 我们的责任
项目维护者有责任澄清可接受行为的标准,并应对任何不可接受的行为采取适当和公平的纠正措施。
项目维护者有权并有责任删除、编辑或拒绝不符合本行为准则的评论、提交、代码、Wiki 编辑、Issue 和其他贡献,或者暂时或永久禁止任何他们认为行为不当、威胁、冒犯或有害的贡献者。
## 范围
本行为准则适用于所有项目空间,也适用于代表该项目或其社区的公共空间。代表项目的示例包括使用官方项目电子邮件地址、通过官方社交媒体帐户发帖,或在在线或离线活动中作为指定代表行事。
## 执行
如发现任何滥用、骚扰或其他不可接受的行为,可以通过联系项目团队进行报告。所有投诉都将得到审查和调查,并将产生被认为必要且适合具体情况的回应。项目团队有义务对事件报告者保密。关于具体执行政策的更多细节可能会单独发布。
不真诚地遵守或执行本行为准则的维护者可能会面临项目领导层的暂时或永久性后果。
## 归属
本行为准则改编自 [Contributor Covenant][homepage],版本 2.1,可在 [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1] 获取。
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html

934
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,934 @@
# 贡献指南 (Contributing Guide)
欢迎来到 **KVideo** 项目!我们非常感谢你愿意为这个项目做出贡献。无论是修复 Bug、添加新功能、改进文档还是提出建议你的每一份贡献都将让这个项目变得更好。
为了确保协作顺畅、代码质量一致,请在提交贡献前仔细阅读本指南。
## 📋 目录
- [行为准则](#行为准则)
- [快速开始](#快速开始)
- [开发环境设置](#开发环境设置)
- [代码规范](#代码规范)
- [Git 工作流程](#git-工作流程)
- [提交规范](#提交规范)
- [Pull Request 指南](#pull-request-指南)
- [设计系统规范](#设计系统规范)
- [测试要求](#测试要求)
- [常见问题](#常见问题)
## 🤝 行为准则
我们致力于构建一个开放、友好、包容的社区环境。请在参与项目时:
- ✅ 保持尊重和礼貌
- ✅ 欢迎不同的观点和经验
- ✅ 接受建设性的批评
- ✅ 专注于对社区最有利的事情
- ❌ 不要使用性别化的语言或图像
- ❌ 不要进行人身攻击或政治攻击
- ❌ 不要骚扰或歧视他人
详细的行为准则请参阅 [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 | 20.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. 安装依赖
```bash
npm install
```
#### 4. 启动开发服务器
```bash
npm run dev
```
访问 `http://localhost:3000` 查看应用。
#### 5. 验证环境
确保以下命令都能正常运行:
```bash
# 代码检查
npm run lint
# 构建测试
npm run build
```
## 📏 代码规范
### 核心规范
#### 1. 文件长度限制 ⚠️
> [!CAUTION]
> **这是项目的硬性规则!所有项目文件必须保持在 150 行以内(除系统文件外)。**
**检查命令:**
```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 "行"}'
```
**如果命令有输出,说明有文件超过 150 行,必须重构!**
**重构策略:**
如果文件超过 150 行,请使用以下方法重构:
##### 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 等文档)不受此限制。
- 提取组件
- 提取 Hook
- 提取工具函数
- 模块化
### 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>

85
Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
# Debug: Show build environment
RUN echo "=== Build Environment ===" && \
pwd && \
echo "=== Files in /app ===" && \
ls -la && \
echo "=== Lockfiles ===" && \
ls -la | grep -E "lock|yarn" || echo "No lockfiles" && \
echo "=== Node version ===" && \
node --version && \
echo "=== NPM version ===" && \
npm --version
# Build Next.js application
RUN set -ex && \
if [ -f yarn.lock ]; then \
echo "Building with Yarn..." && yarn run build 2>&1; \
elif [ -f package-lock.json ]; then \
echo "Building with NPM..." && npm run build 2>&1; \
elif [ -f pnpm-lock.yaml ]; then \
echo "Building with PNPM..." && corepack enable pnpm && pnpm run build 2>&1; \
else \
echo "ERROR: Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# set hostname to localhost
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Kuek Hao Yang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

386
README.md Normal file
View File

@@ -0,0 +1,386 @@
# KVideo
![KVideo Banner](public/icon.png)
> 一个基于 Next.js 16 构建的现代化视频聚合播放平台。采用独特的 "Liquid Glass" 设计语言,提供流畅的视觉体验和强大的视频搜索功能。
**🌐 在线体验:[https://kvideo.pages.dev/](https://kvideo.pages.dev/)**
[![Next.js](https://img.shields.io/badge/Next.js-16.0-black?style=for-the-badge&logo=next.js)](https://nextjs.org/)
[![React](https://img.shields.io/badge/React-19.2-blue?style=for-the-badge&logo=react)](https://react.dev/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-4.0-38B2AC?style=for-the-badge&logo=tailwind-css)](https://tailwindcss.com/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.0-blue?style=for-the-badge&logo=typescript)](https://www.typescriptlang.org/)
[![License](https://img.shields.io/badge/License-MIT-green?style=for-the-badge)](LICENSE)
## 📖 项目简介
**KVideo** 是一个高性能、现代化的视频聚合与播放应用,专注于提供极致的用户体验和视觉设计。本项目利用 Next.js 16 的最新特性,结合 React 19 和 Tailwind CSS v4打造了一个既美观又强大的视频浏览平台。
### 核心设计理念Liquid Glass液态玻璃
项目的视觉设计基于 **"Liquid Glass"** 设计系统,这是一套融合了以下特性的现代化 UI 设计语言:
- **玻璃拟态效果**:通过 `backdrop-filter` 实现的磨砂半透明效果,让 UI 元素如同真实的玻璃材质
- **通用柔和度**:统一使用 `rounded-2xl``rounded-full` 两种圆角半径,创造和谐的视觉体验
- **光影交互**:悬停和聚焦状态下的内发光效果,模拟光线被"捕获"的物理现象
- **流畅动画**:基于物理的 `cubic-bezier` 曲线,实现自然的加速和减速过渡
- **深度层级**:清晰的 z-axis 层次结构,增强空间感和交互反馈
## ✨ 核心功能
### 🎥 智能视频播放
- **HLS 流媒体支持**:原生支持 HLS (.m3u8) 格式,提供流畅的视频播放体验
- **智能缓存机制**Service Worker 驱动的智能缓存系统,自动预加载和缓存视频片段
- **后台下载**:利用观看历史,在后台自动下载历史视频,确保离线也能观看
- **播放控制**:完整的播放控制功能,包括进度条、音量控制、播放速度调节、全屏模式等
- **移动端优化**:专门为移动设备优化的播放器界面和手势控制
### 🔍 多源并行搜索
- **聚合搜索引擎**:同时在多个视频源中并行搜索,大幅提升搜索速度
- **自定义视频源**:支持添加、编辑和管理自定义视频源
- **智能解析**:统一的解析器系统,自动处理不同源的数据格式
- **搜索历史**:自动保存搜索历史,支持快速重新搜索
- **结果排序**:支持按评分、时间、相关性等多种方式排序搜索结果
### 🎬 豆瓣集成
- **详细影视信息**:自动获取豆瓣评分、演员阵容、剧情简介等详细信息
- **推荐系统**:基于豆瓣数据的相关推荐
- **专业评价**:展示豆瓣用户评价和专业影评
### 💾 观看历史管理
- **自动记录**:自动记录观看进度和历史
- **断点续播**:从上次观看位置继续播放
- **历史管理**:支持删除单条历史或清空全部历史
- **隐私保护**:所有数据存储在本地,不上传到服务器
### 📱 响应式设计
- **全端适配**:完美支持桌面、平板和移动设备
- **移动优先**:专门的移动端组件和交互设计
- **触摸优化**:针对触摸屏优化的手势和交互
### 🌙 主题系统
- **深色/浅色模式**:支持系统级主题切换
- **动态主题**:基于 CSS Variables 的动态主题系统
- **无缝过渡**:主题切换时的平滑过渡动画
### ⌨️ 无障碍设计
- **键盘导航**:完整的键盘快捷键支持
- **ARIA 标签**:符合 WCAG 2.2 标准的无障碍实现
- **语义化 HTML**:使用语义化标签提升可访问性
- **高对比度**:确保 4.5:1 的文字对比度
## 🔐 隐私保护
本应用注重用户隐私:
- **本地存储**:所有数据存储在本地浏览器中
- **无服务器数据**:不收集或上传任何用户数据
- **自定义源**:用户可自行配置视频源
## 🔒 密码访问控制
KVideo 支持两种密码保护方式:
### 方式一:本地保存密码
在设置页面中启用密码访问,并添加密码:
- **设备独立**:仅在当前浏览器/设备有效
- **可管理**:可随时添加或删除
- **多密码支持**:可设置多个有效密码
### 方式二:环境变量密码(推荐用于部署)
通过 `ACCESS_PASSWORD` 环境变量设置全局密码:
**Docker 部署:**
```bash
docker run -d -p 3000:3000 -e ACCESS_PASSWORD=your_secret_password --name kvideo kuekhaoyang/kvideo:latest
```
**Vercel 部署:**
在 Vercel 项目设置中添加环境变量:
- 变量名:`ACCESS_PASSWORD`
- 变量值:你的密码
**特点:**
- **全局生效**:所有用户都需要此密码才能访问
- **无法在界面删除**:只能通过修改环境变量更改
- **与本地密码兼容**:两种密码都可以解锁应用
## <20> 自动订阅源配置
可以通过环境变量 `NEXT_PUBLIC_SUBSCRIPTION_SOURCES` 自动配置订阅源,应用启动时会自动加载并设置为自动更新。
**格式:** JSON 数组字符串,包含 `name``url` 字段。
**示例:**
```bash
NEXT_PUBLIC_SUBSCRIPTION_SOURCES='[{"name":"每日更新源","url":"https://example.com/api.json"},{"name":"备用源","url":"https://backup.com/api.json"}]'
```
**Docker 部署:**
```bash
docker run -d -p 3000:3000 -e NEXT_PUBLIC_SUBSCRIPTION_SOURCES='[{"name":"MySource","url":"..."}]' --name kvideo kuekhaoyang/kvideo:latest
```
**Vercel 部署:**
在 Vercel 项目设置中添加环境变量:
- 变量名:`NEXT_PUBLIC_SUBSCRIPTION_SOURCES`
- 变量值:`[{"name":"...","url":"..."}]`
## 📝 自定义源 JSON 格式
如果你想创建自己的订阅源或批量导入源,可以使用以下 JSON 格式。
**基本结构:**
可以是单个对象数组,也可以是包含 `sources``list` 字段的对象。
**源对象字段说明:**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | 是 | 唯一标识符,建议使用英文 |
| `name` | string | 是 | 显示名称 |
| `baseUrl` | string | 是 | API 地址 (例如: `https://example.com/api.php/provide/vod`) |
| `group` | string | 否 | 分组,可选值: `"normal"` (默认) 或 `"adult"` |
| `enabled` | boolean | 否 | 是否启用,默认为 `true` |
| `priority` | number | 否 | 优先级,数字越小优先级越高,默认为 1 |
**示例 JSON**
```json
[
{
"id": "my_source_1",
"name": "我的精选源",
"baseUrl": "https://api.example.com/vod",
"group": "normal",
"priority": 1
},
{
"id": "adult_source_1",
"name": "特殊资源",
"baseUrl": "https://api.adult-source.com/vod",
"group": "adult",
"enabled": true
}
]
```
## 🛠 技术栈
### 前端核心
| 技术 | 版本 | 用途 |
|------|------|------|
| **[Next.js](https://nextjs.org/)** | 16.0.3 | React 框架,使用 App Router |
| **[React](https://react.dev/)** | 19.2.0 | UI 组件库 |
| **[TypeScript](https://www.typescriptlang.org/)** | 5.x | 类型安全的 JavaScript |
| **[Tailwind CSS](https://tailwindcss.com/)** | 4.x | 实用优先的 CSS 框架 |
| **[Zustand](https://github.com/pmndrs/zustand)** | 5.0.2 | 轻量级状态管理 |
### 开发工具
- **ESLint 9**:代码质量检查
- **PostCSS 8**CSS 处理器
- **Vercel Analytics**:性能监控和分析
### 架构特点
- **App Router**Next.js 13+ 的新路由系统,支持服务端组件和流式渲染
- **API Routes**:内置 API 端点,处理豆瓣数据和视频源代理
- **Service Worker**:离线缓存和智能预加载
- **Server Components**:优化首屏加载性能
- **Client Components**:复杂交互和状态管理
## 🚀 快速部署
### 在线体验
访问 **[https://kvideo.vercel.app/](https://kvideo.vercel.app/)** 立即体验,无需安装!
### 部署到自己的服务器
#### 选项 1Vercel 一键部署(推荐)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
1. 点击上方按钮
2. 连接你的 GitHub 账号
3. Vercel 会自动检测 Next.js 项目并部署
4. 几分钟后即可访问你自己的 KVideo 实例
#### 选项 2Cloudflare Pages 部署 (推荐)
此方法完全免费且速度极快,是部署本项目的最佳选择。
1. **Fork 本仓库**:首先将项目 Fork 到你的 GitHub 账户。
2. **创建项目**
- 点击访问 [**Cloudflare Pages - Connect Git**](https://dash.cloudflare.com/?to=/:account/pages/new/provider/github)。
- 如果未连接 GitHub请点击 **Connect GitHub**;若已连接,直接选择你刚刚 Fork 的 `KVideo` 项目并点击 **Begin setup**
3. **配置构建参数**
- **Project name**: 默认为 `kvideo` (建议保持不变,后续链接基于此名称)
- **Framework Preset**: 选择 `Next.js`
- **Build command**: 输入 `npm run pages:build`
- **Build output directory**: 输入 `.vercel/output/static`
- 点击 **Save and Deploy**
4. **⚠️ 关键步骤:修复运行时环境**
> *注意:此时部署虽然显示"Success",但你会发现访问网页会报错。这是因为缺少必要的兼容性配置。请按以下步骤修复:*
- 进入 **[项目设置页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo/settings/production)** (如果你的项目名不是 kvideo请在控制台手动查找 Settings -> Functions)。
- 拉到页面底部找到 **Compatibility flags** 部分。
- 添加标志:`nodejs_compat`
5. **重试部署 (生效配置)**
- 回到 **[项目概览页面](https://dash.cloudflare.com/?to=/:account/pages/view/kvideo)**。
-**Deployments** 列表中,找到最新的那次部署。
- 点击右侧的三个点 `...` 菜单,选择 **Retry deployment**
- 等待新的部署完成后,你的 KVideo 就部署成功了!
#### 选项 3Docker 部署
**从 Docker Hub 拉取(最简单):**
```bash
# 拉取最新版本
docker pull kuekhaoyang/kvideo:latest
docker run -d -p 3000:3000 --name kvideo kuekhaoyang/kvideo:latest
```
应用将在 `http://localhost:3000` 启动。
> **✨ 多架构支持**:镜像支持 2 种主流平台架构:
> - `linux/amd64` - Intel/AMD 64位大多数服务器、PC、Intel Mac
> - `linux/arm64` - ARM 64位Apple Silicon Mac、AWS Graviton、树莓派 4/5
**自己构建镜像:**
```bash
git clone https://github.com/KuekHaoYang/KVideo.git
cd KVideo
docker build -t kvideo .
docker run -d -p 3000:3000 --name kvideo kvideo
```
**使用 Docker Compose**
```bash
docker-compose up -d
```
#### 选项 4传统 Node.js 部署
```bash
# 1. 克隆仓库
git clone https://github.com/KuekHaoYang/KVideo.git
cd KVideo
# 2. 安装依赖
npm install
# 3. 构建项目
npm run build
# 4. 启动生产服务器
npm start
```
应用将在 `http://localhost:3000` 启动。
## 🔄 如何更新
### Vercel 部署
Vercel 会自动检测 GitHub 仓库的更新并重新部署,无需手动操作。
### Docker 部署
当有新版本发布时:
```bash
# 停止并删除旧容器
docker stop kvideo
docker rm kvideo
# 拉取最新镜像
docker pull kuekhaoyang/kvideo:latest
# 运行新容器
docker run -d -p 3000:3000 --name kvideo kuekhaoyang/kvideo:latest
```
### Node.js 部署
```bash
cd KVideo
git pull origin main
npm install
npm run build
npm start
```
> **🔄 自动化部署**:本项目使用 GitHub Actions 自动构建和发布 Docker 镜像。每次代码推送到 main 分支时,会自动构建多架构镜像并推送到 Docker Hub。
## 🤝 贡献代码
我们非常欢迎各种形式的贡献!无论是报告 Bug、提出新功能建议、改进文档还是提交代码你的每一份贡献都让这个项目变得更好。
**想要参与开发?请查看 [贡献指南](CONTRIBUTING.md) 了解详细的开发规范和流程。**
快速开始:
1. **报告 Bug**[提交 Issue](https://github.com/KuekHaoYang/KVideo/issues)
2. **功能建议**:在 Issues 中提出你的想法
3. **代码贡献**Fork → Branch → PR
4. **文档改进**:直接提交 PR
## 📄 许可证
本项目基于 [MIT 许可证](LICENSE) 开源。
## 🙏 致谢
感谢以下开源项目:
- [Next.js](https://nextjs.org/) - React 框架
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
- [Zustand](https://github.com/pmndrs/zustand) - 状态管理
- [React](https://react.dev/) - UI 库
## 📞 联系方式
- **作者**[KuekHaoYang](https://github.com/KuekHaoYang)
- **项目主页**[https://github.com/KuekHaoYang/KVideo](https://github.com/KuekHaoYang/KVideo)
- **问题反馈**[GitHub Issues](https://github.com/KuekHaoYang/KVideo/issues)
---
<div align="center">
Made with ❤️ by <a href="https://github.com/KuekHaoYang">KuekHaoYang</a>
<br>
如果这个项目对你有帮助,请考虑给一个 ⭐️
</div>

10
SECURITY.md Normal file
View File

@@ -0,0 +1,10 @@
# 安全策略 (Security Policy)
## 支持的版本
目前我们仅对项目的最新主分支 (`main`) 提供安全更新支持。
| 版本 | 支持状态 |
| :--- | :--- |
| `main` (最新版) | ✅ 支持 |
| 历史版本 | ❌ 不支持 |

View File

@@ -0,0 +1,136 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
// We still import this type but won't rely on the empty array
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
/**
* Shared handler for fetching content
*/
async function handleCategoryRequest(
sourceList: any[],
categoryParam: string,
page: number,
limit: number
) {
try {
const sourceMap = new Map<string, string>(); // sourceId -> typeId
if (categoryParam) {
const parts = categoryParam.split(',');
parts.forEach(part => {
if (part.includes(':')) {
const [sId, tId] = part.split(':');
sourceMap.set(sId, tId);
} else {
// Legacy: we can't guess without knowledge, but if we have sourceList we can try
const firstSource = sourceList.find(s => s.enabled);
if (firstSource) {
sourceMap.set(firstSource.id, part);
}
}
});
}
let targetSources = [];
if (sourceMap.size > 0) {
targetSources = sourceList.filter(s => sourceMap.has(s.id) && s.enabled);
} else {
targetSources = sourceList.filter(s => s.enabled);
}
if (targetSources.length === 0) {
return NextResponse.json({ videos: [], error: 'No enabled sources provided or found' }, { status: 500 });
}
const fetchPromises = targetSources.map(async (source: any) => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'detail');
url.searchParams.set('pg', page.toString());
if (sourceMap.has(source.id)) {
url.searchParams.set('t', sourceMap.get(source.id)!);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url.toString(), {
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) => ({
vod_id: item.vod_id,
vod_name: item.vod_name,
vod_pic: item.vod_pic,
vod_remarks: item.vod_remarks,
type_name: item.type_name,
source: source.id,
}));
} catch (error) {
console.error(`Failed to fetch from ${source.name}:`, error);
return [];
}
});
const results = await Promise.all(fetchPromises);
const interleavedVideos = [];
const maxLen = Math.max(...results.map(r => r.length));
for (let i = 0; i < maxLen; i++) {
for (let j = 0; j < results.length; j++) {
if (results[j][i]) {
interleavedVideos.push(results[j][i]);
}
}
}
return NextResponse.json({ videos: interleavedVideos });
} catch (error) {
console.error('Category content error:', error);
return NextResponse.json(
{ videos: [], error: 'Failed to fetch category content' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { sources, category, page, limit } = body;
// Use provided sources
return await handleCategoryRequest(
sources || [],
category || '',
parseInt(page || '1'),
parseInt(limit || '20')
);
} catch (error) {
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(ADULT_SOURCES, categoryParam, page, limit);
}

View File

@@ -0,0 +1,173 @@
import { NextResponse } from 'next/server';
import { ADULT_SOURCES } from '@/lib/api/adult-sources';
export const revalidate = 3600; // Cache for 1 hour
interface Category {
type_id: number;
type_name: string;
}
interface SourceCategories {
sourceId: string;
sourceName: string;
categories: Category[];
}
// Shared handler
async function handleTypesRequest(sourceList: any[]) {
try {
const enabledSources = sourceList.filter(s => s.enabled);
const results = await Promise.allSettled(
enabledSources.map(async (source: any) => {
try {
const url = new URL(source.baseUrl);
url.searchParams.set('ac', 'list');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5s timeout
const response = await fetch(url.toString(), {
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);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
return {
sourceId: source.id,
sourceName: source.name,
categories: data.class || []
};
} catch (error) {
console.error(`Failed to fetch categories from ${source.name}:`, error);
return null;
}
})
);
const allTags = [];
// Add "Recommend" tag first
allTags.push({
id: 'recommend',
label: '今日推荐',
value: ''
});
// Map to store merged categories: type_name -> Array of "sourceId:typeId"
// Using a more complex structure to support fuzzy matching
interface MergedCategory {
label: string;
values: string[];
}
const mergedCategories: MergedCategory[] = [];
// Helper to clean label for comparison (remove common noise words)
const cleanLabel = (label: string) => {
return label.replace(/[视频片区专]/g, '');
};
// Helper to check if two labels match fuzzily (>= 4 chars overlap)
const isFuzzyMatch = (label1: string, label2: string) => {
const clean1 = cleanLabel(label1);
const clean2 = cleanLabel(label2);
// If either is too short after cleaning, require exact match
if (clean1.length < 4 || clean2.length < 4) {
return clean1 === clean2;
}
// Check for 4+ char overlap
let overlapCount = 0;
const set1 = new Set(clean1.split(''));
for (const char of clean2) {
if (set1.has(char)) {
overlapCount++;
}
}
return overlapCount >= 4;
};
// Process results
results.forEach((result) => {
if (result.status === 'fulfilled' && result.value) {
const { sourceId, categories } = result.value;
categories.forEach((cat: Category) => {
const typeName = cat.type_name.trim();
// Skip empty type names
if (!typeName) return;
const value = `${sourceId}:${cat.type_id}`;
// Try to find a fuzzy match in existing categories
let matched = false;
for (const existing of mergedCategories) {
if (isFuzzyMatch(existing.label, typeName)) {
existing.values.push(value);
// Update label to the longer one if the new one is longer (usually more descriptive)
// Or keep the shorter one? Let's keep the one that is "cleaner" or just the first one.
// Let's stick to the first one for stability.
matched = true;
break;
}
}
if (!matched) {
mergedCategories.push({
label: typeName,
values: [value]
});
}
});
}
});
// Convert merged categories to tags
mergedCategories.forEach((cat) => {
// Skip categories with no values (shouldn't happen with current logic but good for safety)
if (cat.values.length === 0) return;
// Create a unique ID based on the label (using base64 to be safe)
const id = Buffer.from(cat.label).toString('base64');
allTags.push({
id,
label: cat.label,
value: cat.values.join(',') // Join multiple sources with comma
});
});
return NextResponse.json({ tags: allTags });
} catch (error) {
console.error('Failed to aggregate categories:', error);
return NextResponse.json(
{ tags: [], error: 'Failed to fetch categories' },
{ status: 500 }
);
}
}
export async function POST(request: Request) {
try {
const body = await request.json();
const { sources } = body;
return await handleTypesRequest(sources || []);
} catch (error) {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 });
}
}
export async function GET() {
return await handleTypesRequest(ADULT_SOURCES);
}

31
app/api/config/route.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Config API Route
* Exposes configuration status (never actual values) to the client
*/
import { NextRequest, NextResponse } from 'next/server';
export const runtime = 'edge';
const ACCESS_PASSWORD = process.env.ACCESS_PASSWORD || '';
export async function GET() {
return NextResponse.json({
hasEnvPassword: ACCESS_PASSWORD.length > 0,
});
}
export async function POST(request: NextRequest) {
try {
const { password } = await request.json();
if (!ACCESS_PASSWORD) {
return NextResponse.json({ valid: false, message: 'No env password set' });
}
const valid = password === ACCESS_PASSWORD;
return NextResponse.json({ valid });
} catch {
return NextResponse.json({ valid: false, message: 'Invalid request' }, { status: 400 });
}
}

112
app/api/detail/route.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* 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';
export const runtime = 'edge';
/**
* Shared handler for fetching video details
*/
async function handleDetailRequest(id: string | null, source: string | null, method: string) {
// Validate input
if (!id) {
return NextResponse.json(
{ error: 'Missing video ID parameter' },
{ 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);
}
if (!sourceConfig) {
return NextResponse.json(
{ error: 'Invalid source configuration' },
{ 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 }
);
}
}
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const id = searchParams.get('id');
const source = searchParams.get('source');
return await handleDetailRequest(id, source, 'GET');
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}
// Support POST method for complex requests
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { id, source } = body;
return await handleDetailRequest(id, source, 'POST');
} catch (error) {
console.error('Detail API error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
export const runtime = 'edge';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const tag = searchParams.get('tag') || '热门';
const pageLimit = searchParams.get('page_limit') || '20';
const pageStart = searchParams.get('page_start') || '0';
const type = searchParams.get('type') || 'movie'; // movie or tv
try {
const url = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${encodeURIComponent(tag)}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Referer': 'https://movie.douban.com/',
},
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!response.ok) {
throw new Error(`Douban API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Douban API error:', error);
return NextResponse.json(
{ subjects: [], error: 'Failed to fetch recommendations' },
{ status: 500 }
);
}
}

129
app/api/proxy/route.ts Normal file
View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { processM3u8Content } from '@/lib/utils/proxy-utils';
import { fetchWithRetry } from '@/lib/utils/fetch-with-retry';
export const runtime = 'edge';
// 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';
export async function GET(request: NextRequest) {
const url = request.nextUrl.searchParams.get('url');
if (!url) {
return new NextResponse('Missing URL parameter', { status: 400 });
}
try {
// Extract headers to forward (only essential ones)
const requestHeaders: Record<string, string> = {};
const forwardHeaders = ['cookie', 'range'];
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': '*',
}
}
);
}
}
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',
},
});
}

View File

@@ -0,0 +1,150 @@
/**
* Parallel Streaming Search API Route
* Searches all sources in parallel and streams results immediately as they arrive
* No waiting - results flow in real-time
*/
import { NextRequest } from 'next/server';
import { searchVideos } from '@/lib/api/client';
import { getSourceById } from '@/lib/api/video-sources';
import { getSourceName } from '@/lib/utils/source-names';
export const runtime = 'edge';
export async function POST(request: NextRequest) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
const body = await request.json();
const { query, sources: sourceConfigs, page = 1 } = body;
// Validate input
if (!query || typeof query !== 'string' || query.trim().length === 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: 'Invalid query'
})}\n\n`));
controller.close();
return;
}
// Use provided sources or fallback to empty (client should provide them)
const sources = Array.isArray(sourceConfigs) && sourceConfigs.length > 0
? sourceConfigs
: [];
if (sources.length === 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: 'No valid sources provided'
})}\n\n`));
controller.close();
return;
}
// Send initial status
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'start',
totalSources: sources.length
})}\n\n`));
// Track progress
let completedSources = 0;
let totalVideosFound = 0;
// Search all sources in PARALLEL - don't wait for all to finish
const searchPromises = sources.map(async (source: any) => {
const startTime = performance.now(); // Track start time
try {
// Search this source
const result = await searchVideos(query.trim(), [source], page);
const endTime = performance.now(); // Track end time
const latency = Math.round(endTime - startTime); // Calculate latency in ms
const videos = result[0]?.results || [];
completedSources++;
totalVideosFound += videos.length;
// Stream videos immediately as they arrive WITH latency data
if (videos.length > 0) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'videos',
videos: videos.map((video: any) => ({
...video,
sourceDisplayName: getSourceName(source.id),
latency, // Add latency to each video
})),
source: source.id,
completedSources,
totalSources: sources.length,
latency, // Also include at source level
})}\n\n`));
}
// Send progress update
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
completedSources,
totalSources: sources.length,
totalVideosFound
})}\n\n`));
} catch (error) {
const endTime = performance.now();
const latency = Math.round(endTime - startTime);
// Log error but continue with other sources
console.error(`[Search Parallel] Source ${source.id} failed after ${latency}ms:`, error);
completedSources++;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'progress',
completedSources,
totalSources: sources.length,
totalVideosFound
})}\n\n`));
}
});
// Wait for all sources to complete
await Promise.all(searchPromises);
// Send completion signal
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'complete',
totalVideosFound,
totalSources: sources.length
})}\n\n`));
controller.close();
} catch (error) {
console.error('Search error:', error);
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error'
})}\n\n`));
controller.close();
}
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}

29
app/globals.css Normal file
View File

@@ -0,0 +1,29 @@
@import "tailwindcss";
@import "./scroll-optimization.css";
@import "./styles/variables.css";
@import "./styles/base.css";
@import './styles/transitions.css';
@import './styles/effects.css';
@import './styles/glass.css';
@import "./styles/video-player.css";
@import "./styles/search-history.css";
@keyframes jiggle {
0% { transform: rotate(-1deg); }
50% { transform: rotate(1deg); }
100% { transform: rotate(-1deg); }
}
.animate-jiggle {
animation: jiggle 0.2s infinite;
}
/* Remove standard arrow for number input */
.no-spinner::-webkit-inner-spin-button,
.no-spinner::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.no-spinner {
-moz-appearance: textfield;
}

89
app/layout.tsx Normal file
View File

@@ -0,0 +1,89 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/ThemeProvider";
import { Analytics } from "@vercel/analytics/react";
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
import { PasswordGate } from "@/components/PasswordGate";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "KVideo - 视频聚合平台",
description: "Multi-source video aggregation platform with beautiful Liquid Glass UI",
icons: {
icon: '/icon.png',
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
>
<ThemeProvider>
<PasswordGate>
{children}
</PasswordGate>
<Analytics />
<ServiceWorkerRegister />
</ThemeProvider>
{/* ARIA Live Region for Screen Reader Announcements */}
<div
id="aria-live-announcer"
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
/>
{/* Scroll Performance Optimization Script */}
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
let scrollTimer;
const body = document.body;
function handleScroll() {
body.classList.add('scrolling');
clearTimeout(scrollTimer);
scrollTimer = setTimeout(function() {
body.classList.remove('scrolling');
}, 150);
}
let ticking = false;
window.addEventListener('scroll', function() {
if (!ticking) {
window.requestAnimationFrame(function() {
handleScroll();
ticking = false;
});
ticking = true;
}
}, { passive: true });
})();
`,
}}
/>
</body>
</html>
);
}

86
app/page.tsx Normal file
View File

@@ -0,0 +1,86 @@
'use client';
import { Suspense } from 'react';
import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults';
import { PopularFeatures } from '@/components/home/PopularFeatures';
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults';
import { useHomePage } from '@/lib/hooks/useHomePage';
function HomePage() {
const {
query,
hasSearched,
loading,
results,
availableSources,
completedSources,
totalSources,
handleSearch,
handleReset,
} = useHomePage();
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={{
transform: 'translate3d(0, 0, 0)',
zIndex: 1000
}}>
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
isLoading={loading}
initialQuery={query}
currentSource=""
checkedSources={completedSources}
totalSources={totalSources}
/>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Results Section */}
{(results.length >= 1 || (!loading && results.length > 0)) && (
<SearchResults
results={results}
availableSources={availableSources}
loading={loading}
/>
)}
{/* Popular Features - Homepage */}
{!loading && !hasSearched && <PopularFeatures onSearch={handleSearch} />}
{/* No Results */}
{!loading && hasSearched && results.length === 0 && (
<NoResults onReset={handleReset} />
)}
</main>
{/* Favorites Sidebar - Left */}
<FavoritesSidebar />
{/* Watch History Sidebar - Right */}
<WatchHistorySidebar />
</div>
);
}
export default function Home() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<HomePage />
</Suspense>
);
}

13
app/player/layout.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { Metadata } from 'next';
export const metadata: Metadata = {
referrer: 'no-referrer',
};
export default function PlayerLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}

178
app/player/page.tsx Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import { Suspense, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { VideoPlayer } from '@/components/player/VideoPlayer';
import { VideoMetadata } from '@/components/player/VideoMetadata';
import { EpisodeList } from '@/components/player/EpisodeList';
import { PlayerError } from '@/components/player/PlayerError';
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
import { useHistoryStore } from '@/lib/store/history-store';
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
import { PlayerNavbar } from '@/components/player/PlayerNavbar';
import Image from 'next/image';
function PlayerContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { addToHistory } = useHistoryStore();
const videoId = searchParams.get('id');
const source = searchParams.get('source');
const title = searchParams.get('title');
const episodeParam = searchParams.get('episode');
// Redirect if no video ID or source
if (!videoId || !source) {
router.push('/');
return null;
}
const {
videoData,
loading,
videoError,
currentEpisode,
playUrl,
setCurrentEpisode,
setPlayUrl,
setVideoError,
fetchVideoDetails,
} = useVideoPlayer(videoId, source, episodeParam);
// Add initial history entry when video data is loaded
useEffect(() => {
if (videoData && playUrl && videoId) {
// Map episodes to include index
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
name: ep.name || `${idx + 1}`,
url: ep.url,
index: idx,
})) || [];
addToHistory(
videoId,
videoData.vod_name || title || '未知视频',
playUrl,
currentEpisode,
source,
0, // Initial playback position
0, // Will be updated by VideoPlayer
videoData.vod_pic,
mappedEpisodes
);
}
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
const handleEpisodeClick = (episode: any, index: number) => {
setCurrentEpisode(index);
setPlayUrl(episode.url);
setVideoError('');
// Update URL to reflect current episode
const params = new URLSearchParams(searchParams.toString());
params.set('episode', index.toString());
router.replace(`/player?${params.toString()}`, { scroll: false });
};
// Handle auto-next episode
const handleNextEpisode = () => {
const episodes = videoData?.episodes;
if (!episodes || currentEpisode >= episodes.length - 1) return;
const nextIndex = currentEpisode + 1;
const nextEpisode = episodes[nextIndex];
if (nextEpisode) {
handleEpisodeClick(nextEpisode, nextIndex);
}
};
return (
<div className="min-h-screen bg-[var(--bg-color)]">
{/* Glass Navbar */}
<PlayerNavbar />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{loading ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent mb-4"></div>
<p className="text-[var(--text-color-secondary)]">...</p>
</div>
) : videoError && !videoData ? (
<PlayerError
error={videoError}
onBack={() => router.back()}
onRetry={fetchVideoDetails}
/>
) : (
<div className="grid lg:grid-cols-3 gap-6">
{/* Video Player Section */}
<div className="lg:col-span-2 space-y-6">
<VideoPlayer
playUrl={playUrl}
videoId={videoId || undefined}
currentEpisode={currentEpisode}
onBack={() => router.back()}
totalEpisodes={videoData?.episodes?.length || 1}
onNextEpisode={handleNextEpisode}
/>
<VideoMetadata
videoData={videoData}
source={source}
title={title}
/>
{/* Favorite Button for current video */}
{videoData && videoId && (
<div className="flex items-center gap-3 mt-4">
<FavoriteButton
videoId={videoId}
source={source}
title={videoData.vod_name || title || '未知视频'}
poster={videoData.vod_pic}
type={videoData.type_name}
year={videoData.vod_year}
size={20}
/>
<span className="text-sm text-[var(--text-color-secondary)]">
</span>
</div>
)}
</div>
{/* Episodes Sidebar */}
<div className="lg:col-span-1">
<EpisodeList
episodes={videoData?.episodes || null}
currentEpisode={currentEpisode}
onEpisodeClick={handleEpisodeClick}
/>
</div>
</div>
)}
</main>
{/* Favorites Sidebar - Left */}
<FavoritesSidebar />
{/* Watch History Sidebar - Right */}
<WatchHistorySidebar />
</div>
);
}
export default function PlayerPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-color)]">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<PlayerContent />
</Suspense>
);
}

View File

@@ -0,0 +1,94 @@
/**
* Performance Optimization CSS
* Improves scrolling performance for video grids
*/
/* Enable smooth scrolling with hardware acceleration */
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Optimize scrolling container */
.video-grid-container {
/* Use momentum scrolling on iOS */
-webkit-overflow-scrolling: touch;
/* Force GPU layer for scrolling */
transform: translate3d(0, 0, 0);
will-change: auto;
}
/* Optimize video cards for rendering */
.video-card-wrapper {
/* CSS containment for isolation */
contain: layout style paint;
/* Content visibility for lazy rendering */
content-visibility: auto;
/* Reduce layout shifts */
contain-intrinsic-size: auto 400px;
}
/* Reduce repaints on images */
.video-card-image {
/* Force GPU rendering */
transform: translate3d(0, 0, 0);
/* Prevent unnecessary repaints */
will-change: auto;
/* Optimize image decoding */
image-rendering: auto;
}
/* Optimize badges and overlays */
.video-card-badge {
/* Force GPU layer */
transform: translate3d(0, 0, 0);
/* No backdrop filter during scroll */
will-change: auto;
}
/* Disable expensive effects during scroll */
@media (prefers-reduced-motion: no-preference) {
.video-card:not(:hover) .expensive-effect {
/* Disable blur effects when not hovering */
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* Optimize for mobile devices */
@media (max-width: 768px) {
/* Reduce visual complexity on mobile */
.video-card {
/* Simpler rendering */
will-change: auto;
}
/* Disable expensive hover effects on mobile */
.video-card-overlay {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* Grid optimization */
.optimized-grid {
/* Grid-specific containment */
contain: layout style;
/* Prevent layout thrashing */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
/* Passive event listeners hint */
html {
/* Hint to browser for passive touch events */
touch-action: pan-y;
}

85
app/secret/page.tsx Normal file
View File

@@ -0,0 +1,85 @@
'use client';
import { Suspense } from 'react';
import { SearchForm } from '@/components/search/SearchForm';
import { NoResults } from '@/components/search/NoResults';
import { Navbar } from '@/components/layout/Navbar';
import { SearchResults } from '@/components/home/SearchResults';
import { useSecretHomePage } from '@/lib/hooks/useSecretHomePage';
import { AdultContent } from '@/components/adult/AdultContent';
import { FavoritesSidebar } from '@/components/favorites/FavoritesSidebar';
function SecretHomePage() {
const {
query,
hasSearched,
loading,
results,
availableSources,
completedSources,
totalSources,
handleSearch,
handleReset,
} = useSecretHomePage();
return (
<div className="min-h-screen bg-black">
{/* Glass Navbar */}
<Navbar onReset={handleReset} isSecretMode={true} />
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
transform: 'translate3d(0, 0, 0)',
zIndex: 1000
}}>
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
isLoading={loading}
initialQuery={query}
currentSource=""
checkedSources={completedSources}
totalSources={totalSources}
placeholder="输入关键词开始搜索..."
/>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Results Section */}
{(results.length >= 1 || (!loading && results.length > 0)) && (
<SearchResults
results={results}
availableSources={availableSources}
loading={loading}
/>
)}
{/* No Results */}
{!loading && hasSearched && results.length === 0 && (
<NoResults onReset={handleReset} />
)}
{/* Adult Content - Trending and Latest */}
{!loading && !hasSearched && (
<AdultContent onSearch={handleSearch} />
)}
</main>
{/* Favorites Sidebar - Left */}
<FavoritesSidebar />
</div>
);
}
export default function SecretPage() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-black">
<div className="animate-spin rounded-full h-16 w-16 border-4 border-[var(--accent-color)] border-t-transparent"></div>
</div>
}>
<SecretHomePage />
</Suspense>
);
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { settingsStore, getDefaultAdultSources, type SortOption } from '@/lib/store/settings-store';
import type { VideoSource } from '@/lib/types';
export function useSecretSettingsPage() {
const [adultSources, setAdultSources] = useState<VideoSource[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('default');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
useEffect(() => {
const settings = settingsStore.getSettings();
setAdultSources(settings.adultSources || []);
setSortBy(settings.sortBy);
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
setAdultSources(newSources);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
adultSources: newSources,
});
};
const handleAddSource = (source: VideoSource) => {
const exists = adultSources.some(s => s.id === source.id);
const updated = exists
? adultSources.map(s => s.id === source.id ? source : s)
: [...adultSources, source];
handleSourcesChange(updated);
setEditingSource(null);
};
const handleEditSource = (source: VideoSource) => {
setEditingSource(source);
setIsAddModalOpen(true);
};
const handleRestoreDefaults = () => {
const defaults = getDefaultAdultSources();
handleSourcesChange(defaults);
setIsRestoreDefaultsDialogOpen(false);
};
return {
adultSources,
sortBy,
isAddModalOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsRestoreDefaultsDialogOpen,
setEditingSource,
handleSourcesChange,
handleAddSource,
handleRestoreDefaults,
editingSource,
handleEditSource,
};
}

View File

@@ -0,0 +1,85 @@
'use client';
import { AddSourceModal } from '@/components/settings/AddSourceModal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { AdultSourceSettings } from '@/components/settings/AdultSourceSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { useSecretSettingsPage } from './hooks/useSecretSettingsPage';
import Link from 'next/link';
export default function SecretSettingsPage() {
const {
adultSources,
isAddModalOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsRestoreDefaultsDialogOpen,
handleSourcesChange,
handleAddSource,
handleRestoreDefaults,
editingSource,
handleEditSource,
setEditingSource,
} = useSecretSettingsPage();
return (
<div className="min-h-screen bg-black">
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
{/* Custom Header for Secret Settings */}
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/secret"
className="w-10 h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="返回"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</Link>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]"></h1>
<p className="text-sm text-[var(--text-color-secondary)]"></p>
</div>
</div>
</div>
</div>
{/* Adult Source Management */}
<AdultSourceSettings
sources={adultSources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
</div>
{/* Modals */}
<AddSourceModal
isOpen={isAddModalOpen}
onClose={() => {
setIsAddModalOpen(false);
setEditingSource(null);
}}
onAdd={handleAddSource}
existingIds={adultSources.map(s => s.id)}
initialValues={editingSource}
/>
<ConfirmDialog
isOpen={isRestoreDefaultsDialogOpen}
title="恢复默认成人源"
message="这将重置所有成人源为默认配置。自定义源将被删除。是否继续?"
confirmText="恢复"
cancelText="取消"
onConfirm={handleRestoreDefaults}
onCancel={() => setIsRestoreDefaultsDialogOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,301 @@
import { useState, useEffect } from 'react';
import { settingsStore, getDefaultSources, type SortOption } from '@/lib/store/settings-store';
import type { VideoSource, SourceSubscription } from '@/lib/types';
import {
type ImportResult,
mergeSources,
parseSourcesFromJson,
fetchSourcesFromUrl
} from '@/lib/utils/source-import-utils';
export function useSettingsPage() {
const [sources, setSources] = useState<VideoSource[]>([]);
const [subscriptions, setSubscriptions] = useState<SourceSubscription[]>([]);
const [sortBy, setSortBy] = useState<SortOption>('default');
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isResetDialogOpen, setIsResetDialogOpen] = useState(false);
const [isRestoreDefaultsDialogOpen, setIsRestoreDefaultsDialogOpen] = useState(false);
const [editingSource, setEditingSource] = useState<VideoSource | null>(null);
const [passwordAccess, setPasswordAccess] = useState(false);
const [accessPasswords, setAccessPasswords] = useState<string[]>([]);
const [envPasswordSet, setEnvPasswordSet] = useState(false);
useEffect(() => {
const settings = settingsStore.getSettings();
setSources(settings.sources || []);
setSubscriptions(settings.subscriptions || []);
setSortBy(settings.sortBy);
setPasswordAccess(settings.passwordAccess);
setAccessPasswords(settings.accessPasswords);
// Fetch env password status
fetch('/api/config')
.then(res => res.json())
.then(data => setEnvPasswordSet(data.hasEnvPassword))
.catch(() => setEnvPasswordSet(false));
}, []);
const handleSourcesChange = (newSources: VideoSource[]) => {
setSources(newSources);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources: newSources,
sortBy,
subscriptions,
searchHistory: true,
watchHistory: true,
passwordAccess,
accessPasswords
});
};
const handleAddSource = (source: VideoSource) => {
const exists = sources.some(s => s.id === source.id);
const updated = exists
? sources.map(s => s.id === source.id ? source : s)
: [...sources, source];
handleSourcesChange(updated);
setEditingSource(null);
};
const handleEditSource = (source: VideoSource) => {
setEditingSource(source);
setIsAddModalOpen(true);
};
const handleSortChange = (newSort: SortOption) => {
setSortBy(newSort);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy: newSort,
searchHistory: true,
watchHistory: true,
passwordAccess,
accessPasswords
});
};
const handlePasswordToggle = (enabled: boolean) => {
setPasswordAccess(enabled);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
watchHistory: true,
passwordAccess: enabled,
accessPasswords
});
};
const handleAddPassword = (password: string) => {
const updated = [...accessPasswords, password];
setAccessPasswords(updated);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
watchHistory: true,
passwordAccess,
accessPasswords: updated
});
};
const handleRemovePassword = (password: string) => {
const updated = accessPasswords.filter(p => p !== password);
setAccessPasswords(updated);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
sources,
sortBy,
searchHistory: true,
watchHistory: true,
passwordAccess,
accessPasswords: updated
});
};
const handleExport = (includeSearchHistory: boolean, includeWatchHistory: boolean) => {
const data = settingsStore.exportSettings(includeSearchHistory || includeWatchHistory);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `kvideo-settings-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleImportFile = (jsonString: string): boolean => {
// 1. Try to import as full settings backup
const asBackupSuccess = settingsStore.importSettings(jsonString);
if (asBackupSuccess) {
const settings = settingsStore.getSettings();
setSources(settings.sources);
setSortBy(settings.sortBy);
setSubscriptions(settings.subscriptions || []);
setPasswordAccess(settings.passwordAccess);
setAccessPasswords(settings.accessPasswords);
return true;
}
// 2. Try to import as source list (JSON format)
try {
const result = parseSourcesFromJson(jsonString);
if (result.totalCount > 0) {
return handleImportLink(result, false); // Reuse link import logic
}
} catch {
return false;
}
return false;
};
const handleImportLink = (result: ImportResult, isSync: boolean = false): boolean => {
try {
// Merge normal sources
let updatedSources = mergeSources(sources, result.normalSources);
// Merge adult sources if needed
const currentSettings = settingsStore.getSettings();
let updatedAdultSources = mergeSources(currentSettings.adultSources, result.adultSources);
// Save everything
settingsStore.saveSettings({
...currentSettings,
sources: updatedSources,
adultSources: updatedAdultSources,
});
setSources(updatedSources); // Update local state
// If strictly creating/editing subscription, we don't reload page usually, but here we might want to refresh UI
if (!isSync) {
setTimeout(() => window.location.reload(), 1000);
}
return true;
} catch (e) {
console.error("Import error:", e);
return false;
}
};
// Subscription Handlers
const handleAddSubscription = async (sub: SourceSubscription): Promise<boolean> => {
// Verify we can fetch it
try {
const result = await fetchSourcesFromUrl(sub.url);
// Import the content
handleImportLink(result, true);
// Add subscription to store
const newSubscriptions = [...subscriptions, sub];
setSubscriptions(newSubscriptions);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
subscriptions: newSubscriptions
});
return true;
} catch (e) {
console.error(e);
throw new Error('无法连接到订阅链接或格式错误');
}
};
const handleRemoveSubscription = (id: string) => {
const newSubscriptions = subscriptions.filter(s => s.id !== id);
setSubscriptions(newSubscriptions);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
subscriptions: newSubscriptions
});
};
const handleRefreshSubscription = async (sub: SourceSubscription) => {
try {
const result = await fetchSourcesFromUrl(sub.url);
handleImportLink(result, true);
// Update last updated timestamp
const updatedSubscriptions = subscriptions.map(s =>
s.id === sub.id ? { ...s, lastUpdated: Date.now() } : s
);
setSubscriptions(updatedSubscriptions);
const currentSettings = settingsStore.getSettings();
settingsStore.saveSettings({
...currentSettings,
subscriptions: updatedSubscriptions
});
} catch (e) {
console.error(e);
// Optionally notify user of failure
}
};
const handleRestoreDefaults = () => {
const defaults = getDefaultSources();
handleSourcesChange(defaults);
setIsRestoreDefaultsDialogOpen(false);
};
const handleResetAll = () => {
settingsStore.resetToDefaults();
setIsResetDialogOpen(false);
window.location.reload();
};
return {
sources,
subscriptions,
sortBy,
passwordAccess,
accessPasswords,
envPasswordSet,
isAddModalOpen,
isExportModalOpen,
isImportModalOpen,
isResetDialogOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsExportModalOpen,
setIsImportModalOpen,
setIsResetDialogOpen,
setIsRestoreDefaultsDialogOpen,
setEditingSource,
handleSourcesChange,
handleAddSource,
handleSortChange,
handlePasswordToggle,
handleAddPassword,
handleRemovePassword,
handleExport,
handleImportFile, // Renamed from handleImport
handleImportLink, // New
handleAddSubscription, // New
handleRemoveSubscription, // New
handleRefreshSubscription, // New
handleRestoreDefaults,
handleResetAll,
editingSource,
handleEditSource,
};
}

146
app/settings/page.tsx Normal file
View File

@@ -0,0 +1,146 @@
'use client';
import { Suspense } from 'react';
import { AddSourceModal } from '@/components/settings/AddSourceModal';
import { ExportModal } from '@/components/settings/ExportModal';
import { ImportModal } from '@/components/settings/ImportModal';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { SourceSettings } from '@/components/settings/SourceSettings';
import { SortSettings } from '@/components/settings/SortSettings';
import { DataSettings } from '@/components/settings/DataSettings';
import { PasswordSettings } from '@/components/settings/PasswordSettings';
import { SettingsHeader } from '@/components/settings/SettingsHeader';
import { useSettingsPage } from './hooks/useSettingsPage';
export default function SettingsPage() {
const {
sources,
sortBy,
passwordAccess,
accessPasswords,
envPasswordSet,
isAddModalOpen,
isExportModalOpen,
isImportModalOpen,
isResetDialogOpen,
isRestoreDefaultsDialogOpen,
setIsAddModalOpen,
setIsExportModalOpen,
setIsImportModalOpen,
setIsResetDialogOpen,
setIsRestoreDefaultsDialogOpen,
handleSourcesChange,
handleAddSource,
handleSortChange,
handlePasswordToggle,
handleAddPassword,
handleRemovePassword,
handleExport,
handleImportFile,
handleImportLink,
subscriptions,
handleAddSubscription,
handleRemoveSubscription,
handleRefreshSubscription,
handleRestoreDefaults,
handleResetAll,
editingSource,
handleEditSource,
setEditingSource,
} = useSettingsPage();
return (
<div className="min-h-screen bg-[var(--bg-color)] bg-[image:var(--bg-image)] bg-fixed">
<div className="container mx-auto px-4 py-8 max-w-4xl space-y-8">
{/* Header */}
<SettingsHeader />
{/* Password Settings */}
<PasswordSettings
enabled={passwordAccess}
passwords={accessPasswords}
envPasswordSet={envPasswordSet}
onToggle={handlePasswordToggle}
onAdd={handleAddPassword}
onRemove={handleRemovePassword}
/>
{/* Source Management */}
<SourceSettings
sources={sources}
onSourcesChange={handleSourcesChange}
onRestoreDefaults={() => setIsRestoreDefaultsDialogOpen(true)}
onAddSource={() => {
setEditingSource(null);
setIsAddModalOpen(true);
}}
onEditSource={handleEditSource}
/>
{/* Sort Options */}
<SortSettings
sortBy={sortBy}
onSortChange={handleSortChange}
/>
{/* Data Management */}
<DataSettings
onExport={() => setIsExportModalOpen(true)}
onImport={() => setIsImportModalOpen(true)}
onReset={() => setIsResetDialogOpen(true)}
/>
</div>
{/* Modals */}
<AddSourceModal
isOpen={isAddModalOpen}
onClose={() => {
setIsAddModalOpen(false);
setEditingSource(null);
}}
onAdd={handleAddSource}
existingIds={sources.map(s => s.id)}
initialValues={editingSource}
/>
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
onExport={handleExport}
/>
<ImportModal
isOpen={isImportModalOpen}
onClose={() => setIsImportModalOpen(false)}
onImportFile={handleImportFile}
onImportLink={handleImportLink}
subscriptions={subscriptions}
onAddSubscription={handleAddSubscription}
onRemoveSubscription={handleRemoveSubscription}
onRefreshSubscription={handleRefreshSubscription}
/>
<ConfirmDialog
isOpen={isRestoreDefaultsDialogOpen}
title="恢复默认源"
message="这将重置所有视频源为默认配置。自定义源将被删除。是否继续?"
confirmText="恢复"
cancelText="取消"
onConfirm={handleRestoreDefaults}
onCancel={() => setIsRestoreDefaultsDialogOpen(false)}
/>
<ConfirmDialog
isOpen={isResetDialogOpen}
title="清除所有数据"
message="这将删除所有设置、历史记录、Cookie 和缓存。此操作不可撤销。是否继续?"
confirmText="清除"
cancelText="取消"
onConfirm={handleResetAll}
onCancel={() => setIsResetDialogOpen(false)}
dangerous
/>
</div>
);
}

147
app/styles/base.css Normal file
View File

@@ -0,0 +1,147 @@
/* Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Global Pointer Cursor for Interactive Elements */
button,
[role="button"],
[type="button"],
[type="submit"],
[type="reset"],
a[href],
label[for],
select,
.cursor-pointer {
cursor: pointer;
}
html {
scroll-behavior: auto;
}
/* Focus Styles */
*:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
border-radius: var(--radius-2xl);
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent-color);
outline-offset: 2px;
}
[class*="rounded-full"]:focus-visible {
border-radius: var(--radius-full);
}
/* Scrollbar Styles */
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--accent-color) 60%, transparent);
background-clip: padding-box;
}
.search-history-dropdown::-webkit-scrollbar {
width: 6px;
}
.search-history-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.search-history-dropdown::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--glass-bg) 80%, transparent);
border-radius: var(--radius-full);
}
/* Utilities */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Performance Optimizations */
nav:where(.sticky, .fixed),
.sticky,
.fixed {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
transform: translateZ(0);
}
/* [class*="grid"]>* {
content-visibility: auto;
contain-intrinsic-size: auto 400px;
} */
[class*="grid"]>a {
animation: fadeInUp 0.2s ease-out;
}
img {
content-visibility: auto;
transform: translateZ(0);
}
body.scrolling * {
pointer-events: none !important;
}
.group:hover img,
.glass-card,
nav {
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
[class*="badge"],
[class*="Badge"] {
contain: layout style;
}
.search-history-dropdown {
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}

85
app/styles/effects.css Normal file
View File

@@ -0,0 +1,85 @@
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes spin-slow {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-reverse {
from {
transform: rotate(360deg);
}
to {
transform: rotate(0deg);
}
}
@keyframes bounce-subtle {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0) translateX(0);
opacity: 0.3;
}
50% {
transform: translateY(-20px) translateX(10px);
opacity: 0.8;
}
}
@keyframes gradient-x {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}

65
app/styles/glass.css Normal file
View File

@@ -0,0 +1,65 @@
/* Glass Components */
.glass-card {
background: var(--bg-color);
opacity: 1;
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-sm);
border: 1px solid var(--glass-border);
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out;
transform: translateZ(0);
will-change: transform;
}
.glass-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.glass-input {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-2xl);
color: var(--text-color);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.glass-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-color) 30%, transparent);
}
.glass-button {
background: var(--accent-color);
color: white;
border: none;
border-radius: var(--radius-2xl);
padding: 0.75rem 1.25rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: var(--shadow-sm);
}
.glass-button:hover {
transform: translateY(-2px);
filter: brightness(1.1);
box-shadow: var(--shadow-md);
}
.glass-button:active {
transform: translateY(0) scale(0.98);
filter: brightness(0.95);
}
.glass-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.8rem;
font-weight: 600;
background-color: var(--accent-color);
color: white;
}

View File

@@ -0,0 +1,98 @@
/* Search History Dropdown */
.search-history-dropdown {
max-height: 400px;
overflow-y: auto;
background: var(--bg-color);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-md);
border: 1px solid var(--glass-border);
padding: 0.75rem;
opacity: 0;
transform: translateY(-10px) scale(0.95);
animation: search-dropdown-appear 0.2s ease-out forwards;
transform-origin: top center;
user-select: none;
-webkit-user-select: none;
will-change: transform, opacity;
}
.search-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
margin-bottom: 0.5rem;
}
.search-history-divider {
height: 1px;
width: 100%;
background: var(--glass-border);
margin: 0.5rem 0;
}
.search-history-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.search-history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
border-radius: var(--radius-2xl);
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s ease;
background: transparent;
}
.search-history-item:hover {
background: color-mix(in srgb, var(--accent-color) 10%, transparent);
}
.search-history-item.highlighted {
background: color-mix(in srgb, var(--accent-color) 15%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-color) 30%, transparent);
}
.search-history-remove {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: transparent;
border: none;
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
z-index: 1;
}
.search-history-remove:hover {
background: color-mix(in srgb, var(--text-color-secondary) 20%, transparent);
color: var(--text-color);
transform: scale(1.1);
}
.search-history-remove:active {
transform: scale(0.95);
}
/* Animations */
@keyframes search-dropdown-appear {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}

View File

@@ -0,0 +1,91 @@
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scale-in {
0% {
opacity: 0;
transform: scale(0.9) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes scale-out {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.9) translateY(-10px);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

78
app/styles/variables.css Normal file
View File

@@ -0,0 +1,78 @@
/* CSS Custom Properties */
:root {
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif;
/* Light Mode Palette */
--bg-color-light: #f2f4f7;
--bg-image-light: none;
--text-color-light: #1d1d1f;
--text-color-secondary-light: #6e6e73;
--accent-color-light: #0056b3;
--glass-bg-light: rgba(255, 255, 255, 0.95);
--glass-border-light: rgba(0, 0, 0, 0.05);
--shadow-color-light: rgba(0, 0, 0, 0.05);
/* Dark Mode Palette */
--bg-color-dark: #121212;
--bg-image-dark: linear-gradient(120deg, #1a1a1a 0%, #121212 100%);
--text-color-dark: #f5f5f7;
--text-color-secondary-dark: #8e8e93;
--accent-color-dark: #1A6DBF;
--glass-bg-dark: rgba(30, 30, 30, 0.9);
--glass-border-dark: rgba(255, 255, 255, 0.1);
--shadow-color-dark: rgba(0, 0, 0, 0.3);
/* Universal Variables */
--radius-2xl: 1.5rem;
--radius-full: 9999px;
--shadow-sm: 0 1px 2px var(--shadow-color);
--shadow-md: 0 4px 6px var(--shadow-color);
--transition-fluid: 0.3s ease;
/* Active Theme Variables */
--bg-color: var(--bg-color-light);
--bg-image: var(--bg-image-light);
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
--background: #f2f4f7;
--foreground: #1d1d1f;
}
body {
--bg-color: var(--bg-color-light);
--bg-image: var(--bg-image-light);
--text-color: var(--text-color-light);
--text-color-secondary: var(--text-color-secondary-light);
--accent-color: var(--accent-color-light);
--glass-bg: var(--glass-bg-light);
--glass-border: var(--glass-border-light);
--shadow-color: var(--shadow-color-light);
background-color: var(--bg-color);
background-image: var(--bg-image);
color: var(--text-color);
font-family: var(--font-family-system);
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: none;
}
body.dark,
.dark body {
--bg-color: var(--bg-color-dark);
--bg-image: var(--bg-image-dark);
--text-color: var(--text-color-dark);
--text-color-secondary: var(--text-color-secondary-dark);
--accent-color: var(--accent-color-dark);
--glass-bg: var(--glass-bg-dark);
--glass-border: var(--glass-border-dark);
--shadow-color: var(--shadow-color-dark);
--background: #121212;
--foreground: #f5f5f7;
}

367
app/styles/video-player.css Normal file
View File

@@ -0,0 +1,367 @@
/* Video Player Components */
.spinner {
width: 48px;
height: 48px;
border: 5px solid color-mix(in srgb, var(--glass-bg) 50%, transparent);
border-top-color: var(--accent-color);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: var(--radius-2xl);
color: white;
cursor: pointer;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
position: relative;
z-index: 10;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.btn-icon:active {
transform: scale(0.95);
}
.slider-track {
position: relative;
width: 100%;
height: 8px;
background: rgba(255, 255, 255, 0.3);
border-radius: var(--radius-full);
cursor: pointer;
overflow: visible;
user-select: none;
-webkit-user-select: none;
}
.slider-track.h-1 {
height: 4px;
}
.slider-range {
position: absolute;
left: 0;
top: 0;
height: 100%;
background-color: var(--accent-color);
border-radius: var(--radius-full);
pointer-events: none;
}
.slider-thumb {
position: absolute;
top: 50%;
width: 16px;
height: 16px;
background-color: white;
border-radius: var(--radius-full);
transform: translate(-50%, -50%);
cursor: grab;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
pointer-events: none;
}
.slider-track.h-1 .slider-thumb {
width: 12px;
height: 12px;
}
.slider-track:hover .slider-thumb {
transform: translate(-50%, -50%) scale(1.2);
}
.slider-thumb:active,
.slider-track:active .slider-thumb {
transform: translate(-50%, -50%) scale(1.3);
cursor: grabbing;
}
/* ============================================
LIQUID GLASS VIDEO PLAYER STYLES
============================================ */
/* Glass Controls Bar */
.player-controls-glass {
background: rgba(28, 28, 30, 0.75);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border-radius: var(--radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
position: relative;
z-index: 25;
/* Above loading overlay */
}
.player-controls-glass:hover {
background: rgba(28, 28, 30, 0.85);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
/* Glass Button Style */
.btn-glass {
display: flex;
align-items: center;
justify-content: center;
min-width: 2.5rem;
height: 2.5rem;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-2xl);
color: white;
cursor: pointer;
transition: all 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
.btn-glass:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px) scale(1.05);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
border-color: rgba(255, 255, 255, 0.25);
}
.btn-glass:active {
transform: translateY(0) scale(0.95);
background: rgba(255, 255, 255, 0.15);
}
/* Loading Overlay with Glass Effect */
.loading-overlay-glass {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
z-index: 20;
transition: opacity 0.3s ease;
pointer-events: none;
/* Allow interaction with controls during loading */
}
/* Spinner with Glass Background */
.spinner-glass {
width: 56px;
height: 56px;
border: 4px solid rgba(255, 255, 255, 0.15);
border-top-color: var(--accent-color);
border-radius: var(--radius-full);
animation: spin 1s cubic-bezier(0.4, 0, 0.2, 1) infinite;
box-shadow: 0 0 20px rgba(0, 122, 255, 0.3);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Mode Badge Styles */
.badge-proxy {
background: rgba(255, 149, 0, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 8px rgba(255, 149, 0, 0.3);
}
.badge-direct {
background: rgba(52, 199, 89, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 8px rgba(52, 199, 89, 0.3);
}
/* Toast Notification */
.player-toast {
background: rgba(28, 28, 30, 0.9);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border-radius: var(--radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
padding: 0.75rem 1.25rem;
color: white;
font-size: 0.875rem;
font-weight: 500;
animation: toast-slide-in 0.4s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes toast-slide-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Error Container */
.player-error-glass {
background: rgba(28, 28, 30, 0.85);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border-radius: var(--radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
padding: 2rem;
max-width: 400px;
text-align: center;
}
.player-error-glass .error-icon {
width: 64px;
height: 64px;
margin: 0 auto 1rem;
color: #ff3b30;
filter: drop-shadow(0 0 10px rgba(255, 59, 48, 0.4));
}
.player-error-glass h3 {
font-size: 1.25rem;
font-weight: 600;
color: white;
margin-bottom: 0.5rem;
}
.player-error-glass p {
color: rgba(255, 255, 255, 0.7);
font-size: 0.875rem;
margin-bottom: 1.5rem;
line-height: 1.5;
}
/* Menu Dropdown/Popover */
.player-menu-glass {
background: rgba(28, 28, 30, 0.9);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border-radius: var(--radius-2xl);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
padding: 0.5rem;
min-width: 180px;
animation: menu-appear 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
}
@keyframes menu-appear {
from {
opacity: 0;
transform: translateY(-8px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.player-menu-glass .menu-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
color: white;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
}
.player-menu-glass .menu-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.player-menu-glass .menu-item.active {
background: rgba(0, 122, 255, 0.2);
color: var(--accent-color);
}
.player-menu-glass .menu-divider {
height: 1px;
background: rgba(255, 255, 255, 0.1);
margin: 0.5rem 0;
}
/* Skip Indicator */
.skip-indicator-glass {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: var(--radius-2xl);
padding: 1rem 1.5rem;
color: white;
font-weight: 600;
animation: skip-pulse 0.3s ease-out;
}
@keyframes skip-pulse {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Focus States for Accessibility */
.btn-glass:focus-visible,
.btn-icon:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.5);
}
/* Progress Bar Enhanced */
.progress-bar-glass {
background: rgba(255, 255, 255, 0.2);
border-radius: var(--radius-full);
overflow: hidden;
cursor: pointer;
transition: height 0.2s ease;
}
.progress-bar-glass:hover {
height: 8px;
}
.progress-bar-glass .progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-color), #5ac8fa);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}

172
components/PasswordGate.tsx Normal file
View File

@@ -0,0 +1,172 @@
'use client';
import { useState, useEffect } from 'react';
import { settingsStore } from '@/lib/store/settings-store';
import { Lock } from 'lucide-react';
const SESSION_UNLOCKED_KEY = 'kvideo-unlocked';
export function PasswordGate({ children }: { children: React.ReactNode }) {
const [isLocked, setIsLocked] = useState(true);
const [password, setPassword] = useState('');
const [error, setError] = useState(false);
const [isClient, setIsClient] = useState(false);
const [hasEnvPassword, setHasEnvPassword] = useState(false);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
setIsClient(true);
checkEnvPasswordStatus();
checkLockStatus();
}, []);
const checkEnvPasswordStatus = async () => {
try {
const res = await fetch('/api/config');
const data = await res.json();
setHasEnvPassword(data.hasEnvPassword);
} catch {
// Silently fail - env password not available
}
};
const checkLockStatus = async () => {
const settings = settingsStore.getSettings();
// Check if env password is set
try {
const res = await fetch('/api/config');
const data = await res.json();
const envPasswordSet = data.hasEnvPassword;
// Lock if either local or env password is enabled
if (!settings.passwordAccess && !envPasswordSet) {
setIsLocked(false);
return;
}
} catch {
// If API fails, just check local settings
if (!settings.passwordAccess) {
setIsLocked(false);
return;
}
}
const isUnlocked = sessionStorage.getItem(SESSION_UNLOCKED_KEY) === 'true';
if (isUnlocked) {
setIsLocked(false);
} else {
setIsLocked(true);
}
};
const handleUnlock = async (e: React.FormEvent) => {
e.preventDefault();
setIsValidating(true);
const settings = settingsStore.getSettings();
// First check local passwords
if (settings.accessPasswords.includes(password)) {
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setIsLocked(false);
setError(false);
setIsValidating(false);
return;
}
// Then check env password via API
if (hasEnvPassword) {
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
const data = await res.json();
if (data.valid) {
sessionStorage.setItem(SESSION_UNLOCKED_KEY, 'true');
setIsLocked(false);
setError(false);
setIsValidating(false);
return;
}
} catch {
// API error, proceed to show error
}
}
// Password didn't match
setError(true);
setIsValidating(false);
const form = document.getElementById('password-form');
form?.classList.add('animate-shake');
setTimeout(() => form?.classList.remove('animate-shake'), 500);
};
if (!isClient) return null; // Prevent hydration mismatch
if (!isLocked) {
return <>{children}</>;
}
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-[var(--bg-color)] bg-[image:var(--bg-image)] text-[var(--text-color)]">
<div className="w-full max-w-md p-4">
<form
id="password-form"
onSubmit={handleUnlock}
className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] p-8 shadow-[var(--shadow-md)] flex flex-col items-center gap-6 transition-all duration-[0.4s] cubic-bezier(0.2,0.8,0.2,1)"
>
<div className="w-16 h-16 rounded-[var(--radius-full)] bg-[var(--accent-color)]/10 flex items-center justify-center text-[var(--accent-color)] mb-2 shadow-[var(--shadow-sm)] border border-[var(--glass-border)]">
<Lock size={32} />
</div>
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold">访</h2>
<p className="text-[var(--text-color-secondary)]">访</p>
</div>
<div className="w-full space-y-4">
<div className="space-y-2">
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError(false);
}}
placeholder="输入密码..."
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)]`}
autoFocus
/>
{error && (
<p className="text-sm text-red-500 text-center animate-pulse">
</p>
)}
</div>
<button
type="submit"
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"
>
访
</button>
</div>
</form>
</div>
<style jsx global>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.animate-shake {
animation: shake 0.3s cubic-bezier(.36,.07,.19,.97) both;
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useEffect, useState, useRef, useCallback } from 'react';
interface SearchLoadingAnimationProps {
currentSource?: string;
checkedSources?: number;
totalSources?: number;
isPaused?: boolean;
onComplete?: (checkedSources: number, totalSources: number) => void;
}
export function SearchLoadingAnimation({
currentSource,
checkedSources = 0,
totalSources = 16,
isPaused = false,
onComplete,
}: SearchLoadingAnimationProps) {
const [dots, setDots] = useState('');
const dotIntervalRef = useRef<NodeJS.Timeout | null>(null);
const hasCalledComplete = useRef(false);
// Calculate progress (0-100%)
const progress = totalSources > 0 ? (checkedSources / totalSources) * 100 : 0;
const isComplete = progress >= 100;
// Animation pause/resume logic - Optimized interval
useEffect(() => {
if (isPaused || isComplete) {
if (dotIntervalRef.current) {
clearInterval(dotIntervalRef.current);
dotIntervalRef.current = null;
}
return;
}
dotIntervalRef.current = setInterval(() => {
setDots((prev) => (prev.length >= 3 ? '' : prev + '.'));
}, 600); // Increased from 500ms to 600ms for better performance
return () => {
if (dotIntervalRef.current) {
clearInterval(dotIntervalRef.current);
dotIntervalRef.current = null;
}
};
}, [isPaused, isComplete]);
// Call onComplete callback when animation finishes
useEffect(() => {
if (isComplete && onComplete && !hasCalledComplete.current) {
hasCalledComplete.current = true;
// Small delay to allow animation to settle
const timeout = setTimeout(() => {
onComplete(checkedSources, totalSources);
}, 100);
return () => clearTimeout(timeout);
}
}, [isComplete, onComplete, checkedSources, totalSources]);
const statusText = `${checkedSources}/${totalSources} 个源`;
return (
<div className="w-full space-y-3 animate-fade-in">
{/* Loading Message with Icon */}
<div className="flex items-center justify-center gap-3">
{/* Spinning Icon */}
<svg className="w-5 h-5 animate-spin-slow" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
fill="none"
stroke="var(--accent-color)"
strokeWidth="3"
strokeDasharray="60 40"
strokeLinecap="round"
/>
</svg>
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
{dots}
</span>
</div>
{/* Progress Bar - Unified 0-100% */}
<div className="w-full">
<div
className="h-1 bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] overflow-hidden rounded-[var(--radius-full)]"
>
<div
className="h-full bg-[var(--accent-color)] transition-all duration-500 ease-out relative rounded-[var(--radius-full)]"
style={{
width: `${progress}%`
}}
>
{/* Shimmer Effect - Optimized for GPU with contain for better performance */}
<div
className="absolute inset-0 animate-shimmer"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%)',
willChange: 'transform',
transform: 'translateZ(0)',
contain: 'strict'
}}
></div>
</div>
</div>
{/* Progress Info - Real-time count with pause indicator */}
<div className="flex items-center justify-between mt-2 text-xs text-[var(--text-color-secondary)]">
<span className="flex items-center gap-2">
{statusText}
{isPaused && (
<span className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--glass-bg)] text-[10px]">
</span>
)}
{isComplete && (
<span className="px-2 py-0.5 rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white text-[10px]">
</span>
)}
</span>
<span className="font-medium">{Math.round(progress)}%</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import { useEffect } from 'react';
export function ServiceWorkerRegister() {
useEffect(() => {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(
(registration) => {
// Registration successful
})
.catch((err) => {
// Registration failed
});
});
}
}, []);
return null;
}

View File

@@ -0,0 +1,139 @@
'use client';
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
actualTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
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);
}
}, []);
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 applyThemeWithTransition = () => {
// Abort previous transition if it exists
if (transitionRef.current) {
try {
transitionRef.current.skipTransition();
} catch (e) {
// Ignore if 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') {
try {
// @ts-ignore
transitionRef.current = document.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.)
transitionRef.current = null;
});
}
} catch (error) {
// Fallback if transition fails to start
applyTheme();
}
} else {
// Fallback for browsers that don't support View Transition API
applyTheme();
}
};
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 = 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
}
}
};
}, [theme, mounted]);
return (
<ThemeContext.Provider value={{ theme, setTheme, actualTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}

View File

@@ -0,0 +1,80 @@
'use client';
import { useTheme } from './ThemeProvider';
export function ThemeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div className="inline-flex bg-[var(--glass-bg)] backdrop-blur-xl [-webkit-backdrop-filter:blur(25px)] border border-[var(--glass-border)] rounded-[var(--radius-full)] p-1 shadow-[var(--shadow-sm)]">
<button
onClick={() => setTheme('light')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
cursor-pointer
${theme === 'light'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set light theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
<button
onClick={() => setTheme('dark')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
cursor-pointer
${theme === 'dark'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set dark theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button
onClick={() => setTheme('system')}
className={`
flex items-center justify-center
w-9 h-9
rounded-[var(--radius-full)]
transition-all duration-200
cursor-pointer
${theme === 'system'
? 'bg-[var(--accent-color)] text-white scale-105'
: 'text-[var(--text-color-secondary)] hover:bg-[color-mix(in_srgb,var(--text-color)_10%,transparent)]'
}
`}
aria-label="Set system theme"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
<line x1="8" y1="21" x2="16" y2="21"></line>
<line x1="12" y1="17" x2="12" y2="21"></line>
</svg>
</button>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import { TagManager } from '@/components/home/TagManager';
import { AdultContentGrid } from './AdultContentGrid';
import { useAdultTagManager } from '@/lib/hooks/useAdultTagManager';
import { useAdultContent } from '@/lib/hooks/useAdultContent';
interface AdultContentProps {
onSearch?: (query: string) => void;
}
export function AdultContent({ onSearch }: AdultContentProps) {
const {
tags,
selectedTag,
newTagInput,
showTagManager,
justAddedTag,
setSelectedTag,
setNewTagInput,
setShowTagManager,
setJustAddedTag,
handleAddTag,
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
} = useAdultTagManager();
// Get the category value from selected tag
const categoryValue = tags.find(t => t.id === selectedTag)?.value || '';
const {
videos,
loading,
hasMore,
prefetchRef,
loadMoreRef,
} = useAdultContent(categoryValue);
const handleVideoClick = (video: any) => {
if (onSearch) {
onSearch(video.vod_name);
}
};
return (
<div className="animate-fade-in">
<TagManager
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
newTagInput={newTagInput}
justAddedTag={justAddedTag}
onTagSelect={(tagId) => {
setSelectedTag(tagId);
}}
onTagDelete={handleDeleteTag}
onToggleManager={() => setShowTagManager(!showTagManager)}
onRestoreDefaults={handleRestoreDefaults}
onNewTagInputChange={setNewTagInput}
onAddTag={handleAddTag}
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
/>
<AdultContentGrid
videos={videos}
loading={loading}
hasMore={hasMore}
onVideoClick={handleVideoClick}
prefetchRef={prefetchRef}
loadMoreRef={loadMoreRef}
/>
</div>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import React from 'react';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
interface AdultVideo {
vod_id: string | number;
vod_name: string;
vod_pic?: string;
vod_remarks?: string;
type_name?: string;
source: string;
}
interface AdultContentGridProps {
videos: AdultVideo[];
loading: boolean;
hasMore: boolean;
onVideoClick?: (video: AdultVideo) => void;
prefetchRef: React.RefObject<HTMLDivElement | null>;
loadMoreRef: React.RefObject<HTMLDivElement | null>;
}
export function AdultContentGrid({
videos,
loading,
hasMore,
onVideoClick,
prefetchRef,
loadMoreRef,
}: AdultContentGridProps) {
if (videos.length === 0 && !loading) {
return <AdultGridEmpty />;
}
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{videos.map((video) => (
<Link
key={`${video.source}-${video.vod_id}`}
href={`/secret?q=${encodeURIComponent(video.vod_name)}`}
onClick={(e) => {
// Allow default behavior for modifier keys (new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
onVideoClick?.(video);
}}
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
style={{
position: 'relative',
zIndex: 1,
contentVisibility: 'auto'
}}
onMouseEnter={(e) => (e.currentTarget.style.zIndex = '100')}
onMouseLeave={(e) => (e.currentTarget.style.zIndex = '1')}
>
<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
src={video.vod_pic}
alt={video.vod_name}
fill
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
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)]">
</div>
)}
{video.vod_remarks && (
<div className="absolute top-2 right-2 bg-black/80 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)]">
<span className="text-xs font-bold text-white">
{video.vod_remarks}
</span>
</div>
)}
</div>
<div className="p-3">
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
{video.vod_name}
</h3>
{video.type_name && (
<p className="text-xs text-[var(--text-color-secondary)] mt-1">
{video.type_name}
</p>
)}
</div>
</Card>
</Link>
))}
</div>
{/* Prefetch Trigger - Earlier */}
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
{/* Loading Indicator */}
{loading && <AdultGridLoading />}
{/* Intersection Observer Target */}
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
{/* No More Content */}
{!hasMore && videos.length > 0 && <AdultGridNoMore />}
</>
);
}
function AdultGridLoading() {
return (
<div className="flex justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--accent-color)] border-t-transparent"></div>
<p className="text-sm text-[var(--text-color-secondary)]">...</p>
</div>
</div>
);
}
function AdultGridNoMore() {
return (
<div className="text-center py-12">
<p className="text-[var(--text-color-secondary)]"></p>
</div>
);
}
function AdultGridEmpty() {
return (
<div className="text-center py-20">
<Icons.Film size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p className="text-[var(--text-color-secondary)]"></p>
</div>
);
}

View File

@@ -0,0 +1,109 @@
/**
* FavoriteButton - Reusable favorite toggle button
* Heart icon that fills when favorited, with animation
*/
'use client';
import { memo, useCallback, useState, useEffect } from 'react';
import { useFavoritesStore } from '@/lib/store/favorites-store';
import { Icons } from '@/components/ui/Icon';
interface FavoriteButtonProps {
videoId: string | number;
source: string;
title: string;
poster?: string;
sourceName?: string;
type?: string;
year?: string;
remarks?: string;
className?: string;
size?: number;
showTooltip?: boolean;
}
export const FavoriteButton = memo<FavoriteButtonProps>(({
videoId,
source,
title,
poster,
sourceName,
type,
year,
remarks,
className = '',
size = 20,
showTooltip = true,
}) => {
const { isFavorite, toggleFavorite } = useFavoritesStore();
const [isAnimating, setIsAnimating] = useState(false);
const [isFav, setIsFav] = useState(false);
// Sync with store on mount and when dependencies change
useEffect(() => {
setIsFav(isFavorite(videoId, source));
}, [videoId, source, isFavorite]);
const handleClick = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsAnimating(true);
const newState = toggleFavorite({
videoId,
source,
title,
poster,
sourceName,
type,
year,
remarks,
});
setIsFav(newState);
setTimeout(() => setIsAnimating(false), 300);
}, [videoId, source, title, poster, sourceName, type, year, remarks, toggleFavorite]);
return (
<button
onClick={handleClick}
className={`
flex items-center justify-center
p-2 rounded-full
bg-[var(--glass-bg)] backdrop-blur-[8px]
border border-[var(--glass-border)]
hover:scale-110 active:scale-95
transition-all duration-200 ease-out
cursor-pointer
${isAnimating ? 'scale-125' : ''}
${className}
`}
aria-label={isFav ? '取消收藏' : '收藏'}
title={showTooltip ? (isFav ? '取消收藏' : '收藏') : undefined}
>
{isFav ? (
<span
className="transition-transform duration-200"
style={{
transform: isAnimating ? 'scale(1.2)' : 'scale(1)',
filter: 'drop-shadow(0 0 4px rgba(239, 68, 68, 0.5))',
display: 'flex',
}}
>
<Icons.HeartFilled
size={size}
className="text-red-500"
/>
</span>
) : (
<Icons.Heart
size={size}
className="text-[var(--text-color-secondary)] hover:text-red-400 transition-colors"
/>
)}
</button>
);
});
FavoriteButton.displayName = 'FavoriteButton';

View File

@@ -0,0 +1,23 @@
/**
* FavoritesEmptyState - Empty state component for favorites sidebar
* Matches HistoryEmptyState design for consistency
*/
import { Icons } from '@/components/ui/Icon';
export function FavoritesEmptyState() {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Icons.Inbox
size={64}
className="text-[var(--text-color-secondary)] opacity-50 mb-4"
/>
<p className="text-[var(--text-color-secondary)] text-lg">
</p>
<p className="text-[var(--text-color-secondary)] text-sm mt-2 opacity-70">
</p>
</div>
);
}

View File

@@ -0,0 +1,29 @@
/**
* FavoritesFooter - Footer component for favorites sidebar
* Matches HistoryFooter design for consistency
*/
import { Icons } from '@/components/ui/Icon';
import { Button } from '@/components/ui/Button';
interface FavoritesFooterProps {
hasFavorites: boolean;
onClearAll: () => void;
}
export function FavoritesFooter({ hasFavorites, onClearAll }: FavoritesFooterProps) {
if (!hasFavorites) return null;
return (
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={onClearAll}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
</Button>
</footer>
);
}

View File

@@ -0,0 +1,33 @@
/**
* FavoritesHeader - Header component for favorites sidebar
* Matches HistoryHeader design for consistency
*/
import { Icons } from '@/components/ui/Icon';
interface FavoritesHeaderProps {
onClose: () => void;
}
export function FavoritesHeader({ onClose }: FavoritesHeaderProps) {
return (
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.Heart size={24} className="text-[var(--accent-color)]" />
<h2
id="favorites-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--glass-bg)] rounded-full transition-colors cursor-pointer"
aria-label="关闭"
>
<Icons.X size={24} className="text-[var(--text-color-secondary)]" />
</button>
</header>
);
}

View File

@@ -0,0 +1,108 @@
/**
* FavoritesItem - Individual favorite item card
* Matches HistoryItem layout for consistency
*/
import { Icons } from '@/components/ui/Icon';
import { formatDate } from '@/lib/utils/format-utils';
import type { FavoriteItem } from '@/lib/types';
interface FavoritesItemProps {
item: FavoriteItem;
onRemove: () => void;
}
export function FavoritesItem({ item, onRemove }: FavoritesItemProps) {
const getVideoUrl = (): string => {
const params = new URLSearchParams({
id: item.videoId.toString(),
source: item.source,
title: item.title,
});
return `/player?${params.toString()}`;
};
const handleClick = (event: React.MouseEvent) => {
// Middle mouse or Ctrl/Cmd+click opens in new tab
if (event.button === 1 || event.ctrlKey || event.metaKey) {
event.preventDefault();
window.open(getVideoUrl(), '_blank');
return;
}
};
return (
<div className="group bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all border border-transparent hover:border-[var(--glass-border)]">
<a
href={getVideoUrl()}
onClick={(e) => {
e.preventDefault();
handleClick(e as any);
if (!e.ctrlKey && !e.metaKey) {
window.location.href = getVideoUrl();
}
}}
onAuxClick={(e) => handleClick(e as any)}
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
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 */}
<div className="absolute inset-0 flex items-center justify-center -z-10">
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
{item.title}
</h3>
{item.year && (
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
{item.year}
</p>
)}
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
{item.remarks && (
<span className="truncate">{item.remarks}</span>
)}
<span className="flex-shrink-0">
{formatDate(item.addedAt)}
</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-1 self-start opacity-0 group-hover:opacity-100 transition-opacity">
{/* Remove button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
className="p-1.5 hover:bg-[var(--glass-bg)] rounded-full cursor-pointer"
aria-label="取消收藏"
>
<Icons.Trash size={14} className="text-[var(--text-color-secondary)]" />
</button>
</div>
</div>
</a>
</div>
);
}

View File

@@ -0,0 +1,30 @@
/**
* FavoritesList - Scrollable list of favorite items
*/
import type { FavoriteItem } from '@/lib/types';
import { FavoritesItem } from './FavoritesItem';
import { FavoritesEmptyState } from './FavoritesEmptyState';
interface FavoritesListProps {
favorites: FavoriteItem[];
onRemove: (videoId: string | number, source: string) => void;
}
export function FavoritesList({ favorites, onRemove }: FavoritesListProps) {
if (favorites.length === 0) {
return <FavoritesEmptyState />;
}
return (
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-2 scroll-smooth">
{favorites.map((item) => (
<FavoritesItem
key={`${item.source}:${item.videoId}`}
item={item}
onRemove={() => onRemove(item.videoId, item.source)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,143 @@
/**
* FavoritesSidebar - Main favorites sidebar component
* Positioned on the left side of the screen (opposite to WatchHistorySidebar)
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useFavoritesStore } from '@/lib/store/favorites-store';
import { Icons } from '@/components/ui/Icon';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { FavoritesHeader } from './FavoritesHeader';
import { FavoritesList } from './FavoritesList';
import { FavoritesFooter } from './FavoritesFooter';
import { trapFocus } from '@/lib/accessibility/focus-management';
export function FavoritesSidebar() {
const [isOpen, setIsOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
videoId?: string;
source?: string;
isClearAll?: boolean;
}>({ isOpen: false });
const { favorites, removeFavorite, clearFavorites } = useFavoritesStore();
const sidebarRef = useRef<HTMLElement>(null);
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
// Setup focus trap when sidebar opens
useEffect(() => {
if (isOpen && sidebarRef.current) {
cleanupFocusTrapRef.current = trapFocus(sidebarRef.current);
}
return () => {
if (cleanupFocusTrapRef.current) {
cleanupFocusTrapRef.current();
cleanupFocusTrapRef.current = null;
}
};
}, [isOpen]);
// Handle escape key to close sidebar
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
// Handle delete confirmation
const handleDeleteItem = (videoId: string | number, source: string) => {
setDeleteConfirm({ isOpen: true, videoId: String(videoId), source });
};
const handleClearAll = () => {
setDeleteConfirm({ isOpen: true, isClearAll: true });
};
const confirmDelete = () => {
if (deleteConfirm.isClearAll) {
clearFavorites();
} else if (deleteConfirm.videoId && deleteConfirm.source) {
removeFavorite(deleteConfirm.videoId, deleteConfirm.source);
}
setDeleteConfirm({ isOpen: false });
};
const cancelDelete = () => {
setDeleteConfirm({ isOpen: false });
};
return (
<>
{/* Toggle Button - Left side */}
<button
onClick={() => setIsOpen(true)}
className="fixed left-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
aria-label="打开收藏夹"
>
<Icons.Heart size={24} className="text-[var(--text-color)]" />
</button>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[1999] bg-black/40 opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar - Left side */}
<aside
ref={sidebarRef}
role="complementary"
aria-labelledby="favorites-sidebar-title"
aria-hidden={!isOpen}
style={{
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(-100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className={`fixed top-0 left-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-r border-[var(--glass-border)] rounded-tr-[var(--radius-2xl)] rounded-br-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
<FavoritesHeader onClose={() => setIsOpen(false)} />
<FavoritesList
favorites={favorites}
onRemove={handleDeleteItem}
/>
<FavoritesFooter
hasFavorites={favorites.length > 0}
onClearAll={handleClearAll}
/>
</aside>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
title={deleteConfirm.isClearAll ? '清空收藏夹' : '取消收藏'}
message={
deleteConfirm.isClearAll
? '确定要清空所有收藏吗?此操作无法撤销。'
: '确定要取消收藏这个视频吗?'
}
onConfirm={confirmDelete}
onCancel={cancelDelete}
confirmText="确定"
cancelText="取消"
variant="danger"
/>
</>
);
}

View File

@@ -0,0 +1,23 @@
/**
* HistoryEmptyState - Empty state for watch history
* Displays when no viewing history exists
*/
import { Icons } from '@/components/ui/Icon';
export function HistoryEmptyState() {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Icons.Inbox
size={64}
className="text-[var(--text-color-secondary)] opacity-50 mb-4"
/>
<p className="text-[var(--text-color-secondary)] text-lg">
</p>
<p className="text-[var(--text-color-secondary)] text-sm mt-2 opacity-70">
</p>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { Icons } from '@/components/ui/Icon';
import { Button } from '@/components/ui/Button';
interface HistoryFooterProps {
hasHistory: boolean;
onClearAll: () => void;
}
export function HistoryFooter({ hasHistory, onClearAll }: HistoryFooterProps) {
if (!hasHistory) return null;
return (
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={onClearAll}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
</Button>
</footer>
);
}

View File

@@ -0,0 +1,28 @@
import { Icons } from '@/components/ui/Icon';
interface HistoryHeaderProps {
onClose: () => void;
}
export function HistoryHeader({ onClose }: HistoryHeaderProps) {
return (
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2
id="history-sidebar-title"
className="text-xl font-semibold text-[var(--text-color)]"
>
</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--glass-bg)] rounded-full transition-colors cursor-pointer"
aria-label="关闭"
>
<Icons.X size={24} className="text-[var(--text-color-secondary)]" />
</button>
</header>
);
}

View File

@@ -0,0 +1,126 @@
/**
* HistoryItem - Individual watch history item
* 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';
import { FavoriteButton } from '@/components/favorites/FavoriteButton';
interface HistoryItemProps {
videoId: string | number;
source: string;
title: string;
poster?: string;
episodeIndex: number;
episodes?: Array<{ name: string }>;
playbackPosition: number;
duration: number;
timestamp: number;
onRemove: () => void;
}
export function HistoryItem({
videoId,
source,
title,
poster,
episodeIndex,
episodes,
playbackPosition,
duration,
timestamp,
onRemove,
}: HistoryItemProps) {
const getVideoUrl = (): string => {
const params = new URLSearchParams({
id: videoId.toString(),
source,
title,
episode: episodeIndex.toString(),
});
return `/player?${params.toString()}`;
};
const handleClick = (event: React.MouseEvent) => {
// Middle mouse or Ctrl/Cmd+click opens in new tab
if (event.button === 1 || event.ctrlKey || event.metaKey) {
event.preventDefault();
window.open(getVideoUrl(), '_blank');
return;
}
};
const progress = (playbackPosition / duration) * 100;
const episodeText = episodes && episodes.length > 0
? episodes[episodeIndex]?.name || `${episodeIndex + 1}`
: '';
return (
<div className="group bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all border border-transparent hover:border-[var(--glass-border)]">
<a
href={getVideoUrl()}
onClick={(e) => {
e.preventDefault();
handleClick(e as any);
if (!e.ctrlKey && !e.metaKey) {
window.location.href = getVideoUrl();
}
}}
onAuxClick={(e) => handleClick(e as any)}
className="block"
>
<div className="flex gap-3">
{/* Poster */}
<PosterImage poster={poster} title={title} progress={progress} />
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
{title}
</h3>
{episodeText && (
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
{episodeText}
</p>
)}
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
<span>{formatTime(playbackPosition)} / {formatTime(duration)}</span>
<span>{formatDate(timestamp)}</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-1 self-start opacity-0 group-hover:opacity-100 transition-opacity">
{/* Favorite button */}
<FavoriteButton
videoId={videoId}
source={source}
title={title}
poster={poster}
remarks={episodeText}
size={14}
className="!p-1.5 !bg-transparent !border-0 !shadow-none hover:!bg-[var(--glass-bg)]"
showTooltip={false}
/>
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove();
}}
className="p-1.5 hover:bg-[var(--glass-bg)] rounded-full cursor-pointer"
aria-label="删除"
>
<Icons.Trash size={14} className="text-[var(--text-color-secondary)]" />
</button>
</div>
</div>
</a>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { HistoryItem } from './HistoryItem';
import { HistoryEmptyState } from './HistoryEmptyState';
import type { VideoHistoryItem } from '@/lib/types';
interface HistoryListProps {
history: VideoHistoryItem[];
onRemove: (videoId: string | number, source: string) => void;
}
export function HistoryList({ history, onRemove }: HistoryListProps) {
return (
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
{history.length === 0 ? (
<HistoryEmptyState />
) : (
<div className="space-y-3">
{history.map((item) => (
<HistoryItem
key={`${item.videoId}-${item.source}-${item.timestamp}`}
videoId={item.videoId}
source={item.source}
title={item.title}
poster={item.poster}
episodeIndex={item.episodeIndex}
episodes={item.episodes}
playbackPosition={item.playbackPosition}
duration={item.duration}
timestamp={item.timestamp}
onRemove={() => onRemove(item.videoId, item.source)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
/**
* PosterImage - Video poster with fallback handling
*/
import { Icons } from '@/components/ui/Icon';
interface PosterImageProps {
poster?: string;
title: string;
progress: number;
}
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
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">
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
</div>
)}
{/* Progress overlay */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
<div
className="h-full bg-[var(--accent-color)]"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
/**
* Watch History Sidebar Component
* 观看历史侧边栏组件 - Main layout and state management
*/
'use client';
import { useState, useEffect, useRef } from 'react';
import { useHistoryStore } from '@/lib/store/history-store';
import { Icons } from '@/components/ui/Icon';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { HistoryHeader } from './HistoryHeader';
import { HistoryList } from './HistoryList';
import { HistoryFooter } from './HistoryFooter';
import { trapFocus } from '@/lib/accessibility/focus-management';
export function WatchHistorySidebar() {
const [isOpen, setIsOpen] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
isOpen: boolean;
videoId?: string;
source?: string;
isClearAll?: boolean;
}>({ isOpen: false });
const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore();
const sidebarRef = useRef<HTMLElement>(null);
const cleanupFocusTrapRef = useRef<(() => void) | null>(null);
// Setup focus trap when sidebar opens
useEffect(() => {
if (isOpen && sidebarRef.current) {
cleanupFocusTrapRef.current = trapFocus(sidebarRef.current);
}
return () => {
if (cleanupFocusTrapRef.current) {
cleanupFocusTrapRef.current();
cleanupFocusTrapRef.current = null;
}
};
}, [isOpen]);
// Handle escape key to close sidebar
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen]);
// Handle delete confirmation
const handleDeleteItem = (videoId: string | number, source: string) => {
setDeleteConfirm({ isOpen: true, videoId: String(videoId), source });
};
const handleClearAll = () => {
setDeleteConfirm({ isOpen: true, isClearAll: true });
};
const confirmDelete = () => {
if (deleteConfirm.isClearAll) {
clearHistory();
} else if (deleteConfirm.videoId && deleteConfirm.source) {
removeFromHistory(deleteConfirm.videoId, deleteConfirm.source);
}
setDeleteConfirm({ isOpen: false });
};
const cancelDelete = () => {
setDeleteConfirm({ isOpen: false });
};
return (
<>
{/* Toggle Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-transform duration-200 cursor-pointer"
aria-label="打开观看历史"
>
<Icons.History size={24} className="text-[var(--text-color)]" />
</button>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[1999] bg-black/40 opacity-0 animate-[fadeIn_0.2s_ease-out_forwards]"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside
ref={sidebarRef}
role="complementary"
aria-labelledby="history-sidebar-title"
aria-hidden={!isOpen}
style={{
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[8px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
<HistoryHeader onClose={() => setIsOpen(false)} />
<HistoryList
history={viewingHistory}
onRemove={handleDeleteItem}
/>
<HistoryFooter
hasHistory={viewingHistory.length > 0}
onClearAll={handleClearAll}
/>
</aside>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={deleteConfirm.isOpen}
title={deleteConfirm.isClearAll ? '清空历史记录' : '删除历史记录'}
message={
deleteConfirm.isClearAll
? '确定要清空所有观看历史吗?此操作无法撤销。'
: '确定要删除这条历史记录吗?'
}
onConfirm={confirmDelete}
onCancel={cancelDelete}
confirmText="删除"
cancelText="取消"
variant="danger"
/>
</>
);
}

View File

@@ -0,0 +1,96 @@
/**
* MovieCard - Individual movie card component
* Displays movie poster, title, and rating
*/
import { memo, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
interface DoubanMovie {
id: string;
title: string;
cover: string;
rate: string;
url: string;
}
interface MovieCardProps {
movie: DoubanMovie;
onMovieClick: (movie: DoubanMovie) => void;
}
export const MovieCard = memo(function MovieCard({ movie, onMovieClick }: MovieCardProps) {
const [imageError, setImageError] = useState(false);
const [fallbackError, setFallbackError] = useState(false);
return (
<Link
href={`/?q=${encodeURIComponent(movie.title)}`}
onClick={(e) => {
// Allow default behavior for modifier keys (new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
onMovieClick(movie);
}}
className="group cursor-pointer hover:translate-y-[-2px] transition-transform duration-200 ease-out"
style={{
position: 'relative',
zIndex: 1,
contentVisibility: 'auto'
}}
onMouseEnter={(e) => (e.currentTarget.style.zIndex = '100')}
onMouseLeave={(e) => (e.currentTarget.style.zIndex = '1')}
>
<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)]">
{!imageError ? (
<Image
src={movie.cover}
alt={movie.title}
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)}
/>
) : !fallbackError ? (
<Image
src="/placeholder-poster.svg"
alt={movie.title}
fill
className="object-cover rounded-[var(--radius-2xl)]"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
unoptimized
onError={() => setFallbackError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-[var(--glass-bg)] rounded-[var(--radius-2xl)]">
<p className="text-sm text-[var(--text-muted)]"></p>
</div>
)}
{movie.rate && parseFloat(movie.rate) > 0 && (
<div
className="absolute top-2 right-2 bg-black/80 px-2.5 py-1.5 flex items-center gap-1.5 rounded-[var(--radius-full)]"
>
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs font-bold text-white">
{movie.rate}
</span>
</div>
)}
</div>
<div className="p-3">
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
{movie.title}
</h3>
</div>
</Card>
</Link>
);
});

View File

@@ -0,0 +1,91 @@
/**
* MovieGrid - Grid layout for movie cards with infinite scroll
* Handles movie display and loading states
*/
import { MovieCard } from './MovieCard';
import { Icons } from '@/components/ui/Icon';
interface DoubanMovie {
id: string;
title: string;
cover: string;
rate: string;
url: string;
}
interface MovieGridProps {
movies: DoubanMovie[];
loading: boolean;
hasMore: boolean;
onMovieClick: (movie: DoubanMovie) => void;
prefetchRef: React.RefObject<HTMLDivElement | null>;
loadMoreRef: React.RefObject<HTMLDivElement | null>;
}
export function MovieGrid({
movies,
loading,
hasMore,
onMovieClick,
prefetchRef,
loadMoreRef
}: MovieGridProps) {
if (movies.length === 0 && !loading) {
return <MovieGridEmpty />;
}
return (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{movies.map((movie) => (
<MovieCard
key={movie.id}
movie={movie}
onMovieClick={onMovieClick}
/>
))}
</div>
{/* Prefetch Trigger - Earlier */}
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
{/* Loading Indicator */}
{loading && <MovieGridLoading />}
{/* Intersection Observer Target */}
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
{/* No More Content */}
{!hasMore && movies.length > 0 && <MovieGridNoMore />}
</>
);
}
function MovieGridLoading() {
return (
<div className="flex justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--accent-color)] border-t-transparent"></div>
<p className="text-sm text-[var(--text-color-secondary)]">...</p>
</div>
</div>
);
}
function MovieGridNoMore() {
return (
<div className="text-center py-12">
<p className="text-[var(--text-color-secondary)]"></p>
</div>
);
}
function MovieGridEmpty() {
return (
<div className="text-center py-20">
<Icons.Film size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p className="text-[var(--text-color-secondary)]"></p>
</div>
);
}

View File

@@ -0,0 +1,82 @@
/**
* PopularFeatures - Main component for popular movies section
* Displays Douban movie recommendations with tag filtering and infinite scroll
*/
'use client';
import { TagManager } from './TagManager';
import { MovieGrid } from './MovieGrid';
import { useTagManager } from './hooks/useTagManager';
import { usePopularMovies } from './hooks/usePopularMovies';
interface PopularFeaturesProps {
onSearch?: (query: string) => void;
}
export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
const {
tags,
selectedTag,
newTagInput,
showTagManager,
justAddedTag,
setSelectedTag,
setNewTagInput,
setShowTagManager,
setJustAddedTag,
handleAddTag,
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
} = useTagManager();
const {
movies,
loading,
hasMore,
prefetchRef,
loadMoreRef,
} = usePopularMovies(selectedTag, tags);
const handleMovieClick = (movie: any) => {
if (onSearch) {
onSearch(movie.title);
}
};
return (
<div className="animate-fade-in">
<TagManager
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
newTagInput={newTagInput}
justAddedTag={justAddedTag}
onTagSelect={(tagId) => {
if (tagId === 'custom_色情' || tags.find(t => t.id === tagId)?.label === '色情') {
window.location.href = '/secret';
return;
}
setSelectedTag(tagId);
}}
onTagDelete={handleDeleteTag}
onToggleManager={() => setShowTagManager(!showTagManager)}
onRestoreDefaults={handleRestoreDefaults}
onNewTagInputChange={setNewTagInput}
onAddTag={handleAddTag}
onDragEnd={handleDragEnd}
onJustAddedTagHandled={() => setJustAddedTag(false)}
/>
<MovieGrid
movies={movies}
loading={loading}
hasMore={hasMore}
onMovieClick={handleMovieClick}
prefetchRef={prefetchRef}
loadMoreRef={loadMoreRef}
/>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { ResultsHeader } from '@/components/search/ResultsHeader';
import { SourceBadges } from '@/components/search/SourceBadges';
import { TypeBadges } from '@/components/search/TypeBadges';
import { VideoGrid } from '@/components/search/VideoGrid';
import { useSourceBadges } from '@/lib/hooks/useSourceBadges';
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
import { Video, SourceBadge } from '@/lib/types';
interface SearchResultsProps {
results: Video[];
availableSources: SourceBadge[];
loading: boolean;
}
export function SearchResults({ results, availableSources, loading }: SearchResultsProps) {
// Source badges hook - filters by video source
const {
selectedSources,
filteredVideos: sourceFilteredVideos,
toggleSource,
} = useSourceBadges(results, availableSources);
// Type badges hook - auto-collects and filters by type_name
// Apply on source-filtered results for combined filtering
const {
typeBadges,
selectedTypes,
filteredVideos: finalFilteredVideos,
toggleType,
} = useTypeBadges(sourceFilteredVideos);
if (results.length === 0 && !loading) return null;
return (
<div className="animate-fade-in">
<ResultsHeader
loading={loading}
resultsCount={results.length}
availableSources={availableSources}
/>
{/* Source Badges - Clickable video source filtering */}
{availableSources.length > 0 && (
<SourceBadges
sources={availableSources}
selectedSources={selectedSources}
onToggleSource={toggleSource}
className="mb-6"
/>
)}
{/* Type Badges - Auto-collected from search results */}
{typeBadges.length > 0 && (
<TypeBadges
badges={typeBadges}
selectedTypes={selectedTypes}
onToggleType={toggleType}
className="mb-6"
/>
)}
{/* Display filtered videos (both source and type filters applied) */}
<VideoGrid videos={finalFilteredVideos} />
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { Icons } from '@/components/ui/Icon';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
export interface Tag {
id: string;
label: string;
value: string;
}
interface SortableTagProps {
tag: Tag;
selectedTag: string;
showTagManager: boolean;
onTagSelect: (id: string) => void;
onTagDelete: (id: string) => void;
}
export function SortableTag({
tag,
selectedTag,
showTagManager,
onTagSelect,
onTagDelete,
}: SortableTagProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: tag.id, disabled: !showTagManager });
const style = {
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 10 : 1,
opacity: isDragging ? 0.3 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="relative flex-shrink-0"
>
<div className={`${showTagManager && !isDragging ? 'animate-jiggle' : ''}`}>
<button
onClick={() => !showTagManager && onTagSelect(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap rounded-[var(--radius-full)] cursor-pointer select-none
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
>
{tag.label}
</button>
{showTagManager && (
<button
onClick={(e) => {
e.stopPropagation();
onTagDelete(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors rounded-[var(--radius-full)] cursor-pointer z-20 shadow-sm"
>
<Icons.X size={14} />
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import { Icons } from '@/components/ui/Icon';
interface TagInputProps {
newTagInput: string;
onNewTagInputChange: (value: string) => void;
onAddTag: () => void;
}
export function TagInput({
newTagInput,
onNewTagInputChange,
onAddTag,
}: TagInputProps) {
return (
<div className="mb-6 flex gap-2">
<input
type="text"
value={newTagInput}
onChange={(e) => onNewTagInputChange(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && onAddTag()}
placeholder="添加自定义标签..."
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors rounded-[var(--radius-2xl)]"
/>
<button
onClick={onAddTag}
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity rounded-[var(--radius-2xl)] cursor-pointer"
>
</button>
</div>
);
}

118
components/home/TagList.tsx Normal file
View File

@@ -0,0 +1,118 @@
'use client';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
DragOverlay,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableTag, Tag } from './SortableTag';
import { useState, useRef, useEffect } from 'react';
interface TagListProps {
tags: Tag[];
selectedTag: string;
showTagManager: boolean;
justAddedTag: boolean;
onTagSelect: (tagId: string) => void;
onTagDelete: (tagId: string) => void;
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
}
export function TagList({
tags,
selectedTag,
showTagManager,
justAddedTag,
onTagSelect,
onTagDelete,
onDragEnd,
onJustAddedTagHandled,
}: TagListProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Auto-scroll to end when new tag is added
useEffect(() => {
if (justAddedTag && scrollContainerRef.current) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
behavior: 'smooth',
});
onJustAddedTagHandled();
}
}, [justAddedTag, onJustAddedTagHandled]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
onDragEnd(event);
};
const activeTag = tags.find((t) => t.id === activeId);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div
ref={scrollContainerRef}
className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide"
>
<SortableContext
items={tags.map((t) => t.id)}
strategy={horizontalListSortingStrategy}
>
{tags.map((tag) => (
<SortableTag
key={tag.id}
tag={tag}
selectedTag={selectedTag}
showTagManager={showTagManager}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
/>
))}
</SortableContext>
</div>
<DragOverlay>
{activeId && activeTag ? (
<div className="relative flex-shrink-0 animate-jiggle">
<button className="px-6 py-2.5 text-sm font-semibold whitespace-nowrap rounded-[var(--radius-full)] bg-[var(--accent-color)] text-white shadow-xl scale-110 cursor-grabbing border border-transparent">
{activeTag.label}
</button>
</div>
) : null}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,83 @@
import { Icons } from '@/components/ui/Icon';
import { DragEndEvent } from '@dnd-kit/core';
import { TagInput } from './TagInput';
import { TagList } from './TagList';
import { Tag } from './SortableTag';
interface TagManagerProps {
tags: Tag[];
selectedTag: string;
showTagManager: boolean;
newTagInput: string;
justAddedTag: boolean;
onTagSelect: (tagId: string) => void;
onTagDelete: (tagId: string) => void;
onToggleManager: () => void;
onRestoreDefaults: () => void;
onNewTagInputChange: (value: string) => void;
onAddTag: () => void;
onDragEnd: (event: DragEndEvent) => void;
onJustAddedTagHandled: () => void;
}
export function TagManager({
tags,
selectedTag,
showTagManager,
newTagInput,
justAddedTag,
onTagSelect,
onTagDelete,
onToggleManager,
onRestoreDefaults,
onNewTagInputChange,
onAddTag,
onDragEnd,
onJustAddedTagHandled,
}: TagManagerProps) {
return (
<>
{/* Management Controls */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={onToggleManager}
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2 cursor-pointer"
>
<Icons.Tag size={16} />
{showTagManager ? '完成' : '管理标签'}
</button>
{showTagManager && (
<button
onClick={onRestoreDefaults}
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2 cursor-pointer"
>
<Icons.RefreshCw size={16} />
</button>
)}
</div>
{/* Add Custom Tag */}
{showTagManager && (
<TagInput
newTagInput={newTagInput}
onNewTagInputChange={onNewTagInputChange}
onAddTag={onAddTag}
/>
)}
{/* Tag Filter */}
<TagList
tags={tags}
selectedTag={selectedTag}
showTagManager={showTagManager}
justAddedTag={justAddedTag}
onTagSelect={onTagSelect}
onTagDelete={onTagDelete}
onDragEnd={onDragEnd}
onJustAddedTagHandled={onJustAddedTagHandled}
/>
</>
);
}

View File

@@ -0,0 +1,69 @@
import { useState, useEffect, useCallback } from 'react';
import { useInfiniteScroll } from '@/lib/hooks/useInfiniteScroll';
interface DoubanMovie {
id: string;
title: string;
cover: string;
rate: string;
url: string;
}
const PAGE_LIMIT = 20;
export function usePopularMovies(selectedTag: string, tags: any[]) {
const [movies, setMovies] = useState<DoubanMovie[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const loadMovies = useCallback(async (tag: string, pageStart: number, append = false) => {
if (loading) return;
setLoading(true);
try {
const tagValue = tags.find(t => t.id === tag)?.value || '热门';
const response = await fetch(
`/api/douban/recommend?tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}`
);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
const newMovies = data.subjects || [];
setMovies(prev => append ? [...prev, ...newMovies] : newMovies);
setHasMore(newMovies.length === PAGE_LIMIT);
} catch (error) {
console.error('Failed to load movies:', error);
setHasMore(false);
} finally {
setLoading(false);
}
}, [loading, tags]);
useEffect(() => {
setPage(0);
setMovies([]);
setHasMore(true);
loadMovies(selectedTag, 0, false);
}, [selectedTag]); // eslint-disable-line react-hooks/exhaustive-deps
const { prefetchRef, loadMoreRef } = useInfiniteScroll({
hasMore,
loading,
page,
onLoadMore: (nextPage) => {
setPage(nextPage);
loadMovies(selectedTag, nextPage * PAGE_LIMIT, true);
},
});
return {
movies,
loading,
hasMore,
prefetchRef,
loadMoreRef,
};
}

View File

@@ -0,0 +1,101 @@
import { useState, useEffect } from 'react';
import { DragEndEvent } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
const DEFAULT_TAGS = [
{ id: 'popular', label: '热门', value: '热门' },
{ id: 'latest', label: '最新', value: '最新' },
{ id: 'classic', label: '经典', value: '经典' },
{ id: 'highscore', label: '豆瓣高分', value: '豆瓣高分' },
{ id: 'underrated', label: '冷门佳片', value: '冷门佳片' },
{ id: 'chinese', label: '华语', value: '华语' },
{ id: 'western', label: '欧美', value: '欧美' },
{ id: 'korean', label: '韩国', value: '韩国' },
{ id: 'japanese', label: '日本', value: '日本' },
{ id: 'action', label: '动作', value: '动作' },
{ id: 'comedy', label: '喜剧', value: '喜剧' },
{ id: 'variety', label: '综艺', value: '综艺' },
{ id: 'romance', label: '爱情', value: '爱情' },
{ id: 'scifi', label: '科幻', value: '科幻' },
{ id: 'thriller', label: '悬疑', value: '悬疑' },
{ id: 'horror', label: '恐怖', value: '恐怖' },
{ id: 'healing', label: '治愈', value: '治愈' },
];
const STORAGE_KEY = 'kvideo_custom_tags';
export function useTagManager() {
const [selectedTag, setSelectedTag] = useState('popular');
const [tags, setTags] = useState(DEFAULT_TAGS);
const [newTagInput, setNewTagInput] = useState('');
const [showTagManager, setShowTagManager] = useState(false);
const [justAddedTag, setJustAddedTag] = useState(false);
// Load custom tags from localStorage
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
setTags(JSON.parse(saved));
} catch (e) {
console.error('Failed to parse saved tags', e);
}
}
}, []);
const saveTags = (newTags: typeof DEFAULT_TAGS) => {
setTags(newTags);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags));
};
const handleAddTag = () => {
if (!newTagInput.trim()) return;
const newTag = {
id: `custom_${Date.now()}`,
label: newTagInput.trim(),
value: newTagInput.trim(),
};
saveTags([...tags, newTag]);
setNewTagInput('');
setJustAddedTag(true);
};
const handleDeleteTag = (tagId: string) => {
saveTags(tags.filter(t => t.id !== tagId));
if (selectedTag === tagId) {
setSelectedTag('popular');
}
};
const handleRestoreDefaults = () => {
saveTags(DEFAULT_TAGS);
setSelectedTag('popular');
setShowTagManager(false);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = tags.findIndex((tag) => tag.id === active.id);
const newIndex = tags.findIndex((tag) => tag.id === over.id);
saveTags(arrayMove(tags, oldIndex, newIndex));
}
};
return {
tags,
selectedTag,
newTagInput,
showTagManager,
justAddedTag,
setSelectedTag,
setNewTagInput,
setShowTagManager,
setJustAddedTag,
handleAddTag,
handleDeleteTag,
handleRestoreDefaults,
handleDragEnd,
};
}

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import Image from 'next/image';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
interface NavbarProps {
onReset: () => void;
isSecretMode?: boolean;
}
export function Navbar({ onReset, isSecretMode = false }: NavbarProps) {
const settingsHref = isSecretMode ? '/secret/settings' : '/settings';
return (
<nav className="sticky top-0 z-[2000] pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] px-3 sm:px-6 py-2 sm:py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="flex items-center justify-between gap-2 sm:gap-4">
<Link
href={isSecretMode ? '/secret' : '/'}
className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity cursor-pointer min-w-0"
onClick={onReset}
>
<div className="w-8 h-8 sm:w-10 sm:h-10 relative flex items-center justify-center flex-shrink-0">
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</div>
<div className="flex flex-col min-w-0">
<h1 className="text-lg sm:text-2xl font-bold text-[var(--text-color)] truncate">KVideo</h1>
<p className="text-xs text-[var(--text-color-secondary)] hidden sm:block truncate"></p>
</div>
</Link>
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
<a
href="https://github.com/KuekHaoYang/KVideo"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer hidden sm:flex"
aria-label="GitHub"
>
<Icons.Github size={20} />
</a>
<Link
href={settingsHref}
className="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="设置"
>
<svg className="w-4 h-4 sm:w-5 sm:h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</Link>
<ThemeSwitcher />
</div>
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import { useIsMobile } from '@/lib/hooks/useMobilePlayer';
import { DesktopVideoPlayer } from './DesktopVideoPlayer';
import { MobileVideoPlayer } from './MobileVideoPlayer';
interface CustomVideoPlayerProps {
src: string;
poster?: string;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
shouldAutoPlay?: boolean;
// Episode navigation props for auto-skip/auto-next
totalEpisodes?: number;
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
}
/**
* Smart Video Player that renders different versions based on device
* - Mobile/Tablet: Optimized touch controls, double-tap gestures, orientation lock
* - Desktop: Full-featured player with hover interactions
*/
export function CustomVideoPlayer(props: CustomVideoPlayerProps) {
const isMobile = useIsMobile();
return isMobile
? <MobileVideoPlayer {...props} />
: <DesktopVideoPlayer {...props} />;
}

View File

@@ -0,0 +1,123 @@
'use client';
import { useDesktopPlayerState } from './hooks/useDesktopPlayerState';
import { useDesktopPlayerLogic } from './hooks/useDesktopPlayerLogic';
import { useHlsPlayer } from './hooks/useHlsPlayer';
import { useAutoSkip } from './hooks/useAutoSkip';
import { DesktopControlsWrapper } from './desktop/DesktopControlsWrapper';
import { DesktopOverlayWrapper } from './desktop/DesktopOverlayWrapper';
interface DesktopVideoPlayerProps {
src: string;
poster?: string;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
shouldAutoPlay?: boolean;
// Episode navigation props for auto-skip/auto-next
totalEpisodes?: number;
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
}
export function DesktopVideoPlayer({
src,
poster,
onError,
onTimeUpdate,
initialTime = 0,
shouldAutoPlay = false,
totalEpisodes = 1,
currentEpisodeIndex = 0,
onNextEpisode,
}: DesktopVideoPlayerProps) {
const { refs, state } = useDesktopPlayerState();
// Initialize HLS Player
useHlsPlayer({
videoRef: refs.videoRef,
src,
autoPlay: shouldAutoPlay
});
const {
videoRef,
containerRef,
} = refs;
const {
isPlaying,
currentTime,
duration,
setShowControls,
setIsLoading,
} = state;
const logic = useDesktopPlayerLogic({
src,
initialTime,
shouldAutoPlay,
onError,
onTimeUpdate,
refs,
state
});
// Auto-skip intro/outro and auto-next episode
useAutoSkip({
videoRef,
currentTime,
duration,
isPlaying,
totalEpisodes,
currentEpisodeIndex,
onNextEpisode,
});
const {
handleMouseMove,
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
} = logic;
return (
<div
ref={containerRef}
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden group"
onMouseMove={handleMouseMove}
onMouseLeave={() => isPlaying && setShowControls(false)}
>
{/* Video Element */}
<video
ref={videoRef}
className="w-full h-full object-contain"
poster={poster}
x-webkit-airplay="allow"
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdateEvent}
onLoadedMetadata={handleLoadedMetadata}
onError={handleVideoError}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onClick={togglePlay}
/>
<DesktopOverlayWrapper
state={state}
onTogglePlay={togglePlay}
/>
<DesktopControlsWrapper
src={src}
state={state}
logic={logic}
refs={refs}
/>
</div>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import { useRef, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Icons } from '@/components/ui/Icon';
import { useKeyboardNavigation } from '@/lib/hooks/useKeyboardNavigation';
interface Episode {
name?: string;
url: string;
}
interface EpisodeListProps {
episodes: Episode[] | null;
currentEpisode: number;
onEpisodeClick: (episode: Episode, index: number) => void;
}
export function EpisodeList({ episodes, currentEpisode, onEpisodeClick }: EpisodeListProps) {
const listRef = useRef<HTMLDivElement>(null);
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
// Keyboard navigation
useKeyboardNavigation({
enabled: true,
containerRef: listRef,
currentIndex: currentEpisode,
itemCount: episodes?.length || 0,
orientation: 'vertical',
onNavigate: useCallback((index: number) => {
buttonRefs.current[index]?.focus();
buttonRefs.current[index]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}, []),
onSelect: useCallback((index: number) => {
if (episodes && episodes[index]) {
onEpisodeClick(episodes[index], index);
}
}, [episodes, onEpisodeClick]),
});
return (
<Card hover={false} className="lg:sticky lg:top-32">
<h3 className="text-lg sm:text-xl font-bold text-[var(--text-color)] mb-4 flex items-center gap-2">
<Icons.List size={20} className="sm:w-6 sm:h-6" />
<span></span>
{episodes && (
<Badge variant="primary">{episodes.length}</Badge>
)}
</h3>
<div
ref={listRef}
className="max-h-[400px] sm:max-h-[600px] overflow-y-auto space-y-2 pr-2"
role="radiogroup"
aria-label="剧集选择"
>
{episodes && episodes.length > 0 ? (
episodes.map((episode, index) => (
<button
key={index}
ref={(el) => { buttonRefs.current[index] = el; }}
onClick={() => onEpisodeClick(episode, index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEpisodeClick(episode, index);
}
}}
tabIndex={0}
role="radio"
aria-checked={currentEpisode === index}
aria-current={currentEpisode === index ? 'true' : undefined}
aria-label={`${episode.name || `${index + 1}`}${currentEpisode === index ? ',当前播放' : ''}`}
className={`
w-full px-3 py-2 sm:px-4 sm:py-3 rounded-[var(--radius-2xl)] text-left transition-[var(--transition-fluid)] cursor-pointer
${currentEpisode === index
? 'bg-[var(--accent-color)] text-white shadow-[0_4px_12px_color-mix(in_srgb,var(--accent-color)_50%,transparent)] brightness-110'
: 'bg-[var(--glass-bg)] hover:bg-[var(--glass-hover)] text-[var(--text-color)] border border-[var(--glass-border)]'
}
focus-visible:ring-2 focus-visible:ring-[var(--accent-color)] focus-visible:ring-offset-2
`}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm sm:text-base">
{episode.name || `${index + 1}`}
</span>
{currentEpisode === index && (
<Icons.Play size={16} />
)}
</div>
</button>
))
) : (
<div className="text-center py-8 text-[var(--text-secondary)]">
<Icons.Inbox size={48} className="text-[var(--text-color-secondary)] mx-auto mb-2" />
<p></p>
</div>
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,158 @@
'use client';
import { useScreenOrientation } from '@/lib/hooks/useMobilePlayer';
import { useMobilePlayerState } from './hooks/useMobilePlayerState';
import { useMobilePlayerLogic } from './hooks/useMobilePlayerLogic';
import { useMobileGestures } from './hooks/useMobileGestures';
import { useAutoSkip } from './hooks/useAutoSkip';
import { useHlsPlayer } from './hooks/useHlsPlayer';
import { MobileControlsWrapper } from './mobile/MobileControlsWrapper';
import { MobileOverlay } from './mobile/MobileOverlay';
import { MobileSkipIndicator } from './mobile/MobileSkipIndicator';
interface MobileVideoPlayerProps {
src: string;
poster?: string;
onError?: (error: string) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
initialTime?: number;
shouldAutoPlay?: boolean;
// Episode navigation props for auto-skip/auto-next
totalEpisodes?: number;
currentEpisodeIndex?: number;
onNextEpisode?: () => void;
}
export function MobileVideoPlayer({
src,
poster,
onError,
onTimeUpdate,
initialTime = 0,
shouldAutoPlay = false,
totalEpisodes = 1,
currentEpisodeIndex = 0,
onNextEpisode,
}: MobileVideoPlayerProps) {
const { refs, state } = useMobilePlayerState();
const {
videoRef,
containerRef,
controlsTimeoutRef
} = refs;
// Initialize HLS Player (same as Desktop - fixes Android Chrome playback)
useHlsPlayer({
videoRef,
src,
autoPlay: shouldAutoPlay,
onError,
});
const {
isPlaying,
isFullscreen,
showControls,
isLoading,
showSkipIndicator,
skipAmount,
skipSide,
toastMessage,
showToast,
currentTime,
duration,
setShowControls,
setIsLoading
} = state;
const logic = useMobilePlayerLogic({
src,
initialTime,
shouldAutoPlay,
onError,
onTimeUpdate,
refs,
state
});
// Auto-skip intro/outro and auto-next episode
useAutoSkip({
videoRef,
currentTime,
duration,
isPlaying,
totalEpisodes,
currentEpisodeIndex,
onNextEpisode,
});
const {
skipVideo,
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
} = logic;
// Screen orientation management
useScreenOrientation(isFullscreen);
// Double tap handler
const { handleTap } = useMobileGestures({
skipVideo,
showSkipIndicator,
showControls,
setShowControls,
controlsTimeoutRef,
isPlaying,
togglePlay,
});
return (
<div
ref={containerRef}
className="relative aspect-video bg-black rounded-[var(--radius-2xl)] overflow-hidden"
>
{/* Video Element - src is set by useHlsPlayer hook */}
<video
ref={videoRef}
className="w-full h-full object-contain touch-none"
poster={poster}
onPlay={handlePlay}
onPause={handlePause}
onTimeUpdate={handleTimeUpdateEvent}
onLoadedMetadata={handleLoadedMetadata}
onError={handleVideoError}
onWaiting={() => setIsLoading(true)}
onCanPlay={() => setIsLoading(false)}
onTouchEnd={handleTap}
onClick={(e) => e.preventDefault()}
playsInline
webkit-playsinline="true"
x-webkit-airplay="allow"
/>
<MobileOverlay
isLoading={isLoading}
showToast={showToast}
toastMessage={toastMessage}
/>
<MobileSkipIndicator
showSkipIndicator={showSkipIndicator}
skipSide={skipSide}
skipAmount={skipAmount}
/>
<MobileControlsWrapper
src={src}
state={state}
logic={logic}
refs={refs}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
interface PlayerErrorProps {
error: string;
onBack: () => void;
onRetry: () => void;
}
export function PlayerError({ error, onBack, onRetry }: PlayerErrorProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Card className="max-w-2xl">
<Icons.AlertTriangle size={64} className="mx-auto mb-4 text-red-500" />
<h2 className="text-2xl font-bold text-[var(--text-color)] mb-4"></h2>
<p className="text-[var(--text-color-secondary)] mb-6">{error}</p>
<div className="flex gap-3 justify-center">
<Button
variant="primary"
onClick={onBack}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span></span>
</Button>
<Button
variant="secondary"
onClick={onRetry}
className="flex items-center gap-2"
>
<Icons.RefreshCw size={20} />
<span></span>
</Button>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import Image from 'next/image';
import { Button } from '@/components/ui/Button';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
import { Icons } from '@/components/ui/Icon';
export function PlayerNavbar() {
const router = useRouter();
return (
<nav className="sticky top-0 z-50 pt-4 pb-2 px-4" style={{ transform: 'translateZ(0)' }}>
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-sm)] px-4 sm:px-6 py-4">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
<button
onClick={() => router.push('/')}
className="flex items-center justify-center hover:opacity-80 transition-opacity flex-shrink-0 cursor-pointer"
title="返回首页"
>
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</button>
<Button
variant="secondary"
onClick={() => router.back()}
className="flex items-center gap-2"
>
<Icons.ChevronLeft size={20} />
<span className="hidden sm:inline"></span>
</Button>
</div>
<div className="flex items-center gap-3">
<Link
href="/settings"
className="w-10 h-10 flex items-center justify-center rounded-[var(--radius-full)] bg-[var(--glass-bg)] border border-[var(--glass-border)] text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all duration-200 cursor-pointer"
aria-label="设置"
>
<svg className="w-5 h-5" viewBox="0 -960 960 960" fill="currentColor">
<path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z" />
</svg>
</Link>
<ThemeSwitcher />
</div>
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { Icons } from '@/components/ui/Icon';
import { getSourceName } from '@/lib/utils/source-names';
interface VideoMetadataProps {
videoData: any;
source: string | null;
title?: string | null;
}
export function VideoMetadata({ videoData, source, title }: VideoMetadataProps) {
return (
<Card hover={false}>
<div className="flex flex-col sm:flex-row items-start gap-4">
{videoData?.vod_pic && (
<img
src={videoData.vod_pic}
alt={videoData.vod_name}
className="w-24 h-36 sm:w-32 sm:h-48 object-cover rounded-[var(--radius-2xl)] border border-[var(--glass-border)]"
/>
)}
<div className="flex-1">
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold text-[var(--text-color)] mb-3">
{videoData?.vod_name || title}
</h1>
<div className="flex flex-wrap gap-2 mb-4">
{source && (
<Badge variant="primary" className="backdrop-blur-md">
<Icons.Check size={14} className="mr-1" />
{getSourceName(source)}
</Badge>
)}
{videoData?.type_name && (
<Badge variant="secondary">{videoData.type_name}</Badge>
)}
{videoData?.vod_year && (
<Badge variant="secondary">
<Icons.Calendar size={14} className="mr-1" />
{videoData.vod_year}
</Badge>
)}
{videoData?.vod_area && (
<Badge variant="secondary">
<Icons.Globe size={14} className="mr-1" />
{videoData.vod_area}
</Badge>
)}
</div>
{videoData?.vod_content && (
<p className="text-sm sm:text-base text-[var(--text-secondary)]">
{videoData.vod_content.replace(/<[^>]*>/g, '')}
</p>
)}
{videoData?.vod_actor && (
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-2">
<span className="font-semibold"></span>
{videoData.vod_actor}
</p>
)}
{videoData?.vod_director && (
<p className="text-xs sm:text-sm text-[var(--text-tertiary)] mt-1">
<span className="font-semibold"></span>
{videoData.vod_director}
</p>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,191 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { useHistoryStore } from '@/lib/store/history-store';
import { settingsStore } from '@/lib/store/settings-store';
import { CustomVideoPlayer } from './CustomVideoPlayer';
import { VideoPlayerError } from './VideoPlayerError';
import { VideoPlayerEmpty } from './VideoPlayerEmpty';
interface VideoPlayerProps {
playUrl: string;
videoId?: string;
currentEpisode: number;
onBack: () => void;
// Episode navigation props for auto-skip/auto-next
totalEpisodes?: number;
onNextEpisode?: () => void;
}
export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack, totalEpisodes, onNextEpisode }: VideoPlayerProps) {
const [videoError, setVideoError] = useState<string>('');
const [useProxy, setUseProxy] = useState(false);
const [shouldAutoPlay, setShouldAutoPlay] = useState(true);
const [retryCount, setRetryCount] = useState(0);
const MAX_MANUAL_RETRIES = 20;
const lastSaveTimeRef = useRef(0);
const currentTimeRef = useRef(0);
const durationRef = useRef(0);
const SAVE_INTERVAL = 5000; // 5 seconds throttle
// Get showModeIndicator setting
const [showModeIndicator, setShowModeIndicator] = useState(false);
useEffect(() => {
setShowModeIndicator(settingsStore.getSettings().showModeIndicator);
}, []);
// Use reactive hook to subscribe to history updates
// This ensures the component re-renders when history is hydrated from localStorage
const viewingHistory = useHistoryStore(state => state.viewingHistory);
const searchParams = useSearchParams();
const { addToHistory } = useHistoryStore();
// Get video metadata from URL params
const source = searchParams.get('source') || '';
const title = searchParams.get('title') || '未知视频';
// Get saved progress for this video
const getSavedProgress = () => {
if (!videoId) return 0;
// Directly check HistoryStore for progress
// We prioritize a strict match (including source), but fall back to any match for this video/episode
// This fixes issues where the source parameter might be missing or different
const historyItem = viewingHistory.find(item =>
item.videoId.toString() === videoId?.toString() &&
item.episodeIndex === currentEpisode &&
(source ? item.source === source : true)
) || viewingHistory.find(item =>
item.videoId.toString() === videoId?.toString() &&
item.episodeIndex === currentEpisode
);
return historyItem ? historyItem.playbackPosition : 0;
};
// Save progress function (used by throttle and beforeunload)
const saveProgress = useCallback((currentTime: number, duration: number) => {
if (!videoId || !playUrl || duration === 0 || currentTime <= 1) return;
addToHistory(
videoId,
title,
playUrl,
currentEpisode,
source,
currentTime,
duration,
undefined,
[]
);
}, [videoId, playUrl, title, currentEpisode, source, addToHistory]);
// Handle time updates and save progress (throttled to every 5 seconds)
const handleTimeUpdate = (currentTime: number, duration: number) => {
// Always track current time for beforeunload
currentTimeRef.current = currentTime;
durationRef.current = duration;
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;
saveProgress(currentTime, duration);
}
};
// Save on page leave/refresh
useEffect(() => {
const handleBeforeUnload = () => {
// Save current progress before leaving
if (currentTimeRef.current > 1 && durationRef.current > 0) {
saveProgress(currentTimeRef.current, durationRef.current);
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [saveProgress]);
// Handle video errors
const handleVideoError = (error: string) => {
console.error('Video playback error:', error);
// Auto-retry with proxy if not already using it
if (!useProxy) {
setUseProxy(true);
setShouldAutoPlay(true); // Force autoplay after proxy retry
setVideoError('');
return;
}
setVideoError(error);
};
const handleRetry = () => {
if (retryCount >= MAX_MANUAL_RETRIES) return;
setRetryCount(prev => prev + 1);
setVideoError('');
setShouldAutoPlay(true);
// Toggle proxy to try different path, but since we are already in error state which likely means proxy failed (or direct failed),
// we can try toggling or just force re-render.
// Requirement says: "try without proxy and proxy and same as before"
// We will just toggle useProxy state to force a refresh with/without proxy.
// However, if we want to cycle, we can just toggle.
// But the requirement says "proxy attempt count to 20".
// So we just increment count and maybe toggle proxy or keep it.
// Let's toggle it to give best chance.
// Actually requirement says "try no proxy and proxy and same as before".
// So simple toggle is fine.
setUseProxy(prev => !prev);
};
const finalPlayUrl = useProxy
? `/api/proxy?url=${encodeURIComponent(playUrl)}&retry=${retryCount}` // Add retry param to force fresh request
: playUrl;
if (!playUrl) {
return <VideoPlayerEmpty />;
}
return (
<Card hover={false} className="p-0 overflow-hidden relative">
{/* Mode Indicator Badge - controlled by settings */}
{showModeIndicator && (
<div className="absolute top-3 right-3 z-30">
<span className={`px-2 py-1 text-xs font-medium rounded-full backdrop-blur-md transition-all duration-300 ${useProxy
? 'bg-orange-500/80 text-white'
: 'bg-green-500/80 text-white'
}`}>
{useProxy ? '代理模式' : '直连模式'}
</span>
</div>
)}
{videoError ? (
<VideoPlayerError
error={videoError}
onBack={onBack}
onRetry={handleRetry}
retryCount={retryCount}
maxRetries={MAX_MANUAL_RETRIES}
/>
) : (
<CustomVideoPlayer
key={`${useProxy ? 'proxy' : 'direct'}-${retryCount}`} // Force remount when switching modes or retrying
src={finalPlayUrl}
onError={handleVideoError}
onTimeUpdate={handleTimeUpdate}
initialTime={getSavedProgress()}
shouldAutoPlay={shouldAutoPlay}
totalEpisodes={totalEpisodes}
currentEpisodeIndex={currentEpisode}
onNextEpisode={onNextEpisode}
/>
)}
</Card>
);
}

View File

@@ -0,0 +1,17 @@
'use client';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
export function VideoPlayerEmpty() {
return (
<Card hover={false} className="p-0 overflow-hidden">
<div className="aspect-video bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] flex items-center justify-center border border-[var(--glass-border)]">
<div className="text-center text-[var(--text-secondary)]">
<Icons.TV size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p></p>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
interface VideoPlayerErrorProps {
error: string;
onBack: () => void;
onRetry: () => void;
retryCount: number;
maxRetries: number;
}
export function VideoPlayerError({
error,
onBack,
onRetry,
retryCount,
maxRetries,
}: VideoPlayerErrorProps) {
return (
<div className="aspect-video bg-black rounded-[var(--radius-2xl)] flex items-center justify-center">
{/* Glass Card Container */}
<div
className="player-error-glass animate-in fade-in zoom-in-95 duration-300"
role="alert"
aria-live="assertive"
aria-atomic="true"
>
{/* Glowing Error Icon */}
<div className="relative">
<Icons.AlertTriangle
size={56}
className="error-icon mx-auto mb-4"
/>
{/* Glow effect */}
<div className="absolute inset-0 blur-xl bg-red-500/30 rounded-full -z-10" />
</div>
<h3></h3>
<p>{error}</p>
{/* Action Buttons */}
<div className="flex gap-3 justify-center flex-wrap">
<button
onClick={onBack}
className="btn-glass px-4 py-2 flex items-center gap-2"
>
<Icons.ChevronLeft size={18} />
<span></span>
</button>
{retryCount < maxRetries && (
<button
onClick={onRetry}
className="btn-glass px-4 py-2 flex items-center gap-2 !bg-[var(--accent-color)]/80 hover:!bg-[var(--accent-color)]"
>
<Icons.RefreshCw size={18} />
<span> ({retryCount}/{maxRetries})</span>
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import React from 'react';
import { DesktopProgressBar } from './DesktopProgressBar';
import { DesktopLeftControls } from './DesktopLeftControls';
import { DesktopRightControls } from './DesktopRightControls';
interface DesktopControlsProps {
showControls: boolean;
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
isFullscreen: boolean;
playbackRate: number;
showSpeedMenu: boolean;
showMoreMenu: boolean;
showVolumeBar: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isProxied?: boolean;
progressBarRef: React.RefObject<HTMLDivElement | null>;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
onSkipForward: () => void;
onSkipBackward: () => void;
onToggleMute: () => void;
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
onSpeedMenuMouseEnter: () => void;
onSpeedMenuMouseLeave: () => void;
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
formatTime: (seconds: number) => string;
speeds: number[];
}
export function DesktopControls(props: DesktopControlsProps) {
const {
showControls,
currentTime,
duration,
progressBarRef,
onProgressClick,
onProgressMouseDown,
formatTime,
} = props;
return (
<div
className={`absolute bottom-0 left-0 right-0 z-30 transition-all duration-300 ${showControls ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2'
}`}
style={{ pointerEvents: showControls ? 'auto' : 'none' }}
>
{/* Progress Bar */}
<DesktopProgressBar
progressBarRef={progressBarRef}
currentTime={currentTime}
duration={duration}
onProgressClick={onProgressClick}
onProgressMouseDown={onProgressMouseDown}
/>
{/* Controls Bar */}
<div className="bg-gradient-to-t from-black/90 via-black/70 to-transparent px-4 pb-4 pt-2">
<div className="flex items-center justify-between gap-4">
<DesktopLeftControls {...props} formatTime={formatTime} />
<DesktopRightControls {...props} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React from 'react';
import { DesktopControls } from './DesktopControls';
import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
import { useDesktopPlayerLogic } from '../hooks/useDesktopPlayerLogic';
interface DesktopControlsWrapperProps {
src: string;
state: ReturnType<typeof useDesktopPlayerState>['state'];
logic: ReturnType<typeof useDesktopPlayerLogic>;
refs: ReturnType<typeof useDesktopPlayerState>['refs'];
}
export function DesktopControlsWrapper({ src, state, logic, refs }: DesktopControlsWrapperProps) {
const {
isPlaying,
currentTime,
duration,
volume,
isMuted,
isFullscreen,
showControls,
playbackRate,
showSpeedMenu,
showMoreMenu,
showVolumeBar,
isPiPSupported,
isAirPlaySupported,
setShowSpeedMenu,
setShowMoreMenu,
} = state;
const {
togglePlay,
skipForward,
skipBackward,
toggleMute,
handleVolumeChange,
handleVolumeMouseDown,
toggleFullscreen,
togglePictureInPicture,
showAirPlayMenu,
changePlaybackSpeed,
handleCopyLink,
handleProgressClick,
handleProgressMouseDown,
startSpeedMenuTimeout,
clearSpeedMenuTimeout,
formatTime,
} = logic;
const {
progressBarRef,
volumeBarRef,
moreMenuTimeoutRef,
} = refs;
const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
const isProxied = src.includes('/api/proxy');
return (
<DesktopControls
showControls={showControls}
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
showSpeedMenu={showSpeedMenu}
showMoreMenu={showMoreMenu}
showVolumeBar={showVolumeBar}
isPiPSupported={isPiPSupported}
isAirPlaySupported={isAirPlaySupported}
isProxied={isProxied}
progressBarRef={progressBarRef}
volumeBarRef={volumeBarRef}
onTogglePlay={togglePlay}
onSkipForward={skipForward}
onSkipBackward={skipBackward}
onToggleMute={toggleMute}
onVolumeChange={handleVolumeChange}
onVolumeMouseDown={handleVolumeMouseDown}
onToggleFullscreen={toggleFullscreen}
onTogglePictureInPicture={togglePictureInPicture}
onShowAirPlayMenu={showAirPlayMenu}
onToggleSpeedMenu={() => setShowSpeedMenu(!showSpeedMenu)}
onToggleMoreMenu={() => setShowMoreMenu(!showMoreMenu)}
onSpeedChange={changePlaybackSpeed}
onCopyLink={handleCopyLink}
onProgressClick={handleProgressClick}
onProgressMouseDown={handleProgressMouseDown}
onSpeedMenuMouseEnter={clearSpeedMenuTimeout}
onSpeedMenuMouseLeave={startSpeedMenuTimeout}
onMoreMenuMouseEnter={() => {
if (moreMenuTimeoutRef.current) {
clearTimeout(moreMenuTimeoutRef.current);
}
}}
onMoreMenuMouseLeave={() => {
moreMenuTimeoutRef.current = setTimeout(() => {
setShowMoreMenu(false);
}, 300);
}}
formatTime={formatTime}
speeds={speeds}
/>
);
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { DesktopVolumeControl } from './DesktopVolumeControl';
interface DesktopLeftControlsProps {
isPlaying: boolean;
currentTime: number;
duration: number;
volume: number;
isMuted: boolean;
showVolumeBar: boolean;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
onTogglePlay: () => void;
onSkipForward: () => void;
onSkipBackward: () => void;
onToggleMute: () => void;
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
formatTime: (seconds: number) => string;
}
export function DesktopLeftControls({
isPlaying,
currentTime,
duration,
volume,
isMuted,
showVolumeBar,
volumeBarRef,
onTogglePlay,
onSkipForward,
onSkipBackward,
onToggleMute,
onVolumeChange,
onVolumeMouseDown,
formatTime
}: DesktopLeftControlsProps) {
return (
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={onTogglePlay}
className="btn-icon"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Icons.Pause size={20} /> : <Icons.Play size={20} />}
</button>
{/* Skip Backward 10s */}
<button
onClick={onSkipBackward}
className="btn-icon"
aria-label="Skip backward 10 seconds"
title="后退 10 秒"
>
<Icons.SkipBack size={20} />
</button>
{/* Skip Forward 10s */}
<button
onClick={onSkipForward}
className="btn-icon"
aria-label="Skip forward 10 seconds"
title="快进 10 秒"
>
<Icons.SkipForward size={20} />
</button>
{/* Volume */}
<DesktopVolumeControl
volumeBarRef={volumeBarRef}
volume={volume}
isMuted={isMuted}
showVolumeBar={showVolumeBar}
onToggleMute={onToggleMute}
onVolumeChange={onVolumeChange}
onVolumeMouseDown={onVolumeMouseDown}
/>
{/* Time */}
<span className="text-white text-sm font-medium tabular-nums">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
);
}

View File

@@ -0,0 +1,214 @@
'use client';
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { usePlayerSettings } from '../hooks/usePlayerSettings';
interface DesktopMoreMenuProps {
showMoreMenu: boolean;
isProxied?: boolean;
onToggleMoreMenu: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
}
export function DesktopMoreMenu({
showMoreMenu,
isProxied = false,
onToggleMoreMenu,
onMouseEnter,
onMouseLeave,
onCopyLink
}: DesktopMoreMenuProps) {
const {
autoNextEpisode,
autoSkipIntro,
skipIntroSeconds,
autoSkipOutro,
skipOutroSeconds,
showModeIndicator,
setAutoNextEpisode,
setAutoSkipIntro,
setSkipIntroSeconds,
setAutoSkipOutro,
setSkipOutroSeconds,
setShowModeIndicator,
} = usePlayerSettings();
return (
<div className="relative">
<button
onClick={onToggleMoreMenu}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="btn-icon"
aria-label="More options"
title="更多选项"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="1" />
<circle cx="12" cy="5" r="1" />
<circle cx="12" cy="19" r="1" />
</svg>
</button>
{/* More Menu Dropdown */}
{showMoreMenu && (
<div
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[220px]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={(e) => e.stopPropagation()}
>
{/* Copy Link Options */}
{isProxied ? (
<>
<button
onClick={() => onCopyLink('original')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
>
<Icons.Link size={18} />
<span></span>
</button>
<button
onClick={() => onCopyLink('proxy')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 mt-1 cursor-pointer"
>
<Icons.Link size={18} />
<span></span>
</button>
</>
) : (
<button
onClick={() => onCopyLink('original')}
className="w-full px-4 py-2.5 text-left text-sm text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] rounded-[var(--radius-2xl)] transition-colors flex items-center gap-3 cursor-pointer"
>
<Icons.Link size={18} />
<span></span>
</button>
)}
{/* Divider */}
<div className="h-px bg-[var(--glass-border)] my-2" />
{/* Show Mode Indicator Switch */}
<div className="px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
<Icons.Zap size={18} />
<span></span>
</div>
<button
onClick={() => setShowModeIndicator(!showModeIndicator)}
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${showModeIndicator ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
}`}
aria-checked={showModeIndicator}
role="switch"
>
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${showModeIndicator ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Auto Next Episode Switch */}
<div className="px-4 py-2.5 flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
<Icons.SkipForward size={18} />
<span></span>
</div>
<button
onClick={() => setAutoNextEpisode(!autoNextEpisode)}
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoNextEpisode ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
}`}
aria-checked={autoNextEpisode}
role="switch"
>
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoNextEpisode ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Skip Intro Switch */}
<div className="px-4 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
<Icons.FastForward size={18} />
<span></span>
</div>
<button
onClick={() => setAutoSkipIntro(!autoSkipIntro)}
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipIntro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
}`}
aria-checked={autoSkipIntro}
role="switch"
>
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipIntro ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Expandable Input */}
{autoSkipIntro && (
<div className="mt-2 ml-7 flex items-center gap-2">
<span className="text-xs text-[var(--text-color-secondary)]">:</span>
<input
type="number"
min="0"
max="600"
value={skipIntroSeconds}
onChange={(e) => setSkipIntroSeconds(parseInt(e.target.value) || 0)}
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-[var(--text-color-secondary)]"></span>
</div>
)}
</div>
{/* Skip Outro Switch */}
<div className="px-4 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-sm text-[var(--text-color)]">
<Icons.Rewind size={18} />
<span></span>
</div>
<button
onClick={() => setAutoSkipOutro(!autoSkipOutro)}
className={`relative w-10 h-6 rounded-full transition-colors cursor-pointer ${autoSkipOutro ? 'bg-[var(--accent-color)]' : 'bg-[color-mix(in_srgb,var(--text-color)_20%,transparent)]'
}`}
aria-checked={autoSkipOutro}
role="switch"
>
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform shadow-sm ${autoSkipOutro ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Expandable Input */}
{autoSkipOutro && (
<div className="mt-2 ml-7 flex items-center gap-2">
<span className="text-xs text-[var(--text-color-secondary)]">:</span>
<input
type="number"
min="0"
max="600"
value={skipOutroSeconds}
onChange={(e) => setSkipOutroSeconds(parseInt(e.target.value) || 0)}
className="w-16 px-2 py-1 text-sm text-center bg-[var(--glass-bg)] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] text-[var(--text-color)] focus:outline-none focus:border-[var(--accent-color)] no-spinner"
onClick={(e) => e.stopPropagation()}
/>
<span className="text-xs text-[var(--text-color-secondary)]"></span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
interface DesktopOverlayProps {
isLoading: boolean;
isPlaying: boolean;
showSkipForwardIndicator: boolean;
showSkipBackwardIndicator: boolean;
skipForwardAmount: number;
skipBackwardAmount: number;
isSkipForwardAnimatingOut: boolean;
isSkipBackwardAnimatingOut: boolean;
showToast: boolean;
toastMessage: string | null;
onTogglePlay: () => void;
}
export function DesktopOverlay({
isLoading,
isPlaying,
showSkipForwardIndicator,
showSkipBackwardIndicator,
skipForwardAmount,
skipBackwardAmount,
isSkipForwardAnimatingOut,
isSkipBackwardAnimatingOut,
showToast,
toastMessage,
onTogglePlay
}: DesktopOverlayProps) {
return (
<>
{/* Loading Spinner - Glass Effect */}
{isLoading && (
<div className="loading-overlay-glass">
<div className="spinner-glass"></div>
</div>
)}
{/* Skip Forward Indicator */}
{showSkipForwardIndicator && (
<div className="absolute top-1/2 right-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipForwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
}`}>
+{skipForwardAmount}
</div>
</div>
)}
{/* Skip Backward Indicator */}
{showSkipBackwardIndicator && (
<div className="absolute top-1/2 left-12 -translate-y-1/2 pointer-events-none transition-all duration-300">
<div className={`text-white text-3xl font-bold drop-shadow-[0_4px_8px_rgba(0,0,0,0.8)] ${isSkipBackwardAnimatingOut ? 'animate-scale-out' : 'animate-scale-in'
}`}>
-{skipBackwardAmount}
</div>
</div>
)}
{/* Center Play Button (when paused) */}
{!isPlaying && !isLoading && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<button
onClick={onTogglePlay}
className="pointer-events-auto w-20 h-20 rounded-full bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] flex items-center justify-center transition-all duration-300 hover:scale-110 hover:bg-[var(--accent-color)] shadow-[var(--shadow-md)] will-change-transform cursor-pointer"
aria-label="Play"
>
<Icons.Play size={32} className="text-white ml-1" />
</button>
</div>
)}
{/* Toast Notification */}
{showToast && toastMessage && (
<div className="absolute bottom-24 left-1/2 -translate-x-1/2 z-[200] animate-slide-up">
<div className="bg-[rgba(28,28,30,0.95)] backdrop-blur-[25px] rounded-[var(--radius-2xl)] border border-white/20 shadow-[0_8px_32px_rgba(0,0,0,0.6)] px-6 py-3 flex items-center gap-3 min-w-[200px]">
<Icons.Check size={18} className="text-[#34c759] flex-shrink-0" />
<span className="text-white text-sm font-medium">{toastMessage}</span>
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { DesktopOverlay } from './DesktopOverlay';
import { useDesktopPlayerState } from '../hooks/useDesktopPlayerState';
interface DesktopOverlayWrapperProps {
state: ReturnType<typeof useDesktopPlayerState>['state'];
onTogglePlay: () => void;
}
export function DesktopOverlayWrapper({ state, onTogglePlay }: DesktopOverlayWrapperProps) {
const {
isLoading,
isPlaying,
showSkipForwardIndicator,
showSkipBackwardIndicator,
skipForwardAmount,
skipBackwardAmount,
isSkipForwardAnimatingOut,
isSkipBackwardAnimatingOut,
showToast,
toastMessage,
} = state;
return (
<DesktopOverlay
isLoading={isLoading}
isPlaying={isPlaying}
showSkipForwardIndicator={showSkipForwardIndicator}
showSkipBackwardIndicator={showSkipBackwardIndicator}
skipForwardAmount={skipForwardAmount}
skipBackwardAmount={skipBackwardAmount}
isSkipForwardAnimatingOut={isSkipForwardAnimatingOut}
isSkipBackwardAnimatingOut={isSkipBackwardAnimatingOut}
showToast={showToast}
toastMessage={toastMessage}
onTogglePlay={onTogglePlay}
/>
);
}

View File

@@ -0,0 +1,39 @@
import React, { RefObject } from 'react';
interface DesktopProgressBarProps {
progressBarRef: RefObject<HTMLDivElement | null>;
currentTime: number;
duration: number;
onProgressClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onProgressMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export function DesktopProgressBar({
progressBarRef,
currentTime,
duration,
onProgressClick,
onProgressMouseDown
}: DesktopProgressBarProps) {
return (
<div className="px-4 pb-1">
<div
ref={progressBarRef}
className="slider-track cursor-pointer"
onClick={onProgressClick}
onMouseDown={onProgressMouseDown}
style={{ pointerEvents: 'auto' }}
>
<div
className="slider-range"
style={{ width: `${(currentTime / duration) * 100 || 0}%` }}
/>
<div
className="slider-thumb"
style={{ left: `${(currentTime / duration) * 100 || 0}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Icons } from '@/components/ui/Icon';
import { DesktopSpeedMenu } from './DesktopSpeedMenu';
import { DesktopMoreMenu } from './DesktopMoreMenu';
interface DesktopRightControlsProps {
isFullscreen: boolean;
playbackRate: number;
showSpeedMenu: boolean;
showMoreMenu: boolean;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
isProxied?: boolean;
onToggleFullscreen: () => void;
onTogglePictureInPicture: () => void;
onShowAirPlayMenu: () => void;
onToggleSpeedMenu: () => void;
onToggleMoreMenu: () => void;
onSpeedChange: (speed: number) => void;
onCopyLink: (type?: 'original' | 'proxy') => void;
onSpeedMenuMouseEnter: () => void;
onSpeedMenuMouseLeave: () => void;
onMoreMenuMouseEnter: () => void;
onMoreMenuMouseLeave: () => void;
speeds: number[];
}
export function DesktopRightControls({
isFullscreen,
playbackRate,
showSpeedMenu,
showMoreMenu,
isPiPSupported,
isAirPlaySupported,
isProxied,
onToggleFullscreen,
onTogglePictureInPicture,
onShowAirPlayMenu,
onToggleSpeedMenu,
onToggleMoreMenu,
onSpeedChange,
onCopyLink,
onSpeedMenuMouseEnter,
onSpeedMenuMouseLeave,
onMoreMenuMouseEnter,
onMoreMenuMouseLeave,
speeds
}: DesktopRightControlsProps) {
return (
<div className="flex items-center gap-3">
{/* Playback Speed */}
<DesktopSpeedMenu
showSpeedMenu={showSpeedMenu}
playbackRate={playbackRate}
speeds={speeds}
onSpeedChange={onSpeedChange}
onToggleSpeedMenu={onToggleSpeedMenu}
onMouseEnter={onSpeedMenuMouseEnter}
onMouseLeave={onSpeedMenuMouseLeave}
/>
{/* Picture-in-Picture */}
{isPiPSupported && (
<button
onClick={onTogglePictureInPicture}
className="btn-icon"
aria-label="Picture-in-Picture"
title="画中画"
>
<Icons.PictureInPicture size={20} />
</button>
)}
{/* AirPlay */}
{isAirPlaySupported && (
<button
onClick={onShowAirPlayMenu}
className="btn-icon"
aria-label="AirPlay"
title="AirPlay"
>
<Icons.Airplay size={20} />
</button>
)}
{/* More Menu */}
<DesktopMoreMenu
showMoreMenu={showMoreMenu}
isProxied={isProxied}
onToggleMoreMenu={onToggleMoreMenu}
onMouseEnter={onMoreMenuMouseEnter}
onMouseLeave={onMoreMenuMouseLeave}
onCopyLink={onCopyLink}
/>
{/* Fullscreen */}
<button
onClick={onToggleFullscreen}
className="btn-icon"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
{isFullscreen ? <Icons.Minimize size={20} /> : <Icons.Maximize size={20} />}
</button>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from 'react';
interface DesktopSpeedMenuProps {
showSpeedMenu: boolean;
playbackRate: number;
speeds: number[];
onSpeedChange: (speed: number) => void;
onToggleSpeedMenu: () => void;
onMouseEnter: () => void;
onMouseLeave: () => void;
}
export function DesktopSpeedMenu({
showSpeedMenu,
playbackRate,
speeds,
onSpeedChange,
onToggleSpeedMenu,
onMouseEnter,
onMouseLeave
}: DesktopSpeedMenuProps) {
return (
<div className="relative">
<button
onClick={onToggleSpeedMenu}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
className="btn-icon text-xs font-semibold min-w-[2.5rem]"
aria-label="Playback speed"
>
{playbackRate}x
</button>
{/* Speed Menu */}
{showSpeedMenu && (
<div
className="absolute bottom-full right-0 mb-2 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] p-2 min-w-[5rem]"
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{speeds.map((speed) => (
<button
key={speed}
onClick={() => onSpeedChange(speed)}
className={`w-full px-3 py-2 rounded-[var(--radius-2xl)] text-sm font-medium transition-colors ${playbackRate === speed
? 'bg-[var(--accent-color)] text-white'
: 'text-[var(--text-color)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)]'
}`}
>
{speed}x
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React, { RefObject } from 'react';
import { Icons } from '@/components/ui/Icon';
interface DesktopVolumeControlProps {
volumeBarRef: RefObject<HTMLDivElement | null>;
volume: number;
isMuted: boolean;
showVolumeBar: boolean;
onToggleMute: () => void;
onVolumeChange: (e: React.MouseEvent<HTMLDivElement>) => void;
onVolumeMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void;
}
export function DesktopVolumeControl({
volumeBarRef,
volume,
isMuted,
showVolumeBar,
onToggleMute,
onVolumeChange,
onVolumeMouseDown
}: DesktopVolumeControlProps) {
return (
<div className="flex items-center gap-2 group/volume">
<button
onClick={onToggleMute}
className="btn-icon"
aria-label={isMuted ? 'Unmute' : 'Mute'}
>
{isMuted || volume === 0 ? (
<Icons.VolumeX size={20} />
) : volume < 0.5 ? (
<Icons.Volume1 size={20} />
) : (
<Icons.Volume2 size={20} />
)}
</button>
{/* Volume Bar */}
<div className={`flex items-center gap-2 overflow-hidden transition-all duration-300 ${showVolumeBar
? 'opacity-100 w-32'
: 'opacity-0 w-0 group-hover/volume:opacity-100 group-hover/volume:w-32'
}`}>
<div
ref={volumeBarRef}
className="slider-track h-1 cursor-pointer flex-1"
onClick={onVolumeChange}
onMouseDown={onVolumeMouseDown}
>
<div
className="slider-range h-full"
style={{ width: `${isMuted ? 0 : volume * 100}%` }}
/>
<div
className="slider-thumb"
style={{ left: `${isMuted ? 0 : volume * 100}%` }}
/>
</div>
<span className="text-white text-xs font-medium tabular-nums min-w-[2rem]">
{Math.round((isMuted ? 0 : volume) * 100)}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useCallback } from 'react';
interface UseControlsVisibilityProps {
isPlaying: boolean;
showControls: boolean;
showSpeedMenu: boolean;
setShowControls: (show: boolean) => void;
setShowSpeedMenu: (show: boolean) => void;
controlsTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
speedMenuTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
mouseMoveThrottleRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function useControlsVisibility({
isPlaying,
showControls,
showSpeedMenu,
setShowControls,
setShowSpeedMenu,
controlsTimeoutRef,
speedMenuTimeoutRef,
mouseMoveThrottleRef
}: UseControlsVisibilityProps) {
useEffect(() => {
if (!isPlaying) return;
const hideControls = () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
controlsTimeoutRef.current = setTimeout(() => {
if (isPlaying && !showSpeedMenu) {
setShowControls(false);
}
}, 3000);
};
hideControls();
return () => {
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
};
}, [isPlaying, showSpeedMenu, setShowControls, controlsTimeoutRef]);
const handleMouseMove = useCallback(() => {
if (mouseMoveThrottleRef.current) return;
mouseMoveThrottleRef.current = setTimeout(() => {
mouseMoveThrottleRef.current = null;
}, 200);
if (!showControls) {
setShowControls(true);
}
if (isPlaying && controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
controlsTimeoutRef.current = setTimeout(() => setShowControls(false), 3000);
}
}, [showControls, isPlaying, setShowControls, controlsTimeoutRef, mouseMoveThrottleRef]);
const startSpeedMenuTimeout = useCallback(() => {
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
speedMenuTimeoutRef.current = setTimeout(() => {
setShowSpeedMenu(false);
}, 1500);
}, [speedMenuTimeoutRef, setShowSpeedMenu]);
const clearSpeedMenuTimeout = useCallback(() => {
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
}, [speedMenuTimeoutRef]);
useEffect(() => {
if (showSpeedMenu) {
startSpeedMenuTimeout();
} else {
clearSpeedMenuTimeout();
}
return () => clearSpeedMenuTimeout();
}, [showSpeedMenu, startSpeedMenuTimeout, clearSpeedMenuTimeout]);
return {
handleMouseMove,
startSpeedMenuTimeout,
clearSpeedMenuTimeout
};
}

View File

@@ -0,0 +1,124 @@
import { useEffect } from 'react';
interface UseDesktopShortcutsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
isPlaying: boolean;
volume: number;
isPiPSupported: boolean;
togglePlay: () => void;
toggleMute: () => void;
toggleFullscreen: () => void;
togglePictureInPicture: () => void;
skipForward: () => void;
skipBackward: () => void;
showVolumeBarTemporarily: () => void;
setShowControls: (show: boolean) => void;
setVolume: (volume: number) => void;
setIsMuted: (muted: boolean) => void;
controlsTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function useDesktopShortcuts({
videoRef,
isPlaying,
volume,
isPiPSupported,
togglePlay,
toggleMute,
toggleFullscreen,
togglePictureInPicture,
skipForward,
skipBackward,
showVolumeBarTemporarily,
setShowControls,
setVolume,
setIsMuted,
controlsTimeoutRef,
}: UseDesktopShortcutsProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore shortcuts if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
}
// Show controls on any key press
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (isPlaying) {
controlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
switch (e.key.toLowerCase()) {
case ' ':
case 'k':
e.preventDefault();
togglePlay();
break;
case 'f':
e.preventDefault();
toggleFullscreen();
break;
case 'm':
e.preventDefault();
toggleMute();
break;
case 'p':
if (isPiPSupported) {
e.preventDefault();
togglePictureInPicture();
}
break;
case 'arrowright':
case 'l':
e.preventDefault();
skipForward();
break;
case 'arrowleft':
case 'j':
e.preventDefault();
skipBackward();
break;
case 'arrowup':
e.preventDefault();
const newVolUp = Math.min(1, volume + 0.1);
setVolume(newVolUp);
if (videoRef.current) videoRef.current.volume = newVolUp;
setIsMuted(newVolUp === 0);
showVolumeBarTemporarily();
break;
case 'arrowdown':
e.preventDefault();
const newVolDown = Math.max(0, volume - 0.1);
setVolume(newVolDown);
if (videoRef.current) videoRef.current.volume = newVolDown;
setIsMuted(newVolDown === 0);
showVolumeBarTemporarily();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
videoRef,
isPlaying,
volume,
isPiPSupported,
togglePlay,
toggleMute,
toggleFullscreen,
togglePictureInPicture,
skipForward,
skipBackward,
showVolumeBarTemporarily,
setShowControls,
setVolume,
setIsMuted,
controlsTimeoutRef,
]);
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect } from 'react';
interface UseFullscreenControlsProps {
containerRef: React.RefObject<HTMLDivElement | null>;
videoRef: React.RefObject<HTMLVideoElement | null>;
isFullscreen: boolean;
setIsFullscreen: (fullscreen: boolean) => void;
isPiPSupported: boolean;
isAirPlaySupported: boolean;
setIsPiPSupported: (supported: boolean) => void;
setIsAirPlaySupported: (supported: boolean) => void;
}
export function useFullscreenControls({
containerRef,
videoRef,
isFullscreen,
setIsFullscreen,
isPiPSupported,
isAirPlaySupported,
setIsPiPSupported,
setIsAirPlaySupported
}: UseFullscreenControlsProps) {
useEffect(() => {
if (typeof document !== 'undefined') {
setIsPiPSupported('pictureInPictureEnabled' in document);
}
if (typeof window !== 'undefined') {
setIsAirPlaySupported('WebKitPlaybackTargetAvailabilityEvent' in window);
}
}, [setIsPiPSupported, setIsAirPlaySupported]);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
}, [containerRef, isFullscreen]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, [setIsFullscreen]);
const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current || !isPiPSupported) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoRef.current.requestPictureInPicture();
}
} catch (error) {
console.error('Failed to toggle Picture-in-Picture:', error);
}
}, [videoRef, isPiPSupported]);
const showAirPlayMenu = useCallback(() => {
if (!videoRef.current || !isAirPlaySupported) return;
const video = videoRef.current as any;
if (video.webkitShowPlaybackTargetPicker) {
video.webkitShowPlaybackTargetPicker();
}
}, [videoRef, isAirPlaySupported]);
return {
toggleFullscreen,
togglePictureInPicture,
showAirPlayMenu
};
}

View File

@@ -0,0 +1,140 @@
import { useCallback, useEffect } from 'react';
import { formatTime } from '@/lib/utils/format-utils';
import { usePlaybackPolling } from '../usePlaybackPolling';
interface UsePlaybackControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
isPlaying: boolean;
setIsPlaying: (playing: boolean) => void;
setIsLoading: (loading: boolean) => void;
initialTime: number;
shouldAutoPlay: boolean;
setDuration: (duration: number) => void;
setCurrentTime: (time: number) => void;
onTimeUpdate?: (currentTime: number, duration: number) => void;
onError?: (error: string) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
speedMenuTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
setPlaybackRate: (rate: number) => void;
setShowSpeedMenu: (show: boolean) => void;
}
export function usePlaybackControls({
videoRef,
isPlaying,
setIsPlaying,
setIsLoading,
initialTime,
shouldAutoPlay,
setDuration,
setCurrentTime,
onTimeUpdate,
onError,
isDraggingProgressRef,
speedMenuTimeoutRef,
setPlaybackRate,
setShowSpeedMenu
}: UsePlaybackControlsProps) {
const togglePlay = useCallback(() => {
if (!videoRef.current) return;
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}, [isPlaying, videoRef]);
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
const handleTimeUpdateEvent = useCallback(() => {
if (!videoRef.current || isDraggingProgressRef.current) return;
const current = videoRef.current.currentTime;
const total = videoRef.current.duration;
setCurrentTime(current);
setDuration(total);
if (onTimeUpdate) {
onTimeUpdate(current, total);
}
}, [videoRef, isDraggingProgressRef, setCurrentTime, setDuration, onTimeUpdate]);
const handleLoadedMetadata = useCallback(() => {
if (!videoRef.current) return;
setDuration(videoRef.current.duration);
setIsLoading(false);
// Fix for stuck at 00:00:00:
// Only seek if we are at the very start (to avoid overwriting a previous seek)
if (videoRef.current.currentTime < 0.5) {
// If initialTime is 0, we seek to a tiny offset to help the browser/HLS buffer start.
const startPosition = initialTime > 0 ? initialTime : 0.1;
videoRef.current.currentTime = startPosition;
}
videoRef.current.play().catch((err: Error) => {
console.warn('Autoplay was prevented:', err);
});
}, [videoRef, setDuration, setIsLoading, initialTime]);
// Handle late initialization of initialTime (e.g. from async storage hydration)
useEffect(() => {
if (initialTime > 0 && videoRef.current) {
// Only seek if we haven't progressed far (e.g. still near start)
// AND if the target time is significantly different from current time (> 0.5s)
// This prevents jumping if the user has already started watching and initialTime updates
if (videoRef.current.currentTime < 2 && Math.abs(videoRef.current.currentTime - initialTime) > 0.5) {
videoRef.current.currentTime = initialTime;
}
}
}, [initialTime, videoRef]);
// Force autoplay when shouldAutoPlay is true (for proxy retry)
useEffect(() => {
if (shouldAutoPlay && videoRef.current) {
const playPromise = videoRef.current.play();
if (playPromise !== undefined) {
playPromise.catch((err: Error) => {
console.warn('Force autoplay was prevented:', err);
});
}
}
}, [shouldAutoPlay, videoRef]);
const handleVideoError = useCallback(() => {
setIsLoading(false);
if (onError) {
onError('Video failed to load');
}
}, [setIsLoading, onError]);
const changePlaybackSpeed = useCallback((speed: number) => {
if (!videoRef.current) return;
videoRef.current.playbackRate = speed;
setPlaybackRate(speed);
setShowSpeedMenu(false);
if (speedMenuTimeoutRef.current) {
clearTimeout(speedMenuTimeoutRef.current);
}
}, [videoRef, setPlaybackRate, setShowSpeedMenu, speedMenuTimeoutRef]);
// Polling fallback for AirPlay and throttled events
usePlaybackPolling({
isPlaying,
videoRef,
isDraggingProgressRef,
setCurrentTime,
setDuration,
setIsPlaying
});
return {
togglePlay,
handlePlay,
handlePause,
handleTimeUpdateEvent,
handleLoadedMetadata,
handleVideoError,
changePlaybackSpeed,
formatTime
};
}

View File

@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef } from 'react';
interface UseProgressControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
progressBarRef: React.RefObject<HTMLDivElement | null>;
duration: number;
setCurrentTime: (time: number) => void;
isDraggingProgressRef: React.MutableRefObject<boolean>;
}
export function useProgressControls({
videoRef,
progressBarRef,
duration,
setCurrentTime,
isDraggingProgressRef
}: UseProgressControlsProps) {
const lastDragTimeRef = useRef<number>(0);
const handleProgressClick = useCallback((e: any) => {
if (!videoRef.current || !progressBarRef.current) return;
const rect = progressBarRef.current.getBoundingClientRect();
const pos = (e.clientX - rect.left) / rect.width;
const newTime = pos * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
}, [videoRef, progressBarRef, duration, setCurrentTime]);
const handleProgressMouseDown = useCallback((e: any) => {
e.preventDefault();
isDraggingProgressRef.current = true;
handleProgressClick(e);
}, [isDraggingProgressRef, handleProgressClick]);
useEffect(() => {
const handleProgressMouseMove = (e: MouseEvent) => {
if (!isDraggingProgressRef.current || !progressBarRef.current || !videoRef.current) return;
e.preventDefault();
const rect = progressBarRef.current.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newTime = pos * duration;
lastDragTimeRef.current = newTime;
setCurrentTime(newTime);
};
const handleMouseUp = () => {
if (isDraggingProgressRef.current) {
isDraggingProgressRef.current = false;
if (videoRef.current) {
videoRef.current.currentTime = lastDragTimeRef.current;
}
}
};
document.addEventListener('mousemove', handleProgressMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleProgressMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [duration, isDraggingProgressRef, progressBarRef, videoRef, setCurrentTime]);
return {
handleProgressClick,
handleProgressMouseDown
};
}

View File

@@ -0,0 +1,108 @@
import { useCallback } from 'react';
interface UseSkipControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
duration: number;
setCurrentTime: (time: number) => void;
showSkipForwardIndicator: boolean;
showSkipBackwardIndicator: boolean;
skipForwardAmount: number;
skipBackwardAmount: number;
setShowSkipForwardIndicator: (show: boolean) => void;
setShowSkipBackwardIndicator: (show: boolean) => void;
setSkipForwardAmount: (amount: number) => void;
setSkipBackwardAmount: (amount: number) => void;
setIsSkipForwardAnimatingOut: (animating: boolean) => void;
setIsSkipBackwardAnimatingOut: (animating: boolean) => void;
skipForwardTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
skipBackwardTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function useSkipControls({
videoRef,
duration,
setCurrentTime,
showSkipForwardIndicator,
showSkipBackwardIndicator,
skipForwardAmount,
skipBackwardAmount,
setShowSkipForwardIndicator,
setShowSkipBackwardIndicator,
setSkipForwardAmount,
setSkipBackwardAmount,
setIsSkipForwardAnimatingOut,
setIsSkipBackwardAnimatingOut,
skipForwardTimeoutRef,
skipBackwardTimeoutRef
}: UseSkipControlsProps) {
const skipForward = useCallback(() => {
if (!videoRef.current) return;
setShowSkipBackwardIndicator(false);
setSkipBackwardAmount(0);
setIsSkipBackwardAnimatingOut(false);
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
const newSkipAmount = showSkipForwardIndicator ? skipForwardAmount + 10 : 10;
setSkipForwardAmount(newSkipAmount);
setShowSkipForwardIndicator(true);
setIsSkipForwardAnimatingOut(false);
const targetTime = Math.min(videoRef.current.currentTime + 10, duration);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
skipForwardTimeoutRef.current = setTimeout(() => {
setIsSkipForwardAnimatingOut(true);
setTimeout(() => {
setShowSkipForwardIndicator(false);
setSkipForwardAmount(0);
setIsSkipForwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, duration, showSkipForwardIndicator, skipForwardAmount, skipBackwardTimeoutRef, skipForwardTimeoutRef, setShowSkipBackwardIndicator, setSkipBackwardAmount, setIsSkipBackwardAnimatingOut, setSkipForwardAmount, setShowSkipForwardIndicator, setIsSkipForwardAnimatingOut, setCurrentTime]);
const skipBackward = useCallback(() => {
if (!videoRef.current) return;
setShowSkipForwardIndicator(false);
setSkipForwardAmount(0);
setIsSkipForwardAnimatingOut(false);
if (skipForwardTimeoutRef.current) {
clearTimeout(skipForwardTimeoutRef.current);
}
if (skipBackwardTimeoutRef.current) {
clearTimeout(skipBackwardTimeoutRef.current);
}
const newSkipAmount = showSkipBackwardIndicator ? skipBackwardAmount + 10 : 10;
setSkipBackwardAmount(newSkipAmount);
setShowSkipBackwardIndicator(true);
setIsSkipBackwardAnimatingOut(false);
const targetTime = Math.max(videoRef.current.currentTime - 10, 0);
videoRef.current.currentTime = targetTime;
setCurrentTime(targetTime);
skipBackwardTimeoutRef.current = setTimeout(() => {
setIsSkipBackwardAnimatingOut(true);
setTimeout(() => {
setShowSkipBackwardIndicator(false);
setSkipBackwardAmount(0);
setIsSkipBackwardAnimatingOut(false);
}, 200);
}, 800);
}, [videoRef, showSkipBackwardIndicator, skipBackwardAmount, skipForwardTimeoutRef, skipBackwardTimeoutRef, setShowSkipForwardIndicator, setSkipForwardAmount, setIsSkipForwardAnimatingOut, setSkipBackwardAmount, setShowSkipBackwardIndicator, setIsSkipBackwardAnimatingOut, setCurrentTime]);
return {
skipForward,
skipBackward
};
}

View File

@@ -0,0 +1,44 @@
import { useCallback } from 'react';
interface UseUtilitiesProps {
src: string;
setToastMessage: (message: string | null) => void;
setShowToast: (show: boolean) => void;
toastTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
}
export function useUtilities({
src,
setToastMessage,
setShowToast,
toastTimeoutRef
}: UseUtilitiesProps) {
const showToastNotification = useCallback((message: string) => {
setToastMessage(message);
setShowToast(true);
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
toastTimeoutRef.current = setTimeout(() => {
setShowToast(false);
setTimeout(() => setToastMessage(null), 300);
}, 3000);
}, [setToastMessage, setShowToast, toastTimeoutRef]);
const handleCopyLink = useCallback(async (url?: string) => {
try {
await navigator.clipboard.writeText(url || src);
showToastNotification('链接已复制到剪贴板');
} catch (error) {
console.error('Copy failed:', error);
showToastNotification('复制失败,请重试');
}
}, [src, showToastNotification]);
return {
showToastNotification,
handleCopyLink
};
}

View File

@@ -0,0 +1,94 @@
import { useCallback, useEffect } from 'react';
interface UseVolumeControlsProps {
videoRef: React.RefObject<HTMLVideoElement | null>;
volumeBarRef: React.RefObject<HTMLDivElement | null>;
volume: number;
isMuted: boolean;
setVolume: (volume: number) => void;
setIsMuted: (muted: boolean) => void;
setShowVolumeBar: (show: boolean) => void;
volumeBarTimeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
isDraggingVolumeRef: React.MutableRefObject<boolean>;
}
export function useVolumeControls({
videoRef,
volumeBarRef,
volume,
isMuted,
setVolume,
setIsMuted,
setShowVolumeBar,
volumeBarTimeoutRef,
isDraggingVolumeRef
}: UseVolumeControlsProps) {
const toggleMute = useCallback(() => {
if (!videoRef.current) return;
if (isMuted) {
videoRef.current.volume = volume;
setIsMuted(false);
} else {
videoRef.current.volume = 0;
setIsMuted(true);
}
}, [videoRef, isMuted, volume, setIsMuted]);
const showVolumeBarTemporarily = useCallback(() => {
setShowVolumeBar(true);
if (volumeBarTimeoutRef.current) {
clearTimeout(volumeBarTimeoutRef.current);
}
volumeBarTimeoutRef.current = setTimeout(() => {
setShowVolumeBar(false);
}, 1000);
}, [setShowVolumeBar, volumeBarTimeoutRef]);
const handleVolumeChange = useCallback((e: any) => {
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));
setVolume(pos);
videoRef.current.volume = pos;
setIsMuted(pos === 0);
}, [videoRef, volumeBarRef, setVolume, setIsMuted]);
const handleVolumeMouseDown = useCallback((e: any) => {
e.preventDefault();
isDraggingVolumeRef.current = true;
handleVolumeChange(e);
}, [isDraggingVolumeRef, handleVolumeChange]);
useEffect(() => {
const handleVolumeMouseMove = (e: MouseEvent) => {
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));
setVolume(pos);
videoRef.current.volume = pos;
setIsMuted(pos === 0);
};
const handleMouseUp = () => {
if (isDraggingVolumeRef.current) {
isDraggingVolumeRef.current = false;
}
};
document.addEventListener('mousemove', handleVolumeMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleVolumeMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDraggingVolumeRef, volumeBarRef, videoRef, setVolume, setIsMuted]);
return {
toggleMute,
showVolumeBarTemporarily,
handleVolumeChange,
handleVolumeMouseDown
};
}

View File

@@ -0,0 +1,141 @@
/**
* Parameter builder utilities for mobile player hooks
*/
export function buildPlaybackParams(props: any) {
const {
videoRef,
isPlaying,
setIsPlaying,
setIsLoading,
initialTime,
shouldAutoPlay,
setDuration,
setCurrentTime,
setPlaybackRate,
setShowMoreMenu,
setShowVolumeMenu,
setShowSpeedMenu,
onTimeUpdate,
onError,
isDraggingProgressRef,
isTogglingRef,
} = props;
return {
videoRef,
isPlaying,
setIsPlaying,
setIsLoading,
initialTime,
shouldAutoPlay,
setDuration,
setCurrentTime,
setPlaybackRate,
setShowMoreMenu,
setShowVolumeMenu,
setShowSpeedMenu,
onTimeUpdate,
onError,
isDraggingProgressRef,
isTogglingRef,
};
}
export function buildProgressParams(props: any) {
const { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef } = props;
return { videoRef, progressBarRef, duration, setCurrentTime, isDraggingProgressRef };
}
export function buildSkipParams(props: any) {
const {
videoRef,
duration,
setCurrentTime,
skipAmount,
skipSide,
setSkipAmount,
setSkipSide,
setShowSkipIndicator,
skipTimeoutRef,
} = props;
return {
videoRef,
duration,
setCurrentTime,
skipAmount,
skipSide,
setSkipAmount,
setSkipSide,
setShowSkipIndicator,
skipTimeoutRef,
};
}
export function buildFullscreenParams(props: any) {
const { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported } = props;
return { containerRef, videoRef, isFullscreen, setIsFullscreen, isPiPSupported, setIsPiPSupported };
}
export function buildUtilitiesParams(props: any) {
const {
src,
volume,
isMuted,
videoRef,
setVolume,
setIsMuted,
setViewportWidth,
setToastMessage,
setShowToast,
toastTimeoutRef,
} = props;
return {
src,
volume,
isMuted,
videoRef,
setVolume,
setIsMuted,
setViewportWidth,
setToastMessage,
setShowToast,
toastTimeoutRef,
};
}
export function buildMenuParams(props: any) {
const {
videoRef,
isPlaying,
showMoreMenu,
showVolumeMenu,
showSpeedMenu,
wasPlayingBeforeMenu,
setShowControls,
setShowMoreMenu,
setShowVolumeMenu,
setShowSpeedMenu,
setWasPlayingBeforeMenu,
controlsTimeoutRef,
menuIdleTimeoutRef,
} = props;
return {
videoRef,
isPlaying,
showMoreMenu,
showVolumeMenu,
showSpeedMenu,
wasPlayingBeforeMenu,
setShowControls,
setShowMoreMenu,
setShowVolumeMenu,
setShowSpeedMenu,
setWasPlayingBeforeMenu,
controlsTimeoutRef,
menuIdleTimeoutRef,
};
}

View File

@@ -0,0 +1,97 @@
import { useCallback, useEffect } from 'react';
import { useIsIOS } from '@/lib/hooks/useMobilePlayer';
interface UseMobileFullscreenProps {
containerRef: React.RefObject<HTMLDivElement>;
videoRef: React.RefObject<HTMLVideoElement>;
isFullscreen: boolean;
setIsFullscreen: (fullscreen: boolean) => void;
isPiPSupported: boolean;
setIsPiPSupported: (supported: boolean) => void;
}
export function useMobileFullscreenControls({
containerRef,
videoRef,
isFullscreen,
setIsFullscreen,
isPiPSupported,
setIsPiPSupported
}: UseMobileFullscreenProps) {
const isIOS = useIsIOS();
useEffect(() => {
if (typeof document !== 'undefined') {
setIsPiPSupported('pictureInPictureEnabled' in document);
}
}, [setIsPiPSupported]);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (isIOS && videoRef.current && (videoRef.current as any).webkitEnterFullscreen) {
(videoRef.current as any).webkitEnterFullscreen();
return;
}
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen().catch((err: Error) => console.warn('Fullscreen request failed:', err));
} else if ((containerRef.current as any).webkitRequestFullscreen) {
(containerRef.current as any).webkitRequestFullscreen();
} else if ((containerRef.current as any).webkitRequestFullScreen) {
(containerRef.current as any).webkitRequestFullScreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch((err: Error) => console.warn('Exit fullscreen failed:', err));
} else if ((document as any).webkitExitFullscreen) {
(document as any).webkitExitFullscreen();
} else if ((document as any).webkitCancelFullScreen) {
(document as any).webkitCancelFullScreen();
}
}
}, [containerRef, isFullscreen, isIOS, videoRef]);
useEffect(() => {
const handleFullscreenChange = () => {
const isInFullscreen = !!(
document.fullscreenElement ||
(document as any).webkitFullscreenElement ||
(document as any).webkitCurrentFullScreenElement
);
setIsFullscreen(isInFullscreen);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
};
}, [setIsFullscreen]);
const togglePictureInPicture = useCallback(async () => {
if (!videoRef.current || !isPiPSupported) return;
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else {
await videoRef.current.requestPictureInPicture();
}
} catch (error) {
console.error('Failed to toggle Picture-in-Picture:', error);
}
}, [videoRef, isPiPSupported]);
return {
toggleFullscreen,
togglePictureInPicture
};
}

Some files were not shown because too many files have changed in this diff Show More