diff --git a/server.js b/server.js index ebf7e94..1ce336e 100644 --- a/server.js +++ b/server.js @@ -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() { diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx index a824fb6..0c2eaf8 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -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() {

- {currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'} + {currentRoom.roomType === 'screen' + ? currentRoom.currentState?.type === 'screen' ? '房主正在共享屏幕' : '等待房主开始共享' + : currentRoom.currentState ? '房主正在播放' : '等待房主开始播放'}

房间: {currentRoom.name} | 房主: {currentRoom.ownerName} @@ -246,12 +269,14 @@ export default function WatchRoomPage() {

{currentRoom.currentState.type === 'play' ? `${currentRoom.currentState.videoName || '未知视频'}` - : `${currentRoom.currentState.channelName || '未知频道'}`} + : currentRoom.currentState.type === 'live' + ? `${currentRoom.currentState.channelName || '未知频道'}` + : '屏幕共享进行中'}

)} {!currentRoom.currentState && (

- 当房主开始播放时,您将自动跟随 + {currentRoom.roomType === 'screen' ? '当房主开始共享时,您将自动进入共享页' : '当房主开始播放时,您将自动跟随'}

)}
@@ -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() { )}

- 与好友一起看视频,实时同步播放 + 与好友一起看视频,支持进度同步或屏幕共享

@@ -360,7 +387,7 @@ export default function WatchRoomPage() { )} -
+

房间号

{currentRoom.id}

@@ -369,6 +396,10 @@ export default function WatchRoomPage() {

成员数

{members.length} 人

+
+

房间类型

+

{currentRoom.roomType === 'screen' ? '屏幕共享' : '进度同步'}

+
@@ -402,7 +433,9 @@ export default function WatchRoomPage() { {/* 提示信息 */}

- 💡 前往播放页面或直播页面开始观影,房间成员将自动同步您的操作 + 💡 {currentRoom.roomType === 'screen' + ? '这是屏幕共享房间,创建后将进入共享页,由房主发起屏幕共享' + : '前往播放页面或直播页面开始观影,房间成员将自动同步您的操作'}

@@ -471,6 +504,38 @@ export default function WatchRoomPage() { +
+ +
+ + +
+
+ + + + +
+
+ {isOwner ? ( +
+ +
+
+

共享状态

+
+

类型:屏幕共享

+

状态:{isSharing ? '共享中' : '未开始'}

+

成员:{members.length} 人

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {isOwner ? ( + <> + + + + ) : ( +
+ 房员无需操作,房主开始共享后会自动显示画面。 +
+ )} +
+
+ +
+

+ + 房间成员 +

+
+ {members.map((member) => ( +
+ {member.name} + {member.isOwner && ( + + 房主 + + )} +
+ ))} +
+
+ +
+ 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。 +
+
+
+ + + ); +} diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index 1154e95..0371727 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -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: '/' }, { diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ae0f270..2f16a65 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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(() => { if ( diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index 930ec6a..cbd9af1 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -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; 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(null); const [isEnabled, setIsEnabled] = useState(false); const [toast, setToast] = useState(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, }; diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts new file mode 100644 index 0000000..3fdc57c --- /dev/null +++ b/src/hooks/useScreenShare.ts @@ -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(null); + const remoteVideoRef = useRef(null); + const displayStreamRef = useRef(null); + const remoteStreamRef = useRef(null); + const peerConnectionsRef = useRef>(new Map()); + const stoppingRef = useRef(false); + + const [error, setError] = useState(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, + }; +} diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts index 0f19c75..5700550 100644 --- a/src/hooks/useWatchRoom.ts +++ b/src/hooks/useWatchRoom.ts @@ -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, }; } diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts index 2d4afad..deb1862 100644 --- a/src/lib/watch-room-server.ts +++ b/src/lib/watch-room-server.ts @@ -16,6 +16,8 @@ export class WatchRoomServer { private rooms: Map = new Map(); private members: Map> = new Map(); // roomId -> userId -> Member private socketToRoom: Map = new Map(); // socketId -> RoomMemberInfo + private screenHelpers: Map = new Map(); // roomId -> helperSocketId + private helperToRoom: Map = new Map(); // helperSocketId -> roomId private cleanupInterval: NodeJS.Timeout | null = null; constructor(private io: SocketIOServer) { @@ -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分钟后删除) diff --git a/src/types/watch-room.ts b/src/types/watch-room.ts index 6bbf1ca..a77b914 100644 --- a/src/types/watch-room.ts +++ b/src/types/watch-room.ts @@ -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;