fix: PiP selection logic — last speaker persistence + focused stream support
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.
This commit is contained in:
@@ -70,10 +70,27 @@ interface SelectedStream {
|
|||||||
|
|
||||||
function selectPipStream(
|
function selectPipStream(
|
||||||
participants: ParticipantInfo[],
|
participants: ParticipantInfo[],
|
||||||
focusedId: string | null,
|
lastFocusedId: string | null,
|
||||||
watchingStreams: Set<string>,
|
watchingStreams: Set<string>,
|
||||||
): SelectedStream | null {
|
): 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(
|
const screenSharer = participants.find(
|
||||||
p => p.screenTrack !== null && watchingStreams.has(p.userId),
|
p => p.screenTrack !== null && watchingStreams.has(p.userId),
|
||||||
);
|
);
|
||||||
@@ -81,21 +98,13 @@ function selectPipStream(
|
|||||||
return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' };
|
return { participant: screenSharer, track: screenSharer.screenTrack, type: 'screen' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 2: Focused participant with camera
|
// Priority 4: Remote 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
|
|
||||||
const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null);
|
const remoteWithCamera = participants.find(p => !p.isLocal && p.videoTrack !== null);
|
||||||
if (remoteWithCamera?.videoTrack) {
|
if (remoteWithCamera?.videoTrack) {
|
||||||
return { participant: remoteWithCamera, track: remoteWithCamera.videoTrack, type: 'camera' };
|
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);
|
const localWithCamera = participants.find(p => p.isLocal && p.videoTrack !== null);
|
||||||
if (localWithCamera?.videoTrack) {
|
if (localWithCamera?.videoTrack) {
|
||||||
return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' };
|
return { participant: localWithCamera, track: localWithCamera.videoTrack, type: 'camera' };
|
||||||
@@ -159,16 +168,60 @@ export function PictureInPicture() {
|
|||||||
prevWouldShow.current = wouldShow;
|
prevWouldShow.current = wouldShow;
|
||||||
}, [wouldShow, setPipCollapsed]);
|
}, [wouldShow, setPipCollapsed]);
|
||||||
|
|
||||||
|
// Track last speaker for avatar fallback (persists after they stop speaking)
|
||||||
|
const lastSpeakerRef = useRef<string | null>(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<string | null>(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
|
// Stream selection
|
||||||
const selectedStream = useMemo(
|
const selectedStream = useMemo(
|
||||||
() => selectPipStream(participants, focusedParticipantId, watchingStreams),
|
() => selectPipStream(participants, effectiveLastFocus, watchingStreams),
|
||||||
[participants, focusedParticipantId, 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(() => {
|
const fallbackParticipant = useMemo(() => {
|
||||||
|
// Currently speaking remote
|
||||||
const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity));
|
const speaking = participants.find(p => !p.isLocal && speakingParticipantIds.has(p.identity));
|
||||||
if (speaking) return speaking;
|
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);
|
const remote = participants.find(p => !p.isLocal);
|
||||||
if (remote) return remote;
|
if (remote) return remote;
|
||||||
return participants[0] ?? null;
|
return participants[0] ?? null;
|
||||||
|
|||||||
Reference in New Issue
Block a user