From b21fc16ee6ac27b302eef6189f4bacbad9ec66ba Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 20 May 2026 10:50:18 +0900 Subject: [PATCH] fix(android): clarify channel and voice status icons --- apps/chanora_flutter/lib/main.dart | 89 ++++++++++++++++--- apps/chanora_flutter/lib/src/rust/api.dart | 23 ++++- .../lib/src/rust/frb_generated.dart | 15 +++- core/chanora_core/src/lib.rs | 3 + crates/chanora_bridge/src/api.rs | 9 ++ crates/chanora_bridge/src/frb_generated.rs | 12 +++ crates/chanora_protocol/src/adapter.rs | 18 +++- crates/chanora_protocol/src/dto.rs | 6 ++ 8 files changed, 154 insertions(+), 21 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 5a71737..dc076c4 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -255,6 +255,8 @@ class _BetaHomeState extends State<_BetaHome> { bool _audioStarted = false; rust.BridgeAudioStats? _audioStats; Timer? _statsTimer; + int _statsTick = 0; + bool _voiceStatusRefreshInFlight = false; StreamSubscription? _eventsSub; // v1 voice subsystem state (SDD-094/095/096/097). Driven by @@ -563,15 +565,35 @@ class _BetaHomeState extends State<_BetaHome> { final s = await rust.audioStats(); if (!mounted) return; setState(() => _audioStats = s); + _statsTick += 1; + if (_statsTick % 5 == 0) { + unawaited(_refreshSnapshotForVoiceStatus()); + } } catch (_) {} }); } + Future _refreshSnapshotForVoiceStatus() async { + if (_voiceStatusRefreshInFlight) return; + _voiceStatusRefreshInFlight = true; + try { + final snap = await rust.snapshot(); + if (!mounted) return; + setState(() => _applySnapshot(snap)); + } catch (_) { + // Best-effort visual refresh only. Connection/loss paths still + // surface via the normal bridge events and explicit refreshes. + } finally { + _voiceStatusRefreshInFlight = false; + } + } + @override void dispose() { HardwareKeyboard.instance.removeHandler(_handleFocusedPttKey); _eventsSub?.cancel(); _statsTimer?.cancel(); + _voiceStatusRefreshInFlight = false; _hostCtl.dispose(); _nickCtl.dispose(); _passwordCtl.dispose(); @@ -978,6 +1000,8 @@ class _BetaHomeState extends State<_BetaHome> { Future _onDisconnect() async { _statsTimer?.cancel(); _statsTimer = null; + _statsTick = 0; + _voiceStatusRefreshInFlight = false; try { await rust.disconnect(); } catch (_) {} @@ -1014,6 +1038,8 @@ class _BetaHomeState extends State<_BetaHome> { for (final client in snap.clients) { if (client.id == snap.ownClientId) { _currentVoiceChannelId = client.channel; + _inputMuted = client.inputMuted; + _outputMuted = client.outputMuted; _pendingVoiceChannelId = null; _inChannel = true; _canJoinVoiceChannel = true; @@ -1412,8 +1438,11 @@ class _BetaHomeState extends State<_BetaHome> { ); final snapshotView = _SnapshotView( snapshot: _snapshot!, + audioStats: _audioStats, currentVoiceChannelId: _currentVoiceChannelId, pendingVoiceChannelId: _pendingVoiceChannelId, + localInputMuted: _inputMuted || _hardMute, + localOutputMuted: _outputMuted, hasJoinPending: _pendingVoiceChannelId != null, canJoinVoiceChannel: _canJoinVoiceChannel, onJoinChannel: (ch) => _onJoinChannel(ch), @@ -2056,8 +2085,11 @@ class PttCapabilityBadge extends StatelessWidget { class _SnapshotView extends StatelessWidget { const _SnapshotView({ required this.snapshot, + required this.audioStats, required this.currentVoiceChannelId, required this.pendingVoiceChannelId, + required this.localInputMuted, + required this.localOutputMuted, required this.hasJoinPending, required this.canJoinVoiceChannel, required this.onJoinChannel, @@ -2065,8 +2097,11 @@ class _SnapshotView extends StatelessWidget { }); final rust.BridgeSnapshot snapshot; + final rust.BridgeAudioStats? audioStats; final BigInt? currentVoiceChannelId; final BigInt? pendingVoiceChannelId; + final bool localInputMuted; + final bool localOutputMuted; final bool hasJoinPending; final bool canJoinVoiceChannel; final ValueChanged onJoinChannel; @@ -2143,19 +2178,13 @@ class _SnapshotView extends StatelessWidget { child: ListTile( dense: true, leading: Icon( - ch.id == currentVoiceChannelId ? Icons.volume_up : Icons.tag, + ch.hasPassword ? Icons.lock_outline : Icons.tag, + color: ch.hasPassword + ? theme.colorScheme.onSurfaceVariant + : null, ), title: Text(ch.name), subtitle: Text('id=${ch.id} parent=${ch.parent}'), - trailing: ch.hasPassword && ch.id != currentVoiceChannelId - ? Tooltip( - message: l10n.channelPasswordTitle, - child: Icon( - Icons.lock_outline, - color: theme.colorScheme.onSurfaceVariant, - ), - ) - : null, selected: ch.id == currentVoiceChannelId, onTap: hasJoinPending || @@ -2175,10 +2204,7 @@ class _SnapshotView extends StatelessWidget { child: ListTile( dense: true, visualDensity: VisualDensity.compact, - leading: Icon( - cl.isServerQuery ? Icons.terminal : Icons.person, - size: 18, - ), + leading: _clientVoiceStatusIcon(theme, cl), title: Text( cl.name, style: cl.isServerQuery @@ -2191,6 +2217,41 @@ class _SnapshotView extends StatelessWidget { ], ); } + + Widget _clientVoiceStatusIcon(ThemeData theme, rust.BridgeClient client) { + final isSelf = client.id == snapshot.ownClientId; + final outputMuted = isSelf ? localOutputMuted : client.outputMuted; + final inputMuted = isSelf ? localInputMuted : client.inputMuted; + final speaking = isSelf + ? (audioStats?.pttActive ?? false) + : client.isSpeaking; + + final IconData icon; + final Color color; + final String tooltip; + if (outputMuted) { + icon = Icons.volume_off; + color = theme.colorScheme.error; + tooltip = 'Speaker muted'; + } else if (inputMuted) { + icon = Icons.mic_off; + color = theme.colorScheme.error; + tooltip = 'Microphone muted'; + } else if (speaking) { + icon = Icons.volume_up; + color = theme.colorScheme.primary; + tooltip = 'Speaking'; + } else { + icon = Icons.volume_up_outlined; + color = theme.colorScheme.onSurfaceVariant; + tooltip = 'Not speaking'; + } + + return Tooltip( + message: tooltip, + child: Icon(icon, size: 18, color: color), + ); + } } /// Result of a successful PTT binding capture. Carries only the diff --git a/apps/chanora_flutter/lib/src/rust/api.dart b/apps/chanora_flutter/lib/src/rust/api.dart index 573918d..10241ce 100644 --- a/apps/chanora_flutter/lib/src/rust/api.dart +++ b/apps/chanora_flutter/lib/src/rust/api.dart @@ -356,6 +356,15 @@ class BridgeClient { /// Nickname. final String name; + /// True when this client has muted microphone/input capture. + final bool inputMuted; + + /// True when this client has muted speaker/output audio. + final bool outputMuted; + + /// True when recent inbound voice activity was observed for this client. + final bool isSpeaking; + /// True for TeamSpeak ServerQuery clients. final bool isServerQuery; @@ -363,12 +372,21 @@ class BridgeClient { required this.id, required this.channel, required this.name, + required this.inputMuted, + required this.outputMuted, + required this.isSpeaking, required this.isServerQuery, }); @override int get hashCode => - id.hashCode ^ channel.hashCode ^ name.hashCode ^ isServerQuery.hashCode; + id.hashCode ^ + channel.hashCode ^ + name.hashCode ^ + inputMuted.hashCode ^ + outputMuted.hashCode ^ + isSpeaking.hashCode ^ + isServerQuery.hashCode; @override bool operator ==(Object other) => @@ -378,6 +396,9 @@ class BridgeClient { id == other.id && channel == other.channel && name == other.name && + inputMuted == other.inputMuted && + outputMuted == other.outputMuted && + isSpeaking == other.isSpeaking && isServerQuery == other.isServerQuery; } diff --git a/apps/chanora_flutter/lib/src/rust/frb_generated.dart b/apps/chanora_flutter/lib/src/rust/frb_generated.dart index 24572d1..7ad100b 100644 --- a/apps/chanora_flutter/lib/src/rust/frb_generated.dart +++ b/apps/chanora_flutter/lib/src/rust/frb_generated.dart @@ -1172,13 +1172,16 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { BridgeClient dco_decode_bridge_client(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 4) - throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); + if (arr.length != 7) + throw Exception('unexpected arr length: expect 7 but see ${arr.length}'); return BridgeClient( id: dco_decode_u_64(arr[0]), channel: dco_decode_u_64(arr[1]), name: dco_decode_String(arr[2]), - isServerQuery: dco_decode_bool(arr[3]), + inputMuted: dco_decode_bool(arr[3]), + outputMuted: dco_decode_bool(arr[4]), + isSpeaking: dco_decode_bool(arr[5]), + isServerQuery: dco_decode_bool(arr[6]), ); } @@ -1535,11 +1538,17 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { var var_id = sse_decode_u_64(deserializer); var var_channel = sse_decode_u_64(deserializer); var var_name = sse_decode_String(deserializer); + var var_inputMuted = sse_decode_bool(deserializer); + var var_outputMuted = sse_decode_bool(deserializer); + var var_isSpeaking = sse_decode_bool(deserializer); var var_isServerQuery = sse_decode_bool(deserializer); return BridgeClient( id: var_id, channel: var_channel, name: var_name, + inputMuted: var_inputMuted, + outputMuted: var_outputMuted, + isSpeaking: var_isSpeaking, isServerQuery: var_isServerQuery, ); } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index b3d2933..df859d1 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -2035,6 +2035,9 @@ mod tests { id: chanora_protocol::ClientId(10), channel: chanora_protocol::ChannelId(1), name: "u".into(), + input_muted: false, + output_muted: false, + is_speaking: false, is_server_query: false, }], own_client_id: 10, diff --git a/crates/chanora_bridge/src/api.rs b/crates/chanora_bridge/src/api.rs index 5a36930..359554a 100644 --- a/crates/chanora_bridge/src/api.rs +++ b/crates/chanora_bridge/src/api.rs @@ -368,6 +368,12 @@ pub struct BridgeClient { pub channel: u64, /// Nickname. pub name: String, + /// True when this client has muted microphone/input capture. + pub input_muted: bool, + /// True when this client has muted speaker/output audio. + pub output_muted: bool, + /// True when recent inbound voice activity was observed for this client. + pub is_speaking: bool, /// True for TeamSpeak ServerQuery clients. pub is_server_query: bool, } @@ -418,6 +424,9 @@ impl From for BridgeSnapshot { id: c.id.0, channel: c.channel.0, name: c.name, + input_muted: c.input_muted, + output_muted: c.output_muted, + is_speaking: c.is_speaking, is_server_query: c.is_server_query, }) .collect(), diff --git a/crates/chanora_bridge/src/frb_generated.rs b/crates/chanora_bridge/src/frb_generated.rs index a7b9430..5af5d91 100644 --- a/crates/chanora_bridge/src/frb_generated.rs +++ b/crates/chanora_bridge/src/frb_generated.rs @@ -1292,11 +1292,17 @@ impl SseDecode for crate::api::BridgeClient { let mut var_id = ::sse_decode(deserializer); let mut var_channel = ::sse_decode(deserializer); let mut var_name = ::sse_decode(deserializer); + let mut var_input_muted = ::sse_decode(deserializer); + let mut var_output_muted = ::sse_decode(deserializer); + let mut var_is_speaking = ::sse_decode(deserializer); let mut var_isServerQuery = ::sse_decode(deserializer); return crate::api::BridgeClient { id: var_id, channel: var_channel, name: var_name, + input_muted: var_input_muted, + output_muted: var_output_muted, + is_speaking: var_is_speaking, is_server_query: var_isServerQuery, }; } @@ -1820,6 +1826,9 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeClient { self.id.into_into_dart().into_dart(), self.channel.into_into_dart().into_dart(), self.name.into_into_dart().into_dart(), + self.input_muted.into_into_dart().into_dart(), + self.output_muted.into_into_dart().into_dart(), + self.is_speaking.into_into_dart().into_dart(), self.is_server_query.into_into_dart().into_dart(), ] .into_dart() @@ -2193,6 +2202,9 @@ impl SseEncode for crate::api::BridgeClient { ::sse_encode(self.id, serializer); ::sse_encode(self.channel, serializer); ::sse_encode(self.name, serializer); + ::sse_encode(self.input_muted, serializer); + ::sse_encode(self.output_muted, serializer); + ::sse_encode(self.is_speaking, serializer); ::sse_encode(self.is_server_query, serializer); } } diff --git a/crates/chanora_protocol/src/adapter.rs b/crates/chanora_protocol/src/adapter.rs index 6fd0880..29544a3 100644 --- a/crates/chanora_protocol/src/adapter.rs +++ b/crates/chanora_protocol/src/adapter.rs @@ -17,7 +17,7 @@ //! * Disconnect is requested via a `oneshot`; the task drains //! `tsclientlib`'s outbound events and exits. -use std::time::Duration; +use std::time::{Duration, Instant}; use futures::prelude::*; use std::collections::HashMap; @@ -36,6 +36,8 @@ use tsproto_types::ClientType; use crate::dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot}; use crate::ProtocolError; +const SPEAKING_ACTIVITY_WINDOW: Duration = Duration::from_millis(750); + /// Pick the TeamSpeak `client_version`/platform/signature triple /// (sourced from `ReSpeak/tsdeclarations/Versions.csv`, baked into /// `tsproto-types` at vendor-time) that best matches the *runtime* @@ -509,6 +511,7 @@ async fn connection_task( std::time::Instant, ), > = HashMap::new(); + let mut voice_activity: HashMap = HashMap::new(); // Main loop: pump events, service requests, forward voice. loop { @@ -530,6 +533,7 @@ async fn connection_task( StreamItem::Audio(buf) => { let from = packet_sender_id(&buf); if let Some(from) = from { + voice_activity.insert(from, Instant::now()); if voice_in_tx .try_send(InboundVoice { from_client: from, @@ -612,7 +616,7 @@ async fn connection_task( // 3. Service at most one control request (non-blocking). match rx.try_recv() { Ok(Request::Snapshot(reply)) => { - let snap = build_snapshot(&con); + let snap = build_snapshot(&con, &voice_activity); let _ = reply.send(snap); } Ok(Request::MoveToChannel { @@ -826,7 +830,10 @@ fn emit_subtree<'a, T>( } } -fn build_snapshot(con: &Connection) -> Result { +fn build_snapshot( + con: &Connection, + voice_activity: &HashMap, +) -> Result { let state: &data::Connection = con .get_state() .map_err(|e| ProtocolError::Backend(format!("get_state: {e}")))?; @@ -874,6 +881,11 @@ fn build_snapshot(con: &Connection) -> Result { id: ClientId(c.id.0 as u64), channel: ChannelId(c.channel.0), name: sanitize(&c.name), + input_muted: c.input_muted, + output_muted: c.output_muted || c.output_only_muted, + is_speaking: voice_activity + .get(&(c.id.0 as u64)) + .is_some_and(|last| last.elapsed() <= SPEAKING_ACTIVITY_WINDOW), is_server_query: is_server_query_client_type(&c.client_type), }) .collect(); diff --git a/crates/chanora_protocol/src/dto.rs b/crates/chanora_protocol/src/dto.rs index 7002ddc..b5c7c03 100644 --- a/crates/chanora_protocol/src/dto.rs +++ b/crates/chanora_protocol/src/dto.rs @@ -36,6 +36,12 @@ pub struct ClientInfo { pub channel: ChannelId, /// Nickname, preserved verbatim per ADR-008. pub name: String, + /// True when this client has muted microphone/input capture. + pub input_muted: bool, + /// True when this client has muted speaker/output audio. + pub output_muted: bool, + /// True when recent inbound voice activity was observed for this client. + pub is_speaking: bool, /// True for TeamSpeak ServerQuery clients. pub is_server_query: bool, }