import { useState, useEffect, useRef, useCallback } from 'react'; import { API } from '../api/player'; const TARGET_FRAME_RATE = 60; const MAX_CAPTURE_WIDTH = 1920; const MAX_CAPTURE_HEIGHT = 1080; const RECORDER_TIMESLICE_MS = 16; const MIN_VIDEO_BIT_RATE = 2_500_000; const MAX_VIDEO_BIT_RATE = 8_000_000; const VIDEO_BITS_PER_PIXEL = 0.065; const SOCKET_BUFFER_TARGET_MS = 75; const MIN_SOCKET_BUFFER_BYTES = 32 * 1024; const MIN_RESUME_BUFFER_BYTES = 8 * 1024; interface UseScreenShareResult { isScreenSharing: boolean; isScreenSharingSupported: boolean; toggleScreenShare: () => Promise; stopScreenShare: () => void; } function getBestSupportedMimeType(hasAudio: boolean): string | undefined { // VP8 is substantially cheaper to encode than VP9 on most systems. Prefer it // for real-time capture, then use hardware-friendly H.264 where available. const mimeTypes = hasAudio ? [ 'video/webm;codecs=vp8,opus', 'video/mp4;codecs=avc1.42E01E,mp4a.40.2', 'video/webm;codecs=vp9,opus', 'video/webm', 'video/mp4', ] : [ 'video/webm;codecs=vp8', 'video/mp4;codecs=avc1.42E01E', 'video/webm;codecs=vp9', 'video/webm', 'video/mp4', ]; // Find the first supported mimetype for (const type of mimeTypes) { if (MediaRecorder.isTypeSupported(type)) { console.log(`Using mime type: ${type}`); return type; } } console.warn('No preferred mime types supported by this browser'); return undefined; } function getTargetVideoBitRate(track: MediaStreamTrack): number { const settings = track.getSettings(); const width = settings.width ?? MAX_CAPTURE_WIDTH; const height = settings.height ?? MAX_CAPTURE_HEIGHT; const frameRate = Math.min(settings.frameRate ?? TARGET_FRAME_RATE, TARGET_FRAME_RATE); const target = width * height * frameRate * VIDEO_BITS_PER_PIXEL; return Math.round(Math.min(MAX_VIDEO_BIT_RATE, Math.max(MIN_VIDEO_BIT_RATE, target))); } function getSocketBufferLimits(recorder: MediaRecorder) { const bytesPerSecond = (recorder.videoBitsPerSecond + recorder.audioBitsPerSecond) / 8; const maximum = Math.max( MIN_SOCKET_BUFFER_BYTES, Math.ceil(bytesPerSecond * SOCKET_BUFFER_TARGET_MS / 1000), ); return { maximum, resumeAt: Math.max(MIN_RESUME_BUFFER_BYTES, Math.floor(maximum / 4)), }; } export const useScreenShare = (): UseScreenShareResult => { const [isScreenSharing, setIsScreenSharing] = useState(false); const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false); const screenShareSocketRef = useRef(null); const mediaRecorderRef = useRef(null); const mediaStreamRef = useRef(null); const backpressureTimerRef = useRef(null); const isStartingRef = useRef(false); const sessionGenerationRef = useRef(0); // Check if screen sharing is supported useEffect(() => { setIsScreenSharingSupported( typeof navigator !== 'undefined' && navigator.mediaDevices !== undefined && typeof navigator.mediaDevices.getDisplayMedia === 'function' ); }, []); const stopScreenShare = useCallback(() => { sessionGenerationRef.current += 1; isStartingRef.current = false; if (backpressureTimerRef.current !== null) { window.clearInterval(backpressureTimerRef.current); backpressureTimerRef.current = null; } const recorder = mediaRecorderRef.current; mediaRecorderRef.current = null; if (recorder) { recorder.ondataavailable = null; recorder.onerror = null; if (recorder.state !== 'inactive') { recorder.stop(); } } const mediaStream = mediaStreamRef.current; mediaStreamRef.current = null; mediaStream?.getTracks().forEach((track) => { track.onended = null; track.stop(); }); const socket = screenShareSocketRef.current; screenShareSocketRef.current = null; if (socket) { socket.onopen = null; socket.onclose = null; socket.onerror = null; if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) { socket.close(); } } setIsScreenSharing(false); }, []); const startScreenShare = useCallback(async () => { if (isStartingRef.current || screenShareSocketRef.current) { return; } isStartingRef.current = true; const sessionGeneration = ++sessionGenerationRef.current; try { const mediaStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, width: { max: MAX_CAPTURE_WIDTH }, height: { max: MAX_CAPTURE_HEIGHT }, }, audio: true, }); // getDisplayMedia cannot be programmatically dismissed. If the user // stopped or restarted while its picker was open, discard this result. if (sessionGenerationRef.current !== sessionGeneration) { mediaStream.getTracks().forEach((track) => track.stop()); return; } mediaStreamRef.current = mediaStream; const videoTrack = mediaStream.getVideoTracks()[0]; if (!videoTrack) { throw new Error('The selected source did not provide a video track'); } // Favor smooth motion over static-text quality when the browser needs to // trade capture resolution for frame rate. videoTrack.contentHint = 'motion'; mediaStream.getAudioTracks().forEach((track) => { track.contentHint = 'music'; }); const hasAudio = mediaStream.getAudioTracks().length > 0; const preferredMimeType = getBestSupportedMimeType(hasAudio); const recorderOptions: MediaRecorderOptions = { videoBitsPerSecond: getTargetVideoBitRate(videoTrack), }; if (preferredMimeType) { recorderOptions.mimeType = preferredMimeType; } if (hasAudio) { recorderOptions.audioBitsPerSecond = 128_000; } const mediaRecorder = new MediaRecorder(mediaStream, recorderOptions); mediaRecorderRef.current = mediaRecorder; const mimeType = mediaRecorder.mimeType || preferredMimeType || 'application/octet-stream'; const socketBufferLimits = getSocketBufferLimits(mediaRecorder); console.log( `Screen sharing at up to ${TARGET_FRAME_RATE} fps, ${Math.round(mediaRecorder.videoBitsPerSecond / 1000)} kbps, using ${mimeType}`, ); // Connect to WebSocket const socket = API.startScreenShare(mimeType); screenShareSocketRef.current = socket; isStartingRef.current = false; const resumeRecorderWhenDrained = () => { if (screenShareSocketRef.current !== socket || mediaRecorderRef.current !== mediaRecorder) { if (backpressureTimerRef.current !== null) { window.clearInterval(backpressureTimerRef.current); backpressureTimerRef.current = null; } return; } if (socket.bufferedAmount <= socketBufferLimits.resumeAt && mediaRecorder.state === 'paused') { mediaRecorder.resume(); if (backpressureTimerRef.current !== null) { window.clearInterval(backpressureTimerRef.current); backpressureTimerRef.current = null; } } }; const pauseRecorderForBackpressure = () => { if (mediaRecorder.state === 'recording') { mediaRecorder.pause(); } if (backpressureTimerRef.current === null) { backpressureTimerRef.current = window.setInterval( resumeRecorderWhenDrained, RECORDER_TIMESLICE_MS, ); } }; // Set up WebSocket event handlers socket.onopen = () => { if (screenShareSocketRef.current !== socket) { socket.close(); return; } console.log('Screen sharing WebSocket connected'); setIsScreenSharing(true); mediaRecorder.start(RECORDER_TIMESLICE_MS); }; socket.onclose = () => { console.log('Screen sharing WebSocket closed'); if (screenShareSocketRef.current === socket) { stopScreenShare(); } }; socket.onerror = (error) => { console.error('Screen sharing WebSocket error:', error); if (screenShareSocketRef.current === socket) { stopScreenShare(); } }; mediaRecorder.onerror = (error) => { console.error('Screen sharing recorder error:', error); if (mediaRecorderRef.current === mediaRecorder) { stopScreenShare(); } }; // Send data over WebSocket when available mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0 && screenShareSocketRef.current === socket && socket.readyState === WebSocket.OPEN) { socket.send(event.data); // Browser WebSockets otherwise queue without a limit, turning a brief // network slowdown into permanently stale playback. Pausing capture // bounds that queue and resumes at the live edge once it drains. if (socket.bufferedAmount >= socketBufferLimits.maximum) { pauseRecorderForBackpressure(); } } }; // Handle stream ending (user clicks "Stop sharing") videoTrack.onended = stopScreenShare; } catch (error) { if (sessionGenerationRef.current === sessionGeneration) { console.error('Error starting screen share:', error); stopScreenShare(); } } }, [stopScreenShare]); useEffect(() => { return () => stopScreenShare(); }, [stopScreenShare]); const toggleScreenShare = useCallback(async () => { if (screenShareSocketRef.current || isStartingRef.current) { stopScreenShare(); } else { await startScreenShare(); } }, [startScreenShare, stopScreenShare]); return { isScreenSharing, isScreenSharingSupported, toggleScreenShare, stopScreenShare }; };