// 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.releaseTailMs, required this.channelName, required this.audioStats, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.pttBoundKeyLabel, required this.onToggleMute, required this.onConfigure, required this.onBindKey, 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; /// 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; /// 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: 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.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 ? 'on' : 'off', ), 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. if (isPtt) PttCapabilityBadge( level: pttLevel, backendId: pttBackendId, boundInputClass: pttBoundInputClass, boundKeyLabel: pttBoundKeyLabel, onConfigure: onBindKey, ), 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), ), ), ), ); } }