Compare commits
5 Commits
e2d8e17f62
...
91c59c3ad9
| Author | SHA1 | Date | |
|---|---|---|---|
| 91c59c3ad9 | |||
| 1df184d1ad | |||
| f6e21aa2fe | |||
| 67ac0bf7b6 | |||
| dd1c6a7b52 |
@@ -8,7 +8,7 @@ pnpm build
|
||||
|
||||
- Next.js 16 使用 App Router
|
||||
- TypeScript 严格模式和 ES2023 目标
|
||||
- better-auth 身份验证(邮箱/密码)
|
||||
- better-auth 身份验证(邮箱/用户名/密码)
|
||||
- next-intl 国际化(支持:en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN)
|
||||
- 阿里云千问 TTS (qwen3-tts-flash) 文本转语音
|
||||
- 使用 pnpm,而不是 npm 或 yarn
|
||||
@@ -17,6 +17,6 @@ pnpm build
|
||||
- **优先使用 Server Components**,只在需要交互时使用 Client Components
|
||||
- **新功能应遵循 action-service-repository 架构**
|
||||
- Better-auth 处理会话管理 - 使用 authClient 适配器进行认证操作
|
||||
- 使用 better-auth username 插件支持用户名登录
|
||||
- 组件尽量复用/src/design-system里的可复用组件与/src/components里的业务相关组件
|
||||
- 不要创建index.ts
|
||||
- 每变更一个完整项目自动git commit,如需撤销就git reset
|
||||
|
||||
545
README.md
545
README.md
@@ -1,189 +1,372 @@
|
||||
# 多语言学习平台
|
||||
# 🌍 多语言学习平台
|
||||
|
||||
一个基于 Next.js 构建的全功能多语言学习平台,提供翻译、发音、字幕播放、字母学习等多种语言学习工具,帮助用户更高效地掌握新语言。
|
||||
<div align="center">
|
||||
|
||||
## ✨ 主要功能
|
||||
[](https://nextjs.org/)
|
||||
[](https://reactjs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://www.postgresql.org/)
|
||||
[](./LICENSE)
|
||||
|
||||
- **智能翻译工具** - 支持多语言互译,包含国际音标(IPA)标注
|
||||
- **文本语音合成** - 将文本转换为自然语音,提高发音学习效果
|
||||
- **SRT字幕播放器** - 结合视频字幕学习,支持多种字幕格式
|
||||
- **字母学习模块** - 针对初学者的字母和发音基础学习
|
||||
- **记忆强化工具** - 通过科学记忆法巩固学习内容
|
||||
- **词典查询** - 查询单词和短语,提供详细释义和例句
|
||||
- **个人学习空间** - 用户可以创建、管理和组织自己的学习资料
|
||||
- **用户资料系统** - 支持用户名登录、个人资料页面展示
|
||||
**一个现代化的全栈多语言学习平台,集成 AI 驱动的翻译、发音、词典和学习管理功能**
|
||||
|
||||
## 🛠 技术栈
|
||||
[在线演示](#) · [报告问题](../../issues) · [功能建议](../../issues)
|
||||
|
||||
### 前端框架
|
||||
- **Next.js 16** - React 全栈框架,使用 App Router
|
||||
- **React 19** - 用户界面构建
|
||||
- **TypeScript** - 类型安全的 JavaScript
|
||||
- **Tailwind CSS** - 实用优先的 CSS 框架
|
||||
|
||||
### 数据与后端
|
||||
- **PostgreSQL** - 主数据库
|
||||
- **Prisma** - 现代数据库工具包和 ORM
|
||||
- **better-auth** - 安全的身份验证系统
|
||||
|
||||
### 国际化与辅助功能
|
||||
- **next-intl** - 国际化解决方案
|
||||
- **阿里云千问 TTS** - qwen3-tts-flash 语音合成
|
||||
|
||||
### 开发工具
|
||||
- **ESLint** - 代码质量检查
|
||||
- **pnpm** - 高效的包管理器
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/ # Next.js App Router 路由
|
||||
│ ├── (features)/ # 功能模块路由
|
||||
│ ├── auth/ # 认证相关页面
|
||||
│ ├── profile/ # 用户资料重定向
|
||||
│ ├── users/[username]/ # 用户资料页面
|
||||
│ ├── folders/ # 文件夹管理
|
||||
│ └── api/ # API 路由
|
||||
├── modules/ # 业务模块(action-service-repository 架构)
|
||||
│ ├── auth/ # 认证模块
|
||||
│ ├── folder/ # 文件夹模块
|
||||
│ ├── dictionary/ # 词典模块
|
||||
│ └── translator/ # 翻译模块
|
||||
├── components/ # React 组件
|
||||
│ ├── buttons/ # 按钮组件
|
||||
│ ├── cards/ # 卡片组件
|
||||
│ └── ...
|
||||
├── lib/ # 工具函数和库
|
||||
│ ├── actions/ # Server Actions
|
||||
│ ├── browser/ # 浏览器端工具
|
||||
│ └── server/ # 服务器端工具
|
||||
├── hooks/ # 自定义 React Hooks
|
||||
├── i18n/ # 国际化配置
|
||||
├── shared/ # 共享常量和类型
|
||||
└── config/ # 应用配置
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js 23
|
||||
- PostgreSQL 数据库
|
||||
- pnpm (推荐) 或 npm
|
||||
|
||||
### 本地开发
|
||||
|
||||
1. 克隆项目
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
```
|
||||
|
||||
2. 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. 设置环境变量
|
||||
|
||||
从项目提供的示例文件复制环境变量模板:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
然后编辑 `.env.local` 文件,配置所有必要的环境变量:
|
||||
|
||||
```env
|
||||
# LLM 集成(智谱 AI 用于翻译和 IPA 生成)
|
||||
ZHIPU_API_KEY=your-zhipu-api-key
|
||||
ZHIPU_MODEL_NAME=your-zhipu-model-name
|
||||
|
||||
# 阿里云千问 TTS(文本转语音)
|
||||
DASHSCORE_API_KEY=your-dashscore-api-key
|
||||
|
||||
# 认证
|
||||
BETTER_AUTH_SECRET=your-better-auth-secret
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
GITHUB_CLIENT_ID=your-github-client-id
|
||||
GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/database_name
|
||||
```
|
||||
|
||||
注意:所有带 `your-` 前缀的值需要替换为你的实际配置。
|
||||
|
||||
4. 初始化数据库
|
||||
```bash
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
```
|
||||
|
||||
5. 启动开发服务器
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
### 认证系统
|
||||
|
||||
应用使用 better-auth 提供安全的用户认证系统,支持:
|
||||
- 邮箱/密码登录和注册
|
||||
- **用户名登录**(可通过用户名或邮箱登录)
|
||||
- GitHub OAuth 第三方登录
|
||||
- 邮箱验证功能
|
||||
|
||||
### 后端架构
|
||||
|
||||
项目采用 **Action-Service-Repository 三层架构**:
|
||||
- **Action 层**:处理 Server Actions、表单验证、重定向
|
||||
- **Service 层**:业务逻辑、better-auth 集成
|
||||
- **Repository 层**:Prisma 数据库操作
|
||||
|
||||
### 数据模型
|
||||
|
||||
核心数据模型包括:
|
||||
- **User** - 用户信息(支持用户名、邮箱、头像)
|
||||
- **Folder** - 学习资料文件夹
|
||||
- **Pair** - 语言对(翻译对、词汇对等)
|
||||
- **Session/Account** - 认证会话追踪
|
||||
- **Verification** - 邮箱验证系统
|
||||
|
||||
详细模型定义请参考 [prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
## 🌍 国际化
|
||||
|
||||
应用支持多语言,当前语言文件位于 `messages/` 目录。添加新语言:
|
||||
|
||||
1. 在 `messages/` 目录创建对应语言的 JSON 文件
|
||||
2. 在 `src/i18n/config.ts` 中添加语言配置
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种形式的贡献!请遵循以下步骤:
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 AGPL-3.0 许可证 - 查看 [LICENSE](./LICENSE) 文件了解详情。
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如果您遇到问题或有建议,请通过以下方式联系:
|
||||
|
||||
- 提交 [Issue](../../issues)
|
||||
- 发送邮件至 [goddonebianu@outlook.com]
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
**Happy Learning!** 🌟
|
||||
## ✨ 核心特性
|
||||
|
||||
### 🎯 学习工具
|
||||
- **智能翻译** - 基于 AI 的多语言互译,支持 IPA 音标标注
|
||||
- **词典查询** - 详细的单词释义、词性分析、例句展示
|
||||
- **语音合成** - 阿里云千问 TTS 提供自然的语音输出
|
||||
- **个人学习空间** - 文件夹管理、学习资料组织
|
||||
|
||||
### 🔐 用户系统
|
||||
- **多方式认证** - 邮箱/用户名登录、GitHub OAuth
|
||||
- **个人资料** - 用户主页、学习进度追踪
|
||||
- **数据安全** - better-auth 提供企业级安全保障
|
||||
|
||||
### 🌐 国际化
|
||||
- **8 种语言** - en-US, zh-CN, ja-JP, ko-KR, de-DE, fr-FR, it-IT, ug-CN
|
||||
- **完整本地化** - 所有界面文本支持多语言
|
||||
|
||||
### 🏗️ 技术亮点
|
||||
- **App Router** - 采用 Next.js 16 最新路由系统
|
||||
- **Server Components** - 优先服务端渲染,优化性能
|
||||
- **Action-Service-Repository** - 清晰的三层架构设计
|
||||
- **类型安全** - TypeScript 严格模式 + Zod 验证
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 23+
|
||||
- PostgreSQL 14+
|
||||
- pnpm 8+ (推荐) 或 npm/yarn
|
||||
|
||||
### 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd learn-languages
|
||||
|
||||
# 2. 安装依赖
|
||||
pnpm install
|
||||
|
||||
# 3. 配置环境变量
|
||||
cp .env.example .env.local
|
||||
# 编辑 .env.local 填写必要配置
|
||||
|
||||
# 4. 初始化数据库
|
||||
pnpm prisma generate
|
||||
pnpm prisma db push
|
||||
|
||||
# 5. 启动开发服务器
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 **http://localhost:3000** 开始使用!
|
||||
|
||||
### 环境变量配置
|
||||
|
||||
```env
|
||||
# 🤖 AI 服务(必需)
|
||||
ZHIPU_API_KEY=your-api-key # 智谱 AI - 翻译和词典
|
||||
ZHIPU_MODEL_NAME=your-model-name # 模型名称
|
||||
DASHSCORE_API_KEY=your-api-key # 阿里云 TTS
|
||||
|
||||
# 🔐 认证配置(必需)
|
||||
BETTER_AUTH_SECRET=your-secret # 随机字符串
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# 🐙 GitHub OAuth(可选)
|
||||
GITHUB_CLIENT_ID=your-client-id
|
||||
GITHUB_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# 💾 数据库(必需)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/dbname
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%">
|
||||
|
||||
### 前端
|
||||
- **Next.js 16** - App Router
|
||||
- **React 19** - UI 框架
|
||||
- **TypeScript 5.9** - 类型安全
|
||||
- **Tailwind CSS 4** - 样式方案
|
||||
- **Zustand** - 状态管理
|
||||
- **next-intl** - 国际化
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
|
||||
### 后端
|
||||
- **PostgreSQL** - 关系数据库
|
||||
- **Prisma 7** - ORM
|
||||
- **better-auth** - 认证系统
|
||||
- **智谱 AI** - LLM 服务
|
||||
- **阿里云 TTS** - 语音合成
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目架构
|
||||
|
||||
```
|
||||
learn-languages/
|
||||
├── 📂 src/
|
||||
│ ├── 📂 app/ # Next.js App Router
|
||||
│ │ ├── 📂 (auth)/ # 认证相关页面
|
||||
│ │ ├── 📂 folders/ # 文件夹管理
|
||||
│ │ ├── 📂 users/[username]/ # 用户资料
|
||||
│ │ └── 📂 api/ # API 路由
|
||||
│ │
|
||||
│ ├── 📂 modules/ # 业务模块(三层架构)
|
||||
│ │ ├── 📂 auth/ # 认证模块
|
||||
│ │ ├── 📂 folder/ # 文件夹模块
|
||||
│ │ ├── 📂 dictionary/ # 词典模块
|
||||
│ │ └── 📂 translator/ # 翻译模块
|
||||
│ │
|
||||
│ ├── 📂 components/ # React 组件
|
||||
│ │ ├── 📂 ui/ # 通用 UI 组件
|
||||
│ │ └── 📂 layout/ # 布局组件
|
||||
│ │
|
||||
│ ├── 📂 design-system/ # 设计系统
|
||||
│ │ ├── 📂 base/ # 基础组件
|
||||
│ │ ├── 📂 layout/ # 布局组件
|
||||
│ │ └── 📂 feedback/ # 反馈组件
|
||||
│ │
|
||||
│ ├── 📂 lib/ # 工具库
|
||||
│ │ ├── 📂 bigmodel/ # AI 集成
|
||||
│ │ ├── 📂 browser/ # 浏览器工具
|
||||
│ │ └── 📂 server/ # 服务端工具
|
||||
│ │
|
||||
│ ├── 📂 hooks/ # 自定义 Hooks
|
||||
│ ├── 📂 i18n/ # 国际化配置
|
||||
│ ├── 📂 shared/ # 共享类型和常量
|
||||
│ └── 📂 config/ # 应用配置
|
||||
│
|
||||
├── 📂 prisma/ # 数据库 Schema
|
||||
├── 📂 messages/ # 多语言文件
|
||||
└── 📂 public/ # 静态资源
|
||||
```
|
||||
|
||||
### 架构设计:Action-Service-Repository
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Presentation Layer │
|
||||
│ (Server Components / Client Components)│
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Action Layer │
|
||||
│ • Server Actions │
|
||||
│ • Form Validation (Zod) │
|
||||
│ • Redirect & Error Handling │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Service Layer │
|
||||
│ • Business Logic │
|
||||
│ • better-auth Integration │
|
||||
│ • Cross-module Coordination │
|
||||
└────────────────┬────────────────────────┘
|
||||
│
|
||||
┌────────────────▼────────────────────────┐
|
||||
│ Repository Layer │
|
||||
│ • Prisma Database Operations │
|
||||
│ • Data Access Abstraction │
|
||||
│ • Query Optimization │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 核心模块
|
||||
|
||||
### 认证系统 (auth)
|
||||
|
||||
```typescript
|
||||
// 支持多种登录方式
|
||||
- 邮箱/密码登录
|
||||
- 用户名登录
|
||||
- GitHub OAuth
|
||||
- 邮箱验证
|
||||
```
|
||||
|
||||
### 翻译模块 (translator)
|
||||
|
||||
```typescript
|
||||
// AI 驱动的智能翻译
|
||||
- 多语言互译
|
||||
- IPA 音标标注
|
||||
- 翻译历史记录
|
||||
- 上下文理解
|
||||
```
|
||||
|
||||
### 词典模块 (dictionary)
|
||||
|
||||
```typescript
|
||||
// 智能词典查询
|
||||
- 单词释义
|
||||
- 词性分析
|
||||
- 例句展示
|
||||
- 词频统计
|
||||
```
|
||||
|
||||
### 文件夹模块 (folder)
|
||||
|
||||
```typescript
|
||||
// 学习资料管理
|
||||
- 创建/删除文件夹
|
||||
- 添加语言对
|
||||
- IPA 标注
|
||||
- 批量管理
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 数据模型
|
||||
|
||||
核心数据模型关系:
|
||||
|
||||
```
|
||||
User (用户)
|
||||
├─ Account (账户)
|
||||
├─ Session (会话)
|
||||
├─ Folder (文件夹)
|
||||
│ └─ Pair (语言对)
|
||||
├─ DictionaryLookUp (查询记录)
|
||||
│ └─ DictionaryItem (词典项)
|
||||
│ └─ DictionaryEntry (词条)
|
||||
└─ TranslationHistory (翻译历史)
|
||||
```
|
||||
|
||||
详细模型定义:[prisma/schema.prisma](./prisma/schema.prisma)
|
||||
|
||||
---
|
||||
|
||||
## 🌍 国际化支持
|
||||
|
||||
当前支持的语言:
|
||||
|
||||
| 语言 | 代码 | 区域 |
|
||||
|------|------|------|
|
||||
| English | en-US | 美国 |
|
||||
| 中文 | zh-CN | 中国 |
|
||||
| 日本語 | ja-JP | 日本 |
|
||||
| 한국어 | ko-KR | 韩国 |
|
||||
| Deutsch | de-DE | 德国 |
|
||||
| Français | fr-FR | 法国 |
|
||||
| Italiano | it-IT | 意大利 |
|
||||
| ئۇيغۇرچە | ug-CN | 新疆 |
|
||||
|
||||
添加新语言:
|
||||
|
||||
1. 在 `messages/` 创建语言文件
|
||||
2. 在 `src/i18n/config.ts` 添加配置
|
||||
3. 更新语言选择器组件
|
||||
|
||||
---
|
||||
|
||||
## 🔧 开发指南
|
||||
|
||||
### 可用脚本
|
||||
|
||||
```bash
|
||||
# 开发
|
||||
pnpm dev # 启动开发服务器 (HTTPS)
|
||||
pnpm build # 构建生产版本
|
||||
pnpm start # 启动生产服务器
|
||||
pnpm lint # 代码检查
|
||||
|
||||
# 数据库
|
||||
pnpm prisma studio # 打开数据库 GUI
|
||||
pnpm prisma db push # 同步 Schema
|
||||
pnpm prisma migrate # 创建迁移
|
||||
```
|
||||
|
||||
### 代码规范
|
||||
|
||||
- ✅ TypeScript 严格模式
|
||||
- ✅ ESLint + TypeScript Plugin
|
||||
- ✅ 优先使用 Server Components
|
||||
- ✅ 新功能遵循 Action-Service-Repository
|
||||
- ✅ 所有用户文本需要国际化
|
||||
- ✅ 组件复用设计系统和业务组件
|
||||
|
||||
### 目录约定
|
||||
|
||||
- `modules/` - 业务模块,每个模块包含:
|
||||
- `*-action.ts` - Server Actions
|
||||
- `*-service.ts` - 业务逻辑
|
||||
- `*-repository.ts` - 数据访问
|
||||
- `*-dto.ts` - 数据传输对象
|
||||
- `components/` - 业务相关组件
|
||||
- `design-system/` - 可复用基础组件
|
||||
- `lib/` - 工具函数和库
|
||||
|
||||
---
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
我们欢迎各种贡献!
|
||||
|
||||
### 贡献流程
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add: AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码提交规范
|
||||
|
||||
```
|
||||
feat: 新功能
|
||||
fix: 修复问题
|
||||
docs: 文档变更
|
||||
style: 代码格式
|
||||
refactor: 重构
|
||||
test: 测试相关
|
||||
chore: 构建/工具
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [AGPL-3.0](./LICENSE) 许可证。
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- **问题反馈**:[GitHub Issues](../../issues)
|
||||
- **邮箱**:goddonebianu@outlook.com
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果这个项目对你有帮助,请给一个 ⭐️ Star!**
|
||||
|
||||
Made with ❤️ by the community
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
@@ -17,50 +26,74 @@ export default function LoginPage() {
|
||||
if (session) {
|
||||
router.push(redirectTo ?? "/profile");
|
||||
}
|
||||
});
|
||||
}, [session, router, redirectTo]);
|
||||
|
||||
function login() {
|
||||
const username = (document.getElementById("username") as HTMLInputElement).value;
|
||||
const password = (document.getElementById("password") as HTMLInputElement).value;
|
||||
console.log(username, password);
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
toast.error("请输入用户名和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (username.includes("@")) {
|
||||
authClient.signIn.email({
|
||||
await authClient.signIn.email({
|
||||
email: username,
|
||||
password: username
|
||||
});
|
||||
} else {
|
||||
authClient.signIn.username({
|
||||
await authClient.signIn.username({
|
||||
username: username,
|
||||
password: password,
|
||||
fetchOptions: {
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
router.push(redirectTo ?? "/profile");
|
||||
} catch (error) {
|
||||
toast.error("登录失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen w-screen">
|
||||
<div className="rounded shadow-lg w-96 flex flex-col py-4">
|
||||
<h1 className="text-6xl m-16 text-center">登录</h1>
|
||||
<input type="text"
|
||||
id="username"
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-80">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">登录</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder="用户名或邮箱地址"
|
||||
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
|
||||
<input type="password"
|
||||
id="password"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
|
||||
<button
|
||||
onClick={login}
|
||||
className="text-xl rounded shadow w-16 mx-auto p-2 my-4">
|
||||
确认</button>
|
||||
<Link href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-blue-800"
|
||||
>没有账号?去注册</Link>
|
||||
</div>
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
确认
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/signup" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
没有账号?去注册
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardBody } from "@/design-system/base/card";
|
||||
import { Input } from "@/design-system/base/input";
|
||||
import { PrimaryButton } from "@/design-system/base/button";
|
||||
import { VStack } from "@/design-system/layout/stack";
|
||||
|
||||
export default function SignUpPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const redirectTo = searchParams.get("redirect");
|
||||
|
||||
const session = authClient.useSession().data;
|
||||
const router = useRouter();
|
||||
|
||||
console.log(JSON.stringify({ re: redirectTo }));
|
||||
|
||||
useEffect(() => {
|
||||
if (session) {
|
||||
router.push(redirectTo ?? "/profile");
|
||||
}
|
||||
});
|
||||
}, [session, router, redirectTo]);
|
||||
|
||||
function login() {
|
||||
const username = (document.getElementById("username") as HTMLInputElement).value;
|
||||
const email = (document.getElementById("email") as HTMLInputElement).value;
|
||||
const password = (document.getElementById("password") as HTMLInputElement).value;
|
||||
authClient.signUp.email({
|
||||
const handleSignUp = async () => {
|
||||
if (!username || !email || !password) {
|
||||
toast.error("请填写所有字段");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await authClient.signUp.email({
|
||||
email: email,
|
||||
name: username,
|
||||
username: username,
|
||||
password: password,
|
||||
fetchOptions: {
|
||||
onError: (ctx) => {
|
||||
toast.error(ctx.error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
router.push(redirectTo ?? "/profile");
|
||||
} catch (error) {
|
||||
toast.error("注册失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen w-screen">
|
||||
<div className="rounded shadow-lg w-96 flex flex-col py-4">
|
||||
<h1 className="text-6xl m-16 text-center">注册</h1>
|
||||
<input type="text"
|
||||
id="username"
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
<Card className="w-80">
|
||||
<CardBody>
|
||||
<VStack gap={4} align="center" justify="center">
|
||||
<h1 className="text-3xl font-bold text-center w-full">注册</h1>
|
||||
|
||||
<VStack gap={0} align="center" justify="center" className="w-full">
|
||||
<Input
|
||||
placeholder="用户名"
|
||||
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
|
||||
<input type="email"
|
||||
id="email"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="邮箱地址"
|
||||
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
|
||||
<input type="password"
|
||||
id="password"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
className="mx-auto mb-8 pb-2 w-60 border-b-2 outline-none" />
|
||||
<button
|
||||
onClick={login}
|
||||
className="text-xl rounded shadow w-16 mx-auto p-2 my-4">
|
||||
确认</button>
|
||||
<Link href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-blue-800"
|
||||
>已有账号?去登录</Link>
|
||||
</div>
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<PrimaryButton
|
||||
onClick={handleSignUp}
|
||||
loading={loading}
|
||||
fullWidth
|
||||
>
|
||||
确认
|
||||
</PrimaryButton>
|
||||
|
||||
<Link
|
||||
href={"/login" + (redirectTo ? `?redirect=${redirectTo}` : "")}
|
||||
className="text-center text-primary-500 hover:underline"
|
||||
>
|
||||
已有账号?去登录
|
||||
</Link>
|
||||
</VStack>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,9 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
@{user.username}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-600 text-sm mb-1">
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="flex items-center space-x-4 text-sm">
|
||||
<span className="text-gray-500">
|
||||
Joined: {new Date(user.createdAt).toLocaleDateString()}
|
||||
@@ -91,25 +94,6 @@ export default async function UserPage({ params }: UserPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Section */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("email")}</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-gray-700">{user.email}</span>
|
||||
</div>
|
||||
{user.emailVerified ? (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||
✓ {t("verified")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
|
||||
{t("unverified")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Info */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t("accountInfo")}</h2>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export function SubtitleDisplay({ subtitle }: { subtitle: string }) {
|
||||
const words = subtitle.match(/\b[\w']+(?:-[\w']+)*\b/g) || [];
|
||||
let i = 0;
|
||||
return (
|
||||
<div className="w-full subtitle overflow-auto h-16 mt-2 wrap-break-word bg-black/50 font-sans text-white text-center text-2xl">
|
||||
{words.map((v) => (
|
||||
<span
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`https://www.youdao.com/result?word=${v}&lang=en`,
|
||||
"_blank",
|
||||
);
|
||||
}}
|
||||
key={i++}
|
||||
className="hover:bg-gray-700 hover:underline hover:cursor-pointer"
|
||||
>
|
||||
{v + " "}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useRef, forwardRef, useEffect, useCallback } from "react";
|
||||
import { SubtitleDisplay } from "./SubtitleDisplay";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { RangeInput } from "@/design-system/base/range";
|
||||
import { getIndex, parseSrt, getNearistIndex } from "../subtitle";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type VideoPanelProps = {
|
||||
videoUrl: string | null;
|
||||
srtUrl: string | null;
|
||||
};
|
||||
|
||||
const VideoPanel = forwardRef<HTMLVideoElement, VideoPanelProps>(
|
||||
({ videoUrl, srtUrl }, videoRef) => {
|
||||
const t = useTranslations("srt_player");
|
||||
videoRef = videoRef as React.RefObject<HTMLVideoElement>;
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [srtLength, setSrtLength] = useState<number>(0);
|
||||
const [progress, setProgress] = useState<number>(-1);
|
||||
const [autoPause, setAutoPause] = useState<boolean>(true);
|
||||
const [spanText, setSpanText] = useState<string>("");
|
||||
const [subtitle, setSubtitle] = useState<string>("");
|
||||
const parsedSrtRef = useRef<
|
||||
{ start: number; end: number; text: string; }[] | null
|
||||
>(null);
|
||||
const rafldRef = useRef<number>(0);
|
||||
const ready = useRef({
|
||||
vid: false,
|
||||
sub: false,
|
||||
all: function () {
|
||||
return this.vid && this.sub;
|
||||
},
|
||||
});
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
if (!videoUrl) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
if (video.paused || video.currentTime === 0) {
|
||||
video.play();
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
setIsPlaying(!video.paused);
|
||||
}, [videoRef, videoUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDownEvent = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "n") {
|
||||
next();
|
||||
} else if (e.key === "p") {
|
||||
previous();
|
||||
} else if (e.key === " ") {
|
||||
togglePlayPause();
|
||||
} else if (e.key === "r") {
|
||||
restart();
|
||||
} else if (e.key === "a") {
|
||||
handleAutoPauseToggle();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", handleKeyDownEvent);
|
||||
return () => document.removeEventListener("keydown", handleKeyDownEvent);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const cb = () => {
|
||||
if (ready.current.all()) {
|
||||
if (!parsedSrtRef.current) {
|
||||
} else if (isPlaying) {
|
||||
// 这里负责显示当前时间的字幕与自动暂停
|
||||
const srt = parsedSrtRef.current;
|
||||
const ct = videoRef.current?.currentTime as number;
|
||||
const index = getIndex(srt, ct);
|
||||
if (index !== null) {
|
||||
setSubtitle(srt[index].text);
|
||||
if (
|
||||
autoPause &&
|
||||
ct >= srt[index].end - 0.05 &&
|
||||
ct < srt[index].end
|
||||
) {
|
||||
videoRef.current!.currentTime = srt[index].start;
|
||||
togglePlayPause();
|
||||
}
|
||||
} else {
|
||||
setSubtitle("");
|
||||
}
|
||||
} else {
|
||||
}
|
||||
}
|
||||
rafldRef.current = requestAnimationFrame(cb);
|
||||
};
|
||||
rafldRef.current = requestAnimationFrame(cb);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafldRef.current);
|
||||
};
|
||||
}, [autoPause, isPlaying, togglePlayPause, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoUrl && videoRef.current) {
|
||||
videoRef.current.src = videoUrl;
|
||||
videoRef.current.load();
|
||||
setIsPlaying(false);
|
||||
ready.current["vid"] = true;
|
||||
}
|
||||
}, [videoRef, videoUrl]);
|
||||
useEffect(() => {
|
||||
if (srtUrl) {
|
||||
fetch(srtUrl)
|
||||
.then((response) => response.text())
|
||||
.then((data) => {
|
||||
parsedSrtRef.current = parseSrt(data);
|
||||
setSrtLength(parsedSrtRef.current.length);
|
||||
ready.current["sub"] = true;
|
||||
});
|
||||
}
|
||||
}, [srtUrl]);
|
||||
|
||||
const timeUpdate = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const index = getIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (!index) return;
|
||||
setSpanText(`${index + 1}/${parsedSrtRef.current.length}`);
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (videoRef.current && parsedSrtRef.current) {
|
||||
const newProgress = parseInt(e.target.value);
|
||||
videoRef.current.currentTime =
|
||||
parsedSrtRef.current[newProgress]?.start || 0;
|
||||
setProgress(newProgress);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoPauseToggle = () => {
|
||||
setAutoPause(!autoPause);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i + 1 < parsedSrtRef.current.length) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i + 1].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i - 1 >= 0) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i - 1].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
const restart = () => {
|
||||
if (!parsedSrtRef.current || !videoRef.current) return;
|
||||
const i = getNearistIndex(
|
||||
parsedSrtRef.current,
|
||||
videoRef.current.currentTime,
|
||||
);
|
||||
if (i != null && i >= 0) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[i].start;
|
||||
videoRef.current.play();
|
||||
setIsPlaying(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col">
|
||||
<video
|
||||
className="bg-gray-200"
|
||||
ref={videoRef}
|
||||
onTimeUpdate={timeUpdate}
|
||||
></video>
|
||||
<SubtitleDisplay subtitle={subtitle}></SubtitleDisplay>
|
||||
<div className="buttons flex mt-2 gap-2 flex-wrap">
|
||||
<LightButton onClick={togglePlayPause}>
|
||||
{isPlaying ? t("pause") : t("play")}
|
||||
</LightButton>
|
||||
<LightButton onClick={previous}>{t("previous")}</LightButton>
|
||||
<LightButton onClick={next}>{t("next")}</LightButton>
|
||||
<LightButton onClick={restart}>{t("restart")}</LightButton>
|
||||
<LightButton onClick={handleAutoPauseToggle}>
|
||||
{t("autoPause", { enabled: autoPause ? "Yes" : "No" })}
|
||||
</LightButton>
|
||||
</div>
|
||||
<RangeInput
|
||||
className="seekbar"
|
||||
min={0}
|
||||
max={srtLength}
|
||||
onChange={(value) => {
|
||||
if (videoRef.current && parsedSrtRef.current) {
|
||||
videoRef.current.currentTime = parsedSrtRef.current[value]?.start || 0;
|
||||
setProgress(value);
|
||||
}
|
||||
}}
|
||||
value={progress}
|
||||
/>
|
||||
<span>{spanText}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VideoPanel.displayName = "VideoPanel";
|
||||
|
||||
export { VideoPanel };
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useSrtPlayerStore } from "../store";
|
||||
|
||||
/**
|
||||
* useSubtitleSync - 字幕同步 Hook
|
||||
*
|
||||
* 自动同步视频播放时间与字幕显示,支持自动暂停功能。
|
||||
* 使用 Zustand store 获取状态,无需传入参数。
|
||||
*/
|
||||
export function useSubtitleSync() {
|
||||
const lastSubtitleRef = useRef<number | null>(null);
|
||||
const hasAutoPausedRef = useRef<{ [key: number]: boolean }>({}); // 追踪每个字幕是否已触发自动暂停
|
||||
const rafIdRef = useRef<number>(0);
|
||||
|
||||
// 从 store 获取状态
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
|
||||
// Store actions
|
||||
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
const pause = useSrtPlayerStore((state) => state.pause);
|
||||
|
||||
// 同步循环
|
||||
useEffect(() => {
|
||||
const syncSubtitles = () => {
|
||||
// 从 store 获取最新的 currentTime
|
||||
const currentTime = useSrtPlayerStore.getState().video.currentTime;
|
||||
|
||||
// 获取当前时间对应的字幕索引
|
||||
const getCurrentSubtitleIndex = (time: number): number | null => {
|
||||
for (let i = 0; i < subtitleData.length; i++) {
|
||||
const subtitle = subtitleData[i];
|
||||
if (time >= subtitle.start && time <= subtitle.end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const currentIndex = getCurrentSubtitleIndex(currentTime);
|
||||
|
||||
// 检查字幕是否发生变化
|
||||
if (currentIndex !== lastSubtitleRef.current) {
|
||||
lastSubtitleRef.current = currentIndex;
|
||||
|
||||
if (currentIndex !== null) {
|
||||
const subtitle = subtitleData[currentIndex];
|
||||
setCurrentSubtitle(subtitle.text, currentIndex);
|
||||
} else {
|
||||
setCurrentSubtitle('', null);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否需要自动暂停(每个字幕只触发一次)
|
||||
if (autoPause && currentIndex !== null) {
|
||||
const currentSubtitle = subtitleData[currentIndex];
|
||||
const timeUntilEnd = currentSubtitle.end - currentTime;
|
||||
|
||||
// 在字幕结束前 0.2 秒触发自动暂停
|
||||
if (timeUntilEnd <= 0.2 && timeUntilEnd > 0 && !hasAutoPausedRef.current[currentIndex]) {
|
||||
hasAutoPausedRef.current[currentIndex] = true;
|
||||
seek(currentSubtitle.start);
|
||||
// 使用 setTimeout 确保在 seek 之后暂停
|
||||
setTimeout(() => {
|
||||
pause();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果视频正在播放,继续循环
|
||||
if (useSrtPlayerStore.getState().video.isPlaying) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
}
|
||||
};
|
||||
|
||||
if (subtitleData.length > 0 && isPlaying) {
|
||||
rafIdRef.current = requestAnimationFrame(syncSubtitles);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
}
|
||||
};
|
||||
}, [subtitleData, isPlaying, autoPause, setCurrentSubtitle, seek, pause]);
|
||||
|
||||
// 重置最后字幕引用
|
||||
useEffect(() => {
|
||||
lastSubtitleRef.current = null;
|
||||
hasAutoPausedRef.current = {};
|
||||
}, [subtitleData]);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { VideoPlayerPanel } from "./components/VideoPlayerPanel";
|
||||
import { ControlPanel } from "./components/ControlPanel";
|
||||
import { useVideoSync } from "./hooks/useVideoSync";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import { useSrtPlayerStore } from "./store";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const t = useTranslations("home");
|
||||
const srtT = useTranslations("srt_player");
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
// Store state
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||
|
||||
// Hooks
|
||||
useVideoSync(videoRef);
|
||||
useSubtitleSync();
|
||||
useSrtPlayerShortcuts();
|
||||
|
||||
// Load subtitle when URL changes
|
||||
useEffect(() => {
|
||||
if (subtitleUrl) {
|
||||
loadSubtitle(subtitleUrl)
|
||||
.then((subtitleData) => {
|
||||
setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* Title */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
{t("srtPlayer.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Video Player */}
|
||||
<VideoPlayerPanel ref={videoRef} />
|
||||
|
||||
{/* Control Panel */}
|
||||
<ControlPanel />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
export function parseSrt(data: string) {
|
||||
const lines = data.split(/\r?\n/);
|
||||
const result = [];
|
||||
const re = new RegExp(
|
||||
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||
);
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (i >= lines.length) break;
|
||||
const timeMatch = lines[i].match(re);
|
||||
if (!timeMatch) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const start = toSeconds(timeMatch[1]);
|
||||
const end = toSeconds(timeMatch[2]);
|
||||
i++;
|
||||
let text = "";
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
text += lines[i] + "\n";
|
||||
i++;
|
||||
}
|
||||
result.push({ start, end, text: text.trim() });
|
||||
i++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getNearistIndex(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
ct: number,
|
||||
) {
|
||||
for (let i = 0; i < srt.length; i++) {
|
||||
const s = srt[i];
|
||||
const l = ct - s.start >= 0;
|
||||
const r = ct - s.end >= 0;
|
||||
if (!(l || r)) return i - 1;
|
||||
if (l && !r) return i;
|
||||
}
|
||||
}
|
||||
|
||||
export function getIndex(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
ct: number,
|
||||
) {
|
||||
for (let i = 0; i < srt.length; i++) {
|
||||
if (ct >= srt[i].start && ct <= srt[i].end) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getSubtitle(
|
||||
srt: { start: number; end: number; text: string }[],
|
||||
currentTime: number,
|
||||
) {
|
||||
return (
|
||||
srt.find((sub) => currentTime >= sub.start && currentTime <= sub.end) ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function toSeconds(timeStr: string): number {
|
||||
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||
return parseFloat(
|
||||
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// ==================== Video Types ====================
|
||||
|
||||
export interface VideoState {
|
||||
url: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VideoControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
// ==================== Subtitle Types ====================
|
||||
|
||||
export interface SubtitleEntry {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SubtitleState {
|
||||
url: string | null;
|
||||
data: SubtitleEntry[];
|
||||
currentText: string;
|
||||
currentIndex: number | null;
|
||||
settings: SubtitleSettings;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
position: 'top' | 'center' | 'bottom';
|
||||
fontFamily: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SubtitleControls {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
toggleAutoPause: () => void;
|
||||
}
|
||||
|
||||
// ==================== Controls Types ====================
|
||||
|
||||
export interface ControlState {
|
||||
autoPause: boolean;
|
||||
showShortcuts: boolean;
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ControlActions {
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
// ==================== Store Types ====================
|
||||
|
||||
export interface SrtPlayerStore {
|
||||
// Video state
|
||||
video: VideoState;
|
||||
|
||||
// Subtitle state
|
||||
subtitle: SubtitleState;
|
||||
|
||||
// Controls state
|
||||
controls: ControlState;
|
||||
|
||||
// Video actions
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
restart: () => void;
|
||||
|
||||
// Subtitle actions
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
setSubtitleData: (data: SubtitleEntry[]) => void;
|
||||
setCurrentSubtitle: (text: string, index: number | null) => void;
|
||||
updateSettings: (settings: Partial<SubtitleSettings>) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
|
||||
// Controls actions
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectors = {
|
||||
canPlay: (state: SrtPlayerStore) =>
|
||||
!!state.video.url &&
|
||||
!!state.subtitle.url &&
|
||||
state.subtitle.data.length > 0,
|
||||
|
||||
currentSubtitle: (state: SrtPlayerStore) =>
|
||||
state.subtitle.currentIndex !== null
|
||||
? state.subtitle.data[state.subtitle.currentIndex]
|
||||
: null,
|
||||
|
||||
progress: (state: SrtPlayerStore) => ({
|
||||
current: state.subtitle.currentIndex ?? 0,
|
||||
total: state.subtitle.data.length,
|
||||
}),
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export default async function MemorizePage({
|
||||
|
||||
if (!folder_id) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/auth?redirect=/memorize");
|
||||
if (!session) redirect("/login?redirect=/memorize");
|
||||
|
||||
return (
|
||||
<FolderSelector
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from 'lucide-react';
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play, Settings, Keyboard } from 'lucide-react';
|
||||
import { Button, LightButton } from '@/design-system/base/button';
|
||||
import { Range } from '@/design-system/base/range';
|
||||
import { HStack, VStack } from '@/design-system/layout/stack';
|
||||
import { useSrtPlayerStore } from '../store';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
import { useFileUpload } from '../hooks/useFileUpload';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -14,7 +14,6 @@ export function ControlPanel() {
|
||||
const t = useTranslations('srt_player');
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
|
||||
// Store state
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
@@ -22,8 +21,10 @@ export function ControlPanel() {
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
const showSettings = useSrtPlayerStore((state) => state.controls.showSettings);
|
||||
const showShortcuts = useSrtPlayerStore((state) => state.controls.showShortcuts);
|
||||
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||
|
||||
// Store actions
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
@@ -33,47 +34,45 @@ export function ControlPanel() {
|
||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
const toggleSettings = useSrtPlayerStore((state) => state.toggleSettings);
|
||||
const toggleShortcuts = useSrtPlayerStore((state) => state.toggleShortcuts);
|
||||
const updateSettings = useSrtPlayerStore((state) => state.updateSettings);
|
||||
|
||||
// Computed values
|
||||
const canPlay = useMemo(() => !!videoUrl && !!subtitleUrl && subtitleData.length > 0, [videoUrl, subtitleUrl, subtitleData]);
|
||||
const currentProgress = currentIndex ?? 0;
|
||||
const totalProgress = Math.max(0, subtitleData.length - 1);
|
||||
|
||||
// Handle video upload
|
||||
const handleVideoUpload = useCallback(() => {
|
||||
uploadVideo(setVideoUrl, (error) => {
|
||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadVideo, setVideoUrl, t]);
|
||||
|
||||
// Handle subtitle upload
|
||||
const handleSubtitleUpload = useCallback(() => {
|
||||
uploadSubtitle(setSubtitleUrl, (error) => {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
}, [uploadSubtitle, setSubtitleUrl, t]);
|
||||
|
||||
// Handle seek
|
||||
const handleSeek = useCallback((index: number) => {
|
||||
if (subtitleData[index]) {
|
||||
seek(subtitleData[index].start);
|
||||
}
|
||||
}, [subtitleData, seek]);
|
||||
|
||||
// Handle playback rate change
|
||||
const handlePlaybackRateChange = useCallback(() => {
|
||||
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
const currentIndex = rates.indexOf(playbackRate);
|
||||
const nextIndex = (currentIndex + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndex]);
|
||||
const currentIndexRate = rates.indexOf(playbackRate);
|
||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndexRate]);
|
||||
}, [playbackRate, setPlaybackRate]);
|
||||
|
||||
return (
|
||||
<div className="p-3 bg-gray-50 border-t rounded-b-xl">
|
||||
<VStack gap={3}>
|
||||
{/* Upload Status Cards */}
|
||||
<HStack gap={3}>
|
||||
{/* Video Upload Card */}
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
videoUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
@@ -97,7 +96,6 @@ export function ControlPanel() {
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{/* Subtitle Upload Card */}
|
||||
<div
|
||||
className={`flex-1 p-2 rounded-lg border-2 transition-all ${
|
||||
subtitleUrl ? 'border-gray-800 bg-gray-100' : 'border-gray-300 bg-white'
|
||||
@@ -122,12 +120,10 @@ export function ControlPanel() {
|
||||
</div>
|
||||
</HStack>
|
||||
|
||||
{/* Controls Area */}
|
||||
<VStack
|
||||
gap={4}
|
||||
className={!canPlay ? 'opacity-50 pointer-events-none' : ''}
|
||||
>
|
||||
{/* Playback Controls */}
|
||||
<HStack gap={2} justify="center" wrap>
|
||||
<Button
|
||||
onClick={togglePlayPause}
|
||||
@@ -176,9 +172,22 @@ export function ControlPanel() {
|
||||
>
|
||||
{t('autoPause', { enabled: autoPause ? t('on') : t('off') })}
|
||||
</Button>
|
||||
|
||||
<LightButton
|
||||
onClick={toggleSettings}
|
||||
leftIcon={<Settings className="w-4 h-4" />}
|
||||
>
|
||||
{t('settings')}
|
||||
</LightButton>
|
||||
|
||||
<LightButton
|
||||
onClick={toggleShortcuts}
|
||||
leftIcon={<Keyboard className="w-4 h-4" />}
|
||||
>
|
||||
{t('shortcuts')}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
|
||||
{/* Seek Bar */}
|
||||
<VStack gap={2}>
|
||||
<Range
|
||||
value={currentProgress}
|
||||
@@ -188,19 +197,16 @@ export function ControlPanel() {
|
||||
disabled={!canPlay}
|
||||
/>
|
||||
|
||||
{/* Progress Stats */}
|
||||
<HStack gap={4} justify="between" className="text-sm text-gray-600 px-2">
|
||||
<span>
|
||||
{currentIndex !== null ? `${currentIndex + 1}/${subtitleData.length}` : '0/0'}
|
||||
</span>
|
||||
|
||||
<HStack gap={4}>
|
||||
{/* Playback Rate Badge */}
|
||||
<span className="bg-gray-200 px-2 py-1 rounded text-xs">
|
||||
{playbackRate}x
|
||||
</span>
|
||||
|
||||
{/* Auto Pause Badge */}
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
autoPause ? 'bg-gray-800 text-white' : 'bg-gray-100 text-gray-600'
|
||||
@@ -212,6 +218,92 @@ export function ControlPanel() {
|
||||
</HStack>
|
||||
</VStack>
|
||||
</VStack>
|
||||
|
||||
{showSettings && (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">{t('subtitleSettings')}</h3>
|
||||
<VStack gap={3}>
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('fontSize')}</span>
|
||||
<Range
|
||||
value={settings.fontSize}
|
||||
min={12}
|
||||
max={48}
|
||||
onChange={(value) => updateSettings({ fontSize: value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 w-12">{settings.fontSize}px</span>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('textColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.textColor}
|
||||
onChange={(e) => updateSettings({ textColor: e.target.value })}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('backgroundColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={settings.backgroundColor.replace(/rgba?\([^)]+\)/, '#000000')}
|
||||
onChange={(e) => updateSettings({ backgroundColor: e.target.value })}
|
||||
className="w-8 h-8 rounded cursor-pointer"
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('position')}</span>
|
||||
<HStack gap={2}>
|
||||
{(['top', 'center', 'bottom'] as const).map((pos) => (
|
||||
<Button
|
||||
key={pos}
|
||||
size="sm"
|
||||
variant={settings.position === pos ? 'primary' : 'secondary'}
|
||||
onClick={() => updateSettings({ position: pos })}
|
||||
>
|
||||
{t(pos)}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} className="w-full">
|
||||
<span className="text-sm text-gray-600 w-20">{t('opacity')}</span>
|
||||
<Range
|
||||
value={settings.opacity}
|
||||
min={0.1}
|
||||
max={1}
|
||||
step={0.1}
|
||||
onChange={(value) => updateSettings({ opacity: value })}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 w-12">{Math.round(settings.opacity * 100)}%</span>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showShortcuts && (
|
||||
<div className="p-3 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">{t('keyboardShortcuts')}</h3>
|
||||
<VStack gap={2}>
|
||||
{[
|
||||
{ key: 'Space', desc: t('playPause') },
|
||||
{ key: 'N', desc: t('next') },
|
||||
{ key: 'P', desc: t('previous') },
|
||||
{ key: 'R', desc: t('restart') },
|
||||
{ key: 'A', desc: t('autoPauseToggle') },
|
||||
].map((shortcut) => (
|
||||
<HStack key={shortcut.key} gap={2} justify="between" className="w-full">
|
||||
<kbd className="px-2 py-1 bg-gray-100 rounded text-sm font-mono">{shortcut.key}</kbd>
|
||||
<span className="text-sm text-gray-600">{shortcut.desc}</span>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, forwardRef } from 'react';
|
||||
import { useSrtPlayerStore } from '../store';
|
||||
import { setVideoRef } from '../store';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
import { setVideoRef } from '../stores/srtPlayerStore';
|
||||
|
||||
export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -14,14 +14,12 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
const currentText = useSrtPlayerStore((state) => state.subtitle.currentText);
|
||||
const settings = useSrtPlayerStore((state) => state.subtitle.settings);
|
||||
|
||||
// 设置 video ref 给 store
|
||||
useEffect(() => {
|
||||
setVideoRef(videoRef);
|
||||
}, [videoRef]);
|
||||
|
||||
return (
|
||||
<div className="aspect-video bg-black relative rounded-md overflow-hidden">
|
||||
{/* 空状态提示 */}
|
||||
{(!videoUrl || !subtitleUrl || subtitleData.length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900 bg-opacity-75 z-10">
|
||||
<div className="text-center text-white">
|
||||
@@ -41,7 +39,6 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 视频元素 */}
|
||||
{videoUrl && (
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -51,7 +48,6 @@ export const VideoPlayerPanel = forwardRef<HTMLVideoElement>((_, ref) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 字幕显示覆盖层 */}
|
||||
{subtitleUrl && subtitleData.length > 0 && currentText && (
|
||||
<div
|
||||
className="absolute px-4 py-2 text-center w-full"
|
||||
@@ -9,8 +9,7 @@ export function useFileUpload() {
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
try {
|
||||
// 验证文件大小(限制为1000MB)
|
||||
const maxSize = 1000 * 1024 * 1024; // 1000MB
|
||||
const maxSize = 1000 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
throw new Error(`文件大小超过限制 (${(file.size / 1024 / 1024).toFixed(2)}MB > 1000MB)`);
|
||||
}
|
||||
@@ -34,7 +33,6 @@ export function useFileUpload() {
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('video/')) {
|
||||
onError?.(new Error('请选择有效的视频文件'));
|
||||
return;
|
||||
@@ -61,7 +59,6 @@ export function useFileUpload() {
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
// 验证文件扩展名
|
||||
if (!file.name.toLowerCase().endsWith('.srt')) {
|
||||
onError?.(new Error('请选择.srt格式的字幕文件'));
|
||||
return;
|
||||
@@ -80,6 +77,5 @@ export function useFileUpload() {
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSrtPlayerStore } from "../store";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
/**
|
||||
* useSrtPlayerShortcuts - SRT 播放器快捷键 Hook
|
||||
*
|
||||
* 自动为 SRT 播放器设置键盘快捷键,无需传入参数。
|
||||
* 直接使用 Zustand store 中的 actions。
|
||||
*/
|
||||
export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
@@ -20,7 +14,6 @@ export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
|
||||
if (!enabled) return;
|
||||
|
||||
// 防止在输入框中触发快捷键
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
@@ -61,7 +54,6 @@ export function useSrtPlayerShortcuts(enabled: boolean = true) {
|
||||
}, [enabled, togglePlayPause, nextSubtitle, previousSubtitle, restartSubtitle, toggleAutoPause]);
|
||||
}
|
||||
|
||||
// 保留通用快捷键 Hook 用于其他场景
|
||||
export function useKeyboardShortcuts(
|
||||
shortcuts: Array<{ key: string; action: () => void }>,
|
||||
isEnabled: boolean = true
|
||||
101
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
101
src/app/(features)/srt-player/hooks/useSubtitleSync.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useSrtPlayerStore } from "../stores/srtPlayerStore";
|
||||
|
||||
export function useSubtitleSync() {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastIndexRef = useRef<number | null>(null);
|
||||
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const currentTime = useSrtPlayerStore((state) => state.video.currentTime);
|
||||
|
||||
const setCurrentSubtitle = useSrtPlayerStore((state) => state.setCurrentSubtitle);
|
||||
const pause = useSrtPlayerStore((state) => state.pause);
|
||||
|
||||
const scheduleAutoPause = useCallback(() => {
|
||||
if (!autoPause || !isPlaying) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimeNow = useSrtPlayerStore.getState().video.currentTime;
|
||||
const currentIndexNow = useSrtPlayerStore.getState().subtitle.currentIndex;
|
||||
|
||||
if (currentIndexNow === null || !subtitleData[currentIndexNow]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subtitle = subtitleData[currentIndexNow];
|
||||
const timeUntilEnd = subtitle.end - currentTimeNow;
|
||||
|
||||
if (timeUntilEnd <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const advanceTime = 0.15;
|
||||
const realTimeUntilPause = (timeUntilEnd - advanceTime) / playbackRate;
|
||||
|
||||
if (realTimeUntilPause > 0) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
pause();
|
||||
}, realTimeUntilPause * 1000);
|
||||
}
|
||||
}, [autoPause, isPlaying, subtitleData, playbackRate, pause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!subtitleData || subtitleData.length === 0) {
|
||||
setCurrentSubtitle('', null);
|
||||
lastIndexRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let newIndex: number | null = null;
|
||||
|
||||
for (let i = 0; i < subtitleData.length; i++) {
|
||||
const subtitle = subtitleData[i];
|
||||
if (currentTime >= subtitle.start && currentTime <= subtitle.end) {
|
||||
newIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newIndex !== lastIndexRef.current) {
|
||||
lastIndexRef.current = newIndex;
|
||||
if (newIndex !== null) {
|
||||
setCurrentSubtitle(subtitleData[newIndex].text, newIndex);
|
||||
} else {
|
||||
setCurrentSubtitle('', null);
|
||||
}
|
||||
}
|
||||
}, [subtitleData, currentTime, setCurrentSubtitle]);
|
||||
|
||||
useEffect(() => {
|
||||
scheduleAutoPause();
|
||||
}, [isPlaying, autoPause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying && autoPause) {
|
||||
scheduleAutoPause();
|
||||
}
|
||||
}, [playbackRate, currentTime]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
import { useSrtPlayerStore } from '../store';
|
||||
import { useSrtPlayerStore } from '../stores/srtPlayerStore';
|
||||
|
||||
export function useVideoSync(videoRef: RefObject<HTMLVideoElement | null>) {
|
||||
const setCurrentTime = useSrtPlayerStore((state) => state.setCurrentTime);
|
||||
@@ -1,97 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { LightButton, PageLayout } from "@/components/ui";
|
||||
import { useVideoStore } from "./stores/videoStore";
|
||||
import { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { PageLayout } from "@/components/ui/PageLayout";
|
||||
import { LightButton } from "@/design-system/base/button";
|
||||
import { HStack } from "@/design-system/layout/stack";
|
||||
import { MessageSquareQuote, Video } from "lucide-react";
|
||||
import { useFileUpload } from "./useFileUpload";
|
||||
import { useSubtitleStore } from "./stores/substitleStore";
|
||||
import { getCurrentIndex } from "./subtitleParser";
|
||||
import { Video, FileText, ChevronLeft, ChevronRight, RotateCcw, Pause, Play } from "lucide-react";
|
||||
import { useVideoSync } from "./hooks/useVideoSync";
|
||||
import { useSubtitleSync } from "./hooks/useSubtitleSync";
|
||||
import { useSrtPlayerShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { loadSubtitle } from "./utils/subtitleParser";
|
||||
import { useSrtPlayerStore } from "./stores/srtPlayerStore";
|
||||
import { useFileUpload } from "./hooks/useFileUpload";
|
||||
import { setVideoRef } from "./stores/srtPlayerStore";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function SrtPlayerPage() {
|
||||
const t = useTranslations("home");
|
||||
const srtT = useTranslations("srt_player");
|
||||
|
||||
export default function SRTPlayerPage() {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const { setVideoRef, pause, currentSrc, isPlaying, loadVideo, loaded, getCurrentTime, getDuration, play, setOnTimeUpdate } = useVideoStore();
|
||||
const {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
} = useFileUpload();
|
||||
const {
|
||||
sub,
|
||||
setSub,
|
||||
index,
|
||||
setIndex
|
||||
} = useSubtitleStore();
|
||||
const { uploadVideo, uploadSubtitle } = useFileUpload();
|
||||
|
||||
const subtitleUrl = useSrtPlayerStore((state) => state.subtitle.url);
|
||||
const setSubtitleData = useSrtPlayerStore((state) => state.setSubtitleData);
|
||||
const setSubtitleUrl = useSrtPlayerStore((state) => state.setSubtitleUrl);
|
||||
const setVideoUrl = useSrtPlayerStore((state) => state.setVideoUrl);
|
||||
|
||||
const videoUrl = useSrtPlayerStore((state) => state.video.url);
|
||||
const subtitleData = useSrtPlayerStore((state) => state.subtitle.data);
|
||||
const currentIndex = useSrtPlayerStore((state) => state.subtitle.currentIndex);
|
||||
const isPlaying = useSrtPlayerStore((state) => state.video.isPlaying);
|
||||
const playbackRate = useSrtPlayerStore((state) => state.video.playbackRate);
|
||||
const autoPause = useSrtPlayerStore((state) => state.controls.autoPause);
|
||||
|
||||
const togglePlayPause = useSrtPlayerStore((state) => state.togglePlayPause);
|
||||
const nextSubtitle = useSrtPlayerStore((state) => state.nextSubtitle);
|
||||
const previousSubtitle = useSrtPlayerStore((state) => state.previousSubtitle);
|
||||
const restartSubtitle = useSrtPlayerStore((state) => state.restartSubtitle);
|
||||
const setPlaybackRate = useSrtPlayerStore((state) => state.setPlaybackRate);
|
||||
const toggleAutoPause = useSrtPlayerStore((state) => state.toggleAutoPause);
|
||||
const seek = useSrtPlayerStore((state) => state.seek);
|
||||
|
||||
useVideoSync(videoRef);
|
||||
useSubtitleSync();
|
||||
useSrtPlayerShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
setVideoRef(videoRef);
|
||||
setOnTimeUpdate((time) => {
|
||||
setIndex(getCurrentIndex(sub, time));
|
||||
}, [videoRef]);
|
||||
|
||||
const canPlay = !!videoUrl && !!subtitleUrl && subtitleData.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (subtitleUrl) {
|
||||
loadSubtitle(subtitleUrl)
|
||||
.then((subtitleData) => {
|
||||
setSubtitleData(subtitleData);
|
||||
toast.success(srtT("subtitleLoadSuccess"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(srtT("subtitleLoadFailed") + ": " + error.message);
|
||||
});
|
||||
}
|
||||
}, [srtT, subtitleUrl, setSubtitleData]);
|
||||
|
||||
const handleVideoUpload = () => {
|
||||
uploadVideo((url) => {
|
||||
setVideoUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('videoUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
return () => {
|
||||
setVideoRef();
|
||||
setOnTimeUpdate(() => { });
|
||||
};
|
||||
}, [setVideoRef, setOnTimeUpdate, sub, setIndex]);
|
||||
|
||||
const handleSubtitleUpload = () => {
|
||||
uploadSubtitle((url) => {
|
||||
setSubtitleUrl(url);
|
||||
}, (error) => {
|
||||
toast.error(t('subtitleUploadFailed') + ': ' + error.message);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlaybackRateChange = () => {
|
||||
const rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
const currentIndexRate = rates.indexOf(playbackRate);
|
||||
const nextIndexRate = (currentIndexRate + 1) % rates.length;
|
||||
setPlaybackRate(rates[nextIndexRate]);
|
||||
};
|
||||
|
||||
const currentSubtitle = currentIndex !== null ? subtitleData[currentIndex] : null;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<video ref={videoRef} width="85%" className="mx-auto"></video>
|
||||
|
||||
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
|
||||
{
|
||||
sub[index] && sub[index].text.split(" ").map((s, i) =>
|
||||
<Link key={i}
|
||||
href={`/dictionary?q=${s}`}
|
||||
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer">
|
||||
{s}
|
||||
</Link>
|
||||
)}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-gray-800 mb-2">
|
||||
{t("srtPlayer.name")}
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600">
|
||||
{t("srtPlayer.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<video
|
||||
ref={videoRef}
|
||||
width="85%"
|
||||
className="mx-auto"
|
||||
playsInline
|
||||
/>
|
||||
|
||||
<div className="shadow rounded h-20 w-[85%] mx-auto flex-wrap flex items-begin justify-center">
|
||||
{currentSubtitle && currentSubtitle.text.split(" ").map((s, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/dictionary?q=${s}`}
|
||||
className="px-1 h-fit hover:bg-gray-200 hover:cursor-pointer"
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 上传区域 */}
|
||||
<div className="mx-auto mt-4 flex items-center justify-center flex-wrap gap-2 w-[85%]">
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<Video size={16} />
|
||||
<span className="text-sm">视频文件</span>
|
||||
</div>
|
||||
<LightButton
|
||||
onClick={() => uploadVideo((url) => {
|
||||
loadVideo(url);
|
||||
})}>{loaded ? currentSrc?.split("/").pop() : "视频未上传"}</LightButton>
|
||||
<LightButton onClick={handleVideoUpload} disabled={!!videoUrl}>
|
||||
{videoUrl ? '已上传' : '上传视频'}
|
||||
</LightButton>
|
||||
</div>
|
||||
<div className="border-gray-200 border-2 flex items p-2 justify-between items-center rounded gap-8">
|
||||
<div className="flex items-center flex-col">
|
||||
<MessageSquareQuote size={16} />
|
||||
<span className="text-sm"
|
||||
>{sub.length > 0 ? `字幕已上传 (${sub.length} 条)` : "字幕未上传"}</span>
|
||||
<FileText size={16} />
|
||||
<span className="text-sm">
|
||||
{subtitleData.length > 0 ? `字幕已上传 (${subtitleData.length} 条)` : "字幕未上传"}
|
||||
</span>
|
||||
</div>
|
||||
<LightButton
|
||||
onClick={() =>
|
||||
uploadSubtitle((sub) => {
|
||||
setSub(sub);
|
||||
})
|
||||
}>上传字幕</LightButton>
|
||||
<LightButton onClick={handleSubtitleUpload} disabled={!!subtitleUrl}>
|
||||
{subtitleUrl ? '已上传' : '上传字幕'}
|
||||
</LightButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
/* 控制面板 */
|
||||
sub.length > 0 && loaded &&
|
||||
{canPlay && (
|
||||
<HStack gap={2} className="mx-auto mt-4 w-[85%]" justify={"center"} wrap>
|
||||
{isPlaying() ?
|
||||
LightButton({ children: "pause", onClick: () => pause() }) :
|
||||
LightButton({ children: "play", onClick: () => play() })}
|
||||
<LightButton>previous</LightButton>
|
||||
<LightButton>next</LightButton>
|
||||
<LightButton>restart</LightButton>
|
||||
<LightButton>1x</LightButton>
|
||||
<LightButton>ap(on)</LightButton>
|
||||
{isPlaying ? (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Pause className="w-4 h-4" />}>
|
||||
{srtT('pause')}
|
||||
</LightButton>
|
||||
) : (
|
||||
<LightButton onClick={togglePlayPause} leftIcon={<Play className="w-4 h-4" />}>
|
||||
{srtT('play')}
|
||||
</LightButton>
|
||||
)}
|
||||
<LightButton onClick={previousSubtitle} leftIcon={<ChevronLeft className="w-4 h-4" />}>
|
||||
{srtT('previous')}
|
||||
</LightButton>
|
||||
<LightButton onClick={nextSubtitle} rightIcon={<ChevronRight className="w-4 h-4" />}>
|
||||
{srtT('next')}
|
||||
</LightButton>
|
||||
<LightButton onClick={restartSubtitle} leftIcon={<RotateCcw className="w-4 h-4" />}>
|
||||
{srtT('restart')}
|
||||
</LightButton>
|
||||
<LightButton onClick={handlePlaybackRateChange}>
|
||||
{playbackRate}x
|
||||
</LightButton>
|
||||
<LightButton onClick={toggleAutoPause}>
|
||||
{srtT('autoPause', { enabled: autoPause ? srtT('on') : srtT('off') })}
|
||||
</LightButton>
|
||||
</HStack>
|
||||
}
|
||||
|
||||
)}
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
ControlState,
|
||||
SubtitleSettings,
|
||||
SubtitleEntry,
|
||||
} from './types';
|
||||
} from '../types';
|
||||
import type { RefObject } from 'react';
|
||||
|
||||
let videoRef: RefObject<HTMLVideoElement | null> | null;
|
||||
@@ -19,7 +19,6 @@ export function setVideoRef(ref: RefObject<HTMLVideoElement | null> | null) {
|
||||
videoRef = ref;
|
||||
}
|
||||
|
||||
// 初始状态
|
||||
const initialVideoState: VideoState = {
|
||||
url: null,
|
||||
isPlaying: false,
|
||||
@@ -55,12 +54,10 @@ const initialControlState: ControlState = {
|
||||
export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
// ==================== Initial State ====================
|
||||
video: initialVideoState,
|
||||
subtitle: initialSubtitleState,
|
||||
controls: initialControlState,
|
||||
|
||||
// ==================== Video Actions ====================
|
||||
setVideoUrl: (url) =>
|
||||
set((state) => {
|
||||
if (videoRef?.current) {
|
||||
@@ -111,7 +108,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||
|
||||
pause: () => {
|
||||
if (videoRef?.current) {
|
||||
// 只有在视频正在播放时才暂停,避免重复调用
|
||||
if (!videoRef.current.paused) {
|
||||
videoRef.current.pause();
|
||||
}
|
||||
@@ -146,7 +142,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Subtitle Actions ====================
|
||||
setSubtitleUrl: (url) =>
|
||||
set((state) => ({ subtitle: { ...state.subtitle, url } })),
|
||||
|
||||
@@ -202,7 +197,6 @@ export const useSrtPlayerStore = create<SrtPlayerStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Controls Actions ====================
|
||||
toggleAutoPause: () =>
|
||||
set((state) => ({
|
||||
controls: { ...state.controls, autoPause: !state.controls.autoPause },
|
||||
@@ -1,19 +0,0 @@
|
||||
import { create } from "zustand/react";
|
||||
import { SubtitleEntry } from "../types";
|
||||
import { devtools } from "zustand/middleware";
|
||||
|
||||
interface SubstitleStore {
|
||||
sub: SubtitleEntry[];
|
||||
index: number;
|
||||
setSub: (sub: SubtitleEntry[]) => void;
|
||||
setIndex: (index: number) => void;
|
||||
}
|
||||
|
||||
export const useSubtitleStore = create<SubstitleStore>()(
|
||||
devtools((set) => ({
|
||||
sub: [],
|
||||
index: 0,
|
||||
setSub: (sub) => set({ sub, index: 0 }),
|
||||
setIndex: (index) => set({ index }),
|
||||
}))
|
||||
);
|
||||
@@ -1,112 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
interface VideoStore {
|
||||
videoRef?: React.RefObject<HTMLVideoElement | null>;
|
||||
currentSrc: string | null;
|
||||
loaded: boolean;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
setOnTimeUpdate: (handler: (time: number) => void) => void;
|
||||
setVideoRef: (ref?: React.RefObject<HTMLVideoElement | null>) => void;
|
||||
loadVideo: (url: string, options?: { autoplay?: boolean; muted?: boolean; }) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlay: () => void;
|
||||
seekTo: (time: number) => void;
|
||||
setVolume: (vol: number) => void;
|
||||
getCurrentTime: () => number | undefined;
|
||||
getDuration: () => number | undefined;
|
||||
isPlaying: () => boolean;
|
||||
}
|
||||
|
||||
export const useVideoStore = create<VideoStore>()(
|
||||
devtools((set, get) => ({
|
||||
videoRef: null,
|
||||
currentSrc: null,
|
||||
loaded: false,
|
||||
onTimeUpdate: (time) => { },
|
||||
setOnTimeUpdate: (handler) => {
|
||||
set({ onTimeUpdate: handler });
|
||||
},
|
||||
setVideoRef: (ref) => {
|
||||
set({ videoRef: ref });
|
||||
ref?.current?.addEventListener("timeupdate", () => {
|
||||
const currentTime = get().videoRef?.current?.currentTime;
|
||||
if (currentTime !== undefined) {
|
||||
get().onTimeUpdate(currentTime);
|
||||
}
|
||||
});
|
||||
},
|
||||
loadVideo: (url: string, options = { autoplay: false, muted: false }) => {
|
||||
const { videoRef } = get();
|
||||
const video = videoRef?.current;
|
||||
|
||||
if (!url) {
|
||||
console.warn('loadVideo: empty url provided');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video) {
|
||||
console.debug('loadVideo: video ref not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
video.src = url;
|
||||
if (options.autoplay) {
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
console.debug('Auto play succeeded after src change');
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('Auto play failed after src change:', err);
|
||||
});
|
||||
}
|
||||
set({ currentSrc: url, loaded: true });
|
||||
} catch (err) {
|
||||
console.error('Failed to load video:', err);
|
||||
set({ loaded: false });
|
||||
}
|
||||
},
|
||||
|
||||
play: () => {
|
||||
const video = get().videoRef?.current;
|
||||
if (video) video.play().catch(() => { });
|
||||
},
|
||||
|
||||
pause: () => {
|
||||
const video = get().videoRef?.current;
|
||||
if (video) video.pause();
|
||||
},
|
||||
|
||||
togglePlay: () => {
|
||||
const video = get().videoRef?.current;
|
||||
if (!video) return;
|
||||
if (video.paused) {
|
||||
video.play().catch(() => { });
|
||||
} else {
|
||||
video.pause();
|
||||
}
|
||||
},
|
||||
|
||||
seekTo: (time: number) => {
|
||||
const video = get().videoRef?.current;
|
||||
if (video) video.currentTime = time;
|
||||
},
|
||||
|
||||
setVolume: (vol: number) => {
|
||||
const video = get().videoRef?.current;
|
||||
if (video) video.volume = Math.max(0, Math.min(1, vol));
|
||||
},
|
||||
getCurrentTime: () => get().videoRef?.current?.currentTime,
|
||||
getDuration: () => get().videoRef?.current?.duration,
|
||||
isPlaying: () => {
|
||||
const video = get().videoRef?.current;
|
||||
if (!video) return false;
|
||||
return !video.paused && !video.ended && video.readyState > 2;
|
||||
}
|
||||
}))
|
||||
);
|
||||
@@ -1,89 +0,0 @@
|
||||
import { SubtitleEntry } from "./types";
|
||||
|
||||
export function parseSrt(data: string): SubtitleEntry[] {
|
||||
const lines = data.split(/\r?\n/);
|
||||
const result: SubtitleEntry[] = [];
|
||||
const re = new RegExp(
|
||||
"(\\d{2}:\\d{2}:\\d{2},\\d{3})\\s*-->\\s*(\\d{2}:\\d{2}:\\d{2},\\d{3})",
|
||||
);
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
if (!lines[i].trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
if (i >= lines.length) break;
|
||||
|
||||
const timeMatch = lines[i].match(re);
|
||||
if (!timeMatch) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = toSeconds(timeMatch[1]);
|
||||
const end = toSeconds(timeMatch[2]);
|
||||
i++;
|
||||
|
||||
let text = "";
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
text += lines[i] + "\n";
|
||||
i++;
|
||||
}
|
||||
|
||||
result.push({
|
||||
start,
|
||||
end,
|
||||
text: text.trim(),
|
||||
index: result.length,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getNearestIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number | null {
|
||||
for (let i = 0; i < subtitles.length; i++) {
|
||||
const subtitle = subtitles[i];
|
||||
const isBefore = currentTime - subtitle.start >= 0;
|
||||
const isAfter = currentTime - subtitle.end >= 0;
|
||||
|
||||
if (!isBefore || !isAfter) return i - 1;
|
||||
if (isBefore && !isAfter) return i;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getCurrentIndex(
|
||||
subtitles: SubtitleEntry[],
|
||||
currentTime: number,
|
||||
): number {
|
||||
for (let index = 0; index < subtitles.length; index++) {
|
||||
if (subtitles[index].start <= currentTime && subtitles[index].end >= currentTime) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function toSeconds(timeStr: string): number {
|
||||
const [h, m, s] = timeStr.replace(",", ".").split(":");
|
||||
return parseFloat(
|
||||
(parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s)).toFixed(3),
|
||||
);
|
||||
}
|
||||
|
||||
export function loadSubtitle(url: string): Promise<SubtitleEntry[]> {
|
||||
return fetch(url)
|
||||
.then(response => response.text())
|
||||
.then(data => parseSrt(data))
|
||||
.catch(error => {
|
||||
console.error('加载字幕失败', error);
|
||||
return [];
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,132 @@
|
||||
// ==================== Video Types ====================
|
||||
|
||||
export interface VideoState {
|
||||
url: string | null;
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export interface VideoControls {
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
// ==================== Subtitle Types ====================
|
||||
|
||||
export interface SubtitleEntry {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface SubtitleState {
|
||||
url: string | null;
|
||||
data: SubtitleEntry[];
|
||||
currentText: string;
|
||||
currentIndex: number | null;
|
||||
settings: SubtitleSettings;
|
||||
}
|
||||
|
||||
export interface SubtitleSettings {
|
||||
fontSize: number;
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
position: 'top' | 'center' | 'bottom';
|
||||
fontFamily: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export interface SubtitleControls {
|
||||
next: () => void;
|
||||
previous: () => void;
|
||||
goToIndex: (index: number) => void;
|
||||
toggleAutoPause: () => void;
|
||||
}
|
||||
|
||||
// ==================== Controls Types ====================
|
||||
|
||||
export interface ControlState {
|
||||
autoPause: boolean;
|
||||
showShortcuts: boolean;
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
export interface ControlActions {
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
export interface KeyboardShortcut {
|
||||
key: string;
|
||||
description: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
// ==================== Store Types ====================
|
||||
|
||||
export interface SrtPlayerStore {
|
||||
// Video state
|
||||
video: VideoState;
|
||||
|
||||
// Subtitle state
|
||||
subtitle: SubtitleState;
|
||||
|
||||
// Controls state
|
||||
controls: ControlState;
|
||||
|
||||
// Video actions
|
||||
setVideoUrl: (url: string | null) => void;
|
||||
setPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
setPlaybackRate: (rate: number) => void;
|
||||
setVolume: (volume: number) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
togglePlayPause: () => void;
|
||||
seek: (time: number) => void;
|
||||
restart: () => void;
|
||||
|
||||
// Subtitle actions
|
||||
setSubtitleUrl: (url: string | null) => void;
|
||||
setSubtitleData: (data: SubtitleEntry[]) => void;
|
||||
setCurrentSubtitle: (text: string, index: number | null) => void;
|
||||
updateSettings: (settings: Partial<SubtitleSettings>) => void;
|
||||
nextSubtitle: () => void;
|
||||
previousSubtitle: () => void;
|
||||
restartSubtitle: () => void;
|
||||
|
||||
// Controls actions
|
||||
toggleAutoPause: () => void;
|
||||
toggleShortcuts: () => void;
|
||||
toggleSettings: () => void;
|
||||
}
|
||||
|
||||
// ==================== Selectors ====================
|
||||
|
||||
export const selectors = {
|
||||
canPlay: (state: SrtPlayerStore) =>
|
||||
!!state.video.url &&
|
||||
!!state.subtitle.url &&
|
||||
state.subtitle.data.length > 0,
|
||||
|
||||
currentSubtitle: (state: SrtPlayerStore) =>
|
||||
state.subtitle.currentIndex !== null
|
||||
? state.subtitle.data[state.subtitle.currentIndex]
|
||||
: null,
|
||||
|
||||
progress: (state: SrtPlayerStore) => ({
|
||||
current: state.subtitle.currentIndex ?? 0,
|
||||
total: state.subtitle.data.length,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { loadSubtitle } from "./subtitleParser";
|
||||
|
||||
const createUploadHandler = <T,>(
|
||||
accept: string,
|
||||
validate: (file: File) => boolean,
|
||||
errorMessage: string,
|
||||
processFile: (file: File) => T | Promise<T>
|
||||
) => {
|
||||
return ((
|
||||
onSuccess: (result: T) => void,
|
||||
onError?: (error: Error) => void
|
||||
) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = accept;
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (file) {
|
||||
if (!validate(file)) {
|
||||
onError?.(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await processFile(file);
|
||||
onSuccess(result);
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error : new Error('文件处理失败'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
input.onerror = () => {
|
||||
onError?.(new Error('文件选择失败'));
|
||||
};
|
||||
|
||||
input.click();
|
||||
});
|
||||
};
|
||||
|
||||
export function useFileUpload() {
|
||||
const uploadVideo = createUploadHandler(
|
||||
'video/*',
|
||||
(file) => file.type.startsWith('video/'),
|
||||
'请选择有效的视频文件',
|
||||
(file) => URL.createObjectURL(file)
|
||||
);
|
||||
|
||||
const uploadSubtitle = createUploadHandler(
|
||||
'.srt',
|
||||
(file) => file.name.toLowerCase().endsWith('.srt'),
|
||||
'请选择.srt格式的字幕文件',
|
||||
async (file) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
return loadSubtitle(url);
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
uploadVideo,
|
||||
uploadSubtitle,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,6 @@ export default async function FoldersPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
if (!session) redirect(`/auth?redirect=/folders`);
|
||||
if (!session) redirect(`/login?redirect=/folders`);
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
@@ -139,13 +139,13 @@ export async function signOutAction() {
|
||||
headers: await headers()
|
||||
});
|
||||
|
||||
redirect("/auth");
|
||||
redirect("/login");
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('NEXT_REDIRECT')) {
|
||||
throw e;
|
||||
}
|
||||
console.error("Sign out error:", e);
|
||||
redirect("/auth");
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user