diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 0a40201..32cbf79 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -718,8 +718,14 @@ class _BetaHomeState extends State<_BetaHome> { final next = !_outputMuted; try { await rust.setOutputMuted(muted: next); + await rust.setHardMute( + muted: next || _inputMuted || _hardMuteByPermission, + ); if (!mounted) return; - setState(() => _outputMuted = next); + setState(() { + _outputMuted = next; + _hardMute = next || _inputMuted || _hardMuteByPermission; + }); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); @@ -887,6 +893,7 @@ class _BetaHomeState extends State<_BetaHome> { if (!mounted) return; setState(() { _inputMuted = next; + _hardMute = next; _hardMuteByPermission = false; }); } catch (e) { diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index a98e45e..f5aeece 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -300,6 +300,13 @@ struct ConnectedState { /// for that channel. Kept in memory only; durable secret storage is /// a separate storage-schema decision. channel_passwords: HashMap, + /// Local microphone mute state used to resolve the single + /// transmit hard-mute clamp from independent UI mute buttons. + local_input_muted: bool, + /// Local speaker mute state. Product rule for P0: speaker muted + /// also means mic disabled, so this participates in the local + /// transmit hard-mute clamp without sending server-side deafen. + local_output_muted: bool, } fn normalize_channel_password(password: Option) -> Option { @@ -610,6 +617,8 @@ impl ChanoraSession { sup_inner, join_state, channel_passwords: HashMap::new(), + local_input_muted: false, + local_output_muted: false, }); // TS3 servers auto-place a newly-connected client into the @@ -996,15 +1005,23 @@ impl ChanoraSession { input: Option, output: Option, ) -> Result<(), CoreError> { - let guard = self.inner.lock().await; - let state = guard.as_ref().ok_or(CoreError::NotConnected)?; + let mut guard = self.inner.lock().await; + let state = guard.as_mut().ok_or(CoreError::NotConnected)?; // Server-side output mute/deafen makes TeamSpeak/tsclientlib // consider the client unable to send audio. That is correct // for a server-visible "deafened" state, but our P0 speaker - // button is a local playback mute. Keep output mute local-only - // so Android can still transmit while the user silences remote - // playback on this device. - state.protocol.set_muted(input, None).await?; + // button is a local playback mute. Keep output mute off the + // server-side output/deafen flag, but fold it into the mic + // disabled state below because P0 product semantics are: + // speaker disabled also means microphone disabled. + if let Some(muted) = input { + state.local_input_muted = muted; + } + if let Some(muted) = output { + state.local_output_muted = muted; + } + let mic_disabled = state.local_input_muted || state.local_output_muted; + state.protocol.set_muted(Some(mic_disabled), None).await?; if let Some(muted) = output { if let Some(audio) = state.audio.as_ref() { audio.set_output_muted(muted); @@ -1019,9 +1036,8 @@ impl ChanoraSession { // flooded the log to 200 MB on the Korean test host. // Clamp the transmit-mode selector's hard_mute input so // the gate goes false too. - if let Some(muted) = input { - self.voice_selector.set_hard_mute(muted); - } + self.voice_selector + .set_hard_mute(mic_disabled); Ok(()) }