fix(ui,voice): five Voice Bar / settings UX bugs

1. Hard-mute now informs the server (setInputMuted) in addition to
   clamping the local TransmitGate. Without the server-side flag,
   other clients keep seeing us un-muted; without the local clamp
   a beat of in-flight audio leaks through. Drive both together so
   the mic icon and the actual silence land at the same time.

2. Split the badge's Configure affordance from the Voice Bar's
   'Voice settings' gear. The gear opens the mode + release-tail
   dialog (onConfigure); the badge's configure opens the bind-key
   capture flow directly (new onBindKey). Previously both routed
   to the settings dialog, so 'Voice settings' and the badge's
   'Configure' were the same screen — useless duplication.

3. Bind-key label is now PTT-only. The mode-badge row no longer
   prints 'PTT: Space' when Continuous / Voice Activity is
   selected. A new PTT-only secondary line carries the bound key
   plus the release-tail value together, hidden entirely for
   non-PTT modes.

4. Release-tail row is now PTT-only in BOTH the Voice Bar and the
   Voice settings dialog. The dialog previously kept the slider
   visible across all modes; switching to Continuous left the
   user staring at a control that did nothing.

5. PTT capability badge is now PTT-only. In Continuous and Voice
   Activity modes there is no key binding to surface a capability
   for, so the 'L0Focused (focused)' line + its info sheet and
   the Configure button disappear from the Voice Bar when the
   user isn't in PTT mode.

All five fixes are pure UI; no Rust changes needed. flutter analyze
remains clean (6 pre-existing Radio.groupValue deprecation infos).
This commit is contained in:
EdisonJwa
2026-05-15 23:34:53 +08:00
parent 7f4874f4c0
commit 6a41a0b4db
3 changed files with 85 additions and 51 deletions
+13
View File
@@ -356,7 +356,19 @@ class _BetaHomeState extends State<_BetaHome> {
Future<void> _onToggleHardMute() async { Future<void> _onToggleHardMute() async {
final next = !_hardMute; final next = !_hardMute;
try { 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.setHardMute(muted: next);
await rust.setInputMuted(muted: next);
if (!mounted) return;
setState(() => _inputMuted = next);
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() => _error = e.toString()); setState(() => _error = e.toString());
@@ -822,6 +834,7 @@ class _BetaHomeState extends State<_BetaHome> {
pttBoundKeyLabel: _pttBoundKeyLabel, pttBoundKeyLabel: _pttBoundKeyLabel,
onToggleMute: _onToggleHardMute, onToggleMute: _onToggleHardMute,
onConfigure: _onOpenVoiceSettings, onConfigure: _onOpenVoiceSettings,
onBindKey: () => _onConfigurePtt(context),
onLeave: _onLeaveVoice, onLeave: _onLeaveVoice,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
+27 -13
View File
@@ -27,6 +27,7 @@ class VoiceBar extends StatelessWidget {
required this.pttBoundKeyLabel, required this.pttBoundKeyLabel,
required this.onToggleMute, required this.onToggleMute,
required this.onConfigure, required this.onConfigure,
required this.onBindKey,
required this.onLeave, required this.onLeave,
}); });
@@ -67,17 +68,21 @@ class VoiceBar extends StatelessWidget {
/// Toggle the hard-mute clamp. /// Toggle the hard-mute clamp.
final VoidCallback onToggleMute; final VoidCallback onToggleMute;
/// Open the voice settings dialog. /// Open the voice settings dialog (mode + release tail).
final VoidCallback onConfigure; 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. /// Leave the voice channel.
final VoidCallback onLeave; final VoidCallback onLeave;
String _modeLabel(AppL10n l10n) { String _modeLabel(AppL10n l10n) {
switch (transmitMode) { switch (transmitMode) {
case rust.BridgeTransmitMode.ptt: case rust.BridgeTransmitMode.ptt:
final key = pttBoundKeyLabel.isEmpty ? '' : pttBoundKeyLabel; return l10n.voiceModePtt;
return '${l10n.voiceModePtt}: $key';
case rust.BridgeTransmitMode.continuous: case rust.BridgeTransmitMode.continuous:
return l10n.voiceModeContinuous; return l10n.voiceModeContinuous;
case rust.BridgeTransmitMode.voiceActivity: case rust.BridgeTransmitMode.voiceActivity:
@@ -91,6 +96,7 @@ class VoiceBar extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final stats = audioStats; final stats = audioStats;
final levelActive = stats?.pttActive ?? false; final levelActive = stats?.pttActive ?? false;
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
return Card( return Card(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
@@ -169,11 +175,16 @@ class VoiceBar extends StatelessWidget {
), ),
], ],
), ),
// Row 3: release tail hint (only meaningful for PTT mode) // Row 3: PTT-only secondary line — bound key + release
if (transmitMode == rust.BridgeTransmitMode.ptt) // tail. Hidden entirely for Continuous / Voice Activity
// so the bar stays focused on what's actually in use.
if (isPtt)
Padding( Padding(
padding: const EdgeInsets.only(left: 22, top: 2), padding: const EdgeInsets.only(left: 22, top: 2),
child: Text( child: Text(
'${l10n.voiceModePtt}: '
'${pttBoundKeyLabel.isEmpty ? "" : pttBoundKeyLabel}'
' · '
'${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
@@ -194,14 +205,17 @@ class VoiceBar extends StatelessWidget {
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
// PTT capability badge // PTT capability badge — only relevant when PTT mode is
PttCapabilityBadge( // active. Hidden for Continuous / Voice Activity since
level: pttLevel, // there's no key binding to surface a capability for.
backendId: pttBackendId, if (isPtt)
boundInputClass: pttBoundInputClass, PttCapabilityBadge(
boundKeyLabel: pttBoundKeyLabel, level: pttLevel,
onConfigure: onConfigure, backendId: pttBackendId,
), boundInputClass: pttBoundInputClass,
boundKeyLabel: pttBoundKeyLabel,
onConfigure: onBindKey,
),
if (inChannel) ...[ if (inChannel) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Align( Align(
@@ -103,46 +103,53 @@ class _VoiceSettingsDialogState extends State<VoiceSettingsDialog> {
onChanged: null, onChanged: null,
), ),
const Divider(), const Divider(),
OutlinedButton.icon( // Bind-key + release-tail are PTT-only concepts. Hide
icon: const Icon(Icons.keyboard), // them entirely when the user has switched to a
label: Text(l10n.voiceBindKeyAction), // non-PTT mode so the dialog stays focused on what's
onPressed: () { // actually configurable for that mode.
Navigator.of(context).pop( if (_mode == rust.BridgeTransmitMode.ptt) ...[
VoiceSettingsResult( OutlinedButton.icon(
mode: _mode, icon: const Icon(Icons.keyboard),
releaseTailMs: _releaseTail.round(), label: Text(l10n.voiceBindKeyAction),
bindKeyRequested: true, 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),
),
), ),
); SizedBox(
}, width: 64,
), child: Text(
const SizedBox(height: 8), '${_releaseTail.round()}${l10n.voiceReleaseTailHint}',
Text( style: theme.textTheme.bodySmall,
l10n.voiceReleaseTailLabel, textAlign: TextAlign.end,
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,
),
),
],
),
], ],
), ),
), ),