Skip to content

Commit c951dce

Browse files
committed
Fix the audio is not play in the pip mode
1 parent b4bc604 commit c951dce

2 files changed

Lines changed: 137 additions & 32 deletions

File tree

src/pages/Meeting/MeetingPage.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { useTheme } from '@mui/material/styles';
44
import useMediaQuery from '@mui/material/useMediaQuery';
55
import { ReactionBarSelector } from '@charkour/react-reactions';
66
import { Close } from '@mui/icons-material';
7-
import cloneDeep from 'lodash/cloneDeep';
87

98
import Footer from '../../Components/Footer/Footer.tsx';
109
import VideoCard from '../../Components/Cards/VideoCard.tsx';
@@ -79,7 +78,17 @@ const MeetingPage = React.memo<MeetingPageProps>((props) => {
7978

8079
// Memoized participant list
8180
const allParticipants = useMemo(() => {
82-
const participants = cloneDeep(props.subscribedParticipants || {});
81+
// Clone only the plain `participant` metadata (mutated below via isRaiseHand) — never
82+
// deep-clone `mediaStream`: it's a native MediaStream, and lodash's cloneDeep reduces it
83+
// to a prototype-only shell with no real tracks, silently breaking playback wherever the
84+
// clone is used (this fed Picture-in-Picture, which rendered tiles that never played audio/video).
85+
const participants: Record<string, SubscribedParticipant> = {};
86+
Object.entries(props.subscribedParticipants || {}).forEach(([uid, sp]) => {
87+
participants[uid] = {
88+
mediaStream: sp.mediaStream,
89+
participant: { ...sp.participant },
90+
};
91+
});
8392

8493
// Add raised hand status to subscribed participants
8594
props.raisedHands?.forEach((id) => {
@@ -224,15 +233,15 @@ const MeetingPage = React.memo<MeetingPageProps>((props) => {
224233
useEffect(() => {
225234
const onVisibility = () => {
226235
if (document.hidden) {
227-
autoOpenPiP();
236+
autoOpenPiP(pipOptions);
228237
} else {
229238
autoClosePiP();
230239
}
231240
};
232241

233242
document.addEventListener('visibilitychange', onVisibility);
234243
return () => document.removeEventListener('visibilitychange', onVisibility);
235-
}, [autoOpenPiP, autoClosePiP]);
244+
}, [autoOpenPiP, autoClosePiP, pipOptions]);
236245

237246
// Gallery resize handler
238247
const handleGalleryResize = useCallback((calcDrawer = false) => {

src/utils/PiPManager.tsx

Lines changed: 124 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)