Issue 1: in Continuous transmit mode the talk indicator turned
gray-out / mic disabled after ~30 s and could only be revived by
toggling mic mute. Root cause: SAD-079 MissedKeyUpWatchdog
subscribed to AudioTransmitGate.transmit_active and force-cleared
it after 30 s of true. In PTT mode this is correct (stuck key =
bug). In Continuous mode transmit_active is *supposed* to stay
true indefinitely; the watchdog assumption doesn't hold.
Fix: the watchdog now subscribes to a new ptt_held watch on the
TransmitModeSelector (the raw key-state input, not the resolved
gate). In Continuous mode ptt_held is never set true, so the
watchdog never fires. In PTT mode it still fires on a stuck
key-down as before. The session owns the watchdog (was on the
engine) so it survives engine restarts; it's spawned lazily on the
first start_audio.
MissedKeyUpWatchdog gains spawn_on_signal(rx, on_timeout, timeout)
alongside the existing spawn(gate, timeout) — old shape preserved
for backwards compat. run_watchdog generalised to take any
watch::Receiver<bool> + Box<dyn Fn() + Send + Sync>.
Two new tests:
- watchdog_on_signal_does_not_fire_when_ptt_held_stays_false
(the Continuous-mode regression test)
- watchdog_on_signal_fires_when_signal_stays_true
(the stuck-key case still fires)
Issue 2: the Voice Bar stats line said 'PTT on/off' even when the
user was in Continuous mode where no PTT key is involved. Renamed
to 'Mic on/off' (mode-neutral) and l10n-ised the on/off literal:
- en: 'Mic on' / 'Mic off'
- zh: '麦克风 开启' / '麦克风 关闭'
cargo test --workspace --lib: 80 passed / 0 failed / 1 ignored
(was 78, +2 watchdog tests).
flutter analyze: clean (6 pre-existing Radio.groupValue infos).
286 lines
9.8 KiB
Dart
286 lines
9.8 KiB
Dart
// Voice bar widget (SDD-097). Replaces the legacy `_AudioControls`
|
|
// block that used to live in `main.dart`. Driven by the
|
|
// `BridgeEvent::VoiceState` stream the bridge publishes from the
|
|
// core's transmit-mode selector + release-tail timer.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import '../main.dart' show PttCapabilityBadge;
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
/// Voice bar — surfaces the live voice state, mode badge, hard-mute
|
|
/// toggle, level meter, and a leave-channel affordance.
|
|
class VoiceBar extends StatelessWidget {
|
|
/// Construct a voice bar.
|
|
const VoiceBar({
|
|
super.key,
|
|
required this.inChannel,
|
|
required this.transmitMode,
|
|
required this.hardMute,
|
|
required this.outputMuted,
|
|
required this.releaseTailMs,
|
|
required this.channelName,
|
|
required this.audioStats,
|
|
required this.pttLevel,
|
|
required this.pttBackendId,
|
|
required this.pttBoundInputClass,
|
|
required this.pttBoundKeyLabel,
|
|
required this.onToggleMute,
|
|
required this.onToggleOutputMute,
|
|
required this.onConfigure,
|
|
required this.onLeave,
|
|
});
|
|
|
|
/// True when the session is currently joined to a voice channel.
|
|
final bool inChannel;
|
|
|
|
/// Active transmit mode.
|
|
final rust.BridgeTransmitMode transmitMode;
|
|
|
|
/// Hard-mute clamp state.
|
|
final bool hardMute;
|
|
|
|
/// Speaker (output) mute state. Mirrors the server-broadcast
|
|
/// `ClientOutputMuted` flag plus the engine's local output
|
|
/// silencer — toggling this hushes incoming voice immediately
|
|
/// AND tells the server so other clients see the headphone-off
|
|
/// icon next to our name.
|
|
final bool outputMuted;
|
|
|
|
/// Configured release-tail in milliseconds (0..=500). Surfaced as
|
|
/// a hint underneath the mode badge.
|
|
final int releaseTailMs;
|
|
|
|
/// Channel display name to show in the channel-name pill. Empty
|
|
/// string suppresses the pill (typically when `!inChannel`).
|
|
final String channelName;
|
|
|
|
/// Audio statistics (TX/RX frames + ptt_active) to drive the
|
|
/// level meter. Pass `null` to render an idle meter.
|
|
final rust.BridgeAudioStats? audioStats;
|
|
|
|
/// PTT capability badge inputs — passed through to
|
|
/// [`PttCapabilityBadge`].
|
|
final String pttLevel;
|
|
|
|
/// Backend id, e.g. `focused`, `windows-raw-input`.
|
|
final String pttBackendId;
|
|
|
|
/// Coarse bound input class.
|
|
final String pttBoundInputClass;
|
|
|
|
/// Platform-neutral key label captured by the binding dialog.
|
|
final String pttBoundKeyLabel;
|
|
|
|
/// Toggle the hard-mute clamp.
|
|
final VoidCallback onToggleMute;
|
|
|
|
/// Toggle speaker (output) mute.
|
|
final VoidCallback onToggleOutputMute;
|
|
|
|
/// Open the voice settings dialog. This is the SINGLE entry point
|
|
/// for transmit-mode selection, PTT key binding, and release-tail
|
|
/// configuration. The capability badge below is information-only
|
|
/// and intentionally does NOT have its own configure affordance.
|
|
final VoidCallback onConfigure;
|
|
|
|
/// Leave the voice channel.
|
|
final VoidCallback onLeave;
|
|
|
|
String _modeLabel(AppL10n l10n) {
|
|
switch (transmitMode) {
|
|
case rust.BridgeTransmitMode.ptt:
|
|
return l10n.voiceModePtt;
|
|
case rust.BridgeTransmitMode.continuous:
|
|
return l10n.voiceModeContinuous;
|
|
case rust.BridgeTransmitMode.voiceActivity:
|
|
return '${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})';
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
final stats = audioStats;
|
|
final levelActive = stats?.pttActive ?? false;
|
|
final isPtt = transmitMode == rust.BridgeTransmitMode.ptt;
|
|
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: channel pill + mute toggle
|
|
Row(
|
|
children: [
|
|
if (inChannel && channelName.isNotEmpty) ...[
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.tag,
|
|
size: 14,
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
channelName,
|
|
style: TextStyle(
|
|
color: theme.colorScheme.onPrimaryContainer,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const Spacer(),
|
|
IconButton(
|
|
tooltip: l10n.voiceOutputMuteLabel,
|
|
icon: Icon(
|
|
outputMuted ? Icons.headset_off : Icons.headset,
|
|
),
|
|
isSelected: outputMuted,
|
|
selectedIcon: const Icon(Icons.headset_off),
|
|
onPressed: onToggleOutputMute,
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.voiceHardMuteLabel,
|
|
icon: Icon(hardMute ? Icons.mic_off : Icons.mic),
|
|
isSelected: hardMute,
|
|
selectedIcon: const Icon(Icons.mic_off),
|
|
onPressed: onToggleMute,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 2: mode badge
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
transmitMode == rust.BridgeTransmitMode.ptt
|
|
? Icons.radio_button_checked
|
|
: Icons.podcasts,
|
|
size: 16,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
_modeLabel(l10n),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.tune, size: 16),
|
|
label: Text(l10n.voiceSettingsTitle),
|
|
onPressed: onConfigure,
|
|
),
|
|
],
|
|
),
|
|
// 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,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 4: level meter
|
|
_LevelMeter(active: levelActive),
|
|
const SizedBox(height: 4),
|
|
if (stats != null)
|
|
Text(
|
|
l10n.audioStatsLine(
|
|
stats.framesSent,
|
|
stats.framesReceived,
|
|
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
|
|
),
|
|
style: theme.textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 6),
|
|
// 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.
|
|
// The badge is information-only; the user reaches the
|
|
// bind-key flow through the Voice Bar's settings gear
|
|
// (single configuration entry point — see the comment
|
|
// on `onConfigure`).
|
|
if (isPtt)
|
|
PttCapabilityBadge(
|
|
level: pttLevel,
|
|
backendId: pttBackendId,
|
|
boundInputClass: pttBoundInputClass,
|
|
),
|
|
if (inChannel) ...[
|
|
const SizedBox(height: 6),
|
|
Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: TextButton.icon(
|
|
icon: const Icon(Icons.call_end),
|
|
label: Text(l10n.voiceLeaveAction),
|
|
onPressed: onLeave,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LevelMeter extends StatelessWidget {
|
|
const _LevelMeter({required this.active});
|
|
|
|
final bool active;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
height: 8,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: FractionallySizedBox(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
widthFactor: active ? 0.75 : 0.05,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: active
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.outlineVariant,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|