// 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 'ptt_capability_badge.dart'; import 'voice_compact.dart'; import 'voice_level_meter.dart'; import 'voice_platform.dart'; import 'voice_status_summary.dart'; 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.onConfigure, required this.onPttHeldChanged, this.talkPowerBlocked = false, this.inputLevel, }); final bool inChannel; final rust.BridgeTransmitMode transmitMode; final bool hardMute; final bool outputMuted; final bool talkPowerBlocked; /// 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; /// Real-time input level from the 30 Hz stream (dBFS). /// When non-null, takes precedence over `audioStats.inputLevel`. final double? inputLevel; /// 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; /// 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; /// Drive the press/release edges of the on-screen PTT button on /// touch-only mobile platforms (iOS / iPadOS / Android). On /// desktop platforms this callback is wired but never invoked /// because the on-screen button is only rendered on mobile. /// The callee should map `true` to `setPtt(active: true)` and /// `false` to `setPtt(active: false)`; the Rust release-tail /// timer handles the trailing tail (SDD-096). final ValueChanged onPttHeldChanged; @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; final summary = voiceStatusSummary( l10n: l10n, transmitMode: transmitMode, releaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, isTouchOnly: isTouchOnlyPttHost, inputMuted: hardMute, outputMuted: outputMuted, pttActive: stats?.pttActive ?? false, talkPower: talkPowerBlocked ? 0 : null, neededTalkPower: talkPowerBlocked ? 1 : null, talkPowerGranted: false, ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 0), child: Container( decoration: BoxDecoration( color: summary.talkPowerBlocked ? Colors.amber.withValues(alpha: 0.18) : summary.muted ? theme.colorScheme.errorContainer.withValues(alpha: 0.35) : theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: summary.talkPowerBlocked ? Colors.amber.shade700 : summary.muted ? theme.colorScheme.error : theme.colorScheme.outlineVariant, width: summary.talkPowerBlocked || summary.muted ? 1.5 : 0.5, ), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // Row 1: 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( summary.modeLabel, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), TextButton.icon( icon: const Icon(Icons.tune, size: 16), label: Text(l10n.voiceSettingsTitle), onPressed: onConfigure, ), ], ), // Status row: talk power / mic / speaker state. if (inChannel) ...[ const SizedBox(height: 2), Text( summary.talkPowerBlocked ? 'Insufficient talk power' : summary.statusText, style: theme.textTheme.bodySmall?.copyWith( color: summary.talkPowerBlocked ? Colors.amber.shade700 : summary.muted ? theme.colorScheme.error : theme.colorScheme.onSurfaceVariant, ), ), ], // Row 3: PTT-only secondary content. // // On hardware-keyboard hosts (Windows / macOS / Linux / // Web) this is a one-line bound-key + release-tail hint // sitting right under the mode badge. // // On touch-only hosts (iOS / iPadOS / Android) the // on-screen Push to Talk button is rendered AT THE // BOTTOM of the Voice Bar (see below) so it sits // closest to the user's thumb when the Voice Bar is // pinned to the bottom of a narrow-layout screen. The // release-tail value is folded into the small print // under the button rather than shown here. if (isPtt && !isTouchOnlyPttHost) 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 VoiceLevelMeter(active: levelActive, level: inputLevel ?? stats?.inputLevel), 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`). // // Still shown on touch-only mobile hosts because iOS P0 // acceptance requires an explicit `L0Focused` badge and // explanation that global hotkeys are not available in // the iOS sandbox. if (isPtt) PttCapabilityBadge( level: pttLevel, backendId: pttBackendId, boundInputClass: pttBoundInputClass, ), // On touch-only mobile hosts the Push to Talk button is // the LAST element of the Voice Bar so it lands closest // to the user's thumb when the Voice Bar is pinned to // the bottom of a narrow-layout screen. The release- // tail value sits above the button so the user sees // how long their voice continues after they let go. if (isPtt && isTouchOnlyPttHost) ...[ const SizedBox(height: 4), Center( child: Text( '${l10n.voiceReleaseTailLabel}: $releaseTailMs${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), const SizedBox(height: 8), VoicePttButton( active: levelActive, onHeldChanged: onPttHeldChanged, height: 64, borderRadius: 12, iconSize: 24, iconGap: 10, blurRadius: 12, spreadRadius: 0, listenForPan: true, labelLetterSpacing: null, ), ], // 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. ], ), ), ), ); } }