mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-05-06 22:15:06 +08:00
new
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal 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
51
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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: 报告安全漏洞。
|
||||
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
45
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
35
.github/pull_request_template.md
vendored
Normal 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
44
.gitignore
vendored
Normal 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
|
||||
46
CODE_OF_CONDUCT.md
Normal file
46
CODE_OF_CONDUCT.md
Normal 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
934
CONTRIBUTING.md
Normal 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
85
Dockerfile
Normal 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
21
LICENSE
Normal 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
386
README.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# KVideo
|
||||
|
||||

|
||||
|
||||
> 一个基于 Next.js 16 构建的现代化视频聚合播放平台。采用独特的 "Liquid Glass" 设计语言,提供流畅的视觉体验和强大的视频搜索功能。
|
||||
|
||||
**🌐 在线体验:[https://kvideo.pages.dev/](https://kvideo.pages.dev/)**
|
||||
|
||||
[](https://nextjs.org/)
|
||||
[](https://react.dev/)
|
||||
[](https://tailwindcss.com/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](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/)** 立即体验,无需安装!
|
||||
|
||||
### 部署到自己的服务器
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#### 选项 1:Vercel 一键部署(推荐)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/KuekHaoYang/KVideo)
|
||||
|
||||
1. 点击上方按钮
|
||||
2. 连接你的 GitHub 账号
|
||||
3. Vercel 会自动检测 Next.js 项目并部署
|
||||
4. 几分钟后即可访问你自己的 KVideo 实例
|
||||
|
||||
#### 选项 2:Cloudflare 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 就部署成功了!
|
||||
|
||||
#### 选项 3:Docker 部署
|
||||
|
||||
**从 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
10
SECURITY.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 安全策略 (Security Policy)
|
||||
|
||||
## 支持的版本
|
||||
|
||||
目前我们仅对项目的最新主分支 (`main`) 提供安全更新支持。
|
||||
|
||||
| 版本 | 支持状态 |
|
||||
| :--- | :--- |
|
||||
| `main` (最新版) | ✅ 支持 |
|
||||
| 历史版本 | ❌ 不支持 |
|
||||
136
app/api/adult/category/route.ts
Normal file
136
app/api/adult/category/route.ts
Normal 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);
|
||||
}
|
||||
173
app/api/adult/types/route.ts
Normal file
173
app/api/adult/types/route.ts
Normal 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
31
app/api/config/route.ts
Normal 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
112
app/api/detail/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
36
app/api/douban/recommend/route.ts
Normal file
36
app/api/douban/recommend/route.ts
Normal 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
129
app/api/proxy/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
150
app/api/search-parallel/route.ts
Normal file
150
app/api/search-parallel/route.ts
Normal 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
29
app/globals.css
Normal 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
89
app/layout.tsx
Normal 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
86
app/page.tsx
Normal 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
13
app/player/layout.tsx
Normal 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
178
app/player/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
app/scroll-optimization.css
Normal file
94
app/scroll-optimization.css
Normal 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
85
app/secret/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
app/secret/settings/hooks/useSecretSettingsPage.ts
Normal file
61
app/secret/settings/hooks/useSecretSettingsPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
app/secret/settings/page.tsx
Normal file
85
app/secret/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
app/settings/hooks/useSettingsPage.ts
Normal file
301
app/settings/hooks/useSettingsPage.ts
Normal 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
146
app/settings/page.tsx
Normal 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
147
app/styles/base.css
Normal 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
85
app/styles/effects.css
Normal 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
65
app/styles/glass.css
Normal 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;
|
||||
}
|
||||
98
app/styles/search-history.css
Normal file
98
app/styles/search-history.css
Normal 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);
|
||||
}
|
||||
}
|
||||
91
app/styles/transitions.css
Normal file
91
app/styles/transitions.css
Normal 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
78
app/styles/variables.css
Normal 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
367
app/styles/video-player.css
Normal 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
172
components/PasswordGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
components/SearchLoadingAnimation.tsx
Normal file
131
components/SearchLoadingAnimation.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
components/ServiceWorkerRegister.tsx
Normal file
21
components/ServiceWorkerRegister.tsx
Normal 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;
|
||||
}
|
||||
139
components/ThemeProvider.tsx
Normal file
139
components/ThemeProvider.tsx
Normal 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;
|
||||
}
|
||||
80
components/ThemeSwitcher.tsx
Normal file
80
components/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
76
components/adult/AdultContent.tsx
Normal file
76
components/adult/AdultContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
components/adult/AdultContentGrid.tsx
Normal file
143
components/adult/AdultContentGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
components/favorites/FavoriteButton.tsx
Normal file
109
components/favorites/FavoriteButton.tsx
Normal 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';
|
||||
23
components/favorites/FavoritesEmptyState.tsx
Normal file
23
components/favorites/FavoritesEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
components/favorites/FavoritesFooter.tsx
Normal file
29
components/favorites/FavoritesFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
components/favorites/FavoritesHeader.tsx
Normal file
33
components/favorites/FavoritesHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
components/favorites/FavoritesItem.tsx
Normal file
108
components/favorites/FavoritesItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
components/favorites/FavoritesList.tsx
Normal file
30
components/favorites/FavoritesList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
components/favorites/FavoritesSidebar.tsx
Normal file
143
components/favorites/FavoritesSidebar.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
components/history/HistoryEmptyState.tsx
Normal file
23
components/history/HistoryEmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
components/history/HistoryFooter.tsx
Normal file
24
components/history/HistoryFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
components/history/HistoryHeader.tsx
Normal file
28
components/history/HistoryHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
components/history/HistoryItem.tsx
Normal file
126
components/history/HistoryItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/history/HistoryList.tsx
Normal file
39
components/history/HistoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
components/history/PosterImage.tsx
Normal file
48
components/history/PosterImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
components/history/WatchHistorySidebar.tsx
Normal file
143
components/history/WatchHistorySidebar.tsx
Normal 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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
components/home/MovieCard.tsx
Normal file
96
components/home/MovieCard.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
91
components/home/MovieGrid.tsx
Normal file
91
components/home/MovieGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
components/home/PopularFeatures.tsx
Normal file
82
components/home/PopularFeatures.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
components/home/SearchResults.tsx
Normal file
67
components/home/SearchResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
components/home/SortableTag.tsx
Normal file
79
components/home/SortableTag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
components/home/TagInput.tsx
Normal file
34
components/home/TagInput.tsx
Normal 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
118
components/home/TagList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
components/home/TagManager.tsx
Normal file
83
components/home/TagManager.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
69
components/home/hooks/usePopularMovies.ts
Normal file
69
components/home/hooks/usePopularMovies.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
101
components/home/hooks/useTagManager.ts
Normal file
101
components/home/hooks/useTagManager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
70
components/layout/Navbar.tsx
Normal file
70
components/layout/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
components/player/CustomVideoPlayer.tsx
Normal file
31
components/player/CustomVideoPlayer.tsx
Normal 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} />;
|
||||
}
|
||||
123
components/player/DesktopVideoPlayer.tsx
Normal file
123
components/player/DesktopVideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
components/player/EpisodeList.tsx
Normal file
106
components/player/EpisodeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
components/player/MobileVideoPlayer.tsx
Normal file
158
components/player/MobileVideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
components/player/PlayerError.tsx
Normal file
41
components/player/PlayerError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
components/player/PlayerNavbar.tsx
Normal file
54
components/player/PlayerNavbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
components/player/VideoMetadata.tsx
Normal file
73
components/player/VideoMetadata.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
components/player/VideoPlayer.tsx
Normal file
191
components/player/VideoPlayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
components/player/VideoPlayerEmpty.tsx
Normal file
17
components/player/VideoPlayerEmpty.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
components/player/VideoPlayerError.tsx
Normal file
65
components/player/VideoPlayerError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
components/player/desktop/DesktopControls.tsx
Normal file
81
components/player/desktop/DesktopControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
components/player/desktop/DesktopControlsWrapper.tsx
Normal file
109
components/player/desktop/DesktopControlsWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
86
components/player/desktop/DesktopLeftControls.tsx
Normal file
86
components/player/desktop/DesktopLeftControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
components/player/desktop/DesktopMoreMenu.tsx
Normal file
214
components/player/desktop/DesktopMoreMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
components/player/desktop/DesktopOverlay.tsx
Normal file
84
components/player/desktop/DesktopOverlay.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
components/player/desktop/DesktopOverlayWrapper.tsx
Normal file
39
components/player/desktop/DesktopOverlayWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
components/player/desktop/DesktopProgressBar.tsx
Normal file
39
components/player/desktop/DesktopProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
components/player/desktop/DesktopRightControls.tsx
Normal file
106
components/player/desktop/DesktopRightControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
components/player/desktop/DesktopSpeedMenu.tsx
Normal file
57
components/player/desktop/DesktopSpeedMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
components/player/desktop/DesktopVolumeControl.tsx
Normal file
66
components/player/desktop/DesktopVolumeControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
components/player/hooks/desktop/useControlsVisibility.ts
Normal file
92
components/player/hooks/desktop/useControlsVisibility.ts
Normal 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
|
||||
};
|
||||
}
|
||||
124
components/player/hooks/desktop/useDesktopShortcuts.ts
Normal file
124
components/player/hooks/desktop/useDesktopShortcuts.ts
Normal 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,
|
||||
]);
|
||||
}
|
||||
80
components/player/hooks/desktop/useFullscreenControls.ts
Normal file
80
components/player/hooks/desktop/useFullscreenControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
140
components/player/hooks/desktop/usePlaybackControls.ts
Normal file
140
components/player/hooks/desktop/usePlaybackControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
68
components/player/hooks/desktop/useProgressControls.ts
Normal file
68
components/player/hooks/desktop/useProgressControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
108
components/player/hooks/desktop/useSkipControls.ts
Normal file
108
components/player/hooks/desktop/useSkipControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
44
components/player/hooks/desktop/useUtilities.ts
Normal file
44
components/player/hooks/desktop/useUtilities.ts
Normal 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
|
||||
};
|
||||
}
|
||||
94
components/player/hooks/desktop/useVolumeControls.ts
Normal file
94
components/player/hooks/desktop/useVolumeControls.ts
Normal 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
|
||||
};
|
||||
}
|
||||
141
components/player/hooks/mobile/mobile-player-params.ts
Normal file
141
components/player/hooks/mobile/mobile-player-params.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user