1622 lines
53 KiB
Dart
1622 lines
53 KiB
Dart
// 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<bool> onHeldChanged;
|
||
|
||
@override
|
||
State<VoicePttButton> createState() => _VoicePttButtonState();
|
||
}
|
||
|
||
class _VoicePttButtonState extends State<VoicePttButton> {
|
||
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<void> 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<rust.BridgeTransmitMode> onModeChanged,
|
||
required ValueChanged<int> onReleaseTailChanged,
|
||
required ValueChanged<rust.BridgeAudioProcessingConfig> onAudioConfigChanged,
|
||
}) async {
|
||
await showModalBottomSheet<void>(
|
||
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<rust.BridgeTransmitMode> onModeChanged;
|
||
final ValueChanged<int> onReleaseTailChanged;
|
||
final ValueChanged<rust.BridgeAudioProcessingConfig> 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<bool>? 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<rust.BridgeVadBackend> onChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
return SegmentedButton<rust.BridgeVadBackend>(
|
||
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<AVAudioSessionRouteChange>? _routeSub;
|
||
StreamSubscription<dynamic>? _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<void> _refreshAndroidDevices() async {
|
||
if (_androidLoading) return;
|
||
setState(() => _androidLoading = true);
|
||
try {
|
||
final raw =
|
||
await _androidOutputChannel.invokeListMethod<dynamic>(
|
||
'getOutputDevices',
|
||
) ??
|
||
const [];
|
||
final devices = raw
|
||
.whereType<Map<dynamic, dynamic>>()
|
||
.map(_AndroidAudioOutputDevice.fromMap)
|
||
.toList();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_androidDevices = devices;
|
||
_androidLoading = false;
|
||
});
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() => _androidLoading = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _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<void> _openPicker() async {
|
||
if (!kIsWeb && Platform.isAndroid) {
|
||
final selectedDeviceId = await showModalBottomSheet<String>(
|
||
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<void>(
|
||
'clearCommunicationDevice',
|
||
);
|
||
} else {
|
||
final changed = await _androidOutputChannel.invokeMethod<bool>(
|
||
'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<void>(
|
||
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<void> 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<dynamic, dynamic> 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<AVAudioSessionPortDescription> _availableInputs = const {};
|
||
AVAudioSessionRouteDescription? _route;
|
||
StreamSubscription<AVAudioSessionRouteChange>? _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<void> _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<void> _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<void> _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<void> _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),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|