// Compact voice UI for narrow / mobile layouts (Plan E hybrid: // AppBar mutes + status chip with 2-line live readout + wide bottom- // anchored PTT button + modal sheet for non-essential controls). // // rc.8 follow-up: the AppBar gear icon was removed; the modal sheet // is now the **single** voice-controls surface on mobile. Mode + // release-tail are surfaced inline (radio buttons + slider) inside // the modal. Audio output route picker is driven by `audio_session` // (Ryan Heise, 865k downloads): we enumerate AVAudioSession's // available inputs + current route ourselves and render a Discord/ // WhatsApp-style 'Choose audio' bottom-sheet. Switching is via // AVAudioSession.setPreferredInput(port) + overrideOutputAudioPort // (.speaker | .none). We previously tried `audio_router 1.1.1` // whose iOS path is AVRoutePickerView (the AirPlay button) \u2014 // wrong UI: that only lists AirPlay output destinations, not the // speaker/receiver/Bluetooth choices we want. import 'dart:async' show StreamSubscription, Timer, unawaited; import 'dart:io' show Platform; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:haptic_kit/haptic_kit.dart'; import '../l10n/generated/app_localizations.dart'; import 'ptt_capability_badge.dart'; import '../src/rust/api.dart' as rust; /// Two-line status chip that summarises the current voice state. /// Tap to open the voice details modal. class VoiceStatusChip extends StatelessWidget { /// Construct a status chip. const VoiceStatusChip({ super.key, required this.transmitMode, required this.releaseTailMs, required this.pttBoundKeyLabel, required this.audioStats, required this.isTouchOnly, required this.onTap, }); /// Current transmit mode. final rust.BridgeTransmitMode transmitMode; /// Release-tail in milliseconds. final int releaseTailMs; /// Bound key label (empty on touch-only hosts). final String pttBoundKeyLabel; /// Current audio stats; null while audio engine not running. final rust.BridgeAudioStats? audioStats; /// True on iOS / iPadOS / Android. final bool isTouchOnly; /// Open the voice details modal. final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final stats = audioStats; // For PTT: pttActive = button held. // For Continuous: pttActive = always true (always transmitting). // For VoiceActivity: pttActive = VAD gate open (speech detected). final micOn = switch (transmitMode) { rust.BridgeTransmitMode.continuous => true, _ => stats?.pttActive ?? false, }; final modeLabel = switch (transmitMode) { rust.BridgeTransmitMode.ptt => l10n.voiceModePtt, rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous, rust.BridgeTransmitMode.voiceActivity => l10n.voiceModeVoiceActivity, }; String line1; if (transmitMode == rust.BridgeTransmitMode.ptt) { if (isTouchOnly) { line1 = '$modeLabel \u00b7 ${l10n.voicePttHoldHint}'; } else { line1 = '$modeLabel \u00b7 ${pttBoundKeyLabel.isEmpty ? "\u2014" : pttBoundKeyLabel}'; } } else { line1 = modeLabel; } final tailText = transmitMode == rust.BridgeTransmitMode.ptt ? '$releaseTailMs${l10n.voiceReleaseTailHint} ${l10n.voiceReleaseTailLabel.toLowerCase()}' : null; final micText = micOn ? l10n.voiceMicOn : l10n.voiceMicOff; final line2 = tailText == null ? micText : '$tailText \u00b7 $micText'; return Semantics( button: true, label: '${l10n.voiceSheetTitle}: $line1, $line2', hint: l10n.voiceSettingsTitle, child: Material( type: MaterialType.transparency, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: ExcludeSemantics( child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.outlineVariant, width: 0.5, ), ), child: Row( children: [ Icon( micOn ? Icons.fiber_manual_record : Icons.fiber_manual_record_outlined, size: 12, color: micOn ? theme.colorScheme.primary : theme.colorScheme.outline, ), const SizedBox(width: 8), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( line1, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), ), Text( line2, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), ), const SizedBox(width: 8), Icon( Icons.expand_less, size: 18, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ), ), ), ); } } /// Wide, bottom-anchored push-to-talk button. class VoicePttButton extends StatefulWidget { /// Construct a PTT button. const VoicePttButton({ super.key, required this.active, required this.onHeldChanged, }); /// True while the engine reports the gate open. final bool active; /// Called with `true` on finger-down, `false` on finger-up or cancel. final ValueChanged onHeldChanged; @override State createState() => _VoicePttButtonState(); } class _VoicePttButtonState extends State { bool _pressed = false; @override void initState() { super.initState(); unawaited(Haptics.prepare().catchError((_) => false)); } void _setHeld(bool held) { if (_pressed == held) return; setState(() => _pressed = held); widget.onHeldChanged(held); _playPressHaptic(held); } void _playPressHaptic(bool held) { final haptic = held ? Haptics.impact(HapticImpactStyle.medium) : Haptics.selection(); unawaited(haptic.catchError((_) {})); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final activeNow = _pressed || widget.active; return Semantics( button: true, liveRegion: true, label: activeNow ? l10n.pttTransmitting : l10n.pttHoldToTalk, hint: l10n.pttHoldToTalkSemanticsHint, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => _setHeld(true), onTapUp: (_) => _setHeld(false), onTapCancel: () => _setHeld(false), child: ExcludeSemantics( child: AnimatedContainer( duration: const Duration(milliseconds: 80), height: 56, decoration: BoxDecoration( color: activeNow ? theme.colorScheme.primary : theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(16), boxShadow: activeNow ? [ BoxShadow( color: theme.colorScheme.primary.withAlpha(100), blurRadius: 16, spreadRadius: 2, 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: 28, ), const SizedBox(width: 12), Text( activeNow ? l10n.voiceMicOn : l10n.voiceModePtt, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, letterSpacing: 0.4, color: activeNow ? theme.colorScheme.onPrimary : theme.colorScheme.onPrimaryContainer, ), ), ], ), ), ), ), ), ); } } /// Show the voice controls modal sheet — the single voice-controls /// surface on mobile. Tiles: /// 1. Audio output route picker (iOS native AVRoutePickerView / /// Android Material 3 list). Mobile only. /// 2. Mode radio buttons (PTT / Continuous / Voice Activity). /// 3. Release-tail slider (PTT-only). /// 4. Mic level meter + frame counts. /// 5. Audio processing (NS · AEC · AGC · HPF · VAD) — all modes. /// 6. PTT capability badge. /// /// Mode + release-tail are inlined directly here instead of being /// hidden behind an "Adjust" button → nested dialog. Single-screen /// control panel, zero navigation depth. `onModeChanged` and /// `onReleaseTailChanged` are debounced by the caller so users can /// drag the slider freely. Future showVoiceDetailsSheet( BuildContext context, { required rust.BridgeTransmitMode transmitMode, required int releaseTailMs, required String pttBoundKeyLabel, required String pttLevel, required String pttBackendId, required String pttBoundInputClass, required bool isTouchOnly, required rust.BridgeAudioProcessingConfig initialAudioConfig, required ValueChanged onModeChanged, required ValueChanged onReleaseTailChanged, required ValueChanged onAudioConfigChanged, }) async { await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, useSafeArea: true, builder: (ctx) { return DraggableScrollableSheet( initialChildSize: 0.6, minChildSize: 0.3, maxChildSize: 0.95, expand: false, snap: true, snapSizes: const [0.3, 0.6, 0.95], builder: (ctx, scrollController) => _VoiceSheetBody( scrollController: scrollController, initialMode: transmitMode, initialReleaseTailMs: releaseTailMs, pttBoundKeyLabel: pttBoundKeyLabel, pttLevel: pttLevel, pttBackendId: pttBackendId, pttBoundInputClass: pttBoundInputClass, isTouchOnly: isTouchOnly, initialAudioConfig: initialAudioConfig, onModeChanged: onModeChanged, onReleaseTailChanged: onReleaseTailChanged, onAudioConfigChanged: onAudioConfigChanged, ), ); }, ); } class _VoiceSheetBody extends StatefulWidget { const _VoiceSheetBody({ required this.scrollController, required this.initialMode, required this.initialReleaseTailMs, required this.pttBoundKeyLabel, required this.pttLevel, required this.pttBackendId, required this.pttBoundInputClass, required this.isTouchOnly, required this.initialAudioConfig, required this.onModeChanged, required this.onReleaseTailChanged, required this.onAudioConfigChanged, }); final ScrollController scrollController; final rust.BridgeTransmitMode initialMode; final int initialReleaseTailMs; final String pttBoundKeyLabel; final String pttLevel; final String pttBackendId; final String pttBoundInputClass; final bool isTouchOnly; final rust.BridgeAudioProcessingConfig initialAudioConfig; final ValueChanged onModeChanged; final ValueChanged onReleaseTailChanged; final ValueChanged onAudioConfigChanged; @override State<_VoiceSheetBody> createState() => _VoiceSheetBodyState(); } class _VoiceSheetBodyState extends State<_VoiceSheetBody> { late rust.BridgeTransmitMode _mode = widget.initialMode; late int _tail = widget.initialReleaseTailMs; // Live stats — polled by this widget's own timer so TX/RX update // in real time while the sheet is open, independent of the parent. rust.BridgeAudioStats? _stats; Timer? _statsTimer; // Previous snapshot for computing per-second rates. int _prevSent = 0; int _prevReceived = 0; int _txRate = 0; // frames/s int _rxRate = 0; // frames/s int _rateTickCount = 0; // Audio processing state. late bool _nsEnabled; late bool _aecEnabled; late bool _agcEnabled; late bool _hpfEnabled; late rust.BridgeVadBackend _vadBackend; @override void initState() { super.initState(); final c = widget.initialAudioConfig; _nsEnabled = c.ns != rust.BridgeEffectOwner.off; _aecEnabled = c.aec != rust.BridgeEffectOwner.off; _agcEnabled = c.agc != rust.BridgeEffectOwner.off; _hpfEnabled = c.hpfEnabled; _vadBackend = c.vadBackend == rust.BridgeVadBackend.disabled ? rust.BridgeVadBackend.webrtcVad : c.vadBackend; // Poll audio stats at 80 ms so TX/RX counters and the level meter // update in real time while the sheet is open, independent of the parent. _statsTimer = Timer.periodic(const Duration(milliseconds: 80), (_) async { try { final s = await rust.audioStats(); if (!mounted) return; setState(() { _stats = s; _rateTickCount++; // Compute rates every ~960 ms (12 × 80 ms). if (_rateTickCount >= 12) { _txRate = s.framesSent - _prevSent; _rxRate = s.framesReceived - _prevReceived; _prevSent = s.framesSent; _prevReceived = s.framesReceived; _rateTickCount = 0; } }); } catch (_) {} }); } @override void dispose() { _statsTimer?.cancel(); super.dispose(); } rust.BridgeAudioProcessingConfig _buildConfig() { final c = widget.initialAudioConfig; final isSonora = c.iosMode == rust.BridgeIosVoiceProcessingMode.sonoraExperimental; // VPIO owns enabled effects on the default path. Sonora owns them only in // the experimental raw path. final aecOwner = isSonora ? (_aecEnabled ? rust.BridgeEffectOwner.sonora : rust.BridgeEffectOwner.off) : rust.BridgeEffectOwner.platform; final nsOwner = isSonora ? (_nsEnabled ? rust.BridgeEffectOwner.sonora : rust.BridgeEffectOwner.off) : (_nsEnabled ? rust.BridgeEffectOwner.platform : rust.BridgeEffectOwner.off); final agcOwner = isSonora ? (_agcEnabled ? rust.BridgeEffectOwner.sonora : rust.BridgeEffectOwner.off) : (_agcEnabled ? rust.BridgeEffectOwner.platform : rust.BridgeEffectOwner.off); return rust.BridgeAudioProcessingConfig( route: c.route, iosMode: c.iosMode, processingBackend: c.processingBackend, vadBackend: _vadBackend == rust.BridgeVadBackend.disabled ? rust.BridgeVadBackend.webrtcVad : _vadBackend, aec: aecOwner, ns: nsOwner, agc: agcOwner, hpfEnabled: _hpfEnabled, limiterEnabled: c.limiterEnabled, vadHangoverMs: c.vadHangoverMs, vadPreRollMs: c.vadPreRollMs, vadMinTxMs: c.vadMinTxMs, debugWavDumpEnabled: c.debugWavDumpEnabled, ); } void _notifyAudioConfig() { widget.onAudioConfigChanged(_buildConfig()); } void _setMode(rust.BridgeTransmitMode m) { if (m == _mode) return; setState(() => _mode = m); unawaited(Haptics.selection().catchError((_) {})); widget.onModeChanged(m); } void _setTail(double v) { final ms = v.round(); setState(() => _tail = ms); widget.onReleaseTailChanged(ms); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); final stats = _stats; // Level meter active = transmitting (any mode). final levelActive = switch (_mode) { rust.BridgeTransmitMode.continuous => true, _ => stats?.pttActive ?? false, }; final isPtt = _mode == rust.BridgeTransmitMode.ptt; // Route picker is mobile-only. iOS uses AVAudioSession below; // Android uses AudioManager through a MethodChannel. final showRoutePicker = !kIsWeb && (Platform.isIOS || Platform.isAndroid); return SafeArea( child: SingleChildScrollView( controller: widget.scrollController, padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.voiceSheetTitle, style: theme.textTheme.titleLarge), const SizedBox(height: 12), // 1) Audio output route picker tile (mobile only). if (showRoutePicker) ...[ const _AudioOutputTile(), const SizedBox(height: 8), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), ], // 2) Mode \u2014 inline radio rows. Text( l10n.voiceModeLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), _ModeRow( label: l10n.voiceModePtt, icon: Icons.radio_button_checked, selected: _mode == rust.BridgeTransmitMode.ptt, onTap: () => _setMode(rust.BridgeTransmitMode.ptt), ), _ModeRow( label: l10n.voiceModeContinuous, icon: Icons.podcasts, selected: _mode == rust.BridgeTransmitMode.continuous, onTap: () => _setMode(rust.BridgeTransmitMode.continuous), ), _ModeRow( label: l10n.voiceModeVoiceActivity, icon: Icons.graphic_eq, selected: _mode == rust.BridgeTransmitMode.voiceActivity, onTap: () => _setMode(rust.BridgeTransmitMode.voiceActivity), ), // 3) Release-tail slider (PTT only). if (isPtt) ...[ const SizedBox(height: 8), Row( children: [ Text( l10n.voiceReleaseTailLabel, style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), Text( '$_tail${l10n.voiceReleaseTailHint}', style: theme.textTheme.bodyMedium?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), Slider( value: _tail.toDouble().clamp(0, 500), min: 0, max: 500, divisions: 20, label: '$_tail ms', onChanged: _setTail, ), // Desktop-only: surface the bound key so the user // sees what hardware key is wired. On mobile this // row is suppressed (there is no hardware key; the // PTT button is the input). if (!widget.isTouchOnly && widget.pttBoundKeyLabel.isNotEmpty) ...[ Padding( padding: const EdgeInsets.only(left: 4), child: Text( '${l10n.voiceBoundKeyLabel}: ${widget.pttBoundKeyLabel}', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ], const SizedBox(height: 16), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 12), // 4) Level meter + live TX/RX stats. _LevelMeter(active: levelActive), const SizedBox(height: 6), _StatsRow( txRate: _txRate, rxRate: _rxRate, totalSent: stats?.framesSent ?? 0, totalReceived: stats?.framesReceived ?? 0, transmitting: levelActive, ), // 5) Audio processing — always visible, all modes. const SizedBox(height: 12), Divider(height: 1, color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), Text( 'Audio processing', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 4), _AudioToggleRow( label: 'Noise suppression', subtitle: 'Wiener filter', value: _nsEnabled, onChanged: (v) { setState(() => _nsEnabled = v); _notifyAudioConfig(); }, ), _AudioToggleRow( label: 'Echo cancellation', subtitle: widget.initialAudioConfig.iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing ? 'Always on · managed by platform VPIO' : 'AEC3 adaptive filter · 80 ms tail', value: widget.initialAudioConfig.iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing ? true // always on in VPIO : _aecEnabled, // AEC is always on in VPIO — disable the toggle. onChanged: widget.initialAudioConfig.iosMode == rust.BridgeIosVoiceProcessingMode.platformVoiceProcessing ? null : (v) { setState(() => _aecEnabled = v); _notifyAudioConfig(); }, ), _AudioToggleRow( label: 'Auto gain control', subtitle: 'AGC2 · −18 dBFS target', value: _agcEnabled, onChanged: (v) { setState(() => _agcEnabled = v); _notifyAudioConfig(); }, ), _AudioToggleRow( label: 'High-pass filter', subtitle: '80 Hz · DC removal', value: _hpfEnabled, onChanged: (v) { setState(() => _hpfEnabled = v); _notifyAudioConfig(); }, ), // VAD backend selector. const SizedBox(height: 8), Text( 'Voice activity detection', style: theme.textTheme.labelLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: 2), _VadBackendRow( value: _vadBackend, onChanged: (v) { setState(() => _vadBackend = v); _notifyAudioConfig(); }, ), // 6) PTT capability badge. On iOS this must remain // visible even though the resolved level is always // `L0Focused`, because the P0 acceptance flow requires // honest capability advertising with an explanation of // the sandbox limitation. if (isPtt) ...[ const SizedBox(height: 12), PttCapabilityBadge( level: widget.pttLevel, backendId: widget.pttBackendId, boundInputClass: widget.pttBoundInputClass, ), ], ], ), ), ); } } // ── Live TX/RX stats row ────────────────────────────────────────────────── class _StatsRow extends StatelessWidget { const _StatsRow({ required this.txRate, required this.rxRate, required this.totalSent, required this.totalReceived, required this.transmitting, }); final int txRate; final int rxRate; final int totalSent; final int totalReceived; final bool transmitting; @override Widget build(BuildContext context) { final theme = Theme.of(context); final txColor = transmitting ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant; return Row( children: [ // TX Icon(Icons.upload, size: 12, color: txColor), const SizedBox(width: 3), Text( 'TX $txRate/s · $totalSent', style: theme.textTheme.bodySmall?.copyWith(color: txColor), ), const SizedBox(width: 12), // RX Icon( Icons.download, size: 12, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 3), Text( 'RX $rxRate/s · $totalReceived', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ); } } // ── Audio processing helper widgets ────────────────────────────────────── /// Compact toggle row for a single DSP stage. class _AudioToggleRow extends StatelessWidget { const _AudioToggleRow({ required this.label, required this.subtitle, required this.value, required this.onChanged, }); final String label; final String subtitle; final bool value; final ValueChanged? onChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); final disabled = onChanged == null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: theme.textTheme.bodyMedium?.copyWith( color: disabled ? theme.colorScheme.onSurfaceVariant.withAlpha(120) : null, ), ), Text( subtitle, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant.withAlpha( disabled ? 80 : 160, ), ), ), ], ), ), Switch(value: value, onChanged: onChanged), ], ), ); } } /// Segmented VAD backend selector. class _VadBackendRow extends StatelessWidget { const _VadBackendRow({required this.value, required this.onChanged}); final rust.BridgeVadBackend value; final ValueChanged onChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); return SegmentedButton( style: SegmentedButton.styleFrom( textStyle: theme.textTheme.labelSmall, visualDensity: VisualDensity.compact, ), segments: const [ ButtonSegment( value: rust.BridgeVadBackend.webrtcVad, label: Text('WebRTC'), icon: Icon(Icons.speed, size: 14), ), ButtonSegment( value: rust.BridgeVadBackend.sileroOnnx, label: Text('Silero'), icon: Icon(Icons.psychology, size: 14), ), ButtonSegment( value: rust.BridgeVadBackend.tenVad, label: Text('TEN'), icon: Icon(Icons.graphic_eq, size: 14), ), ], selected: {value}, onSelectionChanged: (s) => onChanged(s.first), ); } } // ── Mode row ────────────────────────────────────────────────────────────── class _ModeRow extends StatelessWidget { const _ModeRow({ required this.label, required this.icon, required this.selected, required this.onTap, }); final String label; final IconData icon; final bool selected; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final disabled = onTap == null; final color = disabled ? theme.colorScheme.onSurfaceVariant.withAlpha(120) : selected ? theme.colorScheme.primary : theme.colorScheme.onSurface; return Semantics( button: true, selected: selected, enabled: !disabled, label: label, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: ExcludeSemantics( child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon( selected ? Icons.radio_button_checked : Icons.radio_button_unchecked, size: 20, color: color, ), const SizedBox(width: 12), Icon(icon, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Text( label, style: theme.textTheme.bodyLarge?.copyWith(color: color), ), ), ], ), ), ), ), ); } } /// Tile that displays the current audio output route + opens the /// native picker on tap. Subscribes to `currentDeviceStream` so the /// row auto-updates when the user plugs in headphones, connects /// AirPods, etc. /// Tile that displays the **active** audio output port (Speaker / /// iPhone receiver / AirPods / wired headset / etc.) and opens a /// 'Choose audio' bottom sheet on tap. Backed by `audio_session`: /// /// * `AVAudioSession.currentRoute.outputs` for the active label. /// * `AVAudioSession.availableInputs` for the picker list of /// selectable inputs (Built-in mic, BT HFP, wired headset, USB). /// * `routeChangeStream` for live updates. /// * `setPreferredInput(port)` for input selection (also moves /// the matching output for HFP/headset/wired). /// * `overrideOutputAudioPort(.speaker | .none)` for the /// speakerphone <-> earpiece toggle. /// /// This is the same primitive used by Discord / WhatsApp / FaceTime /// for their VoIP audio chooser. It is NOT the AirPlay picker /// (`AVRoutePickerView`), which is a different UI for streaming /// audio to other devices. class _AudioOutputTile extends StatefulWidget { const _AudioOutputTile(); @override State<_AudioOutputTile> createState() => _AudioOutputTileState(); } class _AudioOutputTileState extends State<_AudioOutputTile> { static const _androidOutputChannel = MethodChannel('app.audio_output'); static const _androidOutputEvents = EventChannel('app.audio_output/events'); AVAudioSessionPortDescription? _activeOutput; StreamSubscription? _routeSub; StreamSubscription? _androidOutputSub; List<_AndroidAudioOutputDevice> _androidDevices = const []; bool _androidLoading = false; @override void initState() { super.initState(); if (!kIsWeb && Platform.isAndroid) { _refreshAndroidDevices(); _androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen(( _, ) { if (!mounted) return; _refreshAndroidDevices(); }); return; } _refresh(); // Live updates when the user plugs / unplugs / connects a // headset / BT device while the modal sheet is open. _routeSub = AVAudioSession().routeChangeStream.listen((_) { if (!mounted) return; _refresh(); }); } @override void dispose() { _routeSub?.cancel(); _routeSub = null; _androidOutputSub?.cancel(); _androidOutputSub = null; super.dispose(); } Future _refreshAndroidDevices() async { if (_androidLoading) return; setState(() => _androidLoading = true); try { final raw = await _androidOutputChannel.invokeListMethod( 'getOutputDevices', ) ?? const []; final devices = raw .whereType>() .map(_AndroidAudioOutputDevice.fromMap) .toList(); if (!mounted) return; setState(() { _androidDevices = devices; _androidLoading = false; }); } catch (_) { if (!mounted) return; setState(() => _androidLoading = false); } } Future _refresh() async { try { final route = await AVAudioSession().currentRoute; if (!mounted) return; setState(() { // The 'output' port we want to display is whichever output // the system has currently routed to. There is normally one. _activeOutput = route.outputs.isEmpty ? null : route.outputs.first; }); } catch (_) { // Suppress \u2014 AVAudioSession may transiently throw on first // call before the session is active. } } static String _portLabel( AVAudioSessionPort? type, String fallback, AppL10n l10n, ) { switch (type) { case AVAudioSessionPort.builtInSpeaker: return l10n.audioRouteSpeaker; case AVAudioSessionPort.builtInReceiver: return l10n.audioRouteReceiver; case AVAudioSessionPort.bluetoothHfp: case AVAudioSessionPort.bluetoothA2dp: case AVAudioSessionPort.bluetoothLe: return fallback.isEmpty ? l10n.audioRouteBluetooth : fallback; case AVAudioSessionPort.headphones: case AVAudioSessionPort.headsetMic: return fallback.isEmpty ? l10n.audioRouteWiredHeadset : fallback; case AVAudioSessionPort.carAudio: return l10n.audioRouteCarAudio; case AVAudioSessionPort.airPlay: return l10n.audioRouteAirplay; case AVAudioSessionPort.builtInMic: // Built-in mic is implied 'iPhone' \u2014 only seen if we somehow // end up with an input listed as an output. return fallback.isEmpty ? l10n.audioRouteReceiver : fallback; case null: default: return fallback.isEmpty ? l10n.audioRouteUnknown : fallback; } } static IconData _portIcon(AVAudioSessionPort? type) { switch (type) { case AVAudioSessionPort.builtInSpeaker: return Icons.volume_up; case AVAudioSessionPort.builtInReceiver: return Icons.phone_in_talk; case AVAudioSessionPort.bluetoothHfp: case AVAudioSessionPort.bluetoothA2dp: case AVAudioSessionPort.bluetoothLe: return Icons.bluetooth_audio; case AVAudioSessionPort.headphones: case AVAudioSessionPort.headsetMic: return Icons.headset; case AVAudioSessionPort.carAudio: return Icons.directions_car; case AVAudioSessionPort.airPlay: return Icons.airplay; default: return Icons.speaker; } } Future _openPicker() async { if (!kIsWeb && Platform.isAndroid) { final selectedDeviceId = await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) => _AndroidAudioOutputPickerSheet( devices: _androidDevices, loading: _androidLoading, onRefresh: _refreshAndroidDevices, ), ); if (selectedDeviceId == null) return; try { if (selectedDeviceId == 'auto') { await _androidOutputChannel.invokeMethod( 'clearCommunicationDevice', ); } else { final changed = await _androidOutputChannel.invokeMethod( 'setCommunicationDevice', {'deviceId': selectedDeviceId}, ); if (changed != true && mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppL10n.of(context).audioRouteCannotSelect), ), ); } } await _refreshAndroidDevices(); } catch (_) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppL10n.of(context).audioRouteChangeFailed)), ); } return; } await showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (ctx) { return const _AudioOutputPickerSheet(); }, ); // Refresh after the picker closes (user may have changed route). await _refresh(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); if (!kIsWeb && Platform.isAndroid) { final selected = _androidDevices.where((d) => d.isSelected).firstOrNull; final label = selected == null ? l10n.audioRouteSystemDefault : _androidDeviceLabel(selected.type, selected.name, l10n); return InkWell( onTap: _openPicker, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon( _androidDeviceIcon(selected?.type), color: theme.colorScheme.primary, ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.audioOutputLabel, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( _androidLoading ? '${l10n.audioRouteUnknown}…' : label, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, ), ), ], ), ), Icon( Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ); } final port = _activeOutput; final label = _portLabel(port?.portType, port?.portName ?? '', l10n); return InkWell( onTap: _openPicker, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row( children: [ Icon(_portIcon(port?.portType), color: theme.colorScheme.primary), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.audioOutputLabel, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), Text( label, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.w500, ), ), ], ), ), Icon( Icons.chevron_right, color: theme.colorScheme.onSurfaceVariant, ), ], ), ), ); } static String _androidDeviceLabel( String type, String fallback, AppL10n l10n, ) => switch (type) { 'speaker' => l10n.audioRouteSpeaker, 'earpiece' => l10n.audioRouteEarpiece, 'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset, 'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => l10n.audioRouteBluetooth, 'usbHeadset' => fallback.isEmpty ? l10n.audioRouteUsbHeadset : fallback, 'hdmi' => l10n.audioRouteCarAudio, _ => fallback.isEmpty ? l10n.audioRouteOtherDevice : fallback, }; static IconData _androidDeviceIcon(String? type) => switch (type) { 'speaker' => Icons.volume_up, 'earpiece' => Icons.phone_in_talk, 'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset, 'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio, 'hdmi' => Icons.tv, _ => Icons.speaker, }; } class _AndroidAudioOutputPickerSheet extends StatelessWidget { const _AndroidAudioOutputPickerSheet({ required this.devices, required this.loading, required this.onRefresh, }); final List<_AndroidAudioOutputDevice> devices; final bool loading; final Future Function() onRefresh; @override Widget build(BuildContext context) { final l10n = AppL10n.of(context); final theme = Theme.of(context); return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), const SizedBox(height: 16), _PickerRow( icon: Icons.speaker, label: l10n.audioRouteSystemDefault, selected: !devices.any((d) => d.isSelected), onTap: () => Navigator.of(context).pop('auto'), ), if (loading) const Padding( padding: EdgeInsets.symmetric(vertical: 24), child: Center(child: CircularProgressIndicator()), ) else if (devices.isEmpty) TextButton.icon( onPressed: onRefresh, icon: const Icon(Icons.refresh), label: Text(l10n.audioRouteRefreshDevices), ) else for (final device in devices) _PickerRow( icon: _AudioOutputTileState._androidDeviceIcon(device.type), label: _AudioOutputTileState._androidDeviceLabel( device.type, device.name, l10n, ), selected: device.isSelected, onTap: device.isAvailableForCommunication ? () => Navigator.of(context).pop(device.id) : null, ), ], ), ), ); } } class _AndroidAudioOutputDevice { const _AndroidAudioOutputDevice({ required this.id, required this.name, required this.type, required this.isSelected, required this.isAvailableForCommunication, }); final String id; final String name; final String type; final bool isSelected; final bool isAvailableForCommunication; factory _AndroidAudioOutputDevice.fromMap(Map map) { return _AndroidAudioOutputDevice( id: map['id']?.toString() ?? '', name: map['name']?.toString() ?? '', type: map['type']?.toString() ?? 'unknown', isSelected: map['isSelected'] == true, isAvailableForCommunication: map['isAvailableForCommunication'] == true, ); } } /// 'Choose audio' bottom sheet that lists every selectable route /// (Speaker, iPhone receiver, every connected BT / wired / USB /// input). Tap to switch; Speaker / Receiver use /// `overrideOutputAudioPort(.speaker | .none)`, other ports use /// `setPreferredInput(port)` which also moves the paired output /// (e.g. AirPods). class _AudioOutputPickerSheet extends StatefulWidget { const _AudioOutputPickerSheet(); @override State<_AudioOutputPickerSheet> createState() => _AudioOutputPickerSheetState(); } class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Set _availableInputs = const {}; AVAudioSessionRouteDescription? _route; StreamSubscription? _routeSub; bool _loading = true; @override void initState() { super.initState(); _refresh(); _routeSub = AVAudioSession().routeChangeStream.listen((_) { if (!mounted) return; _refresh(); }); } @override void dispose() { _routeSub?.cancel(); _routeSub = null; super.dispose(); } Future _refresh() async { try { final session = AVAudioSession(); final inputs = await session.availableInputs; final route = await session.currentRoute; if (!mounted) return; setState(() { _availableInputs = inputs; _route = route; _loading = false; }); } catch (_) { if (!mounted) return; setState(() => _loading = false); } } Future _selectSpeaker() async { try { // Apple-documented quirk: in .voiceChat mode, calling // setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker) // causes iOS to recalculate the route. Built-in mic naturally // pairs with the receiver (not the speaker), so the system // SILENTLY REVERTS the speaker override and routes audio // back through the earpiece. Net: the await chain returns // successfully ('no exception'), but the user hears no // change. // // Fix: do NOT call setPreferredInput when forcing speaker. // The speaker override is sufficient on its own \u2014 input // remains on whatever the system was already using (built-in // mic by default, or BT/wired if connected and selected // elsewhere). For consistency, only switch input when the // user explicitly picks a non-speaker input row. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.speaker, ); unawaited(Haptics.selection().catchError((_) {})); debugPrint('chanora: audio output -> speakerphone (override applied)'); } catch (e, st) { debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } Future _selectReceiver() async { try { // Same quirk in reverse: removing the speaker override // (.none) is enough to restore the .voiceChat default route, // which is the built-in receiver. Calling setPreferredInput // explicitly here is redundant and risks the same recalc // race that broke _selectSpeaker before. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.none, ); unawaited(Haptics.selection().catchError((_) {})); debugPrint('chanora: audio output -> receiver (override cleared)'); } catch (e, st) { debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } Future _selectInput(AVAudioSessionPortDescription port) async { try { // Drop any speakerphone override so the route follows the // selected input. BT / wired headset / USB inputs pair their // OWN output (the user hears audio through the same device // they speak into), so .none + setPreferredInput is the // correct combo here. await AVAudioSession().overrideOutputAudioPort( AVAudioSessionPortOverride.none, ); await AVAudioSession().setPreferredInput(port); unawaited(Haptics.selection().catchError((_) {})); debugPrint('chanora: audio output -> ${port.portType}'); } catch (e, st) { debugPrint('chanora: _selectInput FAILED: $e\n$st'); } if (!mounted) return; Navigator.of(context).pop(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); if (_loading) { return const SafeArea( child: Padding( padding: EdgeInsets.all(40), child: Center(child: CircularProgressIndicator()), ), ); } final currentOutputType = _route?.outputs.isNotEmpty == true ? _route!.outputs.first.portType : null; final currentInputUid = _route?.inputs.isNotEmpty == true ? _route!.inputs.first.uid : null; // Whether the active route is the speakerphone override (built-in // speaker is the output but the actual session category isn't // playback \u2014 it's playAndRecord + override). final isSpeaker = currentOutputType == AVAudioSessionPort.builtInSpeaker; final isReceiver = currentOutputType == AVAudioSessionPort.builtInReceiver; // Non-built-in inputs (BT / wired / USB / car audio) for the // device-specific rows. Built-in mic is rendered as 'iPhone // (receiver)' above; we filter it out here. final externalInputs = _availableInputs .where((p) => p.portType != AVAudioSessionPort.builtInMic) .toList(); return SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), const SizedBox(height: 16), _PickerRow( icon: Icons.volume_up, label: l10n.audioRouteSpeaker, selected: isSpeaker, onTap: _selectSpeaker, ), _PickerRow( icon: Icons.phone_in_talk, label: l10n.audioRouteReceiver, selected: isReceiver, onTap: _selectReceiver, ), for (final port in externalInputs) _PickerRow( icon: _AudioOutputTileState._portIcon(port.portType), label: _AudioOutputTileState._portLabel( port.portType, port.portName, l10n, ), selected: !isSpeaker && port.uid == currentInputUid, onTap: () => _selectInput(port), ), ], ), ), ); } } class _PickerRow extends StatelessWidget { const _PickerRow({ required this.icon, required this.label, required this.selected, required this.onTap, }); final IconData icon; final String label; final bool selected; final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final enabled = onTap != null; final color = selected ? theme.colorScheme.primary : enabled ? theme.colorScheme.onSurface : theme.colorScheme.onSurfaceVariant.withAlpha(130); return Semantics( button: true, selected: selected, enabled: enabled, label: label, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: ExcludeSemantics( child: Padding( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 4), child: Row( children: [ Icon(icon, color: color), const SizedBox(width: 16), Expanded( child: Text( label, style: theme.textTheme.bodyLarge?.copyWith(color: color), ), ), if (selected) Icon(Icons.check, color: theme.colorScheme.primary), ], ), ), ), ), ); } } 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), ), ), ), ); } }