@@ -112,7 +112,7 @@ export interface UsePictureInPictureReturn {
112112 closePiP : ( ) => void ;
113113 togglePiP : ( options : PiPOpenOptions ) => Promise < boolean > ;
114114 /** Auto-open: tries Document PiP first, falls back to Video PiP if user activation is missing */
115- autoOpen : ( ) => Promise < 'document' | 'video' | false > ;
115+ autoOpen : ( options ?: PiPOpenOptions ) => Promise < 'document' | 'video' | false > ;
116116 /** Close both Document PiP and standard Video PiP */
117117 autoClose : ( ) => void ;
118118}
@@ -241,14 +241,19 @@ class PiPManager {
241241 *
242242 * Returns: 'document' | 'video' | false
243243 */
244- async tryAutoOpen ( options ?: PiPWindowOptions ) : Promise < 'document' | 'video' | false > {
244+ async tryAutoOpen (
245+ content ?: React . ReactElement ,
246+ options ?: PiPWindowOptions ,
247+ ) : Promise < 'document' | 'video' | false > {
245248 if ( this . isOpen ( ) ) return 'document' ;
246249
247250 // 1. Try Document PiP
248251 if ( this . isSupported ( ) ) {
249252 try {
250253 await this . openWindow ( options ) ;
251- this . pipRoot ! . render ( this . lastContent ) ;
254+ const contentToRender = content ?? this . lastContent ;
255+ this . pipRoot ! . render ( contentToRender ) ;
256+ this . lastContent = contentToRender ;
252257 this . notifyState ( true ) ;
253258 return 'document' ;
254259 } catch ( e ) {
@@ -262,11 +267,18 @@ class PiPManager {
262267 }
263268 }
264269
265- // 2. Fallback: standard Video PiP (no user gesture needed from visibilitychange)
270+ // 2. Fallback: standard Video PiP (no user gesture needed from visibilitychange).
271+ // Only consider remote participants' videos — the local self-view (#red5pro-publisher)
272+ // is always muted and would otherwise be picked up silently, per the DOM id convention
273+ // used in AutoLayout/PinnedLayout/TiledLayout (`red5pro-subscriber-${uid}`).
266274 if ( document . pictureInPictureEnabled ) {
267- // Prefer a video that is actively playing
268- const videos = Array . from ( document . querySelectorAll < HTMLVideoElement > ( 'video[autoplay]' ) ) ;
269- const playingVideo = videos . find ( ( v ) => v . readyState >= 2 && ! v . paused ) ?? videos [ 0 ] ;
275+ const videos = Array . from (
276+ document . querySelectorAll < HTMLVideoElement > ( 'video[id^="red5pro-subscriber-"]' ) ,
277+ ) ;
278+ const playingVideo =
279+ videos . find ( ( v ) => v . readyState >= 2 && ! v . paused && ! v . muted ) ??
280+ videos . find ( ( v ) => v . readyState >= 2 && ! v . paused ) ??
281+ videos [ 0 ] ;
270282
271283 if ( playingVideo ) {
272284 try {
@@ -502,13 +514,17 @@ const PIP_STYLES = `
502514
503515 /* Audio-only view */
504516 .pip-audio-only {
517+ position: absolute;
518+ inset: 0;
505519 display: flex;
506520 flex-direction: column;
507521 align-items: center;
508522 justify-content: center;
509523 height: 100%;
510524 gap: 6px;
511525 padding: 8px;
526+ background: #2a2a2a;
527+ z-index: 1;
512528 }
513529
514530 .pip-audio-only-name {
@@ -518,6 +534,21 @@ const PIP_STYLES = `
518534 color: rgba(255,255,255,0.8);
519535 }
520536
537+ /* Tap-to-unmute affordance (shown when autoplay-with-sound is blocked) */
538+ .pip-unmute-badge {
539+ position: absolute;
540+ top: 4px;
541+ right: 4px;
542+ 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;
549+ z-index: 4;
550+ }
551+
521552 /* Empty state */
522553 .pip-empty-state {
523554 flex: 1;
@@ -713,35 +744,74 @@ const PiPParticipant: React.FC<PiPParticipantProps> = ({
713744} ) => {
714745 const videoRef = useRef < HTMLVideoElement > ( null ) ;
715746 const isLocalUser = participant ?. uid === streamName ;
716-
747+ const showVideo = Boolean ( participant ?. videoEnabled && mediaStream ) ;
748+ const [ needsUnmute , setNeedsUnmute ] = useState ( false ) ;
749+
750+ // Always attach the stream (audio track must keep playing even when video is off), and
751+ // drive playback explicitly rather than relying on the `autoplay` attribute: unmuted
752+ // autoplay can be silently blocked (frozen frame, no sound, no error surfaced) unless the
753+ // 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.
717756 useEffect ( ( ) => {
718- if ( videoRef . current && mediaStream && participant ?. videoEnabled ) {
719- videoRef . current . srcObject = mediaStream ;
720- }
721- } , [ mediaStream , participant ?. videoEnabled ] ) ;
757+ const videoEl = videoRef . current ;
758+ if ( ! videoEl || ! mediaStream ) return ;
759+
760+ videoEl . srcObject = mediaStream ;
761+ videoEl . muted = isLocalUser ;
762+ setNeedsUnmute ( false ) ;
763+
764+ videoEl . play ( ) . catch ( ( ) => {
765+ if ( isLocalUser ) return ;
766+ videoEl . muted = true ;
767+ setNeedsUnmute ( true ) ;
768+ videoEl . play ( ) . catch ( ( ) => { } ) ;
769+ } ) ;
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+ } ;
722784
723785 const label = `${ participant ?. name || 'Unknown' } ${ isLocalUser ? ' (You)' : '' } ` ;
724786
725787 return (
726- < div className = "pip-participant-tile" >
788+ < div
789+ className = "pip-participant-tile"
790+ onClick = { needsUnmute ? handleUnmute : undefined }
791+ style = { needsUnmute ? { cursor : 'pointer' } : undefined }
792+ >
727793 { isSpeaking && < div className = "pip-speaking-ring" /> }
728794
729- { participant ?. videoEnabled && mediaStream ? (
730- < video
731- ref = { videoRef }
732- className = "pip-participant-video"
733- autoPlay
734- playsInline
735- muted = { isLocalUser }
736- style = { isLocalUser ? { transform : 'scaleX(-1)' } : undefined }
737- />
738- ) : (
795+ { /* Kept mounted (not unmounted on camera-off) so the audio track never stops playing */ }
796+ < video
797+ ref = { videoRef }
798+ className = "pip-participant-video"
799+ playsInline
800+ style = { {
801+ visibility : showVideo ? 'visible' : 'hidden' ,
802+ ...( isLocalUser ? { transform : 'scaleX(-1)' } : { } ) ,
803+ } }
804+ />
805+
806+ { ! showVideo && (
739807 < div className = "pip-audio-only" >
740808 < Avatar src = { defaultAvatar } sx = { { width : 56 , height : 56 , opacity : 0.85 } } />
741809 < div className = "pip-audio-only-name" > { label } </ div >
742810 </ div >
743811 ) }
744812
813+ { needsUnmute && < div className = "pip-unmute-badge" > 🔇 Tap to unmute</ div > }
814+
745815 < div className = "pip-name-pill" > { label } </ div >
746816 </ div >
747817 ) ;
@@ -1078,11 +1148,37 @@ export const usePictureInPicture = (): UsePictureInPictureReturn => {
10781148 ) ;
10791149
10801150 // ---------- autoOpen ----------
1081- const autoOpen = useCallback ( async ( ) : Promise < 'document' | 'video' | false > => {
1082- const result = await pipManager . tryAutoOpen ( ) ;
1083- if ( result === 'document' ) setIsOpen ( true ) ;
1084- return result ;
1085- } , [ ] ) ;
1151+ const autoOpen = useCallback (
1152+ async ( options ?: PiPOpenOptions ) : Promise < 'document' | 'video' | false > => {
1153+ const content = options ? (
1154+ < PiPGridContent
1155+ participants = { options . participants }
1156+ onClose = { ( ) => {
1157+ setIsOpen ( false ) ;
1158+ pipManager . close ( ) ;
1159+ } }
1160+ onMuteToggle = { options . onMuteToggle }
1161+ onVideoToggle = { options . onVideoToggle }
1162+ onVolumeToggle = { options . onVolumeToggle }
1163+ onToggleMic = { options . onToggleMic }
1164+ onToggleCamera = { options . onToggleCamera }
1165+ onToggleScreenShare = { options . onToggleScreenShare }
1166+ onLeaveRoom = { options . onLeaveRoom }
1167+ isMyMicMuted = { options . isMyMicMuted }
1168+ isMyCamTurnedOff = { options . isMyCamTurnedOff }
1169+ isScreenShared = { options . isScreenShared }
1170+ screenShareStream = { options . screenShareStream }
1171+ talkers = { options . talkers }
1172+ streamName = { options . streamName }
1173+ />
1174+ ) : undefined ;
1175+
1176+ const result = await pipManager . tryAutoOpen ( content ) ;
1177+ if ( result === 'document' ) setIsOpen ( true ) ;
1178+ return result ;
1179+ } ,
1180+ [ ] ,
1181+ ) ;
10861182
10871183 // ---------- autoClose ----------
10881184 const autoClose = useCallback ( ( ) : void => {
0 commit comments