观影室增加屏幕共享

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