@@ -72,6 +72,10 @@ interface PiPParticipantProps {
7272 mediaStream ?: MediaStream ;
7373 isSpeaking ?: boolean ;
7474 streamName : string ;
75+ /** Reports whether this tile's audio is currently blocked by the browser's autoplay policy */
76+ onAutoplayBlockedChange ?: ( uid : string , blocked : boolean ) => void ;
77+ /** Registers (or unregisters, when passed null) a retry function the parent can call to unmute this tile */
78+ registerUnmuteHandler ?: ( uid : string , retry : ( ( ) => void ) | null ) => void ;
7579}
7680
7781interface PiPGridContentProps {
@@ -534,21 +538,39 @@ const PIP_STYLES = `
534538 color: rgba(255,255,255,0.8);
535539 }
536540
537- /* Tap-to-unmute affordance (shown when autoplay-with-sound is blocked) */
541+ /* Per-tile indicator: this tile's audio is blocked (not clickable — see pip-unmute-all-banner ) */
538542 .pip-unmute-badge {
539543 position: absolute;
540544 top: 4px;
541545 right: 4px;
542546 background: rgba(217, 48, 37, 0.85);
543- border-radius: 4px;
544- padding: 2px 6px;
545- font-size: 9px;
546- font-weight: 600;
547- color: #fff;
548- white-space: nowrap;
547+ border-radius: 50%;
548+ width: 18px;
549+ height: 18px;
550+ display: flex;
551+ align-items: center;
552+ justify-content: center;
553+ font-size: 10px;
549554 z-index: 4;
550555 }
551556
557+ /* Single click to unmute every blocked tile at once */
558+ .pip-unmute-all-banner {
559+ flex-shrink: 0;
560+ width: 100%;
561+ border: none;
562+ background: #d93025;
563+ color: #fff;
564+ font-size: 11px;
565+ font-weight: 600;
566+ padding: 6px 8px;
567+ cursor: pointer;
568+ text-align: center;
569+ }
570+ .pip-unmute-all-banner:hover {
571+ background: #c12a1f;
572+ }
573+
552574 /* Empty state */
553575 .pip-empty-state {
554576 flex: 1;
@@ -741,55 +763,70 @@ const PiPParticipant: React.FC<PiPParticipantProps> = ({
741763 mediaStream,
742764 isSpeaking = false ,
743765 streamName,
766+ onAutoplayBlockedChange,
767+ registerUnmuteHandler,
744768} ) => {
745769 const videoRef = useRef < HTMLVideoElement > ( null ) ;
746770 const isLocalUser = participant ?. uid === streamName ;
747771 const showVideo = Boolean ( participant ?. videoEnabled && mediaStream ) ;
748772 const [ needsUnmute , setNeedsUnmute ] = useState ( false ) ;
773+ const uid = participant ?. uid ;
774+
775+ // Retries playback unmuted — exposed to the parent so a single click on the shared
776+ // "enable sound" banner can unlock every blocked tile at once (see PiPGridContent).
777+ const retryUnmute = useCallback ( ( ) => {
778+ const videoEl = videoRef . current ;
779+ if ( ! videoEl ) return ;
780+ videoEl . muted = false ;
781+ videoEl . play ( ) . then (
782+ ( ) => {
783+ setNeedsUnmute ( false ) ;
784+ if ( uid ) onAutoplayBlockedChange ?.( uid , false ) ;
785+ } ,
786+ ( ) => {
787+ videoEl . muted = true ;
788+ setNeedsUnmute ( true ) ;
789+ if ( uid ) onAutoplayBlockedChange ?.( uid , true ) ;
790+ } ,
791+ ) ;
792+ } , [ uid , onAutoplayBlockedChange ] ) ;
793+
794+ useEffect ( ( ) => {
795+ if ( ! uid ) return ;
796+ registerUnmuteHandler ?.( uid , retryUnmute ) ;
797+ return ( ) => {
798+ registerUnmuteHandler ?.( uid , null ) ;
799+ onAutoplayBlockedChange ?.( uid , false ) ;
800+ } ;
801+ } , [ uid , retryUnmute , registerUnmuteHandler , onAutoplayBlockedChange ] ) ;
749802
750803 // Always attach the stream (audio track must keep playing even when video is off), and
751804 // drive playback explicitly rather than relying on the `autoplay` attribute: unmuted
752805 // autoplay can be silently blocked (frozen frame, no sound, no error surfaced) unless the
753806 // window still has a fresh user-activation grant. When play() is rejected, fall back to a
754- // muted play (always allowed) and surface a tap-to-unmute affordance — clicking inside the
755- // PiP window is itself a genuine user gesture, so the retry there reliably succeeds.
807+ // muted play (always allowed) and report the block up so the shared banner can offer a retry.
756808 useEffect ( ( ) => {
757809 const videoEl = videoRef . current ;
758810 if ( ! videoEl || ! mediaStream ) return ;
759811
760812 videoEl . srcObject = mediaStream ;
761813 videoEl . muted = isLocalUser ;
762814 setNeedsUnmute ( false ) ;
815+ if ( uid ) onAutoplayBlockedChange ?.( uid , false ) ;
763816
764817 videoEl . play ( ) . catch ( ( ) => {
765818 if ( isLocalUser ) return ;
766819 videoEl . muted = true ;
767820 setNeedsUnmute ( true ) ;
821+ if ( uid ) onAutoplayBlockedChange ?.( uid , true ) ;
768822 videoEl . play ( ) . catch ( ( ) => { } ) ;
769823 } ) ;
770- } , [ mediaStream , isLocalUser ] ) ;
771-
772- const handleUnmute = ( ) => {
773- const videoEl = videoRef . current ;
774- if ( ! videoEl ) return ;
775- videoEl . muted = false ;
776- videoEl . play ( ) . then (
777- ( ) => setNeedsUnmute ( false ) ,
778- ( ) => {
779- videoEl . muted = true ;
780- setNeedsUnmute ( true ) ;
781- } ,
782- ) ;
783- } ;
824+ } , [ mediaStream , isLocalUser , uid , onAutoplayBlockedChange ] ) ;
784825
785826 const label = `${ participant ?. name || 'Unknown' } ${ isLocalUser ? ' (You)' : '' } ` ;
786827
787828 return (
788- < div
789- className = "pip-participant-tile"
790- onClick = { needsUnmute ? handleUnmute : undefined }
791- style = { needsUnmute ? { cursor : 'pointer' } : undefined }
792- >
829+ < div className = "pip-participant-tile" >
793830 { isSpeaking && < div className = "pip-speaking-ring" /> }
794831
795832 { /* Kept mounted (not unmounted on camera-off) so the audio track never stops playing */ }
@@ -810,7 +847,14 @@ const PiPParticipant: React.FC<PiPParticipantProps> = ({
810847 </ div >
811848 ) }
812849
813- { needsUnmute && < div className = "pip-unmute-badge" > 🔇 Tap to unmute</ div > }
850+ { needsUnmute && (
851+ < div
852+ className = "pip-unmute-badge"
853+ title = "Audio blocked — use the banner above to enable sound"
854+ >
855+ 🔇
856+ </ div >
857+ ) }
814858
815859 < div className = "pip-name-pill" > { label } </ div >
816860 </ div >
@@ -884,6 +928,36 @@ const PiPGridContent: React.FC<PiPGridContentProps> = ({
884928 const speakingIds = talkers . map ( ( t ) => t . streamId ) ;
885929 const gridClass = `pip-participants-grid${ participants . length <= 1 ? ' single-participant' : '' } ` ;
886930
931+ // Retry functions registered by each blocked PiPParticipant, keyed by uid — lets a single
932+ // click on the "enable sound" banner unlock every tile at once.
933+ const unmuteHandlersRef = useRef < Map < string , ( ) => void > > ( new Map ( ) ) ;
934+ const [ blockedUids , setBlockedUids ] = useState < Set < string > > ( new Set ( ) ) ;
935+
936+ const registerUnmuteHandler = useCallback ( ( uid : string , retry : ( ( ) => void ) | null ) => {
937+ if ( retry ) {
938+ unmuteHandlersRef . current . set ( uid , retry ) ;
939+ } else {
940+ unmuteHandlersRef . current . delete ( uid ) ;
941+ }
942+ } , [ ] ) ;
943+
944+ const handleAutoplayBlockedChange = useCallback ( ( uid : string , blocked : boolean ) => {
945+ setBlockedUids ( ( prev ) => {
946+ if ( blocked === prev . has ( uid ) ) return prev ;
947+ const next = new Set ( prev ) ;
948+ if ( blocked ) {
949+ next . add ( uid ) ;
950+ } else {
951+ next . delete ( uid ) ;
952+ }
953+ return next ;
954+ } ) ;
955+ } , [ ] ) ;
956+
957+ const handleUnmuteAll = useCallback ( ( ) => {
958+ unmuteHandlersRef . current . forEach ( ( retry ) => retry ( ) ) ;
959+ } , [ ] ) ;
960+
887961 // Screen share video component
888962 const PiPScreenShareVideo : React . FC < { stream ?: MediaStream } > = ( { stream } ) => {
889963 const videoRef = useRef < HTMLVideoElement > ( null ) ;
@@ -927,6 +1001,8 @@ const PiPGridContent: React.FC<PiPGridContentProps> = ({
9271001 mediaStream = { participants [ 0 ] . mediaStream }
9281002 isSpeaking = { speakingIds . includes ( participants [ 0 ] . participant ?. uid ) }
9291003 streamName = { streamName }
1004+ onAutoplayBlockedChange = { handleAutoplayBlockedChange }
1005+ registerUnmuteHandler = { registerUnmuteHandler }
9301006 />
9311007 )
9321008 ) }
@@ -940,6 +1016,8 @@ const PiPGridContent: React.FC<PiPGridContentProps> = ({
9401016 mediaStream = { pd . mediaStream }
9411017 isSpeaking = { speakingIds . includes ( pd . participant ?. uid ) }
9421018 streamName = { streamName }
1019+ onAutoplayBlockedChange = { handleAutoplayBlockedChange }
1020+ registerUnmuteHandler = { registerUnmuteHandler }
9431021 />
9441022 ) ) }
9451023 </ div >
@@ -958,6 +1036,8 @@ const PiPGridContent: React.FC<PiPGridContentProps> = ({
9581036 mediaStream = { pd . mediaStream }
9591037 isSpeaking = { speakingIds . includes ( pd . participant ?. uid ) }
9601038 streamName = { streamName }
1039+ onAutoplayBlockedChange = { handleAutoplayBlockedChange }
1040+ registerUnmuteHandler = { registerUnmuteHandler }
9611041 />
9621042 ) )
9631043 ) : (
@@ -981,6 +1061,13 @@ const PiPGridContent: React.FC<PiPGridContentProps> = ({
9811061 </ button >
9821062 </ div >
9831063
1064+ { blockedUids . size > 0 && (
1065+ < button className = "pip-unmute-all-banner" onClick = { handleUnmuteAll } >
1066+ 🔇 Tap to enable sound for { blockedUids . size } participant
1067+ { blockedUids . size !== 1 ? 's' : '' }
1068+ </ button >
1069+ ) }
1070+
9841071 { isScreenShared ? renderScreenShareLayout ( ) : renderNormalGrid ( ) }
9851072
9861073 < PiPControlBar
0 commit comments