From 4363f4cdaee3e362a0ca5d7deab1c56524f99cf6 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sun, 7 Dec 2025 00:16:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=A7=82=E5=BD=B1=E5=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WATCH_ROOM_README.md | 112 ++- server.js | 117 ++- src/app/live/page.tsx | 16 + src/app/play/page.tsx | 80 +- src/app/watch-room/list/page.tsx | 181 ----- src/app/watch-room/page.tsx | 729 +++++++++++++++--- src/components/WatchRoomProvider.tsx | 27 +- .../watch-room/ChatFloatingWindow.tsx | 239 +++++- src/components/watch-room/CreateRoomModal.tsx | 189 ----- src/components/watch-room/JoinRoomModal.tsx | 151 ---- src/hooks/useLiveSync.ts | 96 +++ src/hooks/usePlaySync.ts | 352 +++++++++ src/hooks/useWatchRoom.ts | 103 ++- src/lib/watch-room-server.ts | 6 +- src/types/watch-room.ts | 5 + 15 files changed, 1685 insertions(+), 718 deletions(-) delete mode 100644 src/app/watch-room/list/page.tsx delete mode 100644 src/components/watch-room/CreateRoomModal.tsx delete mode 100644 src/components/watch-room/JoinRoomModal.tsx create mode 100644 src/hooks/useLiveSync.ts create mode 100644 src/hooks/usePlaySync.ts diff --git a/WATCH_ROOM_README.md b/WATCH_ROOM_README.md index 2e7f664..4f656cc 100644 --- a/WATCH_ROOM_README.md +++ b/WATCH_ROOM_README.md @@ -33,7 +33,21 @@ - 侧边栏添加观影室入口 - 底部导航栏添加观影室入口 -#### 3. 功能特性 +#### 3. 同步功能 +- ✅ **播放同步** (`usePlaySync` Hook) + - 房主播放/暂停/进度跳转实时同步 + - 房主换集、换源自动同步 + - 房员自动跟随房主操作 + - 房员禁用控制器(显示"观影室模式"提示) + - 防抖机制避免频繁同步 + - 进度差异超过2秒才同步,避免网络抖动 + +- ✅ **直播同步** (`useLiveSync` Hook) + - 房主切换频道实时同步 + - 房员自动跟随频道切换 + - 延迟广播机制避免频繁触发 + +#### 4. 通用功能特性 - ✅ LocalStorage 自动重连 - ✅ 心跳机制(每5秒) - ✅ 房主断开5分钟后自动删除房间 @@ -77,16 +91,6 @@ pnpm watch-room:server # 在另一个终端运行 ## 待实现功能 🚧 ### 高优先级(核心功能) -- ⏳ **播放同步功能** - 修改 `src/app/play/page.tsx` - - 房主播放、暂停、进度同步 - - 房主换集、换源同步 - - 房员禁用播放器控制 - - 房员自动跟随房主进度 - -- ⏳ **直播同步功能** - 修改 `src/app/live/page.tsx` - - 房主切换频道同步 - - 房员自动跟随频道 - - ⏳ **管理面板配置** - 添加观影室开关 - 配置服务器类型(内部/外部) @@ -150,7 +154,9 @@ src/ │ ├── watch-room-server.ts # Socket.IO 服务器逻辑 │ └── watch-room-socket.ts # Socket 客户端管理 ├── hooks/ -│ └── useWatchRoom.ts # React Hook +│ ├── useWatchRoom.ts # 房间管理 Hook +│ ├── usePlaySync.ts # 播放同步 Hook +│ └── useLiveSync.ts # 直播同步 Hook ├── components/ │ ├── WatchRoomProvider.tsx # 全局状态管理 │ └── watch-room/ @@ -207,16 +213,16 @@ interface WatchRoomConfig { - [x] 心跳机制 - [x] 刷新页面自动重连 -### 播放同步测试(待实现) -- [ ] 房主播放/暂停同步 -- [ ] 房主进度跳转同步 -- [ ] 房主换集同步 -- [ ] 房主换源同步 -- [ ] 房员操作同步给全员 +### 播放同步测试 +- [x] 房主播放/暂停同步 +- [x] 房主进度跳转同步 +- [x] 房主换集同步 +- [x] 房主换源同步 +- [x] 房员禁用控制器 -### 直播同步测试(待实现) -- [ ] 房主切换频道同步 -- [ ] 房员自动跟随频道 +### 直播同步测试 +- [x] 房主切换频道同步 +- [x] 房员自动跟随频道 ### 移动端测试 - [x] 底部导航显示正常 @@ -226,22 +232,58 @@ interface WatchRoomConfig { --- +## 完整测试指南 + +### 测试场景 1: 创建房间并观看视频 + +1. **房主操作**: + - 访问观影室页面,点击"创建房间" + - 输入房间名称、描述和昵称 + - 创建后,进入播放页面 (`/play`) + - 选择任意视频并播放 + - 尝试播放/暂停、跳转进度、切换集数、切换源 + +2. **房员操作** (使用另一个浏览器或无痕模式): + - 访问观影室页面,点击"加入房间" + - 输入房间号和昵称 + - 进入相同的播放页面 + - 观察播放器自动同步房主操作 + - 注意: 集数选择器上会显示"观影室模式"覆盖层,无法切换 + +### 测试场景 2: 聊天功能 + +1. 房主和房员都能看到右下角的绿色聊天按钮 +2. 点击打开聊天窗口 +3. 发送文字消息和表情 +4. 验证双方都能收到消息 +5. 测试最小化和关闭功能 + +### 测试场景 3: 直播同步 + +1. **房主操作**: + - 在房间内,进入直播页面 (`/live`) + - 切换不同频道 + +2. **房员操作**: + - 同样进入直播页面 + - 观察频道自动跟随房主切换 + +### 测试场景 4: 自动重连 + +1. 房主或房员刷新页面 +2. 验证自动重连到原房间 +3. 验证聊天记录不丢失 +4. 验证播放状态继续同步 + +--- + ## 下一步计划 -1. **实现播放同步** (`src/app/play/page.tsx`) - - 集成 `useWatchRoom` Hook - - 监听播放器事件并同步 - - 接收服务器播放状态更新 - -2. **实现直播同步** (`src/app/live/page.tsx`) - - 监听频道切换事件 - - 同步频道状态 - -3. **添加管理面板配置** +1. **添加管理面板配置** - 在 `src/app/admin/page.tsx` 添加观影室配置项 - 保存配置到数据库 -4. **实现 WebRTC 语音聊天** +2. **实现 WebRTC 语音聊天** (可选) - 创建 WebRTC 连接管理 - 添加麦克风/喇叭控制按钮 - 实现服务器中转回退 @@ -250,9 +292,9 @@ interface WatchRoomConfig { ## 已知问题 -1. **播放同步功能未实现**: 需要修改 play 和 live 页面 -2. **管理面板配置未添加**: 目前使用默认配置(启用内部服务器) -3. **语音聊天未实现**: 仅支持文字和表情 +1. **管理面板配置未添加**: 目前使用默认配置(启用内部服务器) +2. **语音聊天未实现**: 仅支持文字和表情 +3. **房间成员列表未显示**: 可以在聊天窗口顶部看到在线人数,但没有详细成员列表 --- diff --git a/server.js b/server.js index 4f5add1..40441d9 100644 --- a/server.js +++ b/server.js @@ -18,6 +18,7 @@ class WatchRoomServer { this.rooms = new Map(); this.members = new Map(); this.socketToRoom = new Map(); + this.roomDeletionTimers = new Map(); // 房间延迟删除定时器 this.cleanupInterval = null; this.setupEventHandlers(); this.startCleanupTimer(); @@ -32,6 +33,7 @@ class WatchRoomServer { try { const roomId = this.generateRoomId(); const userId = socket.id; + const ownerToken = this.generateRoomId(); // 生成房主令牌 const room = { id: roomId, @@ -41,6 +43,7 @@ class WatchRoomServer { isPublic: data.isPublic, ownerId: userId, ownerName: data.userName, + ownerToken: ownerToken, // 保存房主令牌 memberCount: 1, currentState: null, createdAt: Date.now(), @@ -86,10 +89,29 @@ class WatchRoomServer { } const userId = socket.id; + let isOwner = false; + + // 检查是否是房主重连(通过 ownerToken 验证) + if (data.ownerToken && data.ownerToken === room.ownerToken) { + isOwner = true; + // 更新房主的 socket.id + room.ownerId = userId; + room.lastOwnerHeartbeat = Date.now(); + this.rooms.set(data.roomId, room); + console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`); + } + + // 取消房间的删除定时器(如果有人重连) + if (this.roomDeletionTimers.has(data.roomId)) { + console.log(`[WatchRoom] Cancelling deletion timer for room ${data.roomId}`); + clearTimeout(this.roomDeletionTimers.get(data.roomId)); + this.roomDeletionTimers.delete(data.roomId); + } + const member = { id: userId, name: data.userName, - isOwner: false, + isOwner: isOwner, lastHeartbeat: Date.now(), }; @@ -104,13 +126,13 @@ class WatchRoomServer { roomId: data.roomId, userId, userName: data.userName, - isOwner: false, + isOwner: isOwner, }); socket.join(data.roomId); socket.to(data.roomId).emit('room:member-joined', member); - console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}`); + console.log(`[WatchRoom] User ${data.userName} joined room ${data.roomId}${isOwner ? ' (as owner)' : ''}`); const members = Array.from(roomMembers?.values() || []); callback({ success: true, room, members }); @@ -131,37 +153,59 @@ class WatchRoomServer { callback(publicRooms); }); - // 播放状态更新 + // 播放状态更新(任何成员都可以触发同步) socket.on('play:update', (state) => { + console.log(`[WatchRoom] Received play:update from ${socket.id}:`, state); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo || !roomInfo.isOwner) return; + if (!roomInfo) { + console.log('[WatchRoom] No room info for socket, ignoring play:update'); + return; + } const room = this.rooms.get(roomInfo.roomId); if (room) { room.currentState = state; this.rooms.set(roomInfo.roomId, room); + console.log(`[WatchRoom] Broadcasting play:update to room ${roomInfo.roomId} from ${roomInfo.userName}`); socket.to(roomInfo.roomId).emit('play:update', state); + } else { + console.log('[WatchRoom] Room not found for play:update'); } }); // 播放进度跳转 socket.on('play:seek', (currentTime) => { + console.log(`[WatchRoom] Received play:seek from ${socket.id}:`, currentTime); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo) { + console.log('[WatchRoom] No room info for socket, ignoring play:seek'); + return; + } + console.log(`[WatchRoom] Broadcasting play:seek to room ${roomInfo.roomId}`); socket.to(roomInfo.roomId).emit('play:seek', currentTime); }); // 播放 socket.on('play:play', () => { + console.log(`[WatchRoom] Received play:play from ${socket.id}`); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo) { + console.log('[WatchRoom] No room info for socket, ignoring play:play'); + return; + } + console.log(`[WatchRoom] Broadcasting play:play to room ${roomInfo.roomId}`); socket.to(roomInfo.roomId).emit('play:play'); }); // 暂停 socket.on('play:pause', () => { + console.log(`[WatchRoom] Received play:pause from ${socket.id}`); const roomInfo = this.socketToRoom.get(socket.id); - if (!roomInfo) return; + if (!roomInfo) { + console.log('[WatchRoom] No room info for socket, ignoring play:pause'); + return; + } + console.log(`[WatchRoom] Broadcasting play:pause to room ${roomInfo.roomId}`); socket.to(roomInfo.roomId).emit('play:pause'); }); @@ -270,12 +314,12 @@ class WatchRoomServer { if (!roomInfo) return; const { roomId, userId, isOwner } = roomInfo; + const room = this.rooms.get(roomId); 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); @@ -283,12 +327,44 @@ class WatchRoomServer { socket.to(roomId).emit('room:member-left', userId); + // 如果是房主主动离开,解散房间并踢出所有成员 if (isOwner) { - console.log(`[WatchRoom] Owner left room ${roomId}, will auto-delete after 5 minutes`); - } + console.log(`[WatchRoom] Owner actively left room ${roomId}, disbanding room`); - if (roomMembers.size === 0) { - this.deleteRoom(roomId); + // 通知所有成员房间被解散 + socket.to(roomId).emit('room:deleted', { reason: 'owner_left' }); + + // 强制所有成员离开房间 + const members = Array.from(roomMembers.keys()); + members.forEach(memberId => { + this.socketToRoom.delete(memberId); + }); + + // 立即删除房间(跳过通知,因为上面已经发送了) + this.deleteRoom(roomId, true); + + // 清除可能存在的删除定时器 + if (this.roomDeletionTimers.has(roomId)) { + clearTimeout(this.roomDeletionTimers.get(roomId)); + this.roomDeletionTimers.delete(roomId); + } + } else { + // 普通成员离开,房间为空时延迟删除 + if (roomMembers.size === 0) { + console.log(`[WatchRoom] Room ${roomId} is now empty, will delete in 30 seconds if no one rejoins`); + + const deletionTimer = setTimeout(() => { + // 再次检查房间是否仍然为空 + const currentRoomMembers = this.members.get(roomId); + if (currentRoomMembers && currentRoomMembers.size === 0) { + console.log(`[WatchRoom] Room ${roomId} deletion timer expired, deleting room`); + this.deleteRoom(roomId); + this.roomDeletionTimers.delete(roomId); + } + }, 30000); // 30秒后删除 + + this.roomDeletionTimers.set(roomId, deletionTimer); + } } } @@ -296,9 +372,14 @@ class WatchRoomServer { this.socketToRoom.delete(socket.id); } - deleteRoom(roomId) { + deleteRoom(roomId, skipNotify = false) { console.log(`[WatchRoom] Deleting room ${roomId}`); - this.io.to(roomId).emit('room:deleted'); + + // 如果不跳过通知,则发送 room:deleted 事件 + if (!skipNotify) { + this.io.to(roomId).emit('room:deleted'); + } + this.rooms.delete(roomId); this.members.delete(roomId); } @@ -329,6 +410,12 @@ class WatchRoomServer { if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } + + // 清理所有房间删除定时器 + for (const timer of this.roomDeletionTimers.values()) { + clearTimeout(timer); + } + this.roomDeletionTimers.clear(); } } diff --git a/src/app/live/page.tsx b/src/app/live/page.tsx index 16c4f7d..2bf5678 100644 --- a/src/app/live/page.tsx +++ b/src/app/live/page.tsx @@ -8,6 +8,8 @@ import { Heart, Radio, Tv } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; +import { useLiveSync } from '@/hooks/useLiveSync'; + import { deleteFavorite, generateStorageKey, @@ -122,6 +124,20 @@ function LivePageClient() { const favoritedRef = useRef(false); const currentChannelRef = useRef(null); + // 观影室同步功能 + const liveSync = useLiveSync({ + currentChannelId: currentChannel?.id || '', + currentChannelName: currentChannel?.name || '', + currentChannelUrl: currentChannel?.url || '', + onChannelChange: (channelId, channelUrl) => { + // 房员接收到频道切换指令 + const channel = currentChannels.find(c => c.id === channelId); + if (channel) { + handleChannelChange(channel); + } + }, + }); + // EPG数据清洗函数 - 去除重叠的节目,保留时间较短的,只显示今日节目 const cleanEpgData = (programs: Array<{ start: string; end: string; title: string }>) => { if (!programs || programs.length === 0) return programs; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index c0e5b87..2ac981c 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -6,6 +6,8 @@ import { Heart } from 'lucide-react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Suspense, useEffect, useRef, useState } from 'react'; +import { usePlaySync } from '@/hooks/usePlaySync'; + import { deleteFavorite, @@ -328,7 +330,28 @@ function PlayPageClient() { needPreferRef.current = needPrefer; }, [needPrefer]); // 集数相关 - const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(0); + const [currentEpisodeIndex, setCurrentEpisodeIndex] = useState(() => { + const episodeParam = searchParams.get('episode'); + if (episodeParam) { + const episode = parseInt(episodeParam, 10); + return episode > 0 ? episode - 1 : 0; // URL 中是 1-based,内部是 0-based + } + return 0; + }); + + // 监听 URL 参数变化,更新集数索引(用于房员跟随换集) + useEffect(() => { + const episodeParam = searchParams.get('episode'); + if (episodeParam) { + const episode = parseInt(episodeParam, 10); + const newIndex = episode > 0 ? episode - 1 : 0; + console.log('[PlayPage] Checking episode from URL:', { urlEpisode: episode, currentIndex: currentEpisodeIndex, newIndex }); + if (newIndex !== currentEpisodeIndex) { + console.log('[PlayPage] URL episode changed, updating index to:', newIndex); + setCurrentEpisodeIndex(newIndex); + } + } + }, [searchParams, currentEpisodeIndex]); const currentSourceRef = useRef(currentSource); const currentIdRef = useRef(currentId); @@ -429,6 +452,9 @@ function PlayPageClient() { 'initing' | 'sourceChanging' >('initing'); + // 播放器就绪状态(用于触发 usePlaySync 的事件监听器设置) + const [playerReady, setPlayerReady] = useState(false); + // 播放进度保存相关 const saveIntervalRef = useRef(null); const lastSaveTimeRef = useRef(0); @@ -439,6 +465,19 @@ function PlayPageClient() { // Wake Lock 相关 const wakeLockRef = useRef(null); + // 观影室同步功能 + const playSync = usePlaySync({ + artPlayerRef, + videoId: currentId || '', // 使用 currentId 状态而不是 searchParams + videoName: videoTitle || detail?.title || '正在加载...', + videoYear: videoYear || detail?.year || '', + searchTitle: searchTitle || '', + currentEpisode: currentEpisodeIndex + 1, + currentSource: currentSource || '', + videoUrl: videoUrl || '', + playerReady: playerReady, // 传递播放器就绪状态 + }); + // ----------------------------------------------------------------------------- // 工具函数(Utils) // ----------------------------------------------------------------------------- @@ -2721,6 +2760,13 @@ function PlayPageClient() { html: '', tooltip: '播放下一集', click: function () { + // 房员禁用下一集按钮 + if (playSync.shouldDisableControls) { + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = '房员无法切换集数,请等待房主操作'; + } + return; + } handleNextEpisode(); }, }, @@ -2731,6 +2777,10 @@ function PlayPageClient() { artPlayerRef.current.on('ready', async () => { setError(null); + // 标记播放器已就绪,触发 usePlaySync 设置事件监听器 + setPlayerReady(true); + console.log('[PlayPage] Player ready, triggering sync setup'); + // 从 art.storage 读取弹幕设置并应用 if (artPlayerRef.current) { const storedDanmakuSettings = artPlayerRef.current.storage.get('danmaku_settings'); @@ -2905,8 +2955,17 @@ function PlayPageClient() { } }); - // 监听视频播放结束事件,自动播放下一集 + // 监听视频播放结束事件,自动播放下一集(房员禁用) artPlayerRef.current.on('video:ended', () => { + // 房员禁用自动播放下一集 + if (playSync.shouldDisableControls) { + console.log('[PlayPage] Member cannot auto-play next episode'); + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = '等待房主切换下一集'; + } + return; + } + const d = detailRef.current; const idx = currentEpisodeIndexRef.current; if (d && d.episodes && idx < d.episodes.length - 1) { @@ -3686,18 +3745,29 @@ function PlayPageClient() { {/* 选集和换源 - 在移动端始终显示,在 lg 及以上可折叠 */}
+ {/* 观影室房员禁用层 */} + {playSync.isInRoom && playSync.shouldDisableControls && ( +
+
+

👥 观影室模式

+

+ {playSync.isOwner ? '您是房主,可以控制播放' : '房主控制中,无法切换集数和播放源'} +

+
+
+ )} {} : handleEpisodeChange} + onSourceChange={playSync.shouldDisableControls ? () => {} : handleSourceChange} currentSource={currentSource} currentId={currentId} videoTitle={searchTitle || videoTitle} diff --git a/src/app/watch-room/list/page.tsx b/src/app/watch-room/list/page.tsx deleted file mode 100644 index 11c2c58..0000000 --- a/src/app/watch-room/list/page.tsx +++ /dev/null @@ -1,181 +0,0 @@ -// 房间列表页面 -'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 index 9989147..56500ea 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -1,114 +1,663 @@ -// 观影室首页 +// 观影室首页 - 选项卡式界面 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { Users, UserPlus, List as ListIcon, Lock, RefreshCw } from 'lucide-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'; +import { useWatchRoomContext } from '@/components/WatchRoomProvider'; +import PageLayout from '@/components/PageLayout'; +import { getAuthInfoFromBrowserCookie } from '@/lib/auth'; +import type { Room } from '@/types/watch-room'; + +type TabType = 'create' | 'join' | 'list'; export default function WatchRoomPage() { const router = useRouter(); - const [showCreateModal, setShowCreateModal] = useState(false); - const [showJoinModal, setShowJoinModal] = useState(false); + const { getRoomList, isConnected, createRoom, joinRoom, currentRoom, isOwner, members } = useWatchRoomContext(); + const [activeTab, setActiveTab] = useState('create'); - 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', - }, + // 获取当前登录用户(在客户端挂载后读取,避免 hydration 错误) + const [currentUsername, setCurrentUsername] = useState('游客'); + + useEffect(() => { + const authInfo = getAuthInfoFromBrowserCookie(); + setCurrentUsername(authInfo?.username || '游客'); + }, []); + + // 创建房间表单 + const [createForm, setCreateForm] = useState({ + roomName: '', + description: '', + password: '', + isPublic: true, + }); + + // 加入房间表单 + const [joinForm, setJoinForm] = useState({ + roomId: '', + password: '', + }); + + // 房间列表 + const [rooms, setRooms] = useState([]); + const [loading, setLoading] = useState(false); + const [createLoading, setCreateLoading] = useState(false); + const [joinLoading, setJoinLoading] = 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); + } + }; + + // 切换到房间列表 tab 时加载房间 + useEffect(() => { + if (activeTab === 'list') { + loadRooms(); + // 每5秒刷新一次 + const interval = setInterval(loadRooms, 5000); + return () => clearInterval(interval); + } + }, [activeTab, isConnected]); + + // 处理创建房间 + const handleCreateRoom = async (e: React.FormEvent) => { + e.preventDefault(); + if (!createForm.roomName.trim()) { + alert('请输入房间名称'); + return; + } + + setCreateLoading(true); + try { + await createRoom({ + name: createForm.roomName.trim(), + description: createForm.description.trim(), + password: createForm.password.trim() || undefined, + isPublic: createForm.isPublic, + userName: currentUsername, + }); + + // 清空表单 + setCreateForm({ + roomName: '', + description: '', + password: '', + isPublic: true, + }); + } catch (error: any) { + alert(error.message || '创建房间失败'); + } finally { + setCreateLoading(false); + } + }; + + // 处理加入房间 + const handleJoinRoom = async (e: React.FormEvent, roomId?: string) => { + e.preventDefault(); + const targetRoomId = roomId || joinForm.roomId.trim().toUpperCase(); + if (!targetRoomId) { + alert('请输入房间ID'); + return; + } + + setJoinLoading(true); + try { + const result = await joinRoom({ + roomId: targetRoomId, + password: joinForm.password.trim() || undefined, + userName: currentUsername, + }); + + // 清空表单 + setJoinForm({ + roomId: '', + password: '', + }); + + // 注意:加入房间后,isOwner 状态会在 useWatchRoom 中更新 + // 跳转逻辑会在 useEffect 中处理 + } catch (error: any) { + alert(error.message || '加入房间失败'); + } finally { + setJoinLoading(false); + } + }; + + // 监听房间状态,房员加入后自动跟随房主播放 + useEffect(() => { + if (!currentRoom || isOwner) return; + + // 房员加入房间后,检查房主的播放状态 + if (currentRoom.currentState) { + const state = currentRoom.currentState; + if (state.type === 'play') { + // 跳转到播放页面,使用完整参数 + console.log('[WatchRoom] Member joining/following, redirecting to play page'); + + const params = new URLSearchParams({ + id: state.videoId, + source: state.source, + episode: String(state.episode || 1), + }); + + // 添加可选参数 + if (state.videoName) params.set('title', state.videoName); + if (state.videoYear) params.set('year', state.videoYear); + if (state.searchTitle) params.set('stitle', state.searchTitle); + + router.push(`/play?${params.toString()}`); + } else if (state.type === 'live') { + // 跳转到直播页面 + console.log('[WatchRoom] Member joining/following, redirecting to live page'); + router.push(`/live?id=${state.channelId}`); + } + } + // 如果房主还没播放,留在观影室页面等待 + }, [currentRoom?.currentState, isOwner, router]); + + // 从房间列表加入房间 + const handleJoinFromList = (room: Room) => { + setJoinForm({ + roomId: room.id, + password: '', + }); + setActiveTab('join'); + }; + + 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 '刚刚'; + }; + + const tabs = [ + { id: 'create' as TabType, label: '创建房间', icon: Users }, + { id: 'join' as TabType, label: '加入房间', icon: UserPlus }, + { id: 'list' as TabType, label: '房间列表', icon: ListIcon }, ]; return ( -
-
- {/* 标题 */} -
-

观影室

-

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

+ +
+ {/* 房员等待提示 */} + {currentRoom && !isOwner && !currentRoom.currentState && ( +
+
+
+
+
+
+

等待房主开始播放

+

+ 房间: {currentRoom.name} | 房主: {currentRoom.ownerName} +

+

+ 当房主开始播放时,您将自动跳转到相同的视频或频道 +

+
+
+
+ )} + + {/* 页面标题 */} +
+

+ + 观影室 + {currentRoom && ( + + ({isOwner ? '房主' : '房员'}) + + )} +

+

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

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

使用说明

-
    -
  • - - - 创建房间:作为房主创建观影室,可以设置房间名称、密码和公开状态 - -
  • -
  • - - - 加入房间:通过房间号加入别人创建的观影室 - -
  • -
  • - - - 房间列表:浏览所有公开的观影室,点击即可加入 - -
  • -
  • - - - 实时同步: - 房主的播放操作会实时同步给所有成员,包括播放、暂停、进度、换集等 - -
  • -
  • - - - 语音聊天:在观影过程中可以使用文字和语音与房间成员交流 - -
  • -
+ {/* 选项卡内容 */} +
+ {/* 创建房间 */} + {activeTab === 'create' && ( +
+
+

+ 创建新房间 +

+ + {/* 如果已在房间内,显示当前房间信息 */} + {currentRoom ? ( +
+ {/* 房间信息卡片 */} +
+
+
+

{currentRoom.name}

+

{currentRoom.description || '暂无描述'}

+
+ {isOwner && ( + + 房主 + + )} +
+ +
+
+

房间号

+

{currentRoom.id}

+
+
+

成员数

+

{members.length} 人

+
+
+
+ + {/* 成员列表 */} +
+

房间成员

+
+ {members.map((member) => ( +
+
+
+ {member.name.charAt(0).toUpperCase()} +
+ + {member.name} + +
+ {member.isOwner && ( + + 房主 + + )} +
+ ))} +
+
+ + {/* 提示信息 */} +
+

+ 💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作 +

+
+
+ ) : ( +
+ {/* 显示当前用户 */} +
+

+ 当前用户:{currentUsername} +

+
+ +
+ + setCreateForm({ ...createForm, roomName: e.target.value })} + placeholder="请输入房间名称" + className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + maxLength={50} + required + /> +
+ +
+ +