fix: video call UI per-peer video activity and layout improvements

- Track per-peer video activity in CallController so each peer's video
  state is independent (fixes frozen frames and incorrect fallback to
  phone-call UI when one peer disables video)
- Replace single RemoteVideoGrid with PeerVideoGrid that shows video
  for active peers and avatar/name for inactive peers
- Use call type (VIDEO vs VOICE) to determine layout instead of relying
  solely on isRemoteVideoActive boolean
- Remove extra spacing in AddParticipantDialog between search field and
  user list
- Fix group video monitor using separate job to avoid conflict with P2P
  monitor

https://claude.ai/code/session_01EFDCu97SLYp3TCeBSAttAe
This commit is contained in:
Claude
2026-04-03 20:45:08 +00:00
parent 136e6a4121
commit 9af306282b
2 changed files with 190 additions and 90 deletions
@@ -141,6 +141,11 @@ class CallController(
// Per-peer video activity monitoring for group calls // Per-peer video activity monitoring for group calls
private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>() private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>()
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>() private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
private var groupVideoMonitorJob: kotlinx.coroutines.Job? = null
// Set of peer pubkeys that are actively sending video frames
private val _activePeerVideos = MutableStateFlow<Set<HexKey>>(emptySet())
val activePeerVideos: StateFlow<Set<HexKey>> = _activePeerVideos.asStateFlow()
private val _isAudioMuted = MutableStateFlow(false) private val _isAudioMuted = MutableStateFlow(false)
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow() val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
@@ -762,13 +767,20 @@ class CallController(
} }
private fun ensureGroupVideoMonitorRunning() { private fun ensureGroupVideoMonitorRunning() {
if (remoteVideoMonitorJob != null) return if (groupVideoMonitorJob != null) return
remoteVideoMonitorJob = groupVideoMonitorJob =
scope.launch { scope.launch {
while (true) { while (true) {
delay(1500) delay(1500)
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val anyActive = perPeerLastFrameTimeMs.values.any { now - it.get() < 2000 } val activePeers = mutableSetOf<HexKey>()
for ((peerKey, lastFrame) in perPeerLastFrameTimeMs) {
if (now - lastFrame.get() < 2000) {
activePeers.add(peerKey)
}
}
_activePeerVideos.value = activePeers
val anyActive = activePeers.isNotEmpty()
_isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000) _isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000)
} }
} }
@@ -925,6 +937,7 @@ class CallController(
_isVideoEnabled.value = false _isVideoEnabled.value = false
_isRemoteVideoActive.value = false _isRemoteVideoActive.value = false
_remoteVideoAspectRatio.value = null _remoteVideoAspectRatio.value = null
_activePeerVideos.value = emptySet()
videoPausedByProximity = false videoPausedByProximity = false
} }
@@ -947,6 +960,8 @@ class CallController(
private fun stopRemoteVideoMonitor() { private fun stopRemoteVideoMonitor() {
remoteVideoMonitorJob?.cancel() remoteVideoMonitorJob?.cancel()
remoteVideoMonitorJob = null remoteVideoMonitorJob = null
groupVideoMonitorJob?.cancel()
groupVideoMonitorJob = null
try { try {
_remoteVideoTrack.value?.removeSink(remoteFrameSink) _remoteVideoTrack.value?.removeSink(remoteFrameSink)
} catch (_: Exception) { } catch (_: Exception) {
@@ -401,7 +401,9 @@ private fun ConnectedCallUI(
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) } val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) } val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
@@ -410,6 +412,7 @@ private fun ConnectedCallUI(
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState() val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState()
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
var showAddParticipant by remember { mutableStateOf(false) } var showAddParticipant by remember { mutableStateOf(false) }
@@ -431,38 +434,65 @@ private fun ConnectedCallUI(
.fillMaxSize() .fillMaxSize()
.background(Color.Black), .background(Color.Black),
) { ) {
// 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
if (isVideoEnabled) {
localVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier =
Modifier
.size(120.dp, 160.dp)
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
)
}
}
// If no video or peer stopped sharing, show avatar
val otherMembers = val otherMembers =
remember(state.allPeerPubKeys) { remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey state.allPeerPubKeys - accountViewModel.account.signer.pubKey
} }
if (!isRemoteVideoActive) {
if (isVideoCall) {
// Video call: always show the peer grid with video for active peers, avatar for inactive
PeerVideoGrid(
peerPubKeys = otherMembers,
remoteVideoTracks = remoteVideoTracks,
activePeerVideos = activePeerVideos,
eglBase = callController?.getEglBase(),
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxSize(),
)
// Local video (small pip in corner) — only when camera is active
if (isVideoEnabled) {
localVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
modifier =
Modifier
.size(120.dp, 160.dp)
.align(Alignment.TopEnd)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(16.dp),
mirror = true,
)
}
}
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp),
)
if (state.pendingPeerPubKeys.isNotEmpty()) {
Text(
text = stringRes(R.string.call_waiting_for_others),
color = Color.White.copy(alpha = 0.5f),
fontSize = 13.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 38.dp),
)
}
} else {
// Voice call: show avatars and names
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -494,18 +524,6 @@ private fun ConnectedCallUI(
) )
} }
} }
} else {
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 14.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.windowInsetsPadding(WindowInsets.statusBars)
.padding(top = 16.dp),
)
} }
// Controls at bottom // Controls at bottom
@@ -602,43 +620,62 @@ private fun ConnectedCallUI(
} }
@Composable @Composable
private fun RemoteVideoGrid( private fun PeerVideoGrid(
peerPubKeys: Set<String>,
remoteVideoTracks: Map<String, VideoTrack>, remoteVideoTracks: Map<String, VideoTrack>,
activePeerVideos: Set<String>,
eglBase: org.webrtc.EglBase?, eglBase: org.webrtc.EglBase?,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val tracks = remember(remoteVideoTracks) { remoteVideoTracks.entries.toList() } val peers = remember(peerPubKeys) { peerPubKeys.toList() }
if (tracks.size == 1) { if (peers.size == 1) {
// Single peer: full screen val peerKey = peers[0]
VideoRenderer( val track = remoteVideoTracks[peerKey]
videoTrack = tracks[0].value, if (track != null && peerKey in activePeerVideos) {
eglBase = eglBase, VideoRenderer(
modifier = modifier, videoTrack = track,
mirror = false, eglBase = eglBase,
) modifier = modifier,
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = modifier,
)
}
} else { } else {
val columns = val columns =
when { when {
tracks.size <= 2 -> 1 peers.size <= 2 -> 1
tracks.size <= 4 -> 2
else -> 2 else -> 2
} }
Column(modifier = modifier) { Column(modifier = modifier) {
tracks.chunked(columns).forEach { row -> peers.chunked(columns).forEach { row ->
Row( Row(
modifier = Modifier.weight(1f).fillMaxWidth(), modifier = Modifier.weight(1f).fillMaxWidth(),
) { ) {
row.forEach { (_, track) -> row.forEach { peerKey ->
VideoRenderer( val track = remoteVideoTracks[peerKey]
videoTrack = track, if (track != null && peerKey in activePeerVideos) {
eglBase = eglBase, VideoRenderer(
modifier = Modifier.weight(1f).fillMaxHeight(), videoTrack = track,
mirror = false, eglBase = eglBase,
) modifier = Modifier.weight(1f).fillMaxHeight(),
mirror = false,
)
} else {
PeerAvatarCell(
peerPubKey = peerKey,
accountViewModel = accountViewModel,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
} }
// Fill empty cells in the last row
repeat(columns - row.size) { repeat(columns - row.size) {
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
} }
@@ -648,6 +685,40 @@ private fun RemoteVideoGrid(
} }
} }
@Composable
private fun PeerAvatarCell(
peerPubKey: String,
accountViewModel: AccountViewModel,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier.background(Color.DarkGray),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
size = 80.dp,
accountViewModel = accountViewModel,
)
Spacer(modifier = Modifier.height(8.dp))
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
fontWeight = FontWeight.Bold,
textColor = Color.White,
)
}
}
}
}
}
@Composable @Composable
private fun VideoRenderer( private fun VideoRenderer(
videoTrack: VideoTrack, videoTrack: VideoTrack,
@@ -725,10 +796,18 @@ private fun PipConnectedCallUI(
} }
} }
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) } val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState() val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState() val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
Box( Box(
modifier = modifier =
@@ -736,24 +815,31 @@ private fun PipConnectedCallUI(
.fillMaxSize() .fillMaxSize()
.background(Color.Black), .background(Color.Black),
) { ) {
// Remote video full screen in PiP if (isVideoCall) {
if (isRemoteVideoActive) { // Video call: show first active peer's video or avatar
remoteVideoTrack?.let { track -> val firstActivePeer = otherMembers.firstOrNull { it in activePeerVideos }
val activeTrack = firstActivePeer?.let { remoteVideoTracks[it] }
if (activeTrack != null) {
VideoRenderer( VideoRenderer(
videoTrack = track, videoTrack = activeTrack,
eglBase = callController?.getEglBase(), eglBase = callController?.getEglBase(),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
mirror = false, mirror = false,
) )
} else {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
GroupCallPictures(
peerPubKeys = otherMembers,
size = 48.dp,
accountViewModel = accountViewModel,
)
}
} }
} } else if (!isRemoteVideoActive) {
val otherMembers =
remember(state.allPeerPubKeys) {
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
}
if (!isRemoteVideoActive) {
// Show small avatar + timer in PiP
Column( Column(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -771,18 +857,18 @@ private fun PipConnectedCallUI(
fontSize = 10.sp, fontSize = 10.sp,
) )
} }
} else {
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
} }
// Timer overlay
Text(
text = formatDuration(elapsed),
color = Color.White.copy(alpha = 0.7f),
fontSize = 10.sp,
modifier =
Modifier
.align(Alignment.TopCenter)
.padding(top = 4.dp),
)
} }
} }
@@ -814,7 +900,6 @@ private fun AddParticipantDialog(
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(modifier = Modifier.height(8.dp))
Box(modifier = Modifier.height(300.dp)) { Box(modifier = Modifier.height(300.dp)) {
ShowUserSuggestionList( ShowUserSuggestionList(
userSuggestions = userSuggestions, userSuggestions = userSuggestions,