观影室增加屏幕共享
This commit is contained in:
@@ -32,6 +32,8 @@ class WatchRoomServer {
|
||||
this.rooms = new Map();
|
||||
this.members = new Map();
|
||||
this.socketToRoom = new Map();
|
||||
this.screenHelpers = new Map();
|
||||
this.helperToRoom = new Map();
|
||||
this.roomDeletionTimers = new Map(); // 房间延迟删除定时器
|
||||
this.cleanupInterval = null;
|
||||
this.setupEventHandlers();
|
||||
@@ -55,6 +57,7 @@ class WatchRoomServer {
|
||||
description: data.description,
|
||||
password: data.password,
|
||||
isPublic: data.isPublic,
|
||||
roomType: data.roomType || 'sync',
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
@@ -260,6 +263,114 @@ class WatchRoomServer {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:helper-register', (data, callback) => {
|
||||
try {
|
||||
const room = this.rooms.get(data.roomId);
|
||||
if (!room) {
|
||||
callback({ success: false, error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.ownerToken !== data.ownerToken) {
|
||||
callback({ success: false, error: '房主身份验证失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
|
||||
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
|
||||
this.helperToRoom.delete(oldHelperSocketId);
|
||||
}
|
||||
|
||||
this.screenHelpers.set(data.roomId, socket.id);
|
||||
this.helperToRoom.set(socket.id, data.roomId);
|
||||
callback({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error registering screen helper:', error);
|
||||
callback({ success: false, error: '注册共享控制窗口失败' });
|
||||
}
|
||||
});
|
||||
|
||||
// 开始屏幕共享
|
||||
socket.on('screen:start', (state) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = state;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:start', state);
|
||||
}
|
||||
});
|
||||
|
||||
// 停止屏幕共享
|
||||
socket.on('screen:stop', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = null;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:stop');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:viewer-ready', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
if (!roomInfo) return;
|
||||
|
||||
const room = this.rooms.get(roomInfo.roomId);
|
||||
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
|
||||
|
||||
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
|
||||
this.io.to(targetSocketId).emit('screen:viewer-ready', {
|
||||
userId: socket.id,
|
||||
});
|
||||
});
|
||||
|
||||
// 屏幕共享 WebRTC 信令
|
||||
socket.on('screen:offer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:offer', {
|
||||
userId: socket.id,
|
||||
offer: data.offer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:answer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:answer', {
|
||||
userId: socket.id,
|
||||
answer: data.answer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:ice', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:ice', {
|
||||
userId: socket.id,
|
||||
candidate: data.candidate,
|
||||
});
|
||||
});
|
||||
|
||||
// 聊天消息
|
||||
socket.on('chat:message', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
@@ -347,6 +458,19 @@ class WatchRoomServer {
|
||||
// 断开连接
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (helperRoomId) {
|
||||
this.helperToRoom.delete(socket.id);
|
||||
if (this.screenHelpers.get(helperRoomId) === socket.id) {
|
||||
this.screenHelpers.delete(helperRoomId);
|
||||
const room = this.rooms.get(helperRoomId);
|
||||
if (room && room.currentState?.type === 'screen') {
|
||||
room.currentState = null;
|
||||
this.rooms.set(helperRoomId, room);
|
||||
this.io.to(helperRoomId).emit('screen:stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleLeaveRoom(socket);
|
||||
});
|
||||
});
|
||||
@@ -425,6 +549,11 @@ class WatchRoomServer {
|
||||
|
||||
this.rooms.delete(roomId);
|
||||
this.members.delete(roomId);
|
||||
const helperSocketId = this.screenHelpers.get(roomId);
|
||||
if (helperSocketId) {
|
||||
this.helperToRoom.delete(helperSocketId);
|
||||
this.screenHelpers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
startCleanupTimer() {
|
||||
|
||||
+95
-18
@@ -10,7 +10,7 @@ import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||
|
||||
import type { Room } from '@/types/watch-room';
|
||||
import type { Room, RoomType } from '@/types/watch-room';
|
||||
|
||||
type TabType = 'create' | 'join' | 'list';
|
||||
|
||||
@@ -34,6 +34,7 @@ export default function WatchRoomPage() {
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
roomType: 'sync' as RoomType,
|
||||
});
|
||||
|
||||
// 加入房间表单
|
||||
@@ -49,26 +50,30 @@ export default function WatchRoomPage() {
|
||||
const [joinLoading, setJoinLoading] = useState(false);
|
||||
|
||||
// 加载房间列表
|
||||
const loadRooms = async () => {
|
||||
const loadRooms = async (showLoading = false) => {
|
||||
if (!isConnected) return;
|
||||
|
||||
setLoading(true);
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const roomList = await getRoomList();
|
||||
setRooms(roomList);
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Failed to load rooms:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 切换到房间列表 tab 时加载房间
|
||||
useEffect(() => {
|
||||
if (activeTab === 'list') {
|
||||
loadRooms();
|
||||
loadRooms(true);
|
||||
// 每5秒刷新一次
|
||||
const interval = setInterval(loadRooms, 5000);
|
||||
const interval = setInterval(() => loadRooms(false), 5000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, isConnected]);
|
||||
@@ -88,6 +93,7 @@ export default function WatchRoomPage() {
|
||||
description: createForm.description.trim(),
|
||||
password: createForm.password.trim() || undefined,
|
||||
isPublic: createForm.isPublic,
|
||||
roomType: createForm.roomType,
|
||||
userName: currentUsername,
|
||||
});
|
||||
|
||||
@@ -97,6 +103,7 @@ export default function WatchRoomPage() {
|
||||
description: '',
|
||||
password: '',
|
||||
isPublic: true,
|
||||
roomType: 'sync',
|
||||
});
|
||||
} catch (error: any) {
|
||||
alert(error.message || '创建房间失败');
|
||||
@@ -141,6 +148,11 @@ export default function WatchRoomPage() {
|
||||
useEffect(() => {
|
||||
if (!currentRoom || isOwner) return;
|
||||
|
||||
if (currentRoom.roomType === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
return;
|
||||
}
|
||||
|
||||
// 房员加入房间后,不立即跳转
|
||||
// 而是监听 play:change 或 live:change 事件(说明房主正在活跃使用)
|
||||
// 这样可以避免房主已经离开play页面但状态未清除的情况
|
||||
@@ -153,6 +165,8 @@ export default function WatchRoomPage() {
|
||||
useEffect(() => {
|
||||
if (!currentRoom || isOwner) return;
|
||||
|
||||
if (currentRoom.roomType === 'screen') return;
|
||||
|
||||
const handlePlayChange = (state: any) => {
|
||||
if (state.type === 'play') {
|
||||
const params = new URLSearchParams({
|
||||
@@ -196,6 +210,13 @@ export default function WatchRoomPage() {
|
||||
}
|
||||
}, [currentRoom, isOwner, router, socket]);
|
||||
|
||||
// 屏幕共享房间创建/加入后直接进入共享页
|
||||
useEffect(() => {
|
||||
if (currentRoom?.roomType === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
}
|
||||
}, [currentRoom?.id, currentRoom?.roomType, router]);
|
||||
|
||||
// 从房间列表加入房间
|
||||
const handleJoinFromList = (room: Room) => {
|
||||
setJoinForm({
|
||||
@@ -237,7 +258,9 @@ export default function WatchRoomPage() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold mb-1">
|
||||
{currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
||||
{currentRoom.roomType === 'screen'
|
||||
? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享'
|
||||
: currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}
|
||||
</h3>
|
||||
<p className="text-sm text-white/80">
|
||||
房间: {currentRoom.name} | 房主: {currentRoom.ownerName}
|
||||
@@ -246,12 +269,14 @@ export default function WatchRoomPage() {
|
||||
<p className="text-xs text-white/90 mt-1">
|
||||
{currentRoom.currentState.type === 'play'
|
||||
? `${currentRoom.currentState.videoName || '未知视频'}`
|
||||
: `${currentRoom.currentState.channelName || '未知频道'}`}
|
||||
: currentRoom.currentState.type === 'live'
|
||||
? `${currentRoom.currentState.channelName || '未知频道'}`
|
||||
: '屏幕共享进行中'}
|
||||
</p>
|
||||
)}
|
||||
{!currentRoom.currentState && (
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
当房主开始播放时,您将自动跟随
|
||||
{currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -280,6 +305,8 @@ export default function WatchRoomPage() {
|
||||
// 普通 live 格式,导航到 live 页面
|
||||
router.push(`/live?id=${state.channelId}`);
|
||||
}
|
||||
} else if (state.type === 'screen') {
|
||||
router.push('/watch-room/screen');
|
||||
}
|
||||
}}
|
||||
className="px-6 py-2 bg-white text-blue-600 font-medium rounded-lg hover:bg-white/90 transition-colors whitespace-nowrap"
|
||||
@@ -303,7 +330,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
与好友一起看视频,实时同步播放
|
||||
与好友一起看视频,支持进度同步或屏幕共享
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +387,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
@@ -369,6 +396,10 @@ export default function WatchRoomPage() {
|
||||
<p className="text-blue-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-blue-100 text-xs mb-1">房间类型</p>
|
||||
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -402,7 +433,9 @@ export default function WatchRoomPage() {
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作
|
||||
💡 {currentRoom.roomType === 'screen'
|
||||
? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享'
|
||||
: '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,6 +504,38 @@ export default function WatchRoomPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
房间类型
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, roomType: 'sync' })}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
createForm.roomType === 'sync'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">进度同步</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">统一播放进度</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCreateForm({ ...createForm, roomType: 'screen' })}
|
||||
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||
createForm.roomType === 'screen'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">屏幕共享</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">房员直接观看房主共享的浏览器画面</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading || !createForm.roomName.trim()}
|
||||
@@ -486,7 +551,7 @@ export default function WatchRoomPage() {
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>提示:</strong>创建房间后,您将成为房主。所有成员的播放进度将自动跟随您的操作。
|
||||
<strong>提示:</strong>创建房间后,您将成为房主。进度同步房会跟随播放状态,屏幕共享房会进入独立共享页。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -518,7 +583,7 @@ export default function WatchRoomPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-4">
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">房间号</p>
|
||||
<p className="text-xl font-mono font-bold">{currentRoom.id}</p>
|
||||
@@ -527,6 +592,10 @@ export default function WatchRoomPage() {
|
||||
<p className="text-green-100 text-xs mb-1">成员数</p>
|
||||
<p className="text-xl font-bold">{members.length} 人</p>
|
||||
</div>
|
||||
<div className="bg-white/10 backdrop-blur rounded-lg p-3">
|
||||
<p className="text-green-100 text-xs mb-1">房间类型</p>
|
||||
<p className="text-base font-bold">{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -560,7 +629,9 @@ export default function WatchRoomPage() {
|
||||
{/* 提示信息 */}
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
💡 {isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||
💡 {currentRoom.roomType === 'screen'
|
||||
? '这是屏幕共享房间,进入后即可观看房主共享画面'
|
||||
: isOwner ? '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作' : '等待房主开始播放,您的播放进度将自动跟随房主'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -617,7 +688,7 @@ export default function WatchRoomPage() {
|
||||
{!currentRoom && (
|
||||
<div className="mt-6 bg-green-50 dark:bg-green-900/20 rounded-lg p-4 border border-green-200 dark:border-green-800">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
<strong>提示:</strong>加入房间后,您的播放进度将自动跟随房主的操作。
|
||||
<strong>提示:</strong>加入进度同步房后将跟随播放,加入屏幕共享房后会进入共享页面。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -633,7 +704,7 @@ export default function WatchRoomPage() {
|
||||
找到 <span className="font-medium text-gray-900 dark:text-gray-100">{rooms.length}</span> 个公开房间
|
||||
</p>
|
||||
<button
|
||||
onClick={loadRooms}
|
||||
onClick={() => loadRooms(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg text-gray-700 dark:text-gray-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
@@ -704,6 +775,10 @@ export default function WatchRoomPage() {
|
||||
<span>房主</span>
|
||||
<span className="font-medium">{room.ownerName}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>类型</span>
|
||||
<span>{room.roomType === 'screen' ? '屏幕共享' : '进度同步'}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-gray-600 dark:text-gray-400">
|
||||
<span>创建时间</span>
|
||||
<span>{formatTime(room.createdAt)}</span>
|
||||
@@ -713,7 +788,9 @@ export default function WatchRoomPage() {
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 truncate">
|
||||
{room.currentState.type === 'play'
|
||||
? `正在播放: ${room.currentState.videoName}`
|
||||
: `正在观看: ${room.currentState.channelName}`}
|
||||
: room.currentState.type === 'live'
|
||||
? `正在观看: ${room.currentState.channelName}`
|
||||
: '正在共享屏幕'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client';
|
||||
|
||||
import { Monitor, MonitorPlay, Users } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
||||
import { useScreenShare } from '@/hooks/useScreenShare';
|
||||
|
||||
const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_';
|
||||
|
||||
export default function WatchRoomScreenPage() {
|
||||
const router = useRouter();
|
||||
const watchRoom = useWatchRoomContext();
|
||||
const { currentRoom, members, leaveRoom } = watchRoom;
|
||||
const {
|
||||
currentRoom: screenRoom,
|
||||
isOwner,
|
||||
isSharing,
|
||||
isStarting,
|
||||
error,
|
||||
localVideoRef,
|
||||
remoteVideoRef,
|
||||
startSharing,
|
||||
stopSharing,
|
||||
} = useScreenShare();
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRoom) {
|
||||
router.replace('/watch-room');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRoom.roomType !== 'screen') {
|
||||
router.replace('/watch-room');
|
||||
}
|
||||
}, [currentRoom, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!screenRoom || !isOwner) return;
|
||||
|
||||
const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`;
|
||||
if (sessionStorage.getItem(key)) return;
|
||||
|
||||
sessionStorage.setItem(key, '1');
|
||||
window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer');
|
||||
}, [isOwner, screenRoom?.id]);
|
||||
|
||||
if (!screenRoom || screenRoom.roomType !== 'screen') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
if (isOwner && isSharing) {
|
||||
stopSharing(true);
|
||||
}
|
||||
leaveRoom();
|
||||
router.push('/watch-room');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200'>
|
||||
<div className='mx-auto flex min-h-screen max-w-7xl flex-col gap-4 px-4 py-4 lg:px-8'>
|
||||
<div className='flex items-center justify-between gap-4 rounded-2xl border border-gray-200 bg-white/90 px-5 py-4 shadow-sm dark:border-gray-800 dark:bg-gray-900/80'>
|
||||
<div>
|
||||
<h1 className='flex items-center gap-2 text-2xl font-semibold'>
|
||||
<Monitor className='h-6 w-6 text-blue-500' />
|
||||
屏幕共享观影室
|
||||
</h1>
|
||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
房间:{screenRoom.name} · 房主:{screenRoom.ownerName}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{isOwner && (
|
||||
<Link
|
||||
href='/?watchRoomNoConnect=1'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
|
||||
>
|
||||
新开主页
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
className='rounded-lg bg-gray-200 px-4 py-2 text-gray-900 dark:bg-gray-700 dark:text-gray-100'
|
||||
>
|
||||
离开房间
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid flex-1 grid-cols-1 gap-4 xl:grid-cols-[1fr_320px]'>
|
||||
<div className='relative flex min-h-[420px] items-center justify-center overflow-hidden rounded-2xl border border-gray-200 bg-black dark:border-gray-800'>
|
||||
{isOwner ? (
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className='h-full w-full bg-black object-contain'
|
||||
/>
|
||||
) : (
|
||||
<video
|
||||
ref={remoteVideoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
controls
|
||||
className='h-full w-full bg-black object-contain'
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSharing && (
|
||||
<div className='absolute px-6 text-center text-white'>
|
||||
<MonitorPlay className='mx-auto mb-3 h-12 w-12 text-white/70' />
|
||||
<p className='text-lg font-medium'>
|
||||
{isOwner ? '点击开始共享,向房员推送浏览器画面' : '等待房主开始共享屏幕'}
|
||||
</p>
|
||||
{isOwner && (
|
||||
<p className='mt-2 text-sm text-white/70'>
|
||||
本页不要关闭;已尝试为你新开一个主页标签页方便继续浏览。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900'>
|
||||
<h2 className='mb-3 font-semibold'>共享状态</h2>
|
||||
<div className='space-y-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||
<p>类型:屏幕共享</p>
|
||||
<p>状态:{isSharing ? '共享中' : '未开始'}</p>
|
||||
<p>成员:{members.length} 人</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className='mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300'>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-4 flex gap-3'>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startSharing()}
|
||||
disabled={isStarting || isSharing}
|
||||
className='flex-1 rounded-lg bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400'
|
||||
>
|
||||
{isStarting ? '启动中...' : isSharing ? '共享中' : '开始共享'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopSharing(true)}
|
||||
disabled={!isSharing}
|
||||
className='rounded-lg bg-red-500 px-4 py-2 text-white disabled:bg-gray-400'
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className='rounded-lg bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:bg-blue-900/20 dark:text-blue-300'>
|
||||
房员无需操作,房主开始共享后会自动显示画面。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900'>
|
||||
<h2 className='mb-3 flex items-center gap-2 font-semibold'>
|
||||
<Users className='h-4 w-4' />
|
||||
房间成员
|
||||
</h2>
|
||||
<div className='space-y-2'>
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className='flex items-center justify-between rounded-lg bg-gray-50 px-3 py-2 dark:bg-gray-800/70'
|
||||
>
|
||||
<span className='text-sm'>{member.name}</span>
|
||||
{member.isOwner && (
|
||||
<span className='rounded bg-yellow-100 px-2 py-1 text-xs text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'>
|
||||
房主
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200'>
|
||||
本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,10 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
||||
};
|
||||
const currentActive = activePath ?? getCurrentFullPath();
|
||||
|
||||
if (pathname === '/watch-room/screen') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [navItems, setNavItems] = useState([
|
||||
{ icon: Home, label: '首页', href: '/' },
|
||||
{
|
||||
|
||||
@@ -63,6 +63,10 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const watchRoomContext = useWatchRoomContextSafe();
|
||||
|
||||
if (pathname === '/watch-room/screen') {
|
||||
return null;
|
||||
}
|
||||
// 若同一次 SPA 会话中已经读取过折叠状态,则直接复用,避免闪烁
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
|
||||
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||
|
||||
@@ -9,7 +10,7 @@ import Toast, { ToastProps } from '@/components/Toast';
|
||||
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import type { ChatMessage, Member, Room, WatchRoomConfig } from '@/types/watch-room';
|
||||
import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig } from '@/types/watch-room';
|
||||
|
||||
// Import type from watch-room-socket
|
||||
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
||||
@@ -31,6 +32,7 @@ interface WatchRoomContextType {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
userName: string;
|
||||
}) => Promise<Room>;
|
||||
joinRoom: (data: {
|
||||
@@ -51,6 +53,8 @@ interface WatchRoomContextType {
|
||||
pause: () => void;
|
||||
changeVideo: (state: any) => void;
|
||||
changeLiveChannel: (state: any) => void;
|
||||
startScreenShare: (state: ScreenState) => void;
|
||||
stopScreenShare: () => void;
|
||||
clearRoomState: () => void;
|
||||
|
||||
// 重连
|
||||
@@ -77,6 +81,7 @@ interface WatchRoomProviderProps {
|
||||
}
|
||||
|
||||
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||
@@ -118,6 +123,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
}, []);
|
||||
|
||||
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
|
||||
const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1';
|
||||
|
||||
// 检查登录状态
|
||||
useEffect(() => {
|
||||
@@ -169,6 +175,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
|
||||
// 加载配置
|
||||
useEffect(() => {
|
||||
if (shouldDisableWatchRoomConnection) {
|
||||
setConfig({
|
||||
enabled: false,
|
||||
serverType: 'internal',
|
||||
});
|
||||
setIsEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
// 使用公共 API 获取观影室配置(不需要管理员权限)
|
||||
@@ -253,12 +268,14 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, [isLoggedIn, shouldDisableWatchRoomConnection]); // 添加 isLoggedIn 作为依赖
|
||||
|
||||
// 清理
|
||||
// 仅在 Provider 卸载时断开,避免路由切换时误断开房间连接
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
watchRoom.disconnect();
|
||||
};
|
||||
}, [isLoggedIn]); // 添加 isLoggedIn 作为依赖
|
||||
}, []);
|
||||
|
||||
const contextValue: WatchRoomContextType = {
|
||||
socket: watchRoom.socket,
|
||||
@@ -281,6 +298,8 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||
pause: watchRoom.pause,
|
||||
changeVideo: watchRoom.changeVideo,
|
||||
changeLiveChannel: watchRoom.changeLiveChannel,
|
||||
startScreenShare: watchRoom.startScreenShare,
|
||||
stopScreenShare: watchRoom.stopScreenShare,
|
||||
clearRoomState: watchRoom.clearRoomState,
|
||||
manualReconnect,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { useWatchRoomContextSafe } from '@/components/WatchRoomProvider';
|
||||
|
||||
import type { ScreenState } from '@/types/watch-room';
|
||||
|
||||
const iceServers = [
|
||||
{ urls: 'stun:stun.cloudflare.com:3478' },
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
];
|
||||
|
||||
export function useScreenShare() {
|
||||
const watchRoom = useWatchRoomContextSafe();
|
||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const displayStreamRef = useRef<MediaStream | null>(null);
|
||||
const remoteStreamRef = useRef<MediaStream | null>(null);
|
||||
const peerConnectionsRef = useRef<Map<string, RTCPeerConnection>>(new Map());
|
||||
const stoppingRef = useRef(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const currentRoom = watchRoom?.currentRoom || null;
|
||||
const socket = watchRoom?.socket || null;
|
||||
const isOwner = watchRoom?.isOwner || false;
|
||||
const members = watchRoom?.members || [];
|
||||
const currentState = currentRoom?.currentState;
|
||||
const isSharing = currentState?.type === 'screen' && currentState.status === 'sharing';
|
||||
|
||||
const closePeerConnection = useCallback((userId: string) => {
|
||||
const pc = peerConnectionsRef.current.get(userId);
|
||||
if (!pc) return;
|
||||
|
||||
pc.onicecandidate = null;
|
||||
pc.ontrack = null;
|
||||
pc.close();
|
||||
peerConnectionsRef.current.delete(userId);
|
||||
}, []);
|
||||
|
||||
const clearRemoteVideo = useCallback(() => {
|
||||
remoteStreamRef.current = null;
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupSharingResources = useCallback(() => {
|
||||
if (stoppingRef.current) return;
|
||||
stoppingRef.current = true;
|
||||
|
||||
peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
|
||||
peerConnectionsRef.current.clear();
|
||||
|
||||
if (displayStreamRef.current) {
|
||||
displayStreamRef.current.getTracks().forEach((track) => {
|
||||
track.onended = null;
|
||||
track.stop();
|
||||
});
|
||||
displayStreamRef.current = null;
|
||||
}
|
||||
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = null;
|
||||
}
|
||||
|
||||
clearRemoteVideo();
|
||||
stoppingRef.current = false;
|
||||
}, [clearRemoteVideo, closePeerConnection]);
|
||||
|
||||
const stopSharing = useCallback((notifyServer = true) => {
|
||||
cleanupSharingResources();
|
||||
|
||||
if (notifyServer && isOwner) {
|
||||
watchRoom?.stopScreenShare();
|
||||
}
|
||||
}, [cleanupSharingResources, isOwner, watchRoom]);
|
||||
|
||||
const createPeerConnection = useCallback((userId: string, ownerMode: boolean) => {
|
||||
const existing = peerConnectionsRef.current.get(userId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pc = new RTCPeerConnection({ iceServers });
|
||||
|
||||
pc.onicecandidate = (event) => {
|
||||
if (event.candidate && socket) {
|
||||
socket.emit('screen:ice', {
|
||||
targetUserId: userId,
|
||||
candidate: event.candidate.toJSON(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (ownerMode && displayStreamRef.current) {
|
||||
displayStreamRef.current.getTracks().forEach((track) => {
|
||||
pc.addTrack(track, displayStreamRef.current!);
|
||||
});
|
||||
} else {
|
||||
pc.ontrack = (event) => {
|
||||
const stream = event.streams[0];
|
||||
remoteStreamRef.current = stream;
|
||||
if (remoteVideoRef.current) {
|
||||
remoteVideoRef.current.srcObject = stream;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
peerConnectionsRef.current.set(userId, pc);
|
||||
return pc;
|
||||
}, [socket]);
|
||||
|
||||
const sendOfferToMember = useCallback(async (memberId: string) => {
|
||||
if (!socket || !displayStreamRef.current) return;
|
||||
|
||||
try {
|
||||
const pc = createPeerConnection(memberId, true);
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
socket.emit('screen:offer', {
|
||||
targetUserId: memberId,
|
||||
offer,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to send offer:', err);
|
||||
setError('无法建立屏幕共享连接');
|
||||
}
|
||||
}, [createPeerConnection, socket]);
|
||||
|
||||
const startSharing = useCallback(async () => {
|
||||
if (!watchRoom || !currentRoom || !isOwner) return;
|
||||
|
||||
setIsStarting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
frameRate: 15,
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: true,
|
||||
});
|
||||
|
||||
displayStreamRef.current = stream;
|
||||
if (localVideoRef.current) {
|
||||
localVideoRef.current.srcObject = stream;
|
||||
}
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack) {
|
||||
videoTrack.onended = () => {
|
||||
stopSharing(true);
|
||||
};
|
||||
}
|
||||
|
||||
const state: ScreenState = {
|
||||
type: 'screen',
|
||||
status: 'sharing',
|
||||
ownerName: currentRoom.ownerName,
|
||||
hasAudio: stream.getAudioTracks().length > 0,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
|
||||
watchRoom.startScreenShare(state);
|
||||
|
||||
await Promise.all(
|
||||
members.filter((member) => !member.isOwner).map((member) => sendOfferToMember(member.id))
|
||||
);
|
||||
} catch (err: any) {
|
||||
console.error('[ScreenShare] Failed to start sharing:', err);
|
||||
setError(err?.message || '开启屏幕共享失败');
|
||||
} finally {
|
||||
setIsStarting(false);
|
||||
}
|
||||
}, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom) return;
|
||||
|
||||
const handleOffer = async (data: { userId: string; offer: RTCSessionDescriptionInit }) => {
|
||||
if (isOwner) return;
|
||||
|
||||
try {
|
||||
const pc = createPeerConnection(data.userId, false);
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
socket.emit('screen:answer', {
|
||||
targetUserId: data.userId,
|
||||
answer,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle offer:', err);
|
||||
setError('接收共享画面失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnswer = async (data: { userId: string; answer: RTCSessionDescriptionInit }) => {
|
||||
if (!isOwner) return;
|
||||
|
||||
const pc = peerConnectionsRef.current.get(data.userId);
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle answer:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIce = async (data: { userId: string; candidate: RTCIceCandidateInit }) => {
|
||||
const pc = peerConnectionsRef.current.get(data.userId);
|
||||
if (!pc) return;
|
||||
|
||||
try {
|
||||
await pc.addIceCandidate(new RTCIceCandidate(data.candidate));
|
||||
} catch (err) {
|
||||
console.error('[ScreenShare] Failed to handle ICE:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScreenStop = () => {
|
||||
if (!isOwner) {
|
||||
peerConnectionsRef.current.forEach((_pc, userId) => closePeerConnection(userId));
|
||||
peerConnectionsRef.current.clear();
|
||||
clearRemoteVideo();
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewerReady = (data: { userId: string }) => {
|
||||
if (!isOwner || !displayStreamRef.current) return;
|
||||
sendOfferToMember(data.userId);
|
||||
};
|
||||
|
||||
socket.on('screen:offer', handleOffer);
|
||||
socket.on('screen:answer', handleAnswer);
|
||||
socket.on('screen:ice', handleIce);
|
||||
socket.on('screen:stop', handleScreenStop);
|
||||
socket.on('screen:viewer-ready', handleViewerReady);
|
||||
|
||||
return () => {
|
||||
socket.off('screen:offer', handleOffer);
|
||||
socket.off('screen:answer', handleAnswer);
|
||||
socket.off('screen:ice', handleIce);
|
||||
socket.off('screen:stop', handleScreenStop);
|
||||
socket.off('screen:viewer-ready', handleViewerReady);
|
||||
};
|
||||
}, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOwner || !isSharing || !displayStreamRef.current) return;
|
||||
|
||||
members
|
||||
.filter((member) => !member.isOwner)
|
||||
.forEach((member) => {
|
||||
if (!peerConnectionsRef.current.has(member.id)) {
|
||||
sendOfferToMember(member.id);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(peerConnectionsRef.current.keys()).forEach((userId) => {
|
||||
const stillInRoom = members.some((member) => member.id === userId && !member.isOwner);
|
||||
if (!stillInRoom) {
|
||||
closePeerConnection(userId);
|
||||
}
|
||||
});
|
||||
}, [closePeerConnection, isOwner, isSharing, members, sendOfferToMember]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupSharingResources();
|
||||
};
|
||||
}, [cleanupSharingResources]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket || !currentRoom || isOwner) return;
|
||||
if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return;
|
||||
|
||||
socket.emit('screen:viewer-ready');
|
||||
}, [currentRoom, currentState, isOwner, socket]);
|
||||
|
||||
return {
|
||||
currentRoom,
|
||||
isOwner,
|
||||
isSharing,
|
||||
isStarting,
|
||||
error,
|
||||
localVideoRef,
|
||||
remoteVideoRef,
|
||||
startSharing,
|
||||
stopSharing,
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
Member,
|
||||
PlayState,
|
||||
Room,
|
||||
RoomType,
|
||||
ScreenState,
|
||||
StoredRoomInfo,
|
||||
WatchRoomConfig,
|
||||
} from '@/types/watch-room';
|
||||
@@ -101,7 +103,7 @@ export function useWatchRoom(
|
||||
|
||||
// 创建房间
|
||||
const createRoom = useCallback(
|
||||
async (data: { name: string; description: string; password?: string; isPublic: boolean; userName: string }) => {
|
||||
async (data: { name: string; description: string; password?: string; isPublic: boolean; roomType: RoomType; userName: string }) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||
throw new Error('Not connected');
|
||||
@@ -295,6 +297,25 @@ export function useWatchRoom(
|
||||
[isOwner]
|
||||
);
|
||||
|
||||
// 开始屏幕共享
|
||||
const startScreenShare = useCallback(
|
||||
(state: ScreenState) => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !isOwner) return;
|
||||
|
||||
sock.emit('screen:start', state);
|
||||
},
|
||||
[isOwner]
|
||||
);
|
||||
|
||||
// 停止屏幕共享
|
||||
const stopScreenShare = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
if (!sock || !isOwner) return;
|
||||
|
||||
sock.emit('screen:stop');
|
||||
}, [isOwner]);
|
||||
|
||||
// 清除房间播放状态(房主离开播放/直播页面时调用)
|
||||
const clearRoomState = useCallback(() => {
|
||||
const sock = watchRoomSocketManager.getSocket();
|
||||
@@ -362,6 +383,19 @@ export function useWatchRoom(
|
||||
}
|
||||
});
|
||||
|
||||
// 屏幕共享事件
|
||||
socket.on('screen:start', (state) => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: state } : null));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:stop', () => {
|
||||
if (currentRoom) {
|
||||
setCurrentRoom((prev) => (prev ? { ...prev, currentState: null } : null));
|
||||
}
|
||||
});
|
||||
|
||||
// 聊天事件
|
||||
socket.on('chat:message', (message) => {
|
||||
setChatMessages((prev) => [...prev, message]);
|
||||
@@ -395,6 +429,8 @@ export function useWatchRoom(
|
||||
socket.off('play:update');
|
||||
socket.off('play:change');
|
||||
socket.off('live:change');
|
||||
socket.off('screen:start');
|
||||
socket.off('screen:stop');
|
||||
socket.off('chat:message');
|
||||
socket.off('state:cleared');
|
||||
socket.off('connect');
|
||||
@@ -431,6 +467,8 @@ export function useWatchRoom(
|
||||
pause,
|
||||
changeVideo,
|
||||
changeLiveChannel,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
clearRoomState,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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 screenHelpers: Map<string, string> = new Map(); // roomId -> helperSocketId
|
||||
private helperToRoom: Map<string, string> = new Map(); // helperSocketId -> roomId
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private io: SocketIOServer<ClientToServerEvents, ServerToClientEvents>) {
|
||||
@@ -40,6 +42,7 @@ export class WatchRoomServer {
|
||||
description: data.description,
|
||||
password: data.password,
|
||||
isPublic: data.isPublic,
|
||||
roomType: data.roomType || 'sync',
|
||||
ownerId: userId,
|
||||
ownerName: data.userName,
|
||||
ownerToken: ownerToken, // 保存房主令牌
|
||||
@@ -199,6 +202,111 @@ export class WatchRoomServer {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:helper-register', (data, callback) => {
|
||||
try {
|
||||
const room = this.rooms.get(data.roomId);
|
||||
if (!room) {
|
||||
callback({ success: false, error: '房间不存在' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (room.ownerToken !== data.ownerToken) {
|
||||
callback({ success: false, error: '房主身份验证失败' });
|
||||
return;
|
||||
}
|
||||
|
||||
const oldHelperSocketId = this.screenHelpers.get(data.roomId);
|
||||
if (oldHelperSocketId && oldHelperSocketId !== socket.id) {
|
||||
this.helperToRoom.delete(oldHelperSocketId);
|
||||
}
|
||||
|
||||
this.screenHelpers.set(data.roomId, socket.id);
|
||||
this.helperToRoom.set(socket.id, data.roomId);
|
||||
callback({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[WatchRoom] Error registering screen helper:', error);
|
||||
callback({ success: false, error: '注册共享控制窗口失败' });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:start', (state) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = state;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:start', state);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:stop', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
const roomId = roomInfo?.roomId || helperRoomId;
|
||||
if (!roomId) return;
|
||||
if (helperRoomId && this.screenHelpers.get(helperRoomId) !== socket.id) return;
|
||||
if (roomInfo && !roomInfo.isOwner) return;
|
||||
|
||||
const room = this.rooms.get(roomId);
|
||||
if (room) {
|
||||
room.currentState = null;
|
||||
this.rooms.set(roomId, room);
|
||||
this.io.to(roomId).emit('screen:stop');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('screen:viewer-ready', () => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
if (!roomInfo) return;
|
||||
|
||||
const room = this.rooms.get(roomInfo.roomId);
|
||||
if (!room || roomInfo.isOwner || room.currentState?.type !== 'screen') return;
|
||||
|
||||
const targetSocketId = this.screenHelpers.get(roomInfo.roomId) || room.ownerId;
|
||||
this.io.to(targetSocketId).emit('screen:viewer-ready', {
|
||||
userId: socket.id,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:offer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:offer', {
|
||||
userId: socket.id,
|
||||
offer: data.offer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:answer', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:answer', {
|
||||
userId: socket.id,
|
||||
answer: data.answer,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('screen:ice', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (!roomInfo && !helperRoomId) return;
|
||||
|
||||
this.io.to(data.targetUserId).emit('screen:ice', {
|
||||
userId: socket.id,
|
||||
candidate: data.candidate,
|
||||
});
|
||||
});
|
||||
|
||||
// 聊天消息
|
||||
socket.on('chat:message', (data) => {
|
||||
const roomInfo = this.socketToRoom.get(socket.id);
|
||||
@@ -303,6 +411,19 @@ export class WatchRoomServer {
|
||||
// 断开连接
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`[WatchRoom] Client disconnected: ${socket.id}`);
|
||||
const helperRoomId = this.helperToRoom.get(socket.id);
|
||||
if (helperRoomId) {
|
||||
this.helperToRoom.delete(socket.id);
|
||||
if (this.screenHelpers.get(helperRoomId) === socket.id) {
|
||||
this.screenHelpers.delete(helperRoomId);
|
||||
const room = this.rooms.get(helperRoomId);
|
||||
if (room && room.currentState?.type === 'screen') {
|
||||
room.currentState = null;
|
||||
this.rooms.set(helperRoomId, room);
|
||||
this.io.to(helperRoomId).emit('screen:stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleLeaveRoom(socket);
|
||||
});
|
||||
});
|
||||
@@ -348,6 +469,11 @@ export class WatchRoomServer {
|
||||
this.io.to(roomId).emit('room:deleted');
|
||||
this.rooms.delete(roomId);
|
||||
this.members.delete(roomId);
|
||||
const helperSocketId = this.screenHelpers.get(roomId);
|
||||
if (helperSocketId) {
|
||||
this.helperToRoom.delete(helperSocketId);
|
||||
this.screenHelpers.delete(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
// 定时清理房间(房主断开5分钟后删除)
|
||||
|
||||
+29
-1
@@ -6,15 +6,18 @@ export interface Room {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
ownerId: string;
|
||||
ownerName: string;
|
||||
ownerToken: string; // 房主令牌,用于重连时验证身份
|
||||
memberCount: number;
|
||||
currentState: PlayState | LiveState | null;
|
||||
currentState: PlayState | LiveState | ScreenState | null;
|
||||
createdAt: number;
|
||||
lastOwnerHeartbeat: number;
|
||||
}
|
||||
|
||||
export type RoomType = 'sync' | 'screen';
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -42,6 +45,14 @@ export interface LiveState {
|
||||
channelUrl: string;
|
||||
}
|
||||
|
||||
export interface ScreenState {
|
||||
type: 'screen';
|
||||
status: 'idle' | 'sharing';
|
||||
ownerName: string;
|
||||
hasAudio?: boolean;
|
||||
startedAt?: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -73,6 +84,12 @@ export interface ServerToClientEvents {
|
||||
'play:pause': () => void;
|
||||
'play:change': (state: PlayState) => void;
|
||||
'live:change': (state: LiveState) => void;
|
||||
'screen:start': (state: ScreenState) => void;
|
||||
'screen:stop': () => void;
|
||||
'screen:viewer-ready': (data: { userId: string }) => void;
|
||||
'screen:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:ice': (data: { userId: string; candidate: RTCIceCandidateInit }) => void;
|
||||
'chat:message': (message: ChatMessage) => void;
|
||||
'voice:offer': (data: { userId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'voice:answer': (data: { userId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
@@ -90,6 +107,7 @@ export interface ClientToServerEvents {
|
||||
description: string;
|
||||
password?: string;
|
||||
isPublic: boolean;
|
||||
roomType: RoomType;
|
||||
userName: string;
|
||||
}, callback: (response: { success: boolean; room?: Room; error?: string }) => void) => void;
|
||||
|
||||
@@ -111,6 +129,16 @@ export interface ClientToServerEvents {
|
||||
'play:change': (state: PlayState) => void;
|
||||
|
||||
'live:change': (state: LiveState) => void;
|
||||
'screen:helper-register': (data: {
|
||||
roomId: string;
|
||||
ownerToken: string;
|
||||
}, callback: (response: { success: boolean; error?: string }) => void) => void;
|
||||
'screen:start': (state: ScreenState) => void;
|
||||
'screen:stop': () => void;
|
||||
'screen:viewer-ready': () => void;
|
||||
'screen:offer': (data: { targetUserId: string; offer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:answer': (data: { targetUserId: string; answer: RTCSessionDescriptionInit }) => void;
|
||||
'screen:ice': (data: { targetUserId: string; candidate: RTCIceCandidateInit }) => void;
|
||||
|
||||
'chat:message': (data: { content: string; type: 'text' | 'emoji' }) => void;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user