From 003050d134c7354908685e810ea47ac54cbddc59 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 6 Dec 2025 21:39:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=82=E5=BD=B1=E5=AE=A4=E8=B5=B7=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WATCH_ROOM_README.md | 265 ++++++++++++ package.json | 9 +- pnpm-lock.yaml | 190 +++++++++ server.js | 388 ++++++++++++++++++ server/watch-room-standalone-server.js | 65 +++ src/app/layout.tsx | 9 +- src/app/watch-room/list/page.tsx | 181 ++++++++ src/app/watch-room/page.tsx | 114 +++++ src/components/MobileBottomNav.tsx | 7 +- src/components/Sidebar.tsx | 7 +- src/components/WatchRoomProvider.tsx | 150 +++++++ .../watch-room/ChatFloatingWindow.tsx | 221 ++++++++++ src/components/watch-room/CreateRoomModal.tsx | 189 +++++++++ src/components/watch-room/JoinRoomModal.tsx | 151 +++++++ src/hooks/useWatchRoom.ts | 344 ++++++++++++++++ src/lib/watch-room-server.ts | 350 ++++++++++++++++ src/lib/watch-room-socket.ts | 130 ++++++ src/types/watch-room.ts | 132 ++++++ 18 files changed, 2895 insertions(+), 7 deletions(-) create mode 100644 WATCH_ROOM_README.md create mode 100644 server.js create mode 100644 server/watch-room-standalone-server.js create mode 100644 src/app/watch-room/list/page.tsx create mode 100644 src/app/watch-room/page.tsx create mode 100644 src/components/WatchRoomProvider.tsx create mode 100644 src/components/watch-room/ChatFloatingWindow.tsx create mode 100644 src/components/watch-room/CreateRoomModal.tsx create mode 100644 src/components/watch-room/JoinRoomModal.tsx create mode 100644 src/hooks/useWatchRoom.ts create mode 100644 src/lib/watch-room-server.ts create mode 100644 src/lib/watch-room-socket.ts create mode 100644 src/types/watch-room.ts diff --git a/WATCH_ROOM_README.md b/WATCH_ROOM_README.md new file mode 100644 index 0000000..2e7f664 --- /dev/null +++ b/WATCH_ROOM_README.md @@ -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 diff --git a/package.json b/package.json index 3454a0e..9143317 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abb868f..36ff96b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/server.js b/server.js new file mode 100644 index 0000000..4f5add1 --- /dev/null +++ b/server.js @@ -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); + }); + }); +}); diff --git a/server/watch-room-standalone-server.js b/server/watch-room-standalone-server.js new file mode 100644 index 0000000..c56d0b0 --- /dev/null +++ b/server/watch-room-standalone-server.js @@ -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); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f52160c..9660fe3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 > - {children} - + + {children} + + + diff --git a/src/app/watch-room/list/page.tsx b/src/app/watch-room/list/page.tsx new file mode 100644 index 0000000..11c2c58 --- /dev/null +++ b/src/app/watch-room/list/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [selectedRoomId, setSelectedRoomId] = useState(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 ( +
+
+ {/* 顶部栏 */} +
+ + + +
+ + {/* 标题 */} +
+

公开房间列表

+

+ 找到{rooms.length}个公开的观影室 +

+
+ + {/* 加载中 */} + {loading && rooms.length === 0 && ( +
+
+ +

加载中...

+
+
+ )} + + {/* 空状态 */} + {!loading && rooms.length === 0 && ( +
+
+ +

暂无公开房间

+

创建一个新房间或通过房间号加入私密房间

+
+
+ )} + + {/* 房间列表 */} + {rooms.length > 0 && ( +
+ {rooms.map((room) => ( +
+
+
+

{room.name}

+ {room.description && ( +

{room.description}

+ )} +
+ {room.password && ( + + )} +
+ +
+
+ 房间号: + {room.id} +
+
+ + {room.memberCount} 人在线 +
+
+ 房主: {room.ownerName} +
+
+ 创建时间: {formatTime(room.createdAt)} +
+ {room.currentState && ( +
+

+ {room.currentState.type === 'play' + ? `正在播放: ${room.currentState.videoName}` + : `正在观看: ${room.currentState.channelName}`} +

+
+ )} +
+ + +
+ ))} +
+ )} +
+ + {/* 加入房间弹窗 */} + {showJoinModal && selectedRoomId && ( + { + setShowJoinModal(false); + setSelectedRoomId(null); + }} + /> + )} +
+ ); +} diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx new file mode 100644 index 0000000..9989147 --- /dev/null +++ b/src/app/watch-room/page.tsx @@ -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 ( +
+
+ {/* 标题 */} +
+

观影室

+

与好友一起看视频,实时同步播放

+
+ + {/* 按钮网格 */} +
+ {buttons.map((button, index) => { + const Icon = button.icon; + return ( + + ); + })} +
+ + {/* 使用说明 */} +
+

使用说明

+
    +
  • + + + 创建房间:作为房主创建观影室,可以设置房间名称、密码和公开状态 + +
  • +
  • + + + 加入房间:通过房间号加入别人创建的观影室 + +
  • +
  • + + + 房间列表:浏览所有公开的观影室,点击即可加入 + +
  • +
  • + + + 实时同步: + 房主的播放操作会实时同步给所有成员,包括播放、暂停、进度、换集等 + +
  • +
  • + + + 语音聊天:在观影过程中可以使用文字和语音与房间成员交流 + +
  • +
+
+
+ + {/* 弹窗 */} + {showCreateModal && setShowCreateModal(false)} />} + {showJoinModal && setShowJoinModal(false)} />} +
+ ); +} diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 43b29aa..9c7bc04 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -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(() => { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 512dc85..311d164 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(() => { diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx new file mode 100644 index 0000000..aaf3bb0 --- /dev/null +++ b/src/components/WatchRoomProvider.tsx @@ -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; + joinRoom: (data: { + roomId: string; + password?: string; + userName: string; + }) => Promise<{ room: Room; members: Member[] }>; + leaveRoom: () => void; + getRoomList: () => Promise; + + // 聊天 + 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(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(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 ( + + {children} + + ); +} diff --git a/src/components/watch-room/ChatFloatingWindow.tsx b/src/components/watch-room/ChatFloatingWindow.tsx new file mode 100644 index 0000000..2777b02 --- /dev/null +++ b/src/components/watch-room/ChatFloatingWindow.tsx @@ -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(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 ( + + ); + } + + // 最小化状态 + if (isMinimized) { + return ( +
+ + 聊天室 + + +
+ ); + } + + // 完整聊天窗口 + return ( +
+ {/* 头部 */} +
+
+ +
+

聊天室

+

{members.length} 人在线

+
+
+
+ + +
+
+ + {/* 消息列表 */} +
+ {chatMessages.length === 0 ? ( +
+
+ +

还没有消息

+

发送第一条消息吧

+
+
+ ) : ( + <> + {chatMessages.map((msg) => ( +
+
+ {msg.userName} + {formatTime(msg.timestamp)} +
+
+ {msg.content} +
+
+ ))} +
+ + )} +
+ + {/* 输入区域 */} +
+ {/* 表情选择器 */} + {showEmojiPicker && ( +
+ {EMOJI_LIST.map((emoji) => ( + + ))} +
+ )} + +
+ + 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} + /> + +
+
+ + {/* 房间信息提示 */} +
+ {isOwner ? ( + 👑 您是房主 + ) : ( + 房间: {watchRoom.currentRoom.name} + )} +
+
+ ); +} diff --git a/src/components/watch-room/CreateRoomModal.tsx b/src/components/watch-room/CreateRoomModal.tsx new file mode 100644 index 0000000..f12dd40 --- /dev/null +++ b/src/components/watch-room/CreateRoomModal.tsx @@ -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 ( +
+
+ {/* 关闭按钮 */} + + + {/* 标题 */} +

创建观影室

+ + {/* 表单 */} +
+ {/* 昵称 */} +
+ + 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} + /> +
+ + {/* 房间名 */} +
+ + 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} + /> +
+ + {/* 备注 */} +
+ +