From 2b810055f72bc6d6c8169b8dcb8cbcd77fad3417 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:05:46 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20PiP=20selection=20logic=20=E2=80=94=20la?= =?UTF-8?q?st=20speaker=20persistence=20+=20focused=20stream=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track last speaker via ref so avatar fallback doesn't snap to array-order first remote when everyone stops talking. Parse :stream suffix from focusedParticipantId so clicking a stream tile in the grid pins that screen share to PiP. Remember last focus via ref so PiP content persists when grid focus is cleared. --- .../src/components/voice/PictureInPicture.tsx | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/packages/web/src/components/voice/PictureInPicture.tsx b/packages/web/src/components/voice/PictureInPicture.tsx index 9e5d4c54..f71db55f 100644 --- a/packages/web/src/components/voice/PictureInPicture.tsx +++ b/packages/web/src/components/voice/PictureInPicture.tsx @@ -70,10 +70,27 @@ interface SelectedStream { function selectPipStream( participants: ParticipantInfo[], - focusedId: string | null, + lastFocusedId: string | null, watchingStreams: Set, ): SelectedStream | null { - // Priority 1: Screen share from a user we're watching + // Priority 1: Last-focused stream tile (identity:stream suffix → screen share) + if (lastFocusedId?.endsWith(':stream')) { + const identity = lastFocusedId.slice(0, -':stream'.length); + const focused = participants.find(p => p.identity === identity); + if (focused?.screenTrack) { + return { participant: focused, track: focused.screenTrack, type: 'screen' }; + } + } + + // Priority 2: Last-focused user tile (identity match → camera) + if (lastFocusedId && !lastFocusedId.endsWith(':stream')) { + const focused = participants.find(p => p.identity === lastFocusedId); + if (focused?.videoTrack) { + return { participant: focused, track: focused.videoTrack, type: 'camera' }; + } + } + + // Priority 3: Screen share from a user we're watching const screenSharer = participants.find( p => p.screenTrack !== null && watchingStreams.has(p.userId), ); @@ -81,21 +98,13 @@ function selectPipStream( return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' }; } - // Priority 2: Focused participant with camera - if (focusedId) { - const focused = participants.find(p => p.identity === focusedId); - if (focused?.videoTrack) { - return { participant: focused, track: focused.videoTrack, type: 'camera' }; - } - } - - // Priority 3: Remote participant with camera + // Priority 4: Remote participant with camera const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null); if (remoteWithCamera?.videoTrack) { return { participant: remoteWithCamera, track: remoteWithCamera.videoTrack, type: 'camera' }; } - // Priority 4: Local participant with camera + // Priority 5: Local participant with camera const localWithCamera = participants.find(p => p.isLocal && p.videoTrack !== null); if (localWithCamera?.videoTrack) { return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' }; @@ -159,16 +168,60 @@ export function PictureInPicture() { prevWouldShow.current = wouldShow; }, [wouldShow, setPipCollapsed]); + // Track last speaker for avatar fallback (persists after they stop speaking) + const lastSpeakerRef = useRef(null); + useEffect(() => { + for (const id of speakingParticipantIds) { + const p = participants.find(pp => pp.identity === id && !pp.isLocal); + if (p) { + lastSpeakerRef.current = p.identity; + break; + } + } + }, [speakingParticipantIds, participants]); + + // Track last non-null focusedParticipantId so PiP persists when grid focus clears + const lastFocusRef = useRef(null); + useEffect(() => { + if (focusedParticipantId) { + lastFocusRef.current = focusedParticipantId; + } + }, [focusedParticipantId]); + + // Clear stale last-focus when the focused participant/track is gone + const effectiveLastFocus = useMemo(() => { + const id = lastFocusRef.current; + if (!id) return null; + if (id.endsWith(':stream')) { + const identity = id.slice(0, -':stream'.length); + const p = participants.find(pp => pp.identity === identity); + if (p?.screenTrack) return id; + } else { + const p = participants.find(pp => pp.identity === id); + if (p?.videoTrack) return id; + } + // Focused content is gone — clear ref + lastFocusRef.current = null; + return null; + }, [participants]); + // Stream selection const selectedStream = useMemo( - () => selectPipStream(participants, focusedParticipantId, watchingStreams), - [participants, focusedParticipantId, watchingStreams], + () => selectPipStream(participants, effectiveLastFocus, watchingStreams), + [participants, effectiveLastFocus, watchingStreams], ); - // Fallback participant for avatar (most relevant remote, or first participant) + // Fallback participant for avatar (last speaker > first remote > local) const fallbackParticipant = useMemo(() => { + // Currently speaking remote const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity)); if (speaking) return speaking; + // Last speaker (persists after they stop) + if (lastSpeakerRef.current) { + const last = participants.find(p => p.identity === lastSpeakerRef.current); + if (last) return last; + } + // Any remote const remote = participants.find(p => !p.isLocal); if (remote) return remote; return participants[0] ?? null;