From 23b8115f36ede0ae9b4a7a3ab24aaa388788ec99 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Mon, 6 Apr 2026 01:30:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B1=8F=E5=B9=95=E5=85=B1=E4=BA=AB=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 8 +++ src/app/watch-room/page.tsx | 4 +- src/app/watch-room/screen/page.tsx | 87 +++++++++++++++++++++++++--- src/components/WatchRoomProvider.tsx | 21 ++++++- src/hooks/useScreenShare.ts | 79 ++++++++++++++++++++++--- src/hooks/useWatchRoom.ts | 41 ++++++++++--- src/lib/watch-room-server.ts | 24 +++++++- src/lib/watch-room-socket.ts | 41 ++++++++++++- 8 files changed, 272 insertions(+), 33 deletions(-) diff --git a/server.js b/server.js index 1ce336e..2cbf79a 100644 --- a/server.js +++ b/server.js @@ -134,6 +134,14 @@ class WatchRoomServer { const roomMembers = this.members.get(data.roomId); if (roomMembers) { + if (isOwner) { + Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => { + if (existingMember.isOwner && memberId !== userId) { + roomMembers.delete(memberId); + } + }); + } + roomMembers.set(userId, member); room.memberCount = roomMembers.size; this.rooms.set(data.roomId, room); diff --git a/src/app/watch-room/page.tsx b/src/app/watch-room/page.tsx index 15f9fa5..6daf4c2 100644 --- a/src/app/watch-room/page.tsx +++ b/src/app/watch-room/page.tsx @@ -583,7 +583,7 @@ export default function WatchRoomPage() { }`} >
进度同步
-
统一播放进度
+
统一播放进度(适合双方网络稳定的情况)
diff --git a/src/app/watch-room/screen/page.tsx b/src/app/watch-room/screen/page.tsx index b8fb56b..0bd73c1 100644 --- a/src/app/watch-room/screen/page.tsx +++ b/src/app/watch-room/screen/page.tsx @@ -3,13 +3,15 @@ import { Monitor, MonitorPlay, Users } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import Toast, { ToastProps } from '@/components/Toast'; import { useWatchRoomContext } from '@/components/WatchRoomProvider'; -import { useScreenShare } from '@/hooks/useScreenShare'; +import { screenShareQualityOptions, type ScreenShareQualityPreset, useScreenShare } from '@/hooks/useScreenShare'; const NEW_TAB_KEY_PREFIX = 'watch_room_screen_home_opened_'; +const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect'; +const SCREEN_SHARE_QUALITY_KEY = 'watch_room_screen_quality'; function getScreenShareHostSupportError() { if (typeof window === 'undefined') return null; @@ -44,17 +46,19 @@ export default function WatchRoomScreenPage() { const watchRoom = useWatchRoomContext(); const { currentRoom, members, leaveRoom } = watchRoom; const [toast, setToast] = useState(null); + const [qualityPreset, setQualityPreset] = useState('smooth'); const { currentRoom: screenRoom, isOwner, isSharing, isStarting, error, + captureSettings, localVideoRef, remoteVideoRef, startSharing, stopSharing, - } = useScreenShare(); + } = useScreenShare(qualityPreset); const showToast = (message: string, type: ToastProps['type'] = 'info') => { setToast({ @@ -65,6 +69,24 @@ export default function WatchRoomScreenPage() { }); }; + const openDetachedPage = useCallback(() => { + window.open('/', '_blank', 'noopener,noreferrer'); + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const saved = window.localStorage.getItem(SCREEN_SHARE_QUALITY_KEY); + if (saved === 'smooth' || saved === 'hd' || saved === 'ultra') { + setQualityPreset(saved); + } + }, []); + + useEffect(() => { + if (typeof window === 'undefined') return; + window.localStorage.setItem(SCREEN_SHARE_QUALITY_KEY, qualityPreset); + }, [qualityPreset]); + useEffect(() => { if (!currentRoom) { router.replace('/watch-room'); @@ -92,12 +114,17 @@ export default function WatchRoomScreenPage() { useEffect(() => { if (!screenRoom || !isOwner) return; + localStorage.setItem(WATCH_ROOM_NO_CONNECT_KEY, '1'); const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`; - if (sessionStorage.getItem(key)) return; + if (!sessionStorage.getItem(key)) { + sessionStorage.setItem(key, '1'); + openDetachedPage(); + } - sessionStorage.setItem(key, '1'); - window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer'); - }, [isOwner, screenRoom?.id]); + return () => { + localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY); + }; + }, [isOwner, openDetachedPage, screenRoom?.id]); if (!screenRoom || screenRoom.roomType !== 'screen') { return null; @@ -111,6 +138,15 @@ export default function WatchRoomScreenPage() { router.push('/watch-room'); }; + const captureSettingsText = captureSettings + ? [ + captureSettings.width && captureSettings.height + ? `${captureSettings.width}x${captureSettings.height}` + : '分辨率未知', + captureSettings.frameRate ? `${Math.round(captureSettings.frameRate)} fps` : '帧率未知', + ].join(' / ') + : '未开始'; + return (
@@ -127,9 +163,13 @@ export default function WatchRoomScreenPage() {
{isOwner && ( { + event.preventDefault(); + openDetachedPage(); + }} className='rounded-lg bg-blue-500 px-4 py-2 text-white' > 新开主页 @@ -188,12 +228,41 @@ export default function WatchRoomScreenPage() {

成员:{members.length} 人

+ {isOwner && ( +
+ 实际采集:{captureSettingsText} +
+ )} + {error && (
{error}
)} + {isOwner && ( +
+ + +

+ 画质越高越清晰,但更依赖网络和设备性能。共享开始后不可切换。 +

+
+ )} +
{isOwner ? ( <> @@ -243,7 +312,7 @@ export default function WatchRoomScreenPage() {
- 本页不再包裹站点导航,便于直接共享。建议使用桌面版 Chrome / Edge,并优先共享标签页。 + 建议使用桌面版 Chrome / Edge,并优先共享标签页。
diff --git a/src/components/WatchRoomProvider.tsx b/src/components/WatchRoomProvider.tsx index cbd9af1..cb464a6 100644 --- a/src/components/WatchRoomProvider.tsx +++ b/src/components/WatchRoomProvider.tsx @@ -2,7 +2,6 @@ 'use client'; import React, { createContext, useCallback,useContext, useEffect, useState } from 'react'; -import { useSearchParams } from 'next/navigation'; import { useWatchRoom } from '@/hooks/useWatchRoom'; @@ -14,6 +13,8 @@ import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig // Import type from watch-room-socket type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket; +const WATCH_ROOM_NO_CONNECT_KEY = 'watch_room_no_connect'; +const WATCH_ROOM_SCREEN_PATH = '/watch-room/screen'; interface WatchRoomContextType { socket: WatchRoomSocket | null; @@ -39,6 +40,7 @@ interface WatchRoomContextType { roomId: string; password?: string; userName: string; + ownerToken?: string; }) => Promise<{ room: Room; members: Member[] }>; leaveRoom: () => void; getRoomList: () => Promise; @@ -81,12 +83,12 @@ 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); const [reconnectFailed, setReconnectFailed] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false); + const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState(null); // 处理房间删除的回调 const handleRoomDeleted = useCallback((data?: { reason?: string }) => { @@ -123,7 +125,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { }, []); const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared); - const shouldDisableWatchRoomConnection = searchParams.get('watchRoomNoConnect') === '1'; + + useEffect(() => { + if (typeof window === 'undefined') return; + + setShouldDisableWatchRoomConnection( + window.location.pathname !== WATCH_ROOM_SCREEN_PATH + && window.localStorage.getItem(WATCH_ROOM_NO_CONNECT_KEY) === '1' + ); + }, []); // 检查登录状态 useEffect(() => { @@ -162,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { roomId: info.roomId, password: info.password, userName: info.userName, + ownerToken: info.ownerToken, }); } catch (error) { console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error); @@ -175,6 +186,10 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) { // 加载配置 useEffect(() => { + if (shouldDisableWatchRoomConnection === null) { + return; + } + if (shouldDisableWatchRoomConnection) { setConfig({ enabled: false, diff --git a/src/hooks/useScreenShare.ts b/src/hooks/useScreenShare.ts index 3fdc57c..da88699 100644 --- a/src/hooks/useScreenShare.ts +++ b/src/hooks/useScreenShare.ts @@ -12,7 +12,51 @@ const iceServers = [ { urls: 'stun:stun1.l.google.com:19302' }, ]; -export function useScreenShare() { +export type ScreenShareQualityPreset = 'smooth' | 'hd' | 'ultra'; + +const SCREEN_SHARE_CONSTRAINTS: Record< + ScreenShareQualityPreset, + { + label: string; + frameRate: number; + width: number; + height: number; + } +> = { + smooth: { + label: '流畅 720p / 15fps', + frameRate: 15, + width: 1280, + height: 720, + }, + hd: { + label: '高清 1080p / 30fps', + frameRate: 30, + width: 1920, + height: 1080, + }, + ultra: { + label: '超清 1440p / 30fps', + frameRate: 30, + width: 2560, + height: 1440, + }, +}; + +export const screenShareQualityOptions = Object.entries(SCREEN_SHARE_CONSTRAINTS).map( + ([value, preset]) => ({ + value: value as ScreenShareQualityPreset, + label: preset.label, + }) +); + +export interface ScreenShareCaptureSettings { + width: number | null; + height: number | null; + frameRate: number | null; +} + +export function useScreenShare(qualityPreset: ScreenShareQualityPreset = 'smooth') { const watchRoom = useWatchRoomContextSafe(); const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); @@ -23,9 +67,11 @@ export function useScreenShare() { const [error, setError] = useState(null); const [isStarting, setIsStarting] = useState(false); + const [captureSettings, setCaptureSettings] = useState(null); const currentRoom = watchRoom?.currentRoom || null; const socket = watchRoom?.socket || null; + const isConnected = watchRoom?.isConnected || false; const isOwner = watchRoom?.isOwner || false; const members = watchRoom?.members || []; const currentState = currentRoom?.currentState; @@ -67,6 +113,7 @@ export function useScreenShare() { localVideoRef.current.srcObject = null; } + setCaptureSettings(null); clearRemoteVideo(); stoppingRef.current = false; }, [clearRemoteVideo, closePeerConnection]); @@ -136,11 +183,12 @@ export function useScreenShare() { setError(null); try { + const constraints = SCREEN_SHARE_CONSTRAINTS[qualityPreset]; const stream = await navigator.mediaDevices.getDisplayMedia({ video: { - frameRate: 15, - width: { ideal: 1280 }, - height: { ideal: 720 }, + frameRate: constraints.frameRate, + width: { ideal: constraints.width }, + height: { ideal: constraints.height }, }, audio: true, }); @@ -152,6 +200,12 @@ export function useScreenShare() { const videoTrack = stream.getVideoTracks()[0]; if (videoTrack) { + const settings = videoTrack.getSettings(); + setCaptureSettings({ + width: typeof settings.width === 'number' ? settings.width : null, + height: typeof settings.height === 'number' ? settings.height : null, + frameRate: typeof settings.frameRate === 'number' ? settings.frameRate : null, + }); videoTrack.onended = () => { stopSharing(true); }; @@ -176,7 +230,7 @@ export function useScreenShare() { } finally { setIsStarting(false); } - }, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]); + }, [currentRoom, isOwner, members, qualityPreset, sendOfferToMember, stopSharing, watchRoom]); useEffect(() => { if (!socket || !currentRoom) return; @@ -231,6 +285,14 @@ export function useScreenShare() { } }; + const handleSocketDisconnect = () => { + 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); @@ -241,6 +303,7 @@ export function useScreenShare() { socket.on('screen:ice', handleIce); socket.on('screen:stop', handleScreenStop); socket.on('screen:viewer-ready', handleViewerReady); + socket.on('disconnect', handleSocketDisconnect); return () => { socket.off('screen:offer', handleOffer); @@ -248,6 +311,7 @@ export function useScreenShare() { socket.off('screen:ice', handleIce); socket.off('screen:stop', handleScreenStop); socket.off('screen:viewer-ready', handleViewerReady); + socket.off('disconnect', handleSocketDisconnect); }; }, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]); @@ -277,11 +341,11 @@ export function useScreenShare() { }, [cleanupSharingResources]); useEffect(() => { - if (!socket || !currentRoom || isOwner) return; + if (!socket || !currentRoom || isOwner || !isConnected) return; if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return; socket.emit('screen:viewer-ready'); - }, [currentRoom, currentState, isOwner, socket]); + }, [currentRoom, currentState, isConnected, isOwner, socket]); return { currentRoom, @@ -289,6 +353,7 @@ export function useScreenShare() { isSharing, isStarting, error, + captureSettings, localVideoRef, remoteVideoRef, startSharing, diff --git a/src/hooks/useWatchRoom.ts b/src/hooks/useWatchRoom.ts index 5700550..6cf245f 100644 --- a/src/hooks/useWatchRoom.ts +++ b/src/hooks/useWatchRoom.ts @@ -30,9 +30,15 @@ export function useWatchRoom( const [chatMessages, setChatMessages] = useState([]); const [isOwner, setIsOwner] = useState(false); const reconnectTimeoutRef = useRef(null); + const rejoinInFlightRef = useRef(false); // 重新加入房间(自动重连) const rejoinRoom = useCallback(async (info: StoredRoomInfo) => { + if (rejoinInFlightRef.current) { + return; + } + + rejoinInFlightRef.current = true; console.log('[WatchRoom] Auto-rejoining room:', info); try { const sock = watchRoomSocketManager.getSocket(); @@ -64,9 +70,21 @@ export function useWatchRoom( } catch (error) { console.error('[WatchRoom] Failed to rejoin room:', error); clearStoredRoomInfo(); + } finally { + rejoinInFlightRef.current = false; } }, []); + const scheduleRejoin = useCallback((info: StoredRoomInfo, delay = 300) => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + } + + reconnectTimeoutRef.current = setTimeout(() => { + rejoinRoom(info); + }, delay); + }, [rejoinRoom]); + // 连接到服务器 const connect = useCallback(async (config: WatchRoomConfig) => { try { @@ -78,15 +96,13 @@ export function useWatchRoom( const storedInfo = getStoredRoomInfo(); if (storedInfo) { console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId); - reconnectTimeoutRef.current = setTimeout(() => { - rejoinRoom(storedInfo); - }, 1000); + scheduleRejoin(storedInfo); } } catch (error) { console.error('[WatchRoom] Failed to connect:', error); setIsConnected(false); } - }, [rejoinRoom]); + }, [scheduleRejoin]); // 断开连接 const disconnect = useCallback(() => { @@ -99,6 +115,7 @@ export function useWatchRoom( setCurrentRoom(null); setMembers([]); setChatMessages([]); + setIsOwner(false); }, []); // 创建房间 @@ -142,7 +159,7 @@ export function useWatchRoom( // 加入房间 const joinRoom = useCallback( - async (data: { roomId: string; password?: string; userName: string }) => { + async (data: { roomId: string; password?: string; userName: string; ownerToken?: string }) => { const sock = watchRoomSocketManager.getSocket(); if (!sock || !watchRoomSocketManager.isConnected()) { throw new Error('Not connected'); @@ -162,7 +179,7 @@ export function useWatchRoom( isOwner: isRoomOwner, userName: data.userName, password: data.password, - ownerToken: isRoomOwner ? response.room.ownerToken : undefined, + ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined, timestamp: Date.now(), }); resolve({ room: response.room, members: response.members }); @@ -343,7 +360,11 @@ export function useWatchRoom( }); socket.on('room:member-joined', (member) => { - setMembers((prev) => [...prev, member]); + setMembers((prev) => { + const next = prev.filter((existing) => existing.id !== member.id); + next.push(member); + return next; + }); }); socket.on('room:member-left', (userId) => { @@ -415,6 +436,10 @@ export function useWatchRoom( // 连接状态 socket.on('connect', () => { setIsConnected(true); + const storedInfo = getStoredRoomInfo(); + if (storedInfo) { + scheduleRejoin(storedInfo); + } }); socket.on('disconnect', () => { @@ -436,7 +461,7 @@ export function useWatchRoom( socket.off('connect'); socket.off('disconnect'); }; - }, [socket, currentRoom, onRoomDeleted, onStateCleared]); + }, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]); // 清理 useEffect(() => { diff --git a/src/lib/watch-room-server.ts b/src/lib/watch-room-server.ts index deb1862..a4bcd30 100644 --- a/src/lib/watch-room-server.ts +++ b/src/lib/watch-room-server.ts @@ -92,15 +92,33 @@ export class WatchRoomServer { } const userId = socket.id; + let isOwner = false; + + if (data.ownerToken && data.ownerToken === room.ownerToken) { + isOwner = true; + room.ownerId = userId; + room.lastOwnerHeartbeat = Date.now(); + this.rooms.set(data.roomId, room); + console.log(`[WatchRoom] Owner ${data.userName} reconnected to room ${data.roomId}`); + } + const member: Member = { id: userId, name: data.userName, - isOwner: false, + isOwner, lastHeartbeat: Date.now(), }; const roomMembers = this.members.get(data.roomId); if (roomMembers) { + if (isOwner) { + Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => { + if (existingMember.isOwner && memberId !== userId) { + roomMembers.delete(memberId); + } + }); + } + roomMembers.set(userId, member); room.memberCount = roomMembers.size; this.rooms.set(data.roomId, room); @@ -110,7 +128,7 @@ export class WatchRoomServer { roomId: data.roomId, userId, userName: data.userName, - isOwner: false, + isOwner, }); socket.join(data.roomId); @@ -118,7 +136,7 @@ export class WatchRoomServer { // 通知房间内其他成员 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 }); diff --git a/src/lib/watch-room-socket.ts b/src/lib/watch-room-socket.ts index 1949d98..02e5ee8 100644 --- a/src/lib/watch-room-socket.ts +++ b/src/lib/watch-room-socket.ts @@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket class WatchRoomSocketManager { private socket: WatchRoomSocket | null = null; private config: WatchRoomConfig | null = null; + private connectionPromise: Promise | null = null; private heartbeatInterval: NodeJS.Timeout | null = null; private heartbeatTimeoutCheck: NodeJS.Timeout | null = null; private lastHeartbeatResponse: number = Date.now(); @@ -25,6 +26,37 @@ class WatchRoomSocketManager { return this.socket; } + if (this.connectionPromise) { + return this.connectionPromise; + } + + if (this.socket) { + this.connectionPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.connectionPromise = null; + reject(new Error('Socket connection timeout')); + }, 10000); + + this.socket!.once('connect', () => { + clearTimeout(timeout); + this.connectionPromise = null; + resolve(this.socket!); + }); + + this.socket!.once('connect_error', (error) => { + clearTimeout(timeout); + this.connectionPromise = null; + reject(error); + }); + + if (!this.socket!.connected) { + this.socket!.connect(); + } + }); + + return this.connectionPromise; + } + this.config = config; const socketOptions = { @@ -72,8 +104,9 @@ class WatchRoomSocketManager { // 设置浏览器可见性监听 this.setupVisibilityListener(); - return new Promise((resolve, reject) => { + this.connectionPromise = new Promise((resolve, reject) => { if (!this.socket) { + this.connectionPromise = null; reject(new Error('Socket not initialized')); return; } @@ -82,6 +115,7 @@ class WatchRoomSocketManager { this.socket.once('connect', () => { // eslint-disable-next-line no-console console.log('[WatchRoom] Connected to server'); + this.connectionPromise = null; if (this.socket) { resolve(this.socket); } @@ -90,9 +124,12 @@ class WatchRoomSocketManager { this.socket.once('connect_error', (error) => { // eslint-disable-next-line no-console console.error('[WatchRoom] Connection error:', error); + this.connectionPromise = null; reject(error); }); }); + + return this.connectionPromise; } disconnect() { @@ -122,6 +159,8 @@ class WatchRoomSocketManager { this.socket.disconnect(); this.socket = null; } + + this.connectionPromise = null; } getSocket(): WatchRoomSocket | null {