// 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, }); /// 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; 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. The pill is // wrapped in `Flexible` so very long channel names // truncate with an ellipsis instead of overflowing the // Voice Bar's column width (320 dp in the wide layout) // and pushing the mute icons under the adjacent channel // tree. Row( children: [ if (inChannel && channelName.isNotEmpty) ...[ Flexible( flex: 100, fit: FlexFit.loose, child: 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), Flexible( child: Text( channelName, maxLines: 1, overflow: TextOverflow.ellipsis, softWrap: false, style: TextStyle( color: theme.colorScheme.onPrimaryContainer, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), ], const SizedBox(width: 8), 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, ), // Leave-voice button intentionally absent: TeamSpeak's // model is "user is always in some channel", not // Discord's join/leave-voice. To stop being heard / // hearing others, mute mic and/or speaker via the // icons at the top of the bar. To physically move, // tap a different channel in the tree below. ], ), ), ); } } 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), ), ), ), ); } }