From 6bb464e7681ef3f59c1c67b18fbf25832fe196f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 19:58:06 +0000 Subject: [PATCH] fix: render all remote video tracks in group WebRTC calls The UI was only showing the first connected peer's video track (remoteVideoTrack) instead of all tracks from the remoteVideoTracks map. This adds a grid layout that renders all remote videos and per-peer frame activity monitoring so the controller correctly detects video activity from any participant. https://claude.ai/code/session_01E4ASQDLSu5CSjiAiAmrNeK --- .../amethyst/service/call/CallController.kt | 54 +++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 68 ++++++++++++++++--- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 5d90caf49..38966be93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -138,6 +138,10 @@ class CallController( } } + // Per-peer video activity monitoring for group calls + private val perPeerFrameSinks = ConcurrentHashMap() + private val perPeerLastFrameTimeMs = ConcurrentHashMap() + private val _isAudioMuted = MutableStateFlow(false) val isAudioMuted: StateFlow = _isAudioMuted.asStateFlow() private val _isVideoEnabled = MutableStateFlow(false) @@ -725,6 +729,49 @@ class CallController( _remoteVideoTrack.value = track startRemoteVideoMonitor(track) } + // Monitor this peer's video activity for group call UI + startPeerVideoMonitor(peerPubKey, track) + } + + private fun startPeerVideoMonitor( + peerPubKey: HexKey, + track: VideoTrack, + ) { + // Remove any existing sink for this peer + stopPeerVideoMonitor(peerPubKey) + + val lastFrameTime = AtomicLong(System.currentTimeMillis()) + perPeerLastFrameTimeMs[peerPubKey] = lastFrameTime + val sink = + VideoSink { frame: VideoFrame -> + lastFrameTime.set(System.currentTimeMillis()) + } + perPeerFrameSinks[peerPubKey] = sink + track.addSink(sink) + ensureGroupVideoMonitorRunning() + } + + private fun stopPeerVideoMonitor(peerPubKey: HexKey) { + val sink = perPeerFrameSinks.remove(peerPubKey) ?: return + perPeerLastFrameTimeMs.remove(peerPubKey) + val track = _remoteVideoTracks.value[peerPubKey] + try { + track?.removeSink(sink) + } catch (_: Exception) { + } + } + + private fun ensureGroupVideoMonitorRunning() { + if (remoteVideoMonitorJob != null) return + remoteVideoMonitorJob = + scope.launch { + while (true) { + delay(1500) + val now = System.currentTimeMillis() + val anyActive = perPeerLastFrameTimeMs.values.any { now - it.get() < 2000 } + _isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000) + } + } } private fun onPeerDisconnected(peerPubKey: HexKey) { @@ -764,6 +811,8 @@ class CallController( } catch (e: Exception) { Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e) } + // Clean up per-peer video monitor + stopPeerVideoMonitor(peerPubKey) // Update remote video tracks val currentTracks = _remoteVideoTracks.value if (peerPubKey in currentTracks) { @@ -807,6 +856,11 @@ class CallController( NotificationUtils.cancelCallNotification(context) stopRemoteVideoMonitor() + // Clean up per-peer video monitors + for (peerPubKey in perPeerFrameSinks.keys.toList()) { + stopPeerVideoMonitor(peerPubKey) + } + // Dispose all peer sessions for (ps in peerSessions.values) { try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 2cdeb79fb..1bf2bad31 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -399,7 +400,8 @@ private fun ConnectedCallUI( } val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } - val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState() + val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptyMap()) } + val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } @@ -429,16 +431,13 @@ private fun ConnectedCallUI( .fillMaxSize() .background(Color.Black), ) { - // Remote video (full screen background) — only when peer is actively sending - if (isRemoteVideoActive) { - remoteVideoTrack?.let { track -> - VideoRenderer( - videoTrack = track, - eglBase = callController?.getEglBase(), - modifier = Modifier.fillMaxSize(), - mirror = false, - ) - } + // Remote video(s) — render all peers in a grid when actively sending + if (isRemoteVideoActive && remoteVideoTracks.isNotEmpty()) { + RemoteVideoGrid( + remoteVideoTracks = remoteVideoTracks, + eglBase = callController?.getEglBase(), + modifier = Modifier.fillMaxSize(), + ) } // Local video (small pip in corner) — only when camera is active @@ -602,6 +601,53 @@ private fun ConnectedCallUI( } } +@Composable +private fun RemoteVideoGrid( + remoteVideoTracks: Map, + eglBase: org.webrtc.EglBase?, + modifier: Modifier = Modifier, +) { + val tracks = remember(remoteVideoTracks) { remoteVideoTracks.entries.toList() } + + if (tracks.size == 1) { + // Single peer: full screen + VideoRenderer( + videoTrack = tracks[0].value, + eglBase = eglBase, + modifier = modifier, + mirror = false, + ) + } else { + val columns = + when { + tracks.size <= 2 -> 1 + tracks.size <= 4 -> 2 + else -> 2 + } + + Column(modifier = modifier) { + tracks.chunked(columns).forEach { row -> + Row( + modifier = Modifier.weight(1f).fillMaxWidth(), + ) { + row.forEach { (_, track) -> + VideoRenderer( + videoTrack = track, + eglBase = eglBase, + modifier = Modifier.weight(1f).fillMaxHeight(), + mirror = false, + ) + } + // Fill empty cells in the last row + repeat(columns - row.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } + } + } +} + @Composable private fun VideoRenderer( videoTrack: VideoTrack,