From 24500380a964ecfa44a32a96002c51913149542f Mon Sep 17 00:00:00 2001 From: Babaev Makhmoud Date: Tue, 23 Sep 2025 00:50:05 +0300 Subject: [PATCH] Handle autoplay denials for remote call participants --- .../call/components/CallParticipantsGrid.tsx | 203 ++++++++++++++++-- frontend/src/index.css | 31 +++ 2 files changed, 211 insertions(+), 23 deletions(-) diff --git a/frontend/src/features/call/components/CallParticipantsGrid.tsx b/frontend/src/features/call/components/CallParticipantsGrid.tsx index a964c77..fdfd1c2 100644 --- a/frontend/src/features/call/components/CallParticipantsGrid.tsx +++ b/frontend/src/features/call/components/CallParticipantsGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { RefObject } from 'react'; import type { RemoteParticipant } from '../hooks/useCallPeers'; @@ -18,6 +18,118 @@ export const CallParticipantsGrid = ({ containerRef, isFullscreen, }: CallParticipantsGridProps) => { + const blockedElementsRef = useRef(new Set()); + const [autoplayBlocked, setAutoplayBlocked] = useState(false); + + const isAutoplayDeniedError = (error: unknown): error is DOMException => + error instanceof DOMException && error.name === 'NotAllowedError'; + + const updateAutoplayBlockedState = useCallback(() => { + setAutoplayBlocked(blockedElementsRef.current.size > 0); + }, []); + + const attemptToPlay = useCallback( + (element: HTMLVideoElement) => { + let playPromise: Promise | undefined; + + try { + playPromise = element.play(); + } catch (error) { + if (isAutoplayDeniedError(error)) { + blockedElementsRef.current.add(element); + updateAutoplayBlockedState(); + return; + } + + blockedElementsRef.current.delete(element); + updateAutoplayBlockedState(); + return; + } + + if (!playPromise) { + blockedElementsRef.current.delete(element); + updateAutoplayBlockedState(); + return; + } + + playPromise + .then(() => { + blockedElementsRef.current.delete(element); + updateAutoplayBlockedState(); + }) + .catch((error) => { + if (isAutoplayDeniedError(error)) { + blockedElementsRef.current.add(element); + updateAutoplayBlockedState(); + return; + } + + blockedElementsRef.current.delete(element); + updateAutoplayBlockedState(); + }); + }, + [updateAutoplayBlockedState], + ); + + const retryBlockedElements = useCallback(() => { + const blockedElements = Array.from(blockedElementsRef.current); + + blockedElements.forEach((element) => { + if (!element.isConnected) { + blockedElementsRef.current.delete(element); + return; + } + + attemptToPlay(element); + }); + + updateAutoplayBlockedState(); + }, [attemptToPlay, updateAutoplayBlockedState]); + + useEffect(() => { + if (!autoplayBlocked) { + return; + } + + if (typeof document === 'undefined') { + return; + } + + const target = containerRef.current ?? document; + + const handleInteraction = () => { + retryBlockedElements(); + }; + + target.addEventListener('click', handleInteraction); + target.addEventListener('touchend', handleInteraction); + + retryBlockedElements(); + + return () => { + target.removeEventListener('click', handleInteraction); + target.removeEventListener('touchend', handleInteraction); + }; + }, [autoplayBlocked, containerRef, retryBlockedElements]); + + const clearBlockedElementById = useCallback( + (participantId: string) => { + let removed = false; + + blockedElementsRef.current.forEach((element) => { + if (element.dataset?.autoplayParticipantId === participantId) { + blockedElementsRef.current.delete(element); + removed = true; + } + }); + + if (removed) { + updateAutoplayBlockedState(); + } + }, + [updateAutoplayBlockedState], + ); + const attachStreamToElement = useCallback( (element: HTMLVideoElement | null, stream: MediaStream | null | undefined) => { if (!element) { @@ -30,18 +142,45 @@ export const CallParticipantsGrid = ({ if (stream && currentStream !== stream) { element.srcObject = stream; streamAttached = true; + } else if (!stream) { + blockedElementsRef.current.delete(element); + updateAutoplayBlockedState(); } if ((streamAttached || element.paused) && element.srcObject) { - element.play().catch(() => undefined); + attemptToPlay(element); + } + }, + [attemptToPlay, updateAutoplayBlockedState], + ); + + const handleRemoteVideoElement = useCallback( + ( + element: HTMLVideoElement | null, + participantId: string, + stream: MediaStream | null | undefined, + ) => { + if (!element) { + clearBlockedElementById(participantId); + return; } + + element.dataset.autoplayParticipantId = participantId; + attachStreamToElement(element, stream); }, - [], + [attachStreamToElement, clearBlockedElementById], ); const handleLocalVideoRef = useCallback( (element: HTMLVideoElement | null) => { + const previousElement = localVideoRef.current; + if (!element && previousElement) { + blockedElementsRef.current.delete(previousElement); + updateAutoplayBlockedState(); + } + localVideoRef.current = element; + if (!element) { return; } @@ -50,7 +189,7 @@ export const CallParticipantsGrid = ({ element.muted = true; attachStreamToElement(element, stream); }, - [attachStreamToElement, localStreamRef, localVideoRef], + [attachStreamToElement, localStreamRef, localVideoRef, updateAutoplayBlockedState], ); if (isFullscreen && remoteParticipants.length > 0) { @@ -58,15 +197,24 @@ export const CallParticipantsGrid = ({ return (
+ {autoplayBlocked && ( + + )}
-
@@ -83,16 +231,16 @@ export const CallParticipantsGrid = ({ {secondaryParticipants.length > 0 && (
- {secondaryParticipants.map((participant) => ( -
-