观影室起步

This commit is contained in:
mtvpls
2025-12-06 21:39:37 +08:00
parent 4f126e89f0
commit 003050d134
18 changed files with 2895 additions and 7 deletions

265
WATCH_ROOM_README.md Normal file
View 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

View File

@@ -3,9 +3,9 @@
"version": "0.1.0",
"private": true,
"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",
"start": "next start",
"start": "NODE_ENV=production node server.js",
"lint": "next lint",
"lint:fix": "eslint src --fix && pnpm format",
"lint:strict": "eslint --max-warnings=0 src",
@@ -16,7 +16,8 @@
"format:check": "prettier -c .",
"gen:manifest": "node scripts/generate-manifest.js",
"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": {
"@dnd-kit/core": "^6.3.1",
@@ -47,6 +48,8 @@
"react-dom": "^18.3.1",
"react-icons": "^5.4.0",
"redis": "^4.6.7",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"swiper": "^11.2.8",
"tailwind-merge": "^2.6.0",
"vidstack": "^0.6.15",

190
pnpm-lock.yaml generated
View File

@@ -92,6 +92,12 @@ importers:
redis:
specifier: ^4.6.7
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:
specifier: ^11.2.8
version: 11.2.8
@@ -1331,6 +1337,9 @@ packages:
'@sinonjs/fake-timers@8.1.0':
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':
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
@@ -1493,6 +1502,9 @@ packages:
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.
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/crypto-js@4.2.2':
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
@@ -1835,6 +1847,10 @@ packages:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
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:
resolution: {integrity: sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==}
@@ -2118,6 +2134,10 @@ packages:
base-x@5.0.1:
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:
resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==}
hasBin: true
@@ -2369,9 +2389,17 @@ packages:
convert-source-map@2.0.0:
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:
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:
resolution: {integrity: sha512-KmE+bMjWMXJbkWCeY4FJX/npHuZPNr9XF9q9CIQ/bpFwi1qHfCmSiKarrCcRa0LO4fWjk93pVoeRtJAkTGcYNw==}
engines: {node: '>=12', npm: '>=6'}
@@ -2481,6 +2509,15 @@ packages:
supports-color:
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:
resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==}
engines: {node: '>=6.0'}
@@ -2624,6 +2661,17 @@ packages:
encoding-sniffer@0.2.1:
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:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -3958,6 +4006,10 @@ packages:
natural-compare@1.4.0:
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:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@@ -4666,6 +4718,21 @@ packages:
snake-case@3.0.4:
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:
resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==}
@@ -5158,6 +5225,10 @@ packages:
resolution: {integrity: sha512-4yoz1kEWqUjzi5zsPbAS/903QXSYp0UOtHsPpp7p9rHAw/W+dkInskAE386Fat3oKRROwO98d9ZB0G4cObgUyw==}
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:
resolution: {integrity: sha512-pI2aixBuOpu/LSnRgNJ40tU/KFW+x1X+O2bW1hz946ZZShDM5oqRXF9pavDOuckHAHPgUN9HYUr9vUNTBUPF1Q==}
engines: {node: '>=16'}
@@ -5348,12 +5419,28 @@ packages:
utf-8-validate:
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:
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
xmlchars@2.2.0:
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:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6852,6 +6939,8 @@ snapshots:
dependencies:
'@sinonjs/commons': 1.8.6
'@socket.io/component-emitter@3.1.2': {}
'@surma/rollup-plugin-off-main-thread@2.2.3':
dependencies:
ejs: 3.1.10
@@ -7049,6 +7138,10 @@ snapshots:
dependencies:
bs58: 6.0.0
'@types/cors@2.8.19':
dependencies:
'@types/node': 24.0.3
'@types/crypto-js@4.2.2': {}
'@types/eslint-scope@3.7.7':
@@ -7397,6 +7490,11 @@ snapshots:
abab@2.0.6: {}
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
acorn-globals@6.0.0:
dependencies:
acorn: 7.4.1
@@ -7720,6 +7818,8 @@ snapshots:
base-x@5.0.1: {}
base64id@2.0.0: {}
baseline-browser-mapping@2.8.32: {}
big.js@5.2.2: {}
@@ -7974,10 +8074,17 @@ snapshots:
convert-source-map@2.0.0: {}
cookie@0.7.2: {}
core-js-compat@3.43.0:
dependencies:
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):
dependencies:
'@types/node': 24.0.3
@@ -8089,6 +8196,10 @@ snapshots:
dependencies:
ms: 2.1.3
debug@4.3.7:
dependencies:
ms: 2.1.3
debug@4.4.1(supports-color@9.4.0):
dependencies:
ms: 2.1.3
@@ -8220,6 +8331,36 @@ snapshots:
iconv-lite: 0.6.3
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:
dependencies:
graceful-fs: 4.2.11
@@ -9987,6 +10128,8 @@ snapshots:
natural-compare@1.4.0: {}
negotiator@0.6.3: {}
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):
@@ -10692,6 +10835,47 @@ snapshots:
dot-case: 3.0.4
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-map-js@1.2.1: {}
@@ -11231,6 +11415,8 @@ snapshots:
validator@13.15.23: {}
vary@1.1.2: {}
vidstack@0.6.15:
dependencies:
maverick.js: 0.37.0
@@ -11537,10 +11723,14 @@ snapshots:
ws@7.5.10: {}
ws@8.17.1: {}
xml-name-validator@3.0.0: {}
xmlchars@2.2.0: {}
xmlhttprequest-ssl@2.1.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

388
server.js Normal file
View 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);
});
});
});

View 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);
});
});

View File

@@ -10,6 +10,8 @@ import { getConfig } from '@/lib/config';
import { GlobalErrorIndicator } from '../components/GlobalErrorIndicator';
import { SiteProvider } from '../components/SiteProvider';
import { ThemeProvider } from '../components/ThemeProvider';
import { WatchRoomProvider } from '../components/WatchRoomProvider';
import ChatFloatingWindow from '../components/watch-room/ChatFloatingWindow';
const inter = Inter({ subsets: ['latin'] });
export const dynamic = 'force-dynamic';
@@ -120,8 +122,11 @@ export default async function RootLayout({
disableTransitionOnChange
>
<SiteProvider siteName={siteName} announcement={announcement}>
{children}
<GlobalErrorIndicator />
<WatchRoomProvider>
{children}
<GlobalErrorIndicator />
<ChatFloatingWindow />
</WatchRoomProvider>
</SiteProvider>
</ThemeProvider>
</body>

View 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
View 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>
);
}

View File

@@ -2,7 +2,7 @@
'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 { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -52,6 +52,11 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
label: '直播',
href: '/live',
},
{
icon: Users,
label: '观影室',
href: '/watch-room',
},
]);
useEffect(() => {

View File

@@ -2,7 +2,7 @@
'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 { usePathname, useSearchParams } from 'next/navigation';
import {
@@ -143,6 +143,11 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
label: '直播',
href: '/live',
},
{
icon: Users,
label: '观影室',
href: '/watch-room',
},
]);
useEffect(() => {

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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);
}

View 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);
}
}
}

View 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
View 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;
}