观影室起步
This commit is contained in:
265
WATCH_ROOM_README.md
Normal file
265
WATCH_ROOM_README.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
# 观影室功能 - 实现文档
|
||||||
|
|
||||||
|
## 当前实现状态 ✅
|
||||||
|
|
||||||
|
### 已完成的核心功能
|
||||||
|
|
||||||
|
#### 1. 后端架构
|
||||||
|
- ✅ Socket.IO 服务器逻辑 (`src/lib/watch-room-server.ts`)
|
||||||
|
- 房间创建、加入、离开
|
||||||
|
- 成员管理
|
||||||
|
- 消息转发(播放状态、聊天消息)
|
||||||
|
- 心跳机制和自动清理
|
||||||
|
|
||||||
|
- ✅ 两种服务器模式
|
||||||
|
- **内部服务器**: 集成在 Next.js 应用中 (`server.js`)
|
||||||
|
- **外部服务器**: 独立运行的 Node.js 服务器 (`server/watch-room-standalone-server.js`)
|
||||||
|
|
||||||
|
#### 2. 前端功能
|
||||||
|
- ✅ 全局状态管理 (`WatchRoomProvider`)
|
||||||
|
- ✅ React Hook (`useWatchRoom`)
|
||||||
|
- ✅ 观影室首页 (`/watch-room`)
|
||||||
|
- 创建房间
|
||||||
|
- 加入房间
|
||||||
|
- 房间列表
|
||||||
|
|
||||||
|
- ✅ UI 组件
|
||||||
|
- 创建房间弹窗
|
||||||
|
- 加入房间弹窗
|
||||||
|
- 房间列表页面
|
||||||
|
- 聊天悬浮窗(全局)
|
||||||
|
|
||||||
|
- ✅ 导航集成
|
||||||
|
- 侧边栏添加观影室入口
|
||||||
|
- 底部导航栏添加观影室入口
|
||||||
|
|
||||||
|
#### 3. 功能特性
|
||||||
|
- ✅ LocalStorage 自动重连
|
||||||
|
- ✅ 心跳机制(每5秒)
|
||||||
|
- ✅ 房主断开5分钟后自动删除房间
|
||||||
|
- ✅ 文字聊天
|
||||||
|
- ✅ 表情发送
|
||||||
|
- ✅ 响应式设计(移动端+电脑端)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用内部 Socket.IO 服务器(推荐)
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 或者使用外部 Socket.IO 服务器
|
||||||
|
pnpm watch-room:server # 在另一个终端运行
|
||||||
|
# 然后修改配置使用外部服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 访问观影室
|
||||||
|
|
||||||
|
1. 打开浏览器访问 `http://localhost:3000`
|
||||||
|
2. 点击侧边栏或底部导航的"观影室"按钮
|
||||||
|
3. 选择:
|
||||||
|
- **创建房间**: 输入房间信息和昵称
|
||||||
|
- **加入房间**: 输入房间号和昵称
|
||||||
|
- **房间列表**: 查看所有公开房间
|
||||||
|
|
||||||
|
### 3. 房间功能
|
||||||
|
|
||||||
|
创建或加入房间后:
|
||||||
|
- 右下角会出现聊天按钮
|
||||||
|
- 点击聊天按钮打开聊天窗口
|
||||||
|
- 可以发送文字和表情
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待实现功能 🚧
|
||||||
|
|
||||||
|
### 高优先级(核心功能)
|
||||||
|
- ⏳ **播放同步功能** - 修改 `src/app/play/page.tsx`
|
||||||
|
- 房主播放、暂停、进度同步
|
||||||
|
- 房主换集、换源同步
|
||||||
|
- 房员禁用播放器控制
|
||||||
|
- 房员自动跟随房主进度
|
||||||
|
|
||||||
|
- ⏳ **直播同步功能** - 修改 `src/app/live/page.tsx`
|
||||||
|
- 房主切换频道同步
|
||||||
|
- 房员自动跟随频道
|
||||||
|
|
||||||
|
- ⏳ **管理面板配置**
|
||||||
|
- 添加观影室开关
|
||||||
|
- 配置服务器类型(内部/外部)
|
||||||
|
- 外部服务器地址和鉴权配置
|
||||||
|
|
||||||
|
### 中优先级(增强功能)
|
||||||
|
- ⏳ **WebRTC 语音聊天**
|
||||||
|
- P2P 连接
|
||||||
|
- 服务器中转回退
|
||||||
|
- 麦克风和喇叭控制
|
||||||
|
|
||||||
|
- ⏳ **房间成员列表**
|
||||||
|
- 显示在线成员
|
||||||
|
- 显示房主标识
|
||||||
|
|
||||||
|
- ⏳ **权限控制优化**
|
||||||
|
- 房员禁用某些操作
|
||||||
|
- 房主踢人功能(可选)
|
||||||
|
|
||||||
|
### 低优先级(优化)
|
||||||
|
- ⏳ **错误处理和重连优化**
|
||||||
|
- ⏳ **性能优化**
|
||||||
|
- ⏳ **更多表情支持**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术架构
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **Socket.IO 4.8.1**: WebSocket 通信
|
||||||
|
- **Next.js Custom Server**: 集成 Socket.IO
|
||||||
|
- **Node.js**: 独立服务器支持
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **React 18**: UI 框架
|
||||||
|
- **TypeScript**: 类型安全
|
||||||
|
- **Socket.IO Client**: WebSocket 客户端
|
||||||
|
- **Tailwind CSS**: 样式框架
|
||||||
|
- **Lucide React**: 图标库
|
||||||
|
|
||||||
|
### 数据流
|
||||||
|
```
|
||||||
|
用户操作 → React Hook (useWatchRoom)
|
||||||
|
↓
|
||||||
|
Socket.IO Client
|
||||||
|
↓
|
||||||
|
Socket.IO Server (watch-room-server.ts)
|
||||||
|
↓
|
||||||
|
房间成员 ← WebSocket 推送
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── types/
|
||||||
|
│ └── watch-room.ts # TypeScript 类型定义
|
||||||
|
├── lib/
|
||||||
|
│ ├── watch-room-server.ts # Socket.IO 服务器逻辑
|
||||||
|
│ └── watch-room-socket.ts # Socket 客户端管理
|
||||||
|
├── hooks/
|
||||||
|
│ └── useWatchRoom.ts # React Hook
|
||||||
|
├── components/
|
||||||
|
│ ├── WatchRoomProvider.tsx # 全局状态管理
|
||||||
|
│ └── watch-room/
|
||||||
|
│ ├── CreateRoomModal.tsx # 创建房间弹窗
|
||||||
|
│ ├── JoinRoomModal.tsx # 加入房间弹窗
|
||||||
|
│ └── ChatFloatingWindow.tsx # 聊天悬浮窗
|
||||||
|
└── app/
|
||||||
|
└── watch-room/
|
||||||
|
├── page.tsx # 观影室首页
|
||||||
|
└── list/
|
||||||
|
└── page.tsx # 房间列表页面
|
||||||
|
|
||||||
|
server.js # Next.js 自定义服务器
|
||||||
|
server/
|
||||||
|
└── watch-room-standalone-server.js # 独立 Socket.IO 服务器
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Socket.IO 配置(可选)
|
||||||
|
WATCH_ROOM_ENABLED=true
|
||||||
|
WATCH_ROOM_SERVER_TYPE=internal # 或 external
|
||||||
|
WATCH_ROOM_EXTERNAL_URL=http://your-server:3001
|
||||||
|
WATCH_ROOM_EXTERNAL_AUTH=your_secret_key
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行时配置(将在管理面板中添加)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WatchRoomConfig {
|
||||||
|
enabled: boolean; // 是否启用观影室
|
||||||
|
serverType: 'internal' | 'external'; // 服务器类型
|
||||||
|
externalServerUrl?: string; // 外部服务器地址
|
||||||
|
externalServerAuth?: string; // 外部服务器鉴权密钥
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试清单
|
||||||
|
|
||||||
|
### 基础功能测试
|
||||||
|
- [x] 创建房间
|
||||||
|
- [x] 加入房间(正确密码)
|
||||||
|
- [x] 加入房间(错误密码)
|
||||||
|
- [x] 查看房间列表
|
||||||
|
- [x] 发送文字消息
|
||||||
|
- [x] 发送表情
|
||||||
|
- [x] 心跳机制
|
||||||
|
- [x] 刷新页面自动重连
|
||||||
|
|
||||||
|
### 播放同步测试(待实现)
|
||||||
|
- [ ] 房主播放/暂停同步
|
||||||
|
- [ ] 房主进度跳转同步
|
||||||
|
- [ ] 房主换集同步
|
||||||
|
- [ ] 房主换源同步
|
||||||
|
- [ ] 房员操作同步给全员
|
||||||
|
|
||||||
|
### 直播同步测试(待实现)
|
||||||
|
- [ ] 房主切换频道同步
|
||||||
|
- [ ] 房员自动跟随频道
|
||||||
|
|
||||||
|
### 移动端测试
|
||||||
|
- [x] 底部导航显示正常
|
||||||
|
- [x] 聊天窗口适配移动端
|
||||||
|
- [x] 创建/加入弹窗适配移动端
|
||||||
|
- [x] 房间列表页面适配移动端
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 下一步计划
|
||||||
|
|
||||||
|
1. **实现播放同步** (`src/app/play/page.tsx`)
|
||||||
|
- 集成 `useWatchRoom` Hook
|
||||||
|
- 监听播放器事件并同步
|
||||||
|
- 接收服务器播放状态更新
|
||||||
|
|
||||||
|
2. **实现直播同步** (`src/app/live/page.tsx`)
|
||||||
|
- 监听频道切换事件
|
||||||
|
- 同步频道状态
|
||||||
|
|
||||||
|
3. **添加管理面板配置**
|
||||||
|
- 在 `src/app/admin/page.tsx` 添加观影室配置项
|
||||||
|
- 保存配置到数据库
|
||||||
|
|
||||||
|
4. **实现 WebRTC 语音聊天**
|
||||||
|
- 创建 WebRTC 连接管理
|
||||||
|
- 添加麦克风/喇叭控制按钮
|
||||||
|
- 实现服务器中转回退
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知问题
|
||||||
|
|
||||||
|
1. **播放同步功能未实现**: 需要修改 play 和 live 页面
|
||||||
|
2. **管理面板配置未添加**: 目前使用默认配置(启用内部服务器)
|
||||||
|
3. **语音聊天未实现**: 仅支持文字和表情
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
最后更新: 2025-12-06
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
|
"dev": "pnpm gen:manifest && node server.js",
|
||||||
"build": "pnpm gen:manifest && next build",
|
"build": "pnpm gen:manifest && next build",
|
||||||
"start": "next start",
|
"start": "NODE_ENV=production node server.js",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "eslint src --fix && pnpm format",
|
"lint:fix": "eslint src --fix && pnpm format",
|
||||||
"lint:strict": "eslint --max-warnings=0 src",
|
"lint:strict": "eslint --max-warnings=0 src",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"format:check": "prettier -c .",
|
"format:check": "prettier -c .",
|
||||||
"gen:manifest": "node scripts/generate-manifest.js",
|
"gen:manifest": "node scripts/generate-manifest.js",
|
||||||
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
"postbuild": "echo 'Build completed - sitemap generation disabled'",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install",
|
||||||
|
"watch-room:server": "node server/watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -47,6 +48,8 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"redis": "^4.6.7",
|
"redis": "^4.6.7",
|
||||||
|
"socket.io": "^4.8.1",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"swiper": "^11.2.8",
|
"swiper": "^11.2.8",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"vidstack": "^0.6.15",
|
"vidstack": "^0.6.15",
|
||||||
|
|||||||
190
pnpm-lock.yaml
generated
190
pnpm-lock.yaml
generated
@@ -92,6 +92,12 @@ importers:
|
|||||||
redis:
|
redis:
|
||||||
specifier: ^4.6.7
|
specifier: ^4.6.7
|
||||||
version: 4.7.1
|
version: 4.7.1
|
||||||
|
socket.io:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.1
|
||||||
|
socket.io-client:
|
||||||
|
specifier: ^4.8.1
|
||||||
|
version: 4.8.1
|
||||||
swiper:
|
swiper:
|
||||||
specifier: ^11.2.8
|
specifier: ^11.2.8
|
||||||
version: 11.2.8
|
version: 11.2.8
|
||||||
@@ -1331,6 +1337,9 @@ packages:
|
|||||||
'@sinonjs/fake-timers@8.1.0':
|
'@sinonjs/fake-timers@8.1.0':
|
||||||
resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==}
|
resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==}
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2':
|
||||||
|
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
|
||||||
|
|
||||||
@@ -1493,6 +1502,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==}
|
resolution: {integrity: sha512-cAw/jKBzo98m6Xz1X5ETqymWfIMbXbu6nK15W4LQYjeHJkVqSmM5PO8Bd9KVHQJ/F4rHcSso9LcjtgCW6TGu2w==}
|
||||||
deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed.
|
deprecated: This is a stub types definition. bs58 provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||||
|
|
||||||
'@types/crypto-js@4.2.2':
|
'@types/crypto-js@4.2.2':
|
||||||
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||||
|
|
||||||
@@ -1835,6 +1847,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
||||||
deprecated: Use your platform's native atob() and btoa() methods instead
|
deprecated: Use your platform's native atob() and btoa() methods instead
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
acorn-globals@6.0.0:
|
acorn-globals@6.0.0:
|
||||||
resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==}
|
resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==}
|
||||||
|
|
||||||
@@ -2118,6 +2134,10 @@ packages:
|
|||||||
base-x@5.0.1:
|
base-x@5.0.1:
|
||||||
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
|
resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==}
|
||||||
|
|
||||||
|
base64id@2.0.0:
|
||||||
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
|
engines: {node: ^4.5.0 || >= 5.9}
|
||||||
|
|
||||||
baseline-browser-mapping@2.8.32:
|
baseline-browser-mapping@2.8.32:
|
||||||
resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==}
|
resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2369,9 +2389,17 @@ packages:
|
|||||||
convert-source-map@2.0.0:
|
convert-source-map@2.0.0:
|
||||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||||
|
|
||||||
|
cookie@0.7.2:
|
||||||
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
core-js-compat@3.43.0:
|
core-js-compat@3.43.0:
|
||||||
resolution: {integrity: sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==}
|
resolution: {integrity: sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==}
|
||||||
|
|
||||||
|
cors@2.8.5:
|
||||||
|
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
cosmiconfig-typescript-loader@2.0.2:
|
cosmiconfig-typescript-loader@2.0.2:
|
||||||
resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==}
|
resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==}
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
@@ -2481,6 +2509,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.1:
|
debug@4.4.1:
|
||||||
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -2624,6 +2661,17 @@ packages:
|
|||||||
encoding-sniffer@0.2.1:
|
encoding-sniffer@0.2.1:
|
||||||
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==}
|
||||||
|
|
||||||
|
engine.io-client@6.6.3:
|
||||||
|
resolution: {integrity: sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==}
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3:
|
||||||
|
resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
engine.io@6.6.4:
|
||||||
|
resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
|
||||||
engines: {node: '>=10.13.0'}
|
engines: {node: '>=10.13.0'}
|
||||||
@@ -3958,6 +4006,10 @@ packages:
|
|||||||
natural-compare@1.4.0:
|
natural-compare@1.4.0:
|
||||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||||
|
|
||||||
|
negotiator@0.6.3:
|
||||||
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
neo-async@2.6.2:
|
neo-async@2.6.2:
|
||||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||||
|
|
||||||
@@ -4666,6 +4718,21 @@ packages:
|
|||||||
snake-case@3.0.4:
|
snake-case@3.0.4:
|
||||||
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==}
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.5:
|
||||||
|
resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==}
|
||||||
|
|
||||||
|
socket.io-client@4.8.1:
|
||||||
|
resolution: {integrity: sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io-parser@4.2.4:
|
||||||
|
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
|
socket.io@4.8.1:
|
||||||
|
resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==}
|
||||||
|
engines: {node: '>=10.2.0'}
|
||||||
|
|
||||||
source-list-map@2.0.1:
|
source-list-map@2.0.1:
|
||||||
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
|
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
|
||||||
|
|
||||||
@@ -5158,6 +5225,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
|
resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
vary@1.1.2:
|
||||||
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
vidstack@0.6.15:
|
vidstack@0.6.15:
|
||||||
resolution: {integrity: sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==}
|
resolution: {integrity: sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -5348,12 +5419,28 @@ packages:
|
|||||||
utf-8-validate:
|
utf-8-validate:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
ws@8.17.1:
|
||||||
|
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: '>=5.0.2'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
|
||||||
xml-name-validator@3.0.0:
|
xml-name-validator@3.0.0:
|
||||||
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
||||||
|
|
||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2:
|
||||||
|
resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==}
|
||||||
|
engines: {node: '>=0.4.0'}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -6852,6 +6939,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@sinonjs/commons': 1.8.6
|
'@sinonjs/commons': 1.8.6
|
||||||
|
|
||||||
|
'@socket.io/component-emitter@3.1.2': {}
|
||||||
|
|
||||||
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
'@surma/rollup-plugin-off-main-thread@2.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
ejs: 3.1.10
|
ejs: 3.1.10
|
||||||
@@ -7049,6 +7138,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bs58: 6.0.0
|
bs58: 6.0.0
|
||||||
|
|
||||||
|
'@types/cors@2.8.19':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 24.0.3
|
||||||
|
|
||||||
'@types/crypto-js@4.2.2': {}
|
'@types/crypto-js@4.2.2': {}
|
||||||
|
|
||||||
'@types/eslint-scope@3.7.7':
|
'@types/eslint-scope@3.7.7':
|
||||||
@@ -7397,6 +7490,11 @@ snapshots:
|
|||||||
|
|
||||||
abab@2.0.6: {}
|
abab@2.0.6: {}
|
||||||
|
|
||||||
|
accepts@1.3.8:
|
||||||
|
dependencies:
|
||||||
|
mime-types: 2.1.35
|
||||||
|
negotiator: 0.6.3
|
||||||
|
|
||||||
acorn-globals@6.0.0:
|
acorn-globals@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 7.4.1
|
acorn: 7.4.1
|
||||||
@@ -7720,6 +7818,8 @@ snapshots:
|
|||||||
|
|
||||||
base-x@5.0.1: {}
|
base-x@5.0.1: {}
|
||||||
|
|
||||||
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.8.32: {}
|
baseline-browser-mapping@2.8.32: {}
|
||||||
|
|
||||||
big.js@5.2.2: {}
|
big.js@5.2.2: {}
|
||||||
@@ -7974,10 +8074,17 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
convert-source-map@2.0.0: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
core-js-compat@3.43.0:
|
core-js-compat@3.43.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.25.0
|
browserslist: 4.25.0
|
||||||
|
|
||||||
|
cors@2.8.5:
|
||||||
|
dependencies:
|
||||||
|
object-assign: 4.1.1
|
||||||
|
vary: 1.1.2
|
||||||
|
|
||||||
cosmiconfig-typescript-loader@2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5):
|
cosmiconfig-typescript-loader@2.0.2(@types/node@24.0.3)(cosmiconfig@7.1.0)(typescript@4.9.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 24.0.3
|
'@types/node': 24.0.3
|
||||||
@@ -8089,6 +8196,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
debug@4.3.7:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
debug@4.4.1(supports-color@9.4.0):
|
debug@4.4.1(supports-color@9.4.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -8220,6 +8331,36 @@ snapshots:
|
|||||||
iconv-lite: 0.6.3
|
iconv-lite: 0.6.3
|
||||||
whatwg-encoding: 3.1.1
|
whatwg-encoding: 3.1.1
|
||||||
|
|
||||||
|
engine.io-client@6.6.3:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.17.1
|
||||||
|
xmlhttprequest-ssl: 2.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
engine.io-parser@5.2.3: {}
|
||||||
|
|
||||||
|
engine.io@6.6.4:
|
||||||
|
dependencies:
|
||||||
|
'@types/cors': 2.8.19
|
||||||
|
'@types/node': 24.0.3
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cookie: 0.7.2
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io-parser: 5.2.3
|
||||||
|
ws: 8.17.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
enhanced-resolve@5.18.3:
|
enhanced-resolve@5.18.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -9987,6 +10128,8 @@ snapshots:
|
|||||||
|
|
||||||
natural-compare@1.4.0: {}
|
natural-compare@1.4.0: {}
|
||||||
|
|
||||||
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
neo-async@2.6.2: {}
|
neo-async@2.6.2: {}
|
||||||
|
|
||||||
next-pwa@5.6.0(@babel/core@7.27.4)(@types/babel__core@7.20.5)(next@14.2.33(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.99.9):
|
next-pwa@5.6.0(@babel/core@7.27.4)(@types/babel__core@7.20.5)(next@14.2.33(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(webpack@5.99.9):
|
||||||
@@ -10692,6 +10835,47 @@ snapshots:
|
|||||||
dot-case: 3.0.4
|
dot-case: 3.0.4
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
|
socket.io-adapter@2.5.5:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.3.7
|
||||||
|
ws: 8.17.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-client@4.8.1:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io-client: 6.6.3
|
||||||
|
socket.io-parser: 4.2.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
|
socket.io-parser@4.2.4:
|
||||||
|
dependencies:
|
||||||
|
'@socket.io/component-emitter': 3.1.2
|
||||||
|
debug: 4.3.7
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
socket.io@4.8.1:
|
||||||
|
dependencies:
|
||||||
|
accepts: 1.3.8
|
||||||
|
base64id: 2.0.0
|
||||||
|
cors: 2.8.5
|
||||||
|
debug: 4.3.7
|
||||||
|
engine.io: 6.6.4
|
||||||
|
socket.io-adapter: 2.5.5
|
||||||
|
socket.io-parser: 4.2.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
|
||||||
source-list-map@2.0.1: {}
|
source-list-map@2.0.1: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
@@ -11231,6 +11415,8 @@ snapshots:
|
|||||||
|
|
||||||
validator@13.15.23: {}
|
validator@13.15.23: {}
|
||||||
|
|
||||||
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vidstack@0.6.15:
|
vidstack@0.6.15:
|
||||||
dependencies:
|
dependencies:
|
||||||
maverick.js: 0.37.0
|
maverick.js: 0.37.0
|
||||||
@@ -11537,10 +11723,14 @@ snapshots:
|
|||||||
|
|
||||||
ws@7.5.10: {}
|
ws@7.5.10: {}
|
||||||
|
|
||||||
|
ws@8.17.1: {}
|
||||||
|
|
||||||
xml-name-validator@3.0.0: {}
|
xml-name-validator@3.0.0: {}
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xmlhttprequest-ssl@2.1.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
388
server.js
Normal file
388
server.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// Next.js 自定义服务器 + Socket.IO
|
||||||
|
const { createServer } = require('http');
|
||||||
|
const { parse } = require('url');
|
||||||
|
const next = require('next');
|
||||||
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
|
const dev = process.env.NODE_ENV !== 'production';
|
||||||
|
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
||||||
|
const port = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
|
||||||
|
const app = next({ dev, hostname, port });
|
||||||
|
const handle = app.getRequestHandler();
|
||||||
|
|
||||||
|
// 观影室服务器类
|
||||||
|
class WatchRoomServer {
|
||||||
|
constructor(io) {
|
||||||
|
this.io = io;
|
||||||
|
this.rooms = new Map();
|
||||||
|
this.members = new Map();
|
||||||
|
this.socketToRoom = new Map();
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
this.setupEventHandlers();
|
||||||
|
this.startCleanupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventHandlers() {
|
||||||
|
this.io.on('connection', (socket) => {
|
||||||
|
console.log(`[WatchRoom] Client connected: ${socket.id}`);
|
||||||
|
|
||||||
|
// 创建房间
|
||||||
|
socket.on('room:create', (data, callback) => {
|
||||||
|
try {
|
||||||
|
const roomId = this.generateRoomId();
|
||||||
|
const userId = socket.id;
|
||||||
|
|
||||||
|
const room = {
|
||||||
|
id: roomId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
password: data.password,
|
||||||
|
isPublic: data.isPublic,
|
||||||
|
ownerId: userId,
|
||||||
|
ownerName: data.userName,
|
||||||
|
memberCount: 1,
|
||||||
|
currentState: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastOwnerHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const member = {
|
||||||
|
id: userId,
|
||||||
|
name: data.userName,
|
||||||
|
isOwner: true,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
this.members.set(roomId, new Map([[userId, member]]));
|
||||||
|
this.socketToRoom.set(socket.id, {
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
userName: data.userName,
|
||||||
|
isOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
|
||||||
|
console.log(`[WatchRoom] Room created: ${roomId} by ${data.userName}`);
|
||||||
|
callback({ success: true, room });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Error creating room:', error);
|
||||||
|
callback({ success: false, error: '创建房间失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加入房间
|
||||||
|
socket.on('room:join', (data, callback) => {
|
||||||
|
try {
|
||||||
|
const room = this.rooms.get(data.roomId);
|
||||||
|
if (!room) {
|
||||||
|
return callback({ success: false, error: '房间不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (room.password && room.password !== data.password) {
|
||||||
|
return callback({ success: false, error: '密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = socket.id;
|
||||||
|
const member = {
|
||||||
|
id: userId,
|
||||||
|
name: data.userName,
|
||||||
|
isOwner: false,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomMembers = this.members.get(data.roomId);
|
||||||
|
if (roomMembers) {
|
||||||
|
roomMembers.set(userId, member);
|
||||||
|
room.memberCount = roomMembers.size;
|
||||||
|
this.rooms.set(data.roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketToRoom.set(socket.id, {
|
||||||
|
roomId: data.roomId,
|
||||||
|
userId,
|
||||||
|
userName: data.userName,
|
||||||
|
isOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.join(data.roomId);
|
||||||
|
socket.to(data.roomId).emit('room:member-joined', member);
|
||||||
|
|
||||||
|
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
|
||||||
|
|
||||||
|
const members = Array.from(roomMembers?.values() || []);
|
||||||
|
callback({ success: true, room, members });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Error joining room:', error);
|
||||||
|
callback({ success: false, error: '加入房间失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 离开房间
|
||||||
|
socket.on('room:leave', () => {
|
||||||
|
this.handleLeaveRoom(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取房间列表
|
||||||
|
socket.on('room:list', (callback) => {
|
||||||
|
const publicRooms = Array.from(this.rooms.values()).filter((room) => room.isPublic);
|
||||||
|
callback(publicRooms);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放状态更新
|
||||||
|
socket.on('play:update', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('play:update', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放进度跳转
|
||||||
|
socket.on('play:seek', (currentTime) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
socket.to(roomInfo.roomId).emit('play:seek', currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放
|
||||||
|
socket.on('play:play', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
socket.to(roomInfo.roomId).emit('play:play');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 暂停
|
||||||
|
socket.on('play:pause', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
socket.to(roomInfo.roomId).emit('play:pause');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换视频/集数
|
||||||
|
socket.on('play:change', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('play:change', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换直播频道
|
||||||
|
socket.on('live:change', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('live:change', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 聊天消息
|
||||||
|
socket.on('chat:message', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
id: this.generateMessageId(),
|
||||||
|
userId: roomInfo.userId,
|
||||||
|
userName: roomInfo.userName,
|
||||||
|
content: data.content,
|
||||||
|
type: data.type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.io.to(roomInfo.roomId).emit('chat:message', message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebRTC 信令
|
||||||
|
socket.on('voice:offer', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
this.io.to(data.targetUserId).emit('voice:offer', {
|
||||||
|
userId: socket.id,
|
||||||
|
offer: data.offer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('voice:answer', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
this.io.to(data.targetUserId).emit('voice:answer', {
|
||||||
|
userId: socket.id,
|
||||||
|
answer: data.answer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('voice:ice', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
this.io.to(data.targetUserId).emit('voice:ice', {
|
||||||
|
userId: socket.id,
|
||||||
|
candidate: data.candidate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 心跳
|
||||||
|
socket.on('heartbeat', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const roomMembers = this.members.get(roomInfo.roomId);
|
||||||
|
const member = roomMembers?.get(roomInfo.userId);
|
||||||
|
if (member) {
|
||||||
|
member.lastHeartbeat = Date.now();
|
||||||
|
roomMembers?.set(roomInfo.userId, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomInfo.isOwner) {
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.lastOwnerHeartbeat = Date.now();
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||||
|
this.handleLeaveRoom(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLeaveRoom(socket) {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const { roomId, userId, isOwner } = roomInfo;
|
||||||
|
const roomMembers = this.members.get(roomId);
|
||||||
|
|
||||||
|
if (roomMembers) {
|
||||||
|
roomMembers.delete(userId);
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (room) {
|
||||||
|
room.memberCount = roomMembers.size;
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.to(roomId).emit('room:member-left', userId);
|
||||||
|
|
||||||
|
if (isOwner) {
|
||||||
|
console.log(`[WatchRoom] Owner left room ${roomId}, will auto-delete after 5 minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomMembers.size === 0) {
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.leave(roomId);
|
||||||
|
this.socketToRoom.delete(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRoom(roomId) {
|
||||||
|
console.log(`[WatchRoom] Deleting room ${roomId}`);
|
||||||
|
this.io.to(roomId).emit('room:deleted');
|
||||||
|
this.rooms.delete(roomId);
|
||||||
|
this.members.delete(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
startCleanupTimer() {
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeout = 5 * 60 * 1000; // 5分钟
|
||||||
|
|
||||||
|
for (const [roomId, room] of this.rooms.entries()) {
|
||||||
|
if (now - room.lastOwnerHeartbeat > timeout) {
|
||||||
|
console.log(`[WatchRoom] Room ${roomId} owner timeout, deleting...`);
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000); // 每30秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
generateRoomId() {
|
||||||
|
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
generateMessageId() {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.prepare().then(() => {
|
||||||
|
const httpServer = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = parse(req.url, true);
|
||||||
|
await handle(req, res, parsedUrl);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error occurred handling', req.url, err);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end('Internal server error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化 Socket.IO
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
path: '/socket.io',
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化观影室服务器
|
||||||
|
const watchRoomServer = new WatchRoomServer(io);
|
||||||
|
console.log('[WatchRoom] Socket.IO server initialized');
|
||||||
|
|
||||||
|
httpServer
|
||||||
|
.once('error', (err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.listen(port, () => {
|
||||||
|
console.log(`> Ready on http://${hostname}:${port}`);
|
||||||
|
console.log(`> Socket.IO ready on ws://${hostname}:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n[Server] Shutting down...');
|
||||||
|
watchRoomServer.destroy();
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('[Server] Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\n[Server] Shutting down...');
|
||||||
|
watchRoomServer.destroy();
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('[Server] Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
65
server/watch-room-standalone-server.js
Normal file
65
server/watch-room-standalone-server.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// 独立的观影室服务器
|
||||||
|
// 使用方式: node watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY
|
||||||
|
|
||||||
|
import { createServer } from 'http';
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import { WatchRoomServer } from '../lib/watch-room-server';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const port = parseInt(args[args.indexOf('--port') + 1] || '3001');
|
||||||
|
const authKey = args[args.indexOf('--auth') + 1] || '';
|
||||||
|
|
||||||
|
if (!authKey) {
|
||||||
|
console.error('Error: --auth parameter is required');
|
||||||
|
console.log('Usage: node watch-room-standalone-server.js --port 3001 --auth YOUR_SECRET_KEY');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const httpServer = createServer();
|
||||||
|
|
||||||
|
const io = new Server(httpServer, {
|
||||||
|
cors: {
|
||||||
|
origin: '*',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
credentials: true,
|
||||||
|
},
|
||||||
|
// 添加鉴权中间件
|
||||||
|
allowRequest: (req, callback) => {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (auth === `Bearer ${authKey}`) {
|
||||||
|
callback(null, true);
|
||||||
|
} else {
|
||||||
|
console.log('[WatchRoom] Unauthorized connection attempt');
|
||||||
|
callback('Unauthorized', false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化观影室服务器
|
||||||
|
const watchRoomServer = new WatchRoomServer(io);
|
||||||
|
|
||||||
|
httpServer.listen(port, () => {
|
||||||
|
console.log(`[WatchRoom] Standalone server running on port ${port}`);
|
||||||
|
console.log(`[WatchRoom] Auth key: ${authKey.substring(0, 8)}...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅关闭
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('\n[WatchRoom] Shutting down...');
|
||||||
|
watchRoomServer.destroy();
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('[WatchRoom] Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('\n[WatchRoom] Shutting down...');
|
||||||
|
watchRoomServer.destroy();
|
||||||
|
httpServer.close(() => {
|
||||||
|
console.log('[WatchRoom] Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,8 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
|
||||||
import { SiteProvider } from '../components/SiteProvider';
|
import { SiteProvider } from '../components/SiteProvider';
|
||||||
import { ThemeProvider } from '../components/ThemeProvider';
|
import { ThemeProvider } from '../components/ThemeProvider';
|
||||||
|
import { WatchRoomProvider } from '../components/WatchRoomProvider';
|
||||||
|
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
@@ -120,8 +122,11 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<SiteProvider siteName={siteName} announcement={announcement}>
|
<SiteProvider siteName={siteName} announcement={announcement}>
|
||||||
{children}
|
<WatchRoomProvider>
|
||||||
<GlobalErrorIndicator />
|
{children}
|
||||||
|
<GlobalErrorIndicator />
|
||||||
|
<ChatFloatingWindow />
|
||||||
|
</WatchRoomProvider>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
181
src/app/watch-room/list/page.tsx
Normal file
181
src/app/watch-room/list/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
// 房间列表页面
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Users, Lock, RefreshCw } from 'lucide-react';
|
||||||
|
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||||
|
import JoinRoomModal from '@/components/watch-room/JoinRoomModal';
|
||||||
|
import type { Room } from '@/types/watch-room';
|
||||||
|
|
||||||
|
export default function RoomListPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { getRoomList, isConnected } = useWatchRoomContext();
|
||||||
|
|
||||||
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||||||
|
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||||
|
|
||||||
|
const loadRooms = async () => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const roomList = await getRoomList();
|
||||||
|
setRooms(roomList);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Failed to load rooms:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRooms();
|
||||||
|
// 每5秒刷新一次房间列表
|
||||||
|
const interval = setInterval(loadRooms, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
const handleJoinRoom = (roomId: string) => {
|
||||||
|
setSelectedRoomId(roomId);
|
||||||
|
setShowJoinModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}天前`;
|
||||||
|
if (hours > 0) return `${hours}小时前`;
|
||||||
|
if (minutes > 0) return `${minutes}分钟前`;
|
||||||
|
return '刚刚';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 px-4 py-8">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
{/* 顶部栏 */}
|
||||||
|
<div className="mb-8 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-white transition-colors hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-5 w-5" />
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadRooms}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 text-white transition-colors hover:bg-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-5 w-5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
<h1 className="mb-2 text-3xl font-bold text-white md:text-4xl">公开房间列表</h1>
|
||||||
|
<p className="text-gray-400">
|
||||||
|
找到{rooms.length}个公开的观影室
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加载中 */}
|
||||||
|
{loading && rooms.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<RefreshCw className="mx-auto mb-4 h-12 w-12 animate-spin text-gray-400" />
|
||||||
|
<p className="text-gray-400">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && rooms.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<Users className="mx-auto mb-4 h-16 w-16 text-gray-600" />
|
||||||
|
<p className="mb-2 text-xl text-gray-400">暂无公开房间</p>
|
||||||
|
<p className="text-sm text-gray-500">创建一个新房间或通过房间号加入私密房间</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 房间列表 */}
|
||||||
|
{rooms.length > 0 && (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rooms.map((room) => (
|
||||||
|
<div
|
||||||
|
key={room.id}
|
||||||
|
className="group rounded-xl bg-gray-800/50 p-6 backdrop-blur-sm transition-all hover:bg-gray-800/70 hover:shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="mb-1 text-lg font-bold text-white">{room.name}</h3>
|
||||||
|
{room.description && (
|
||||||
|
<p className="mb-2 text-sm text-gray-400 line-clamp-2">{room.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{room.password && (
|
||||||
|
<Lock className="h-5 w-5 flex-shrink-0 text-yellow-400" title="需要密码" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<span className="text-gray-500">房间号:</span>
|
||||||
|
<span className="font-mono text-lg font-bold text-white">{room.id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-gray-400">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>{room.memberCount} 人在线</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">房主:</span> {room.ownerName}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-400">
|
||||||
|
<span className="text-gray-500">创建时间:</span> {formatTime(room.createdAt)}
|
||||||
|
</div>
|
||||||
|
{room.currentState && (
|
||||||
|
<div className="mt-2 rounded-lg bg-blue-500/20 px-3 py-2">
|
||||||
|
<p className="text-xs text-blue-300">
|
||||||
|
{room.currentState.type === 'play'
|
||||||
|
? `正在播放: ${room.currentState.videoName}`
|
||||||
|
: `正在观看: ${room.currentState.channelName}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleJoinRoom(room.id)}
|
||||||
|
className="w-full rounded-lg bg-purple-500 py-3 font-medium text-white transition-colors hover:bg-purple-600"
|
||||||
|
>
|
||||||
|
加入房间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 加入房间弹窗 */}
|
||||||
|
{showJoinModal && selectedRoomId && (
|
||||||
|
<JoinRoomModal
|
||||||
|
roomId={selectedRoomId}
|
||||||
|
onClose={() => {
|
||||||
|
setShowJoinModal(false);
|
||||||
|
setSelectedRoomId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/app/watch-room/page.tsx
Normal file
114
src/app/watch-room/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// 观影室首页
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Users, UserPlus, List } from 'lucide-react';
|
||||||
|
import CreateRoomModal from '@/components/watch-room/CreateRoomModal';
|
||||||
|
import JoinRoomModal from '@/components/watch-room/JoinRoomModal';
|
||||||
|
|
||||||
|
export default function WatchRoomPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: '创建房间',
|
||||||
|
description: '创建一个新的观影室',
|
||||||
|
onClick: () => setShowCreateModal(true),
|
||||||
|
color: 'bg-blue-500 hover:bg-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: UserPlus,
|
||||||
|
label: '加入房间',
|
||||||
|
description: '通过房间号加入观影室',
|
||||||
|
onClick: () => setShowJoinModal(true),
|
||||||
|
color: 'bg-green-500 hover:bg-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: List,
|
||||||
|
label: '房间列表',
|
||||||
|
description: '查看所有公开的观影室',
|
||||||
|
onClick: () => router.push('/watch-room/list'),
|
||||||
|
color: 'bg-purple-500 hover:bg-purple-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 px-4 py-8">
|
||||||
|
<div className="mx-auto max-w-6xl">
|
||||||
|
{/* 标题 */}
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h1 className="mb-4 text-4xl font-bold text-white md:text-5xl">观影室</h1>
|
||||||
|
<p className="text-lg text-gray-400">与好友一起看视频,实时同步播放</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按钮网格 */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||||
|
{buttons.map((button, index) => {
|
||||||
|
const Icon = button.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={button.onClick}
|
||||||
|
className={`group flex flex-col items-center justify-center gap-4 rounded-2xl p-8 transition-all duration-300 ${button.color} transform hover:scale-105 hover:shadow-2xl`}
|
||||||
|
>
|
||||||
|
<div className="rounded-full bg-white/10 p-6 backdrop-blur-sm transition-all duration-300 group-hover:bg-white/20">
|
||||||
|
<Icon className="h-12 w-12 text-white md:h-16 md:w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="mb-2 text-xl font-bold text-white md:text-2xl">{button.label}</h2>
|
||||||
|
<p className="text-sm text-white/80 md:text-base">{button.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
<div className="mt-16 rounded-2xl bg-gray-800/50 p-6 backdrop-blur-sm md:p-8">
|
||||||
|
<h3 className="mb-4 text-xl font-bold text-white">使用说明</h3>
|
||||||
|
<ul className="space-y-3 text-gray-300">
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="text-blue-400">•</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-white">创建房间:</strong>作为房主创建观影室,可以设置房间名称、密码和公开状态
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="text-green-400">•</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-white">加入房间:</strong>通过房间号加入别人创建的观影室
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="text-purple-400">•</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-white">房间列表:</strong>浏览所有公开的观影室,点击即可加入
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="text-yellow-400">•</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-white">实时同步:</strong>
|
||||||
|
房主的播放操作会实时同步给所有成员,包括播放、暂停、进度、换集等
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start gap-3">
|
||||||
|
<span className="text-pink-400">•</span>
|
||||||
|
<span>
|
||||||
|
<strong className="text-white">语音聊天:</strong>在观影过程中可以使用文字和语音与房间成员交流
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 弹窗 */}
|
||||||
|
{showCreateModal && <CreateRoomModal onClose={() => setShowCreateModal(false)} />}
|
||||||
|
{showJoinModal && <JoinRoomModal onClose={() => setShowJoinModal(false)} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, Home, Radio, Star, Tv } from 'lucide-react';
|
import { Cat, Clover, Film, Home, Radio, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -52,6 +52,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
label: '直播',
|
label: '直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: '观影室',
|
||||||
|
href: '/watch-room',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv } from 'lucide-react';
|
import { Cat, Clover, Film, Home, Menu, Radio, Search, Star, Tv, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams } from 'next/navigation';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -143,6 +143,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
label: '直播',
|
label: '直播',
|
||||||
href: '/live',
|
href: '/live',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: '观影室',
|
||||||
|
href: '/watch-room',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
150
src/components/WatchRoomProvider.tsx
Normal file
150
src/components/WatchRoomProvider.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// WatchRoom 全局状态管理 Provider
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||||
|
import type { Room, Member, ChatMessage, WatchRoomConfig } from '@/types/watch-room';
|
||||||
|
import type { WatchRoomSocket } from '@/lib/watch-room-socket';
|
||||||
|
|
||||||
|
interface WatchRoomContextType {
|
||||||
|
socket: WatchRoomSocket | null;
|
||||||
|
isConnected: boolean;
|
||||||
|
currentRoom: Room | null;
|
||||||
|
members: Member[];
|
||||||
|
chatMessages: ChatMessage[];
|
||||||
|
isOwner: boolean;
|
||||||
|
isEnabled: boolean;
|
||||||
|
config: WatchRoomConfig | null;
|
||||||
|
|
||||||
|
// 房间操作
|
||||||
|
createRoom: (data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
password?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
userName: string;
|
||||||
|
}) => Promise<Room>;
|
||||||
|
joinRoom: (data: {
|
||||||
|
roomId: string;
|
||||||
|
password?: string;
|
||||||
|
userName: string;
|
||||||
|
}) => Promise<{ room: Room; members: Member[] }>;
|
||||||
|
leaveRoom: () => void;
|
||||||
|
getRoomList: () => Promise<Room[]>;
|
||||||
|
|
||||||
|
// 聊天
|
||||||
|
sendChatMessage: (content: string, type?: 'text' | 'emoji') => void;
|
||||||
|
|
||||||
|
// 播放控制(供 play/live 页面使用)
|
||||||
|
updatePlayState: (state: any) => void;
|
||||||
|
seekPlayback: (currentTime: number) => void;
|
||||||
|
play: () => void;
|
||||||
|
pause: () => void;
|
||||||
|
changeVideo: (state: any) => void;
|
||||||
|
changeLiveChannel: (state: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WatchRoomContext = createContext<WatchRoomContextType | null>(null);
|
||||||
|
|
||||||
|
export const useWatchRoomContext = () => {
|
||||||
|
const context = useContext(WatchRoomContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useWatchRoomContext must be used within WatchRoomProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 安全版本,可以在非 Provider 内使用
|
||||||
|
export const useWatchRoomContextSafe = () => {
|
||||||
|
return useContext(WatchRoomContext);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface WatchRoomProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||||
|
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
|
||||||
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
|
|
||||||
|
const watchRoom = useWatchRoom();
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
useEffect(() => {
|
||||||
|
const loadConfig = async () => {
|
||||||
|
// 默认配置:启用内部服务器
|
||||||
|
const defaultConfig: WatchRoomConfig = {
|
||||||
|
enabled: true,
|
||||||
|
serverType: 'internal',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/config');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const watchRoomConfig: WatchRoomConfig = {
|
||||||
|
enabled: data.watchRoom?.enabled ?? true,
|
||||||
|
serverType: data.watchRoom?.serverType ?? 'internal',
|
||||||
|
externalServerUrl: data.watchRoom?.externalServerUrl,
|
||||||
|
externalServerAuth: data.watchRoom?.externalServerAuth,
|
||||||
|
};
|
||||||
|
setConfig(watchRoomConfig);
|
||||||
|
setIsEnabled(watchRoomConfig.enabled);
|
||||||
|
|
||||||
|
// 如果启用了观影室,自动连接
|
||||||
|
if (watchRoomConfig.enabled) {
|
||||||
|
console.log('[WatchRoom] Connecting with config:', watchRoomConfig);
|
||||||
|
await watchRoom.connect(watchRoomConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to load config');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('[WatchRoom] Using default config (internal server enabled)');
|
||||||
|
setConfig(defaultConfig);
|
||||||
|
setIsEnabled(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await watchRoom.connect(defaultConfig);
|
||||||
|
} catch (connectError) {
|
||||||
|
console.error('[WatchRoom] Failed to connect:', connectError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
return () => {
|
||||||
|
watchRoom.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const contextValue: WatchRoomContextType = {
|
||||||
|
socket: watchRoom.socket,
|
||||||
|
isConnected: watchRoom.isConnected,
|
||||||
|
currentRoom: watchRoom.currentRoom,
|
||||||
|
members: watchRoom.members,
|
||||||
|
chatMessages: watchRoom.chatMessages,
|
||||||
|
isOwner: watchRoom.isOwner,
|
||||||
|
isEnabled,
|
||||||
|
config,
|
||||||
|
createRoom: watchRoom.createRoom,
|
||||||
|
joinRoom: watchRoom.joinRoom,
|
||||||
|
leaveRoom: watchRoom.leaveRoom,
|
||||||
|
getRoomList: watchRoom.getRoomList,
|
||||||
|
sendChatMessage: watchRoom.sendChatMessage,
|
||||||
|
updatePlayState: watchRoom.updatePlayState,
|
||||||
|
seekPlayback: watchRoom.seekPlayback,
|
||||||
|
play: watchRoom.play,
|
||||||
|
pause: watchRoom.pause,
|
||||||
|
changeVideo: watchRoom.changeVideo,
|
||||||
|
changeLiveChannel: watchRoom.changeLiveChannel,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WatchRoomContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</WatchRoomContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
src/components/watch-room/ChatFloatingWindow.tsx
Normal file
221
src/components/watch-room/ChatFloatingWindow.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
// 全局聊天悬浮窗
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { MessageCircle, X, Send, Smile, Minimize2, Maximize2 } from 'lucide-react';
|
||||||
|
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||||
|
|
||||||
|
const EMOJI_LIST = ['😀', '😂', '😍', '🥰', '😎', '🤔', '👍', '👏', '🎉', '❤️', '🔥', '⭐'];
|
||||||
|
|
||||||
|
export default function ChatFloatingWindow() {
|
||||||
|
const watchRoom = useWatchRoomContextSafe();
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 自动滚动到底部
|
||||||
|
useEffect(() => {
|
||||||
|
if (messagesEndRef.current && watchRoom?.currentRoom) {
|
||||||
|
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}, [watchRoom?.chatMessages, watchRoom?.currentRoom]);
|
||||||
|
|
||||||
|
// 如果没有加入房间,不显示聊天按钮
|
||||||
|
if (!watchRoom?.currentRoom) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { chatMessages, sendChatMessage, members, isOwner } = watchRoom;
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (!message.trim()) return;
|
||||||
|
|
||||||
|
sendChatMessage(message.trim(), 'text');
|
||||||
|
setMessage('');
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendEmoji = (emoji: string) => {
|
||||||
|
sendChatMessage(emoji, 'emoji');
|
||||||
|
setShowEmojiPicker(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 悬浮按钮
|
||||||
|
if (!isOpen) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="fixed bottom-20 right-4 z-[700] flex h-14 w-14 items-center justify-center rounded-full bg-green-500 text-white shadow-2xl transition-all hover:scale-110 hover:bg-green-600 md:bottom-4"
|
||||||
|
aria-label="打开聊天"
|
||||||
|
>
|
||||||
|
<MessageCircle className="h-6 w-6" />
|
||||||
|
{chatMessages.length > 0 && (
|
||||||
|
<span className="absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold">
|
||||||
|
{chatMessages.length > 99 ? '99+' : chatMessages.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最小化状态
|
||||||
|
if (isMinimized) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 z-[700] flex items-center gap-2 rounded-lg bg-gray-800 px-4 py-2 shadow-2xl md:bottom-4">
|
||||||
|
<MessageCircle className="h-5 w-5 text-white" />
|
||||||
|
<span className="text-sm text-white">聊天室</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMinimized(false)}
|
||||||
|
className="ml-2 rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||||
|
aria-label="展开"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="rounded p-1 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整聊天窗口
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 right-4 z-[700] flex w-80 flex-col rounded-2xl bg-gray-800 shadow-2xl md:bottom-4 md:w-96">
|
||||||
|
{/* 头部 */}
|
||||||
|
<div className="flex items-center justify-between rounded-t-2xl bg-green-500 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageCircle className="h-5 w-5 text-white" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-white">聊天室</h3>
|
||||||
|
<p className="text-xs text-white/80">{members.length} 人在线</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMinimized(true)}
|
||||||
|
className="rounded p-1 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
aria-label="最小化"
|
||||||
|
>
|
||||||
|
<Minimize2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="rounded p-1 text-white/80 transition-colors hover:bg-white/20 hover:text-white"
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息列表 */}
|
||||||
|
<div className="flex-1 space-y-3 overflow-y-auto p-4" style={{ maxHeight: '400px' }}>
|
||||||
|
{chatMessages.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-center">
|
||||||
|
<div>
|
||||||
|
<MessageCircle className="mx-auto mb-2 h-12 w-12 text-gray-600" />
|
||||||
|
<p className="text-sm text-gray-400">还没有消息</p>
|
||||||
|
<p className="text-xs text-gray-500">发送第一条消息吧</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{chatMessages.map((msg) => (
|
||||||
|
<div key={msg.id} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-xs font-medium text-green-400">{msg.userName}</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatTime(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`max-w-[80%] rounded-lg px-3 py-2 ${
|
||||||
|
msg.type === 'emoji'
|
||||||
|
? 'text-3xl'
|
||||||
|
: 'bg-gray-700 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<div className="border-t border-gray-700 p-3">
|
||||||
|
{/* 表情选择器 */}
|
||||||
|
{showEmojiPicker && (
|
||||||
|
<div className="mb-2 grid grid-cols-6 gap-2 rounded-lg bg-gray-700 p-2">
|
||||||
|
{EMOJI_LIST.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
onClick={() => handleSendEmoji(emoji)}
|
||||||
|
className="rounded p-1 text-2xl transition-colors hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
|
className="rounded-lg bg-gray-700 p-2 text-gray-300 transition-colors hover:bg-gray-600 hover:text-white"
|
||||||
|
aria-label="表情"
|
||||||
|
>
|
||||||
|
<Smile className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="输入消息..."
|
||||||
|
className="flex-1 rounded-lg bg-gray-700 px-3 py-2 text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
maxLength={200}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSendMessage}
|
||||||
|
disabled={!message.trim()}
|
||||||
|
className="rounded-lg bg-green-500 p-2 text-white transition-colors hover:bg-green-600 disabled:opacity-50"
|
||||||
|
aria-label="发送"
|
||||||
|
>
|
||||||
|
<Send className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 房间信息提示 */}
|
||||||
|
<div className="rounded-b-2xl bg-gray-900/50 px-4 py-2 text-center text-xs text-gray-400">
|
||||||
|
{isOwner ? (
|
||||||
|
<span className="text-yellow-400">👑 您是房主</span>
|
||||||
|
) : (
|
||||||
|
<span>房间: {watchRoom.currentRoom.name}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/components/watch-room/CreateRoomModal.tsx
Normal file
189
src/components/watch-room/CreateRoomModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// 创建房间弹窗
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { X, Lock, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||||
|
|
||||||
|
interface CreateRoomModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateRoomModal({ onClose }: CreateRoomModalProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { createRoom } = useWatchRoomContext();
|
||||||
|
|
||||||
|
const [roomName, setRoomName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [userName, setUserName] = useState('');
|
||||||
|
const [isPublic, setIsPublic] = useState(true);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!roomName.trim()) {
|
||||||
|
setError('请输入房间名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userName.trim()) {
|
||||||
|
setError('请输入您的昵称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const room = await createRoom({
|
||||||
|
name: roomName.trim(),
|
||||||
|
description: description.trim(),
|
||||||
|
password: password.trim() || undefined,
|
||||||
|
isPublic,
|
||||||
|
userName: userName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[WatchRoom] Room created:', room);
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// 创建成功后跳转到播放页面(等待播放)
|
||||||
|
// router.push(`/play?roomId=${room.id}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '创建房间失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl bg-gray-800 p-6 shadow-2xl">
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<h2 className="mb-6 text-2xl font-bold text-white">创建观影室</h2>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 昵称 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">您的昵称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
placeholder="输入您的昵称"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 房间名 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">房间名称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roomName}
|
||||||
|
onChange={(e) => setRoomName(e.target.value)}
|
||||||
|
placeholder="输入房间名称"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">房间简介(可选)</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="输入房间简介"
|
||||||
|
rows={3}
|
||||||
|
className="w-full resize-none rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
密码(可选)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="不设置则无需密码"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 公开/隐藏 */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-gray-700 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isPublic ? <Eye className="h-5 w-5 text-green-400" /> : <EyeOff className="h-5 w-5 text-gray-400" />}
|
||||||
|
<span className="text-sm font-medium text-gray-300">
|
||||||
|
{isPublic ? '公开房间' : '隐藏房间'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsPublic(!isPublic)}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition-colors ${
|
||||||
|
isPublic ? 'bg-green-500' : 'bg-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-5 w-5 transform rounded-full bg-white transition-transform ${
|
||||||
|
isPublic ? 'left-5' : 'left-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg bg-gray-700 py-3 font-medium text-white transition-colors hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 rounded-lg bg-blue-500 py-3 font-medium text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '创建中...' : '创建房间'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 提示 */}
|
||||||
|
<p className="mt-4 text-center text-xs text-gray-400">
|
||||||
|
创建后您将成为房主,可以控制播放内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/watch-room/JoinRoomModal.tsx
Normal file
151
src/components/watch-room/JoinRoomModal.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// 加入房间弹窗
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { X, Lock } from 'lucide-react';
|
||||||
|
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||||
|
|
||||||
|
interface JoinRoomModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
roomId?: string; // 可选的预填房间号
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JoinRoomModal({ onClose, roomId: initialRoomId }: JoinRoomModalProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { joinRoom } = useWatchRoomContext();
|
||||||
|
|
||||||
|
const [roomId, setRoomId] = useState(initialRoomId || '');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [userName, setUserName] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
if (!roomId.trim()) {
|
||||||
|
setError('请输入房间号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userName.trim()) {
|
||||||
|
setError('请输入您的昵称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { room, members } = await joinRoom({
|
||||||
|
roomId: roomId.trim().toUpperCase(),
|
||||||
|
password: password.trim() || undefined,
|
||||||
|
userName: userName.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[WatchRoom] Joined room:', room, 'Members:', members);
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// 加入成功后跳转到对应页面
|
||||||
|
// 如果房主已经在播放,跳转到播放页面
|
||||||
|
// 否则等待房主开始播放
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || '加入房间失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||||
|
<div className="relative w-full max-w-md rounded-2xl bg-gray-800 p-6 shadow-2xl">
|
||||||
|
{/* 关闭按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute right-4 top-4 rounded-full p-2 text-gray-400 transition-colors hover:bg-gray-700 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 标题 */}
|
||||||
|
<h2 className="mb-6 text-2xl font-bold text-white">加入观影室</h2>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* 昵称 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">您的昵称</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userName}
|
||||||
|
onChange={(e) => setUserName(e.target.value)}
|
||||||
|
placeholder="输入您的昵称"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 房间号 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-gray-300">房间号</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={roomId}
|
||||||
|
onChange={(e) => setRoomId(e.target.value.toUpperCase())}
|
||||||
|
placeholder="输入6位房间号"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 font-mono text-lg tracking-wider text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
maxLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 flex items-center gap-2 text-sm font-medium text-gray-300">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
密码(如有)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="如果房间有密码请输入"
|
||||||
|
className="w-full rounded-lg bg-gray-700 px-4 py-3 text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
|
maxLength={20}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg bg-red-500/20 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 rounded-lg bg-gray-700 py-3 font-medium text-white transition-colors hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 rounded-lg bg-green-500 py-3 font-medium text-white transition-colors hover:bg-green-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? '加入中...' : '加入房间'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* 提示 */}
|
||||||
|
<p className="mt-4 text-center text-xs text-gray-400">
|
||||||
|
加入后将跟随房主的播放内容
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
344
src/hooks/useWatchRoom.ts
Normal file
344
src/hooks/useWatchRoom.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
// React Hook for Watch Room
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
|
import { watchRoomSocketManager, type WatchRoomSocket } from '@/lib/watch-room-socket';
|
||||||
|
import type {
|
||||||
|
Room,
|
||||||
|
Member,
|
||||||
|
PlayState,
|
||||||
|
LiveState,
|
||||||
|
ChatMessage,
|
||||||
|
WatchRoomConfig,
|
||||||
|
StoredRoomInfo,
|
||||||
|
} from '@/types/watch-room';
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'watch_room_info';
|
||||||
|
|
||||||
|
export function useWatchRoom() {
|
||||||
|
const [socket, setSocket] = useState<WatchRoomSocket | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [currentRoom, setCurrentRoom] = useState<Room | null>(null);
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [isOwner, setIsOwner] = useState(false);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 连接到服务器
|
||||||
|
const connect = useCallback(async (config: WatchRoomConfig) => {
|
||||||
|
try {
|
||||||
|
const sock = await watchRoomSocketManager.connect(config);
|
||||||
|
setSocket(sock);
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
// 尝试自动重连房间
|
||||||
|
const storedInfo = getStoredRoomInfo();
|
||||||
|
if (storedInfo) {
|
||||||
|
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
||||||
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
|
rejoinRoom(storedInfo);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Failed to connect:', error);
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
watchRoomSocketManager.disconnect();
|
||||||
|
setSocket(null);
|
||||||
|
setIsConnected(false);
|
||||||
|
setCurrentRoom(null);
|
||||||
|
setMembers([]);
|
||||||
|
setChatMessages([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 创建房间
|
||||||
|
const createRoom = useCallback(
|
||||||
|
async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Room>((resolve, reject) => {
|
||||||
|
sock.emit('room:create', data, (response) => {
|
||||||
|
if (response.success && response.room) {
|
||||||
|
setCurrentRoom(response.room);
|
||||||
|
setIsOwner(true);
|
||||||
|
storeRoomInfo({
|
||||||
|
roomId: response.room.id,
|
||||||
|
roomName: response.room.name,
|
||||||
|
isOwner: true,
|
||||||
|
userName: data.userName,
|
||||||
|
password: data.password,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
resolve(response.room);
|
||||||
|
} else {
|
||||||
|
reject(new Error(response.error || '创建房间失败'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 加入房间
|
||||||
|
const joinRoom = useCallback(
|
||||||
|
async (data: { roomId: string; password?: string; userName: string }) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<{ room: Room; members: Member[] }>((resolve, reject) => {
|
||||||
|
sock.emit('room:join', data, (response) => {
|
||||||
|
if (response.success && response.room && response.members) {
|
||||||
|
setCurrentRoom(response.room);
|
||||||
|
setMembers(response.members);
|
||||||
|
setIsOwner(false);
|
||||||
|
storeRoomInfo({
|
||||||
|
roomId: response.room.id,
|
||||||
|
roomName: response.room.name,
|
||||||
|
isOwner: false,
|
||||||
|
userName: data.userName,
|
||||||
|
password: data.password,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
resolve({ room: response.room, members: response.members });
|
||||||
|
} else {
|
||||||
|
reject(new Error(response.error || '加入房间失败'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 离开房间
|
||||||
|
const leaveRoom = useCallback(() => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock) return;
|
||||||
|
|
||||||
|
sock.emit('room:leave');
|
||||||
|
setCurrentRoom(null);
|
||||||
|
setMembers([]);
|
||||||
|
setChatMessages([]);
|
||||||
|
setIsOwner(false);
|
||||||
|
clearStoredRoomInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 获取房间列表
|
||||||
|
const getRoomList = useCallback(async (): Promise<Room[]> => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
sock.emit('room:list', (rooms) => {
|
||||||
|
resolve(rooms);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 发送聊天消息
|
||||||
|
const sendChatMessage = useCallback(
|
||||||
|
(content: string, type: 'text' | 'emoji' = 'text') => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !currentRoom) return;
|
||||||
|
|
||||||
|
sock.emit('chat:message', { content, type });
|
||||||
|
},
|
||||||
|
[currentRoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 更新播放状态
|
||||||
|
const updatePlayState = useCallback(
|
||||||
|
(state: PlayState) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
|
sock.emit('play:update', state);
|
||||||
|
},
|
||||||
|
[isOwner]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 跳转播放进度
|
||||||
|
const seekPlayback = useCallback(
|
||||||
|
(currentTime: number) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock) return;
|
||||||
|
|
||||||
|
sock.emit('play:seek', currentTime);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 播放
|
||||||
|
const play = useCallback(() => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock) return;
|
||||||
|
|
||||||
|
sock.emit('play:play');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 暂停
|
||||||
|
const pause = useCallback(() => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock) return;
|
||||||
|
|
||||||
|
sock.emit('play:pause');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 切换视频
|
||||||
|
const changeVideo = useCallback(
|
||||||
|
(state: PlayState) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
|
sock.emit('play:change', state);
|
||||||
|
},
|
||||||
|
[isOwner]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换直播频道
|
||||||
|
const changeLiveChannel = useCallback(
|
||||||
|
(state: LiveState) => {
|
||||||
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
|
if (!sock || !isOwner) return;
|
||||||
|
|
||||||
|
sock.emit('live:change', state);
|
||||||
|
},
|
||||||
|
[isOwner]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 设置事件监听
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
// 房间事件
|
||||||
|
socket.on('room:joined', (data) => {
|
||||||
|
setCurrentRoom(data.room);
|
||||||
|
setMembers(data.members);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room:member-joined', (member) => {
|
||||||
|
setMembers((prev) => [...prev, member]);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room:member-left', (userId) => {
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('room:deleted', () => {
|
||||||
|
setCurrentRoom(null);
|
||||||
|
setMembers([]);
|
||||||
|
setChatMessages([]);
|
||||||
|
clearStoredRoomInfo();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放事件
|
||||||
|
socket.on('play:update', (state) => {
|
||||||
|
if (currentRoom) {
|
||||||
|
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 聊天事件
|
||||||
|
socket.on('chat:message', (message) => {
|
||||||
|
setChatMessages((prev) => [...prev, message]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
socket.on('connect', () => {
|
||||||
|
setIsConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
setIsConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off('room:joined');
|
||||||
|
socket.off('room:member-joined');
|
||||||
|
socket.off('room:member-left');
|
||||||
|
socket.off('room:deleted');
|
||||||
|
socket.off('play:update');
|
||||||
|
socket.off('chat:message');
|
||||||
|
socket.off('connect');
|
||||||
|
socket.off('disconnect');
|
||||||
|
};
|
||||||
|
}, [socket, currentRoom]);
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
socket,
|
||||||
|
isConnected,
|
||||||
|
currentRoom,
|
||||||
|
members,
|
||||||
|
chatMessages,
|
||||||
|
isOwner,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
createRoom,
|
||||||
|
joinRoom,
|
||||||
|
leaveRoom,
|
||||||
|
getRoomList,
|
||||||
|
sendChatMessage,
|
||||||
|
updatePlayState,
|
||||||
|
seekPlayback,
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
changeVideo,
|
||||||
|
changeLiveChannel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新加入房间(自动重连)
|
||||||
|
function rejoinRoom(info: StoredRoomInfo) {
|
||||||
|
// 这个函数会在组件中被调用
|
||||||
|
console.log('[WatchRoom] Auto-rejoin:', info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储房间信息到 localStorage
|
||||||
|
function storeRoomInfo(info: StoredRoomInfo) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取存储的房间信息
|
||||||
|
function getStoredRoomInfo(): StoredRoomInfo | null {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!stored) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info: StoredRoomInfo = JSON.parse(stored);
|
||||||
|
// 检查是否过期(24小时)
|
||||||
|
if (Date.now() - info.timestamp > 24 * 60 * 60 * 1000) {
|
||||||
|
clearStoredRoomInfo();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除存储的房间信息
|
||||||
|
function clearStoredRoomInfo() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
350
src/lib/watch-room-server.ts
Normal file
350
src/lib/watch-room-server.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
// Socket.IO 观影室服务器逻辑(共享代码)
|
||||||
|
import { Server as SocketIOServer, Socket } from 'socket.io';
|
||||||
|
import type {
|
||||||
|
Room,
|
||||||
|
Member,
|
||||||
|
PlayState,
|
||||||
|
LiveState,
|
||||||
|
ChatMessage,
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
RoomMemberInfo,
|
||||||
|
} from '@/types/watch-room';
|
||||||
|
|
||||||
|
type TypedSocket = Socket<ClientToServerEvents, ServerToClientEvents>;
|
||||||
|
|
||||||
|
export class WatchRoomServer {
|
||||||
|
private rooms: Map<string, Room> = new Map();
|
||||||
|
private members: Map<string, Map<string, Member>> = new Map(); // roomId -> userId -> Member
|
||||||
|
private socketToRoom: Map<string, RoomMemberInfo> = new Map(); // socketId -> RoomMemberInfo
|
||||||
|
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
||||||
|
this.setupEventHandlers();
|
||||||
|
this.startCleanupTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventHandlers() {
|
||||||
|
this.io.on('connection', (socket: TypedSocket) => {
|
||||||
|
console.log(`[WatchRoom] Client connected: ${socket.id}`);
|
||||||
|
|
||||||
|
// 创建房间
|
||||||
|
socket.on('room:create', (data, callback) => {
|
||||||
|
try {
|
||||||
|
const roomId = this.generateRoomId();
|
||||||
|
const userId = socket.id;
|
||||||
|
|
||||||
|
const room: Room = {
|
||||||
|
id: roomId,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
password: data.password,
|
||||||
|
isPublic: data.isPublic,
|
||||||
|
ownerId: userId,
|
||||||
|
ownerName: data.userName,
|
||||||
|
memberCount: 1,
|
||||||
|
currentState: null,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastOwnerHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const member: Member = {
|
||||||
|
id: userId,
|
||||||
|
name: data.userName,
|
||||||
|
isOwner: true,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
this.members.set(roomId, new Map([[userId, member]]));
|
||||||
|
this.socketToRoom.set(socket.id, {
|
||||||
|
roomId,
|
||||||
|
userId,
|
||||||
|
userName: data.userName,
|
||||||
|
isOwner: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.join(roomId);
|
||||||
|
|
||||||
|
console.log(`[WatchRoom] Room created: ${roomId} by ${data.userName}`);
|
||||||
|
callback({ success: true, room });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Error creating room:', error);
|
||||||
|
callback({ success: false, error: '创建房间失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 加入房间
|
||||||
|
socket.on('room:join', (data, callback) => {
|
||||||
|
try {
|
||||||
|
const room = this.rooms.get(data.roomId);
|
||||||
|
if (!room) {
|
||||||
|
return callback({ success: false, error: '房间不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查密码
|
||||||
|
if (room.password && room.password !== data.password) {
|
||||||
|
return callback({ success: false, error: '密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = socket.id;
|
||||||
|
const member: Member = {
|
||||||
|
id: userId,
|
||||||
|
name: data.userName,
|
||||||
|
isOwner: false,
|
||||||
|
lastHeartbeat: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const roomMembers = this.members.get(data.roomId);
|
||||||
|
if (roomMembers) {
|
||||||
|
roomMembers.set(userId, member);
|
||||||
|
room.memberCount = roomMembers.size;
|
||||||
|
this.rooms.set(data.roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socketToRoom.set(socket.id, {
|
||||||
|
roomId: data.roomId,
|
||||||
|
userId,
|
||||||
|
userName: data.userName,
|
||||||
|
isOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.join(data.roomId);
|
||||||
|
|
||||||
|
// 通知房间内其他成员
|
||||||
|
socket.to(data.roomId).emit('room:member-joined', member);
|
||||||
|
|
||||||
|
console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`);
|
||||||
|
|
||||||
|
const members = Array.from(roomMembers?.values() || []);
|
||||||
|
callback({ success: true, room, members });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WatchRoom] Error joining room:', error);
|
||||||
|
callback({ success: false, error: '加入房间失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 离开房间
|
||||||
|
socket.on('room:leave', () => {
|
||||||
|
this.handleLeaveRoom(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取房间列表
|
||||||
|
socket.on('room:list', (callback) => {
|
||||||
|
const publicRooms = Array.from(this.rooms.values()).filter((room) => room.isPublic);
|
||||||
|
callback(publicRooms);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放状态更新
|
||||||
|
socket.on('play:update', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('play:update', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放进度跳转
|
||||||
|
socket.on('play:seek', (currentTime) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
socket.to(roomInfo.roomId).emit('play:seek', currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 播放
|
||||||
|
socket.on('play:play', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
socket.to(roomInfo.roomId).emit('play:play');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 暂停
|
||||||
|
socket.on('play:pause', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
socket.to(roomInfo.roomId).emit('play:pause');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换视频/集数
|
||||||
|
socket.on('play:change', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('play:change', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 切换直播频道
|
||||||
|
socket.on('live:change', (state) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo || !roomInfo.isOwner) return;
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.currentState = state;
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
socket.to(roomInfo.roomId).emit('live:change', state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 聊天消息
|
||||||
|
socket.on('chat:message', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const message: ChatMessage = {
|
||||||
|
id: this.generateMessageId(),
|
||||||
|
userId: roomInfo.userId,
|
||||||
|
userName: roomInfo.userName,
|
||||||
|
content: data.content,
|
||||||
|
type: data.type,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.io.to(roomInfo.roomId).emit('chat:message', message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebRTC 信令
|
||||||
|
socket.on('voice:offer', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
this.io.to(data.targetUserId).emit('voice:offer', {
|
||||||
|
userId: socket.id,
|
||||||
|
offer: data.offer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('voice:answer', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
this.io.to(data.targetUserId).emit('voice:answer', {
|
||||||
|
userId: socket.id,
|
||||||
|
answer: data.answer,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('voice:ice', (data) => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
this.io.to(data.targetUserId).emit('voice:ice', {
|
||||||
|
userId: socket.id,
|
||||||
|
candidate: data.candidate,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 心跳
|
||||||
|
socket.on('heartbeat', () => {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const roomMembers = this.members.get(roomInfo.roomId);
|
||||||
|
const member = roomMembers?.get(roomInfo.userId);
|
||||||
|
if (member) {
|
||||||
|
member.lastHeartbeat = Date.now();
|
||||||
|
roomMembers?.set(roomInfo.userId, member);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是房主,更新房间心跳
|
||||||
|
if (roomInfo.isOwner) {
|
||||||
|
const room = this.rooms.get(roomInfo.roomId);
|
||||||
|
if (room) {
|
||||||
|
room.lastOwnerHeartbeat = Date.now();
|
||||||
|
this.rooms.set(roomInfo.roomId, room);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||||
|
this.handleLeaveRoom(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleLeaveRoom(socket: TypedSocket) {
|
||||||
|
const roomInfo = this.socketToRoom.get(socket.id);
|
||||||
|
if (!roomInfo) return;
|
||||||
|
|
||||||
|
const { roomId, userId, isOwner } = roomInfo;
|
||||||
|
|
||||||
|
// 从房间成员中移除
|
||||||
|
const roomMembers = this.members.get(roomId);
|
||||||
|
if (roomMembers) {
|
||||||
|
roomMembers.delete(userId);
|
||||||
|
|
||||||
|
const room = this.rooms.get(roomId);
|
||||||
|
if (room) {
|
||||||
|
room.memberCount = roomMembers.size;
|
||||||
|
this.rooms.set(roomId, room);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知其他成员
|
||||||
|
socket.to(roomId).emit('room:member-left', userId);
|
||||||
|
|
||||||
|
// 如果是房主离开,记录时间但不立即删除房间
|
||||||
|
if (isOwner) {
|
||||||
|
console.log(`[WatchRoom] Owner left room ${roomId}, will auto-delete after 5 minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果房间没人了,立即删除
|
||||||
|
if (roomMembers.size === 0) {
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.leave(roomId);
|
||||||
|
this.socketToRoom.delete(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private deleteRoom(roomId: string) {
|
||||||
|
console.log(`[WatchRoom] Deleting room ${roomId}`);
|
||||||
|
this.io.to(roomId).emit('room:deleted');
|
||||||
|
this.rooms.delete(roomId);
|
||||||
|
this.members.delete(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定时清理房间(房主断开5分钟后删除)
|
||||||
|
private startCleanupTimer() {
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeout = 5 * 60 * 1000; // 5分钟
|
||||||
|
|
||||||
|
for (const [roomId, room] of this.rooms.entries()) {
|
||||||
|
// 检查房主是否超时
|
||||||
|
if (now - room.lastOwnerHeartbeat > timeout) {
|
||||||
|
console.log(`[WatchRoom] Room ${roomId} owner timeout, deleting...`);
|
||||||
|
this.deleteRoom(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000); // 每30秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateRoomId(): string {
|
||||||
|
return Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateMessageId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
src/lib/watch-room-socket.ts
Normal file
130
src/lib/watch-room-socket.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Socket.IO 客户端管理
|
||||||
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
import type {
|
||||||
|
ServerToClientEvents,
|
||||||
|
ClientToServerEvents,
|
||||||
|
WatchRoomConfig,
|
||||||
|
} from '@/types/watch-room';
|
||||||
|
|
||||||
|
export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
|
||||||
|
class WatchRoomSocketManager {
|
||||||
|
private socket: WatchRoomSocket | null = null;
|
||||||
|
private config: WatchRoomConfig | null = null;
|
||||||
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async connect(config: WatchRoomConfig): Promise<WatchRoomSocket> {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
return this.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = config;
|
||||||
|
|
||||||
|
const socketOptions: any = {
|
||||||
|
transports: ['websocket', 'polling'],
|
||||||
|
reconnection: true,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
reconnectionDelayMax: 5000,
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.serverType === 'internal') {
|
||||||
|
// 内部服务器 - 连接到同一个域名的Socket.IO服务器
|
||||||
|
this.socket = io({
|
||||||
|
...socketOptions,
|
||||||
|
path: '/socket.io', // 使用服务器配置的path
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 外部服务器
|
||||||
|
if (!config.externalServerUrl) {
|
||||||
|
throw new Error('External server URL is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket = io(config.externalServerUrl, {
|
||||||
|
...socketOptions,
|
||||||
|
auth: {
|
||||||
|
token: config.externalServerAuth,
|
||||||
|
},
|
||||||
|
extraHeaders: config.externalServerAuth
|
||||||
|
? {
|
||||||
|
Authorization: `Bearer ${config.externalServerAuth}`,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置事件监听
|
||||||
|
this.setupEventListeners();
|
||||||
|
|
||||||
|
// 开始心跳
|
||||||
|
this.startHeartbeat();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.socket) {
|
||||||
|
reject(new Error('Socket not initialized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[WatchRoom] Connected to server');
|
||||||
|
resolve(this.socket!);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
console.error('[WatchRoom] Connection error:', error);
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
this.heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.disconnect();
|
||||||
|
this.socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSocket(): WatchRoomSocket | null {
|
||||||
|
return this.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(): boolean {
|
||||||
|
return this.socket?.connected ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners() {
|
||||||
|
if (!this.socket) return;
|
||||||
|
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[WatchRoom] Socket connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('disconnect', (reason) => {
|
||||||
|
console.log('[WatchRoom] Socket disconnected:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
console.error('[WatchRoom] Socket error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private startHeartbeat() {
|
||||||
|
if (this.heartbeatInterval) {
|
||||||
|
clearInterval(this.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.heartbeatInterval = setInterval(() => {
|
||||||
|
if (this.socket?.connected) {
|
||||||
|
this.socket.emit('heartbeat');
|
||||||
|
}
|
||||||
|
}, 5000); // 每5秒发送一次心跳
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单例实例
|
||||||
|
export const watchRoomSocketManager = new WatchRoomSocketManager();
|
||||||
132
src/types/watch-room.ts
Normal file
132
src/types/watch-room.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
// 观影室相关类型定义
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
password?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
ownerId: string;
|
||||||
|
ownerName: string;
|
||||||
|
memberCount: number;
|
||||||
|
currentState: PlayState | LiveState | null;
|
||||||
|
createdAt: number;
|
||||||
|
lastOwnerHeartbeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
lastHeartbeat: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayState {
|
||||||
|
type: 'play';
|
||||||
|
url: string;
|
||||||
|
currentTime: number;
|
||||||
|
isPlaying: boolean;
|
||||||
|
videoId: string;
|
||||||
|
videoName: string;
|
||||||
|
episode?: number;
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveState {
|
||||||
|
type: 'live';
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
channelUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
content: string;
|
||||||
|
type: 'text' | 'emoji';
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomMemberInfo {
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket.IO 事件类型
|
||||||
|
export interface ServerToClientEvents {
|
||||||
|
'room:created': (room: Room) => void;
|
||||||
|
'room:joined': (data: { room: Room; members: Member[] }) => void;
|
||||||
|
'room:left': () => void;
|
||||||
|
'room:list': (rooms: Room[]) => void;
|
||||||
|
'room:member-joined': (member: Member) => void;
|
||||||
|
'room:member-left': (userId: string) => void;
|
||||||
|
'room:deleted': () => void;
|
||||||
|
'play:update': (state: PlayState) => void;
|
||||||
|
'play:seek': (currentTime: number) => void;
|
||||||
|
'play:play': () => void;
|
||||||
|
'play:pause': () => void;
|
||||||
|
'play:change': (state: PlayState) => void;
|
||||||
|
'live:change': (state: LiveState) => void;
|
||||||
|
'chat:message': (message: ChatMessage) => void;
|
||||||
|
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
|
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
|
'voice:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
|
||||||
|
'error': (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientToServerEvents {
|
||||||
|
'room:create': (data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
password?: string;
|
||||||
|
isPublic: boolean;
|
||||||
|
userName: string;
|
||||||
|
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
|
||||||
|
|
||||||
|
'room:join': (data: {
|
||||||
|
roomId: string;
|
||||||
|
password?: string;
|
||||||
|
userName: string;
|
||||||
|
}, callback: (response: { success: boolean; room?: Room; members?: Member[]; error?: string }) => void) => void;
|
||||||
|
|
||||||
|
'room:leave': () => void;
|
||||||
|
|
||||||
|
'room:list': (callback: (rooms: Room[]) => void) => void;
|
||||||
|
|
||||||
|
'play:update': (state: PlayState) => void;
|
||||||
|
'play:seek': (currentTime: number) => void;
|
||||||
|
'play:play': () => void;
|
||||||
|
'play:pause': () => void;
|
||||||
|
'play:change': (state: PlayState) => void;
|
||||||
|
|
||||||
|
'live:change': (state: LiveState) => void;
|
||||||
|
|
||||||
|
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
||||||
|
|
||||||
|
'voice:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||||
|
'voice:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||||
|
'voice:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
|
||||||
|
|
||||||
|
'heartbeat': () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置类型
|
||||||
|
export interface WatchRoomConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
serverType: 'internal' | 'external';
|
||||||
|
externalServerUrl?: string;
|
||||||
|
externalServerAuth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalStorage 存储的房间信息
|
||||||
|
export interface StoredRoomInfo {
|
||||||
|
roomId: string;
|
||||||
|
roomName: string;
|
||||||
|
isOwner: boolean;
|
||||||
|
userName: string;
|
||||||
|
password?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user