观影室增加屏幕共享
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user