// 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 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../l10n/generated/app_localizations.dart'; import '../main.dart' show PttCapabilityBadge; import '../src/rust/api.dart' as rust; /// True when the host is a mobile platform without a hardware /// keyboard the user would bind a PTT key on. iOS / iPadOS / /// Android fall here. macOS / Linux / Windows / Web fall on the /// hardware-key path. bool get _isTouchOnlyPttHost { if (kIsWeb) return false; return Platform.isIOS || Platform.isAndroid; } /// 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.onPttHeldChanged, }); /// 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; /// 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; 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 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 _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`). // // Hidden on touch-only mobile hosts (iOS / iPadOS / // Android) because the capability story there is always // "L0Focused via on-screen button" and that's already // visually obvious from the PTT button being on the // bar. Showing a degraded-capability badge there would // be redundant + confusing. if (isPtt && !_isTouchOnlyPttHost) 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), _PttHoldButton( active: levelActive, onHeldChanged: onPttHeldChanged, ), ], // 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), ), ), ), ); } } /// On-screen push-to-talk button for touch-only mobile platforms /// (iOS / iPadOS / Android). Hardware-keyboard hosts hide this in /// favour of a bound key. /// /// Behaviour: /// * `onPanDown` (finger touches the button) → fires /// `onHeldChanged(true)`. The Rust release-tail timer treats /// this as `key_down`. /// * `onPanEnd` / `onPanCancel` (finger lifts or drags off) → /// fires `onHeldChanged(false)` → `key_up` → tail expires → /// mic closes. /// /// Using `GestureDetector` rather than `Listener` because we want /// gesture-arena semantics: if the user starts dragging the /// channel-tree underneath, the PTT should release. `onPanCancel` /// fires in that case. /// /// The button visually mirrors the `_LevelMeter` state via the /// `active` flag so the user gets feedback that holding actually /// engaged the mic. class _PttHoldButton extends StatefulWidget { const _PttHoldButton({required this.active, required this.onHeldChanged}); final bool active; final ValueChanged onHeldChanged; @override State<_PttHoldButton> createState() => _PttHoldButtonState(); } class _PttHoldButtonState extends State<_PttHoldButton> { bool _pressed = false; void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final activeNow = _pressed || widget.active; final l10n = AppL10n.of(context); return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), onPanDown: (_) => _setHeld(true), onPanEnd: (_) => _setHeld(false), onPanCancel: () => _setHeld(false), child: AnimatedContainer( duration: const Duration(milliseconds: 80), height: 64, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), blurRadius: 12, offset: const Offset(0, 2), ), ] : null, ), child: Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( activeNow ? Icons.mic : Icons.mic_none, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, size: 24, ), const SizedBox(width: 10), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, ), ), ], ), ), ), ); } }