屏幕共享优化

This commit is contained in:
mtvpls
2026-04-06 01:30:24 +08:00
parent 14d4c483f3
commit 23b8115f36
8 changed files with 272 additions and 33 deletions
+2 -2
View File
@@ -583,7 +583,7 @@ export default function WatchRoomPage() {
}`}
>
<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>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
<button
type="button"
@@ -595,7 +595,7 @@ export default function WatchRoomPage() {
}`}
>
<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>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400"></div>
</button>
</div>
</div>
+78 -9
View File
@@ -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<ToastProps | null>(null);
const [qualityPreset, setQualityPreset] = useState<ScreenShareQualityPreset>('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 (
<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'>
@@ -127,9 +163,13 @@ export default function WatchRoomScreenPage() {
<div className='flex items-center gap-2'>
{isOwner && (
<Link
href='/?watchRoomNoConnect=1'
href='/'
target='_blank'
rel='noreferrer'
onClick={(event) => {
event.preventDefault();
openDetachedPage();
}}
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
>
@@ -188,12 +228,41 @@ export default function WatchRoomScreenPage() {
<p>{members.length} </p>
</div>
{isOwner && (
<div className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
{captureSettingsText}
</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>
)}
{isOwner && (
<div className='mt-4'>
<label className='mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300'>
</label>
<select
value={qualityPreset}
onChange={(event) => setQualityPreset(event.target.value as ScreenShareQualityPreset)}
disabled={isStarting || isSharing}
className='w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 disabled:cursor-not-allowed disabled:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:disabled:bg-gray-900'
>
{screenShareQualityOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<p className='mt-2 text-xs text-gray-500 dark:text-gray-400'>
</p>
</div>
)}
<div className='mt-4 flex gap-3'>
{isOwner ? (
<>
@@ -243,7 +312,7 @@ export default function WatchRoomScreenPage() {
</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
使 Chrome / Edge
</div>
</div>
</div>
+18 -3
View File
@@ -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<Room[]>;
@@ -81,12 +83,12 @@ 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);
const [reconnectFailed, setReconnectFailed] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState<boolean | null>(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,
+72 -7
View File
@@ -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<HTMLVideoElement | null>(null);
const remoteVideoRef = useRef<HTMLVideoElement | null>(null);
@@ -23,9 +67,11 @@ export function useScreenShare() {
const [error, setError] = useState<string | null>(null);
const [isStarting, setIsStarting] = useState(false);
const [captureSettings, setCaptureSettings] = useState<ScreenShareCaptureSettings | null>(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,
+33 -8
View File
@@ -30,9 +30,15 @@ export function useWatchRoom(
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isOwner, setIsOwner] = useState(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(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(() => {
+21 -3
View File
@@ -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 });
+40 -1
View File
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>
class WatchRoomSocketManager {
private socket: WatchRoomSocket | null = null;
private config: WatchRoomConfig | null = null;
private connectionPromise: Promise<WatchRoomSocket> | 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 {