屏幕共享优化
This commit is contained in:
@@ -134,6 +134,14 @@ class WatchRoomServer {
|
|||||||
|
|
||||||
const roomMembers = this.members.get(data.roomId);
|
const roomMembers = this.members.get(data.roomId);
|
||||||
if (roomMembers) {
|
if (roomMembers) {
|
||||||
|
if (isOwner) {
|
||||||
|
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||||
|
if (existingMember.isOwner && memberId !== userId) {
|
||||||
|
roomMembers.delete(memberId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
roomMembers.set(userId, member);
|
roomMembers.set(userId, member);
|
||||||
room.memberCount = roomMembers.size;
|
room.memberCount = roomMembers.size;
|
||||||
this.rooms.set(data.roomId, room);
|
this.rooms.set(data.roomId, room);
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ export default function WatchRoomPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="font-medium text-gray-900 dark:text-gray-100">进度同步</div>
|
<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>
|
||||||
<button
|
<button
|
||||||
type="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="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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
import { Monitor, MonitorPlay, Users } from 'lucide-react';
|
import { Monitor, MonitorPlay, Users } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import Toast, { ToastProps } from '@/components/Toast';
|
import Toast, { ToastProps } from '@/components/Toast';
|
||||||
import { useWatchRoomContext } from '@/components/WatchRoomProvider';
|
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 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() {
|
function getScreenShareHostSupportError() {
|
||||||
if (typeof window === 'undefined') return null;
|
if (typeof window === 'undefined') return null;
|
||||||
@@ -44,17 +46,19 @@ export default function WatchRoomScreenPage() {
|
|||||||
const watchRoom = useWatchRoomContext();
|
const watchRoom = useWatchRoomContext();
|
||||||
const { currentRoom, members, leaveRoom } = watchRoom;
|
const { currentRoom, members, leaveRoom } = watchRoom;
|
||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
|
const [qualityPreset, setQualityPreset] = useState<ScreenShareQualityPreset>('smooth');
|
||||||
const {
|
const {
|
||||||
currentRoom: screenRoom,
|
currentRoom: screenRoom,
|
||||||
isOwner,
|
isOwner,
|
||||||
isSharing,
|
isSharing,
|
||||||
isStarting,
|
isStarting,
|
||||||
error,
|
error,
|
||||||
|
captureSettings,
|
||||||
localVideoRef,
|
localVideoRef,
|
||||||
remoteVideoRef,
|
remoteVideoRef,
|
||||||
startSharing,
|
startSharing,
|
||||||
stopSharing,
|
stopSharing,
|
||||||
} = useScreenShare();
|
} = useScreenShare(qualityPreset);
|
||||||
|
|
||||||
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
|
const showToast = (message: string, type: ToastProps['type'] = 'info') => {
|
||||||
setToast({
|
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(() => {
|
useEffect(() => {
|
||||||
if (!currentRoom) {
|
if (!currentRoom) {
|
||||||
router.replace('/watch-room');
|
router.replace('/watch-room');
|
||||||
@@ -92,12 +114,17 @@ export default function WatchRoomScreenPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!screenRoom || !isOwner) return;
|
if (!screenRoom || !isOwner) return;
|
||||||
|
|
||||||
|
localStorage.setItem(WATCH_ROOM_NO_CONNECT_KEY, '1');
|
||||||
const key = `${NEW_TAB_KEY_PREFIX}${screenRoom.id}`;
|
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');
|
return () => {
|
||||||
window.open('/?watchRoomNoConnect=1', '_blank', 'noopener,noreferrer');
|
localStorage.removeItem(WATCH_ROOM_NO_CONNECT_KEY);
|
||||||
}, [isOwner, screenRoom?.id]);
|
};
|
||||||
|
}, [isOwner, openDetachedPage, screenRoom?.id]);
|
||||||
|
|
||||||
if (!screenRoom || screenRoom.roomType !== 'screen') {
|
if (!screenRoom || screenRoom.roomType !== 'screen') {
|
||||||
return null;
|
return null;
|
||||||
@@ -111,6 +138,15 @@ export default function WatchRoomScreenPage() {
|
|||||||
router.push('/watch-room');
|
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 (
|
return (
|
||||||
<div className='min-h-screen bg-white text-gray-900 dark:bg-black dark:text-gray-200'>
|
<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='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'>
|
<div className='flex items-center gap-2'>
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<Link
|
<Link
|
||||||
href='/?watchRoomNoConnect=1'
|
href='/'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noreferrer'
|
rel='noreferrer'
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openDetachedPage();
|
||||||
|
}}
|
||||||
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
|
className='rounded-lg bg-blue-500 px-4 py-2 text-white'
|
||||||
>
|
>
|
||||||
新开主页
|
新开主页
|
||||||
@@ -188,12 +228,41 @@ export default function WatchRoomScreenPage() {
|
|||||||
<p>成员:{members.length} 人</p>
|
<p>成员:{members.length} 人</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<div className='mt-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
实际采集:{captureSettingsText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{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'>
|
<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}
|
{error}
|
||||||
</div>
|
</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'>
|
<div className='mt-4 flex gap-3'>
|
||||||
{isOwner ? (
|
{isOwner ? (
|
||||||
<>
|
<>
|
||||||
@@ -243,7 +312,7 @@ export default function WatchRoomScreenPage() {
|
|||||||
</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'>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
|
import React, { createContext, useCallback,useContext, useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
|
||||||
|
|
||||||
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
import { useWatchRoom } from '@/hooks/useWatchRoom';
|
||||||
|
|
||||||
@@ -14,6 +13,8 @@ import type { ChatMessage, Member, Room, RoomType, ScreenState, WatchRoomConfig
|
|||||||
|
|
||||||
// Import type from watch-room-socket
|
// Import type from watch-room-socket
|
||||||
type WatchRoomSocket = import('@/lib/watch-room-socket').WatchRoomSocket;
|
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 {
|
interface WatchRoomContextType {
|
||||||
socket: WatchRoomSocket | null;
|
socket: WatchRoomSocket | null;
|
||||||
@@ -39,6 +40,7 @@ interface WatchRoomContextType {
|
|||||||
roomId: string;
|
roomId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
ownerToken?: string;
|
||||||
}) => Promise<{ room: Room; members: Member[] }>;
|
}) => Promise<{ room: Room; members: Member[] }>;
|
||||||
leaveRoom: () => void;
|
leaveRoom: () => void;
|
||||||
getRoomList: () => Promise<Room[]>;
|
getRoomList: () => Promise<Room[]>;
|
||||||
@@ -81,12 +83,12 @@ interface WatchRoomProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
|
const [config, setConfig] = useState<WatchRoomConfig | null>(null);
|
||||||
const [isEnabled, setIsEnabled] = useState(false);
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
const [toast, setToast] = useState<ToastProps | null>(null);
|
const [toast, setToast] = useState<ToastProps | null>(null);
|
||||||
const [reconnectFailed, setReconnectFailed] = useState(false);
|
const [reconnectFailed, setReconnectFailed] = useState(false);
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||||
|
const [shouldDisableWatchRoomConnection, setShouldDisableWatchRoomConnection] = useState<boolean | null>(null);
|
||||||
|
|
||||||
// 处理房间删除的回调
|
// 处理房间删除的回调
|
||||||
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
const handleRoomDeleted = useCallback((data?: { reason?: string }) => {
|
||||||
@@ -123,7 +125,15 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const watchRoom = useWatchRoom(handleRoomDeleted, handleStateCleared);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -162,6 +172,7 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
roomId: info.roomId,
|
roomId: info.roomId,
|
||||||
password: info.password,
|
password: info.password,
|
||||||
userName: info.userName,
|
userName: info.userName,
|
||||||
|
ownerToken: info.ownerToken,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
|
console.error('[WatchRoomProvider] Failed to rejoin room after reconnect:', error);
|
||||||
@@ -175,6 +186,10 @@ export function WatchRoomProvider({ children }: WatchRoomProviderProps) {
|
|||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (shouldDisableWatchRoomConnection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldDisableWatchRoomConnection) {
|
if (shouldDisableWatchRoomConnection) {
|
||||||
setConfig({
|
setConfig({
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -12,7 +12,51 @@ const iceServers = [
|
|||||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
{ 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 watchRoom = useWatchRoomContextSafe();
|
||||||
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
const remoteVideoRef = 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 [error, setError] = useState<string | null>(null);
|
||||||
const [isStarting, setIsStarting] = useState(false);
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
const [captureSettings, setCaptureSettings] = useState<ScreenShareCaptureSettings | null>(null);
|
||||||
|
|
||||||
const currentRoom = watchRoom?.currentRoom || null;
|
const currentRoom = watchRoom?.currentRoom || null;
|
||||||
const socket = watchRoom?.socket || null;
|
const socket = watchRoom?.socket || null;
|
||||||
|
const isConnected = watchRoom?.isConnected || false;
|
||||||
const isOwner = watchRoom?.isOwner || false;
|
const isOwner = watchRoom?.isOwner || false;
|
||||||
const members = watchRoom?.members || [];
|
const members = watchRoom?.members || [];
|
||||||
const currentState = currentRoom?.currentState;
|
const currentState = currentRoom?.currentState;
|
||||||
@@ -67,6 +113,7 @@ export function useScreenShare() {
|
|||||||
localVideoRef.current.srcObject = null;
|
localVideoRef.current.srcObject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCaptureSettings(null);
|
||||||
clearRemoteVideo();
|
clearRemoteVideo();
|
||||||
stoppingRef.current = false;
|
stoppingRef.current = false;
|
||||||
}, [clearRemoteVideo, closePeerConnection]);
|
}, [clearRemoteVideo, closePeerConnection]);
|
||||||
@@ -136,11 +183,12 @@ export function useScreenShare() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const constraints = SCREEN_SHARE_CONSTRAINTS[qualityPreset];
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||||
video: {
|
video: {
|
||||||
frameRate: 15,
|
frameRate: constraints.frameRate,
|
||||||
width: { ideal: 1280 },
|
width: { ideal: constraints.width },
|
||||||
height: { ideal: 720 },
|
height: { ideal: constraints.height },
|
||||||
},
|
},
|
||||||
audio: true,
|
audio: true,
|
||||||
});
|
});
|
||||||
@@ -152,6 +200,12 @@ export function useScreenShare() {
|
|||||||
|
|
||||||
const videoTrack = stream.getVideoTracks()[0];
|
const videoTrack = stream.getVideoTracks()[0];
|
||||||
if (videoTrack) {
|
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 = () => {
|
videoTrack.onended = () => {
|
||||||
stopSharing(true);
|
stopSharing(true);
|
||||||
};
|
};
|
||||||
@@ -176,7 +230,7 @@ export function useScreenShare() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsStarting(false);
|
setIsStarting(false);
|
||||||
}
|
}
|
||||||
}, [currentRoom, isOwner, members, sendOfferToMember, stopSharing, watchRoom]);
|
}, [currentRoom, isOwner, members, qualityPreset, sendOfferToMember, stopSharing, watchRoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !currentRoom) return;
|
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 }) => {
|
const handleViewerReady = (data: { userId: string }) => {
|
||||||
if (!isOwner || !displayStreamRef.current) return;
|
if (!isOwner || !displayStreamRef.current) return;
|
||||||
sendOfferToMember(data.userId);
|
sendOfferToMember(data.userId);
|
||||||
@@ -241,6 +303,7 @@ export function useScreenShare() {
|
|||||||
socket.on('screen:ice', handleIce);
|
socket.on('screen:ice', handleIce);
|
||||||
socket.on('screen:stop', handleScreenStop);
|
socket.on('screen:stop', handleScreenStop);
|
||||||
socket.on('screen:viewer-ready', handleViewerReady);
|
socket.on('screen:viewer-ready', handleViewerReady);
|
||||||
|
socket.on('disconnect', handleSocketDisconnect);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('screen:offer', handleOffer);
|
socket.off('screen:offer', handleOffer);
|
||||||
@@ -248,6 +311,7 @@ export function useScreenShare() {
|
|||||||
socket.off('screen:ice', handleIce);
|
socket.off('screen:ice', handleIce);
|
||||||
socket.off('screen:stop', handleScreenStop);
|
socket.off('screen:stop', handleScreenStop);
|
||||||
socket.off('screen:viewer-ready', handleViewerReady);
|
socket.off('screen:viewer-ready', handleViewerReady);
|
||||||
|
socket.off('disconnect', handleSocketDisconnect);
|
||||||
};
|
};
|
||||||
}, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]);
|
}, [clearRemoteVideo, closePeerConnection, createPeerConnection, currentRoom, isOwner, sendOfferToMember, socket]);
|
||||||
|
|
||||||
@@ -277,11 +341,11 @@ export function useScreenShare() {
|
|||||||
}, [cleanupSharingResources]);
|
}, [cleanupSharingResources]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket || !currentRoom || isOwner) return;
|
if (!socket || !currentRoom || isOwner || !isConnected) return;
|
||||||
if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return;
|
if (currentState?.type !== 'screen' || currentState.status !== 'sharing') return;
|
||||||
|
|
||||||
socket.emit('screen:viewer-ready');
|
socket.emit('screen:viewer-ready');
|
||||||
}, [currentRoom, currentState, isOwner, socket]);
|
}, [currentRoom, currentState, isConnected, isOwner, socket]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentRoom,
|
currentRoom,
|
||||||
@@ -289,6 +353,7 @@ export function useScreenShare() {
|
|||||||
isSharing,
|
isSharing,
|
||||||
isStarting,
|
isStarting,
|
||||||
error,
|
error,
|
||||||
|
captureSettings,
|
||||||
localVideoRef,
|
localVideoRef,
|
||||||
remoteVideoRef,
|
remoteVideoRef,
|
||||||
startSharing,
|
startSharing,
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ export function useWatchRoom(
|
|||||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||||
const [isOwner, setIsOwner] = useState(false);
|
const [isOwner, setIsOwner] = useState(false);
|
||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const rejoinInFlightRef = useRef(false);
|
||||||
|
|
||||||
// 重新加入房间(自动重连)
|
// 重新加入房间(自动重连)
|
||||||
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
const rejoinRoom = useCallback(async (info: StoredRoomInfo) => {
|
||||||
|
if (rejoinInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rejoinInFlightRef.current = true;
|
||||||
console.log('[WatchRoom] Auto-rejoining room:', info);
|
console.log('[WatchRoom] Auto-rejoining room:', info);
|
||||||
try {
|
try {
|
||||||
const sock = watchRoomSocketManager.getSocket();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
@@ -64,9 +70,21 @@ export function useWatchRoom(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoom] Failed to rejoin room:', error);
|
console.error('[WatchRoom] Failed to rejoin room:', error);
|
||||||
clearStoredRoomInfo();
|
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) => {
|
const connect = useCallback(async (config: WatchRoomConfig) => {
|
||||||
try {
|
try {
|
||||||
@@ -78,15 +96,13 @@ export function useWatchRoom(
|
|||||||
const storedInfo = getStoredRoomInfo();
|
const storedInfo = getStoredRoomInfo();
|
||||||
if (storedInfo) {
|
if (storedInfo) {
|
||||||
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
console.log('[WatchRoom] Attempting to reconnect to room:', storedInfo.roomId);
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
scheduleRejoin(storedInfo);
|
||||||
rejoinRoom(storedInfo);
|
|
||||||
}, 1000);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[WatchRoom] Failed to connect:', error);
|
console.error('[WatchRoom] Failed to connect:', error);
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
}
|
}
|
||||||
}, [rejoinRoom]);
|
}, [scheduleRejoin]);
|
||||||
|
|
||||||
// 断开连接
|
// 断开连接
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
@@ -99,6 +115,7 @@ export function useWatchRoom(
|
|||||||
setCurrentRoom(null);
|
setCurrentRoom(null);
|
||||||
setMembers([]);
|
setMembers([]);
|
||||||
setChatMessages([]);
|
setChatMessages([]);
|
||||||
|
setIsOwner(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 创建房间
|
// 创建房间
|
||||||
@@ -142,7 +159,7 @@ export function useWatchRoom(
|
|||||||
|
|
||||||
// 加入房间
|
// 加入房间
|
||||||
const joinRoom = useCallback(
|
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();
|
const sock = watchRoomSocketManager.getSocket();
|
||||||
if (!sock || !watchRoomSocketManager.isConnected()) {
|
if (!sock || !watchRoomSocketManager.isConnected()) {
|
||||||
throw new Error('Not connected');
|
throw new Error('Not connected');
|
||||||
@@ -162,7 +179,7 @@ export function useWatchRoom(
|
|||||||
isOwner: isRoomOwner,
|
isOwner: isRoomOwner,
|
||||||
userName: data.userName,
|
userName: data.userName,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
ownerToken: isRoomOwner ? response.room.ownerToken : undefined,
|
ownerToken: isRoomOwner ? (response.room.ownerToken || data.ownerToken) : undefined,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
resolve({ room: response.room, members: response.members });
|
resolve({ room: response.room, members: response.members });
|
||||||
@@ -343,7 +360,11 @@ export function useWatchRoom(
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('room:member-joined', (member) => {
|
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) => {
|
socket.on('room:member-left', (userId) => {
|
||||||
@@ -415,6 +436,10 @@ export function useWatchRoom(
|
|||||||
// 连接状态
|
// 连接状态
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
setIsConnected(true);
|
setIsConnected(true);
|
||||||
|
const storedInfo = getStoredRoomInfo();
|
||||||
|
if (storedInfo) {
|
||||||
|
scheduleRejoin(storedInfo);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
@@ -436,7 +461,7 @@ export function useWatchRoom(
|
|||||||
socket.off('connect');
|
socket.off('connect');
|
||||||
socket.off('disconnect');
|
socket.off('disconnect');
|
||||||
};
|
};
|
||||||
}, [socket, currentRoom, onRoomDeleted, onStateCleared]);
|
}, [socket, currentRoom, onRoomDeleted, onStateCleared, scheduleRejoin]);
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -92,15 +92,33 @@ export class WatchRoomServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userId = socket.id;
|
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 = {
|
const member: Member = {
|
||||||
id: userId,
|
id: userId,
|
||||||
name: data.userName,
|
name: data.userName,
|
||||||
isOwner: false,
|
isOwner,
|
||||||
lastHeartbeat: Date.now(),
|
lastHeartbeat: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const roomMembers = this.members.get(data.roomId);
|
const roomMembers = this.members.get(data.roomId);
|
||||||
if (roomMembers) {
|
if (roomMembers) {
|
||||||
|
if (isOwner) {
|
||||||
|
Array.from(roomMembers.entries()).forEach(([memberId, existingMember]) => {
|
||||||
|
if (existingMember.isOwner && memberId !== userId) {
|
||||||
|
roomMembers.delete(memberId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
roomMembers.set(userId, member);
|
roomMembers.set(userId, member);
|
||||||
room.memberCount = roomMembers.size;
|
room.memberCount = roomMembers.size;
|
||||||
this.rooms.set(data.roomId, room);
|
this.rooms.set(data.roomId, room);
|
||||||
@@ -110,7 +128,7 @@ export class WatchRoomServer {
|
|||||||
roomId: data.roomId,
|
roomId: data.roomId,
|
||||||
userId,
|
userId,
|
||||||
userName: data.userName,
|
userName: data.userName,
|
||||||
isOwner: false,
|
isOwner,
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.join(data.roomId);
|
socket.join(data.roomId);
|
||||||
@@ -118,7 +136,7 @@ export class WatchRoomServer {
|
|||||||
// 通知房间内其他成员
|
// 通知房间内其他成员
|
||||||
socket.to(data.roomId).emit('room:member-joined', member);
|
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() || []);
|
const members = Array.from(roomMembers?.values() || []);
|
||||||
callback({ success: true, room, members });
|
callback({ success: true, room, members });
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type WatchRoomSocket = Socket<ServerToClientEvents, ClientToServerEvents>
|
|||||||
class WatchRoomSocketManager {
|
class WatchRoomSocketManager {
|
||||||
private socket: WatchRoomSocket | null = null;
|
private socket: WatchRoomSocket | null = null;
|
||||||
private config: WatchRoomConfig | null = null;
|
private config: WatchRoomConfig | null = null;
|
||||||
|
private connectionPromise: Promise<WatchRoomSocket> | null = null;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||||
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
|
private heartbeatTimeoutCheck: NodeJS.Timeout | null = null;
|
||||||
private lastHeartbeatResponse: number = Date.now();
|
private lastHeartbeatResponse: number = Date.now();
|
||||||
@@ -25,6 +26,37 @@ class WatchRoomSocketManager {
|
|||||||
return this.socket;
|
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;
|
this.config = config;
|
||||||
|
|
||||||
const socketOptions = {
|
const socketOptions = {
|
||||||
@@ -72,8 +104,9 @@ class WatchRoomSocketManager {
|
|||||||
// 设置浏览器可见性监听
|
// 设置浏览器可见性监听
|
||||||
this.setupVisibilityListener();
|
this.setupVisibilityListener();
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
this.connectionPromise = new Promise((resolve, reject) => {
|
||||||
if (!this.socket) {
|
if (!this.socket) {
|
||||||
|
this.connectionPromise = null;
|
||||||
reject(new Error('Socket not initialized'));
|
reject(new Error('Socket not initialized'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -82,6 +115,7 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.once('connect', () => {
|
this.socket.once('connect', () => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[WatchRoom] Connected to server');
|
console.log('[WatchRoom] Connected to server');
|
||||||
|
this.connectionPromise = null;
|
||||||
if (this.socket) {
|
if (this.socket) {
|
||||||
resolve(this.socket);
|
resolve(this.socket);
|
||||||
}
|
}
|
||||||
@@ -90,9 +124,12 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.once('connect_error', (error) => {
|
this.socket.once('connect_error', (error) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('[WatchRoom] Connection error:', error);
|
console.error('[WatchRoom] Connection error:', error);
|
||||||
|
this.connectionPromise = null;
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return this.connectionPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {
|
disconnect() {
|
||||||
@@ -122,6 +159,8 @@ class WatchRoomSocketManager {
|
|||||||
this.socket.disconnect();
|
this.socket.disconnect();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.connectionPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSocket(): WatchRoomSocket | null {
|
getSocket(): WatchRoomSocket | null {
|
||||||
|
|||||||
Reference in New Issue
Block a user