diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/RadioSpeakerIcon.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/RadioSpeakerIcon.kt new file mode 100644 index 00000000..74a81881 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/RadioSpeakerIcon.kt @@ -0,0 +1,36 @@ +package com.utun.chat.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp + +/** Волны показывают речь независимо от mute; красное перечёркивание всегда показывает локальное отключение. */ +@Composable +fun RadioSpeakerIcon(speaking: Boolean, muted: Boolean, label: String, modifier: Modifier = Modifier.size(24.dp)) { + val color = when { muted -> Color(0xFFEF5350); speaking -> Color(0xFF4CAF50); else -> Color(0xFF9E9E9E) } + Canvas(modifier.semantics { contentDescription = label }) { + val unit = size.minDimension / 24f + val speaker = Path().apply { + moveTo(4 * unit, 9 * unit); lineTo(8 * unit, 9 * unit); lineTo(13 * unit, 5 * unit) + lineTo(13 * unit, 19 * unit); lineTo(8 * unit, 15 * unit); lineTo(4 * unit, 15 * unit); close() + } + drawPath(speaker, color) + if (speaking) { + val stroke = Stroke(1.8f * unit, cap = StrokeCap.Round) + drawArc(color, -60f, 120f, false, Offset(9 * unit, 7 * unit), Size(10 * unit, 10 * unit), style = stroke) + drawArc(color, -60f, 120f, false, Offset(6 * unit, 4 * unit), Size(16 * unit, 16 * unit), style = stroke) + } + if (muted) drawLine(color, Offset(2 * unit, 22 * unit), Offset(22 * unit, 2 * unit), + strokeWidth = 1.8f * unit, cap = StrokeCap.Round) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt index bfb1f54c..ac607d0f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Shield import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -50,6 +49,7 @@ import com.utun.chat.data.MemberLinks import com.utun.chat.data.LinkStatus import com.utun.chat.data.LocalSocketState import com.utun.chat.ui.components.PortableRadioIcon +import com.utun.chat.ui.components.RadioSpeakerIcon import com.utun.chat.viewmodel.ChatViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -70,6 +70,10 @@ fun MemberListScreen( val localSockets by viewModel.localSockets.collectAsState() val channelOwner by viewModel.channelOwner.collectAsState() val radioMuteFailed by viewModel.radioMuteFailed.collectAsState() + val radioActive by viewModel.radioActive.collectAsState() + val radioTalkState by viewModel.radioTalkState.collectAsState() + val transmittingGroup by viewModel.radioAudio.transmittingGroup.collectAsState() + val groupId = channel.id.toULongOrNull()?.toLong() LaunchedEffect(selectedMember?.nodeId) { val nodeId = selectedMember?.nodeId @@ -95,6 +99,8 @@ fun MemberListScreen( MemberListItem( member = m, group = channel.id, + speaking = radioActive && if (m.isSelf) transmittingGroup == groupId && groupId != null + else radioTalkState.groupId == groupId && m.nodeId in radioTalkState.speakers, isSelected = selectedMember?.nodeId == m.nodeId, onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, onCall = { viewModel.startCall(channel.id, m.nodeId, m.name) }, @@ -166,6 +172,7 @@ fun MemberListScreen( private fun MemberListItem( member: ChatMember, group: String, + speaking: Boolean, isSelected: Boolean = false, onClick: () -> Unit, onCall: () -> Unit, @@ -250,11 +257,16 @@ private fun MemberListItem( ) } - if (!member.isSelf) { + val radioStatus = stringResource(if (speaking) R.string.radio_member_speaking else R.string.radio_member_silent) + + if (member.radioMuted) " · " + stringResource(R.string.radio_member_muted) else "" + if (member.isSelf) { + Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { + RadioSpeakerIcon(speaking, false, radioStatus) + } + } else { IconToggleButton(checked = member.radioMuted, onCheckedChange = { onRadioMute() }) { - Icon(Icons.AutoMirrored.Filled.VolumeUp, - stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute), - tint = if (member.radioMuted) Color(0xFFEF5350) else Color(0xFF4CAF50)) + RadioSpeakerIcon(speaking, member.radioMuted, radioStatus + ". " + + stringResource(if (member.radioMuted) R.string.radio_member_listen else R.string.radio_member_mute)) } IconButton(onClick = onInvite) { Icon(Icons.Filled.Add, stringResource(R.string.invite_to_group)) } IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }