diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index 787bcb8..1e22dee 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -356,7 +356,19 @@ class _BetaHomeState extends State<_BetaHome> { Future _onToggleHardMute() async { final next = !_hardMute; try { + // Hard-mute is two coordinated effects: + // * setHardMute — local TransmitGate clamp; we stop sending + // Opus frames the instant this returns. + // * setInputMuted — server-side ClientMuted flag so other + // clients see the mic-off icon next to our name and the + // server stops relaying any in-flight frames. + // Sending only one of them is user-confusing; clients see + // silence but no icon, or icon but a beat of audio leaks + // through. Drive them together. await rust.setHardMute(muted: next); + await rust.setInputMuted(muted: next); + if (!mounted) return; + setState(() => _inputMuted = next); } catch (e) { if (!mounted) return; setState(() => _error = e.toString()); @@ -822,6 +834,7 @@ class _BetaHomeState extends State<_BetaHome> { pttBoundKeyLabel: _pttBoundKeyLabel, onToggleMute: _onToggleHardMute, onConfigure: _onOpenVoiceSettings, + onBindKey: () => _onConfigurePtt(context), onLeave: _onLeaveVoice, ), const SizedBox(height: 12), diff --git a/apps/chanora_flutter/lib/widgets/voice_bar.dart b/apps/chanora_flutter/lib/widgets/voice_bar.dart index 0d55fae..7941035 100644 --- a/apps/chanora_flutter/lib/widgets/voice_bar.dart +++ b/apps/chanora_flutter/lib/widgets/voice_bar.dart @@ -27,6 +27,7 @@ class VoiceBar extends StatelessWidget { required this.pttBoundKeyLabel, required this.onToggleMute, required this.onConfigure, + required this.onBindKey, required this.onLeave, }); @@ -67,17 +68,21 @@ class VoiceBar extends StatelessWidget { /// Toggle the hard-mute clamp. final VoidCallback onToggleMute; - /// Open the voice settings dialog. + /// Open the voice settings dialog (mode + release tail). final VoidCallback onConfigure; + /// Open the bind-key capture flow directly (skips the settings + /// dialog). Surfaced from the capability badge when PTT mode is + /// active. + final VoidCallback onBindKey; + /// Leave the voice channel. final VoidCallback onLeave; String _modeLabel(AppL10n l10n) { switch (transmitMode) { case rust.BridgeTransmitMode.ptt: - final key = pttBoundKeyLabel.isEmpty ? '—' : pttBoundKeyLabel; - return '${l10n.voiceModePtt}: $key'; + return l10n.voiceModePtt; case rust.BridgeTransmitMode.continuous: return l10n.voiceModeContinuous; case rust.BridgeTransmitMode.voiceActivity: @@ -91,6 +96,7 @@ class VoiceBar extends StatelessWidget { final theme = Theme.of(context); final stats = audioStats; final levelActive = stats?.pttActive ?? false; + final isPtt = transmitMode == rust.BridgeTransmitMode.ptt; return Card( margin: EdgeInsets.zero, @@ -169,11 +175,16 @@ class VoiceBar extends StatelessWidget { ), ], ), - // Row 3: release tail hint (only meaningful for PTT mode) - if (transmitMode == rust.BridgeTransmitMode.ptt) + // Row 3: PTT-only secondary line — bound key + release + // tail. Hidden entirely for Continuous / Voice Activity + // so the bar stays focused on what's actually in use. + if (isPtt) Padding( padding: const EdgeInsets.only(left: 22, top: 2), child: Text( + '${l10n.voiceModePtt}: ' + '${pttBoundKeyLabel.isEmpty ? "—" : pttBoundKeyLabel}' + ' · ' '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, @@ -194,14 +205,17 @@ class VoiceBar extends StatelessWidget { style: theme.textTheme.bodySmall, ), const SizedBox(height: 6), - // PTT capability badge - PttCapabilityBadge( - level: pttLevel, - backendId: pttBackendId, - boundInputClass: pttBoundInputClass, - boundKeyLabel: pttBoundKeyLabel, - onConfigure: onConfigure, - ), + // PTT capability badge — only relevant when PTT mode is + // active. Hidden for Continuous / Voice Activity since + // there's no key binding to surface a capability for. + if (isPtt) + PttCapabilityBadge( + level: pttLevel, + backendId: pttBackendId, + boundInputClass: pttBoundInputClass, + boundKeyLabel: pttBoundKeyLabel, + onConfigure: onBindKey, + ), if (inChannel) ...[ const SizedBox(height: 6), Align( diff --git a/apps/chanora_flutter/lib/widgets/voice_settings.dart b/apps/chanora_flutter/lib/widgets/voice_settings.dart index f4f213b..daf2134 100644 --- a/apps/chanora_flutter/lib/widgets/voice_settings.dart +++ b/apps/chanora_flutter/lib/widgets/voice_settings.dart @@ -103,46 +103,53 @@ class _VoiceSettingsDialogState extends State { onChanged: null, ), const Divider(), - OutlinedButton.icon( - icon: const Icon(Icons.keyboard), - label: Text(l10n.voiceBindKeyAction), - onPressed: () { - Navigator.of(context).pop( - VoiceSettingsResult( - mode: _mode, - releaseTailMs: _releaseTail.round(), - bindKeyRequested: true, + // Bind-key + release-tail are PTT-only concepts. Hide + // them entirely when the user has switched to a + // non-PTT mode so the dialog stays focused on what's + // actually configurable for that mode. + if (_mode == rust.BridgeTransmitMode.ptt) ...[ + OutlinedButton.icon( + icon: const Icon(Icons.keyboard), + label: Text(l10n.voiceBindKeyAction), + onPressed: () { + Navigator.of(context).pop( + VoiceSettingsResult( + mode: _mode, + releaseTailMs: _releaseTail.round(), + bindKeyRequested: true, + ), + ); + }, + ), + const SizedBox(height: 8), + Text( + l10n.voiceReleaseTailLabel, + style: theme.textTheme.titleSmall, + ), + Row( + children: [ + Expanded( + child: Slider( + value: _releaseTail, + min: 0, + max: 500, + divisions: 20, // step 25 ms + label: + '${_releaseTail.round()}${l10n.voiceReleaseTailHint}', + onChanged: (v) => setState(() => _releaseTail = v), + ), ), - ); - }, - ), - const SizedBox(height: 8), - Text( - l10n.voiceReleaseTailLabel, - style: theme.textTheme.titleSmall, - ), - Row( - children: [ - Expanded( - child: Slider( - value: _releaseTail, - min: 0, - max: 500, - divisions: 20, // step 25 ms - label: '${_releaseTail.round()}${l10n.voiceReleaseTailHint}', - onChanged: (v) => setState(() => _releaseTail = v), + SizedBox( + width: 64, + child: Text( + '${_releaseTail.round()}${l10n.voiceReleaseTailHint}', + style: theme.textTheme.bodySmall, + textAlign: TextAlign.end, + ), ), - ), - SizedBox( - width: 64, - child: Text( - '${_releaseTail.round()}${l10n.voiceReleaseTailHint}', - style: theme.textTheme.bodySmall, - textAlign: TextAlign.end, - ), - ), - ], - ), + ], + ), + ], ], ), ),