Skip to content

Commit 58021cf

Browse files
committed
Improve unmute for all option
1 parent c951dce commit 58021cf

1 file changed

Lines changed: 116 additions & 29 deletions

File tree

src/utils/PiPManager.tsx

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

7781
interface 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

Comments
 (0)