Files
chanora/apps/chanora_flutter/lib/widgets/voice_compact.dart
T
EdisonJwa b1d117033e fix(ios,audio): don't call setPreferredInput after speaker/receiver override (silent route reversion)
User report: 'speakerphone (built-in mic input)' logs printed
successfully but audio output didn't actually switch to speaker.
No exception thrown by either AVAudioSession call.

Root cause:

Previous _selectSpeaker called:
  1. await overrideOutputAudioPort(.speaker)
  2. await setPreferredInput(builtInMic)

Apple-documented behavior in .voiceChat mode: when
setPreferredInput is called, iOS recalculates the entire route
based on the natural input/output pairing for the selected port.

Built-in mic's natural output pairing is the receiver/earpiece
(matches the 'I'm talking on a phone' UX of .voiceChat). So step 2
caused iOS to SILENTLY REVERT the speaker override from step 1
and route output back through the earpiece.

Net: await chain returned without exception (both calls 'succeeded'
in API terms), debugPrint logged the success message, but the user
heard the call audio coming out of the earpiece, not the speaker.

Same issue affected _selectReceiver (after the speaker bug fix
was reverted): redundant setPreferredInput(builtInMic) call risked
the same recalc race.

Fix:

  * _selectSpeaker: only call overrideOutputAudioPort(.speaker).
    No setPreferredInput. The override alone is sufficient \u2014 the
    input stays on whatever the system was already using (built-in
    mic by default, or BT/wired if connected).

  * _selectReceiver: only call overrideOutputAudioPort(.none).
    No setPreferredInput. Removing the speaker override naturally
    returns to .voiceChat's default route (receiver).

  * _selectInput (BT / wired / USB): unchanged. These inputs PAIR
    their own output device, so .none + setPreferredInput is the
    correct combo (user hears audio through the same device they
    speak into).

flutter build ios --release --no-codesign: 21.2 s, Runner.app
30.4 MB.
2026-05-16 21:36:59 +08:00

974 lines
32 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;
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 '../l10n/generated/app_localizations.dart';
import '../main.dart' show PttCapabilityBadge;
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;
final micOn = stats?.pttActive ?? false;
final modeLabel = switch (transmitMode) {
rust.BridgeTransmitMode.ptt => l10n.voiceModePtt,
rust.BridgeTransmitMode.continuous => l10n.voiceModeContinuous,
rust.BridgeTransmitMode.voiceActivity =>
'${l10n.voiceModeVoiceActivity} (${l10n.voiceModeComingSoon})',
};
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 Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
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;
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 l10n = AppL10n.of(context);
final activeNow = _pressed || widget.active;
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: 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; VoiceActivity disabled
/// coming-soon).
/// 3. Release-tail slider (PTT-only).
/// 4. Mic level meter.
/// 5. TX / RX frame counts.
/// 6. PTT capability badge (desktop only).
///
/// 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.BridgeAudioStats? audioStats,
required rust.BridgeTransmitMode transmitMode,
required int releaseTailMs,
required String pttBoundKeyLabel,
required String pttLevel,
required String pttBackendId,
required String pttBoundInputClass,
required bool isTouchOnly,
required ValueChanged<rust.BridgeTransmitMode> onModeChanged,
required ValueChanged<int> onReleaseTailChanged,
}) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (ctx) {
return _VoiceSheetBody(
audioStats: audioStats,
initialMode: transmitMode,
initialReleaseTailMs: releaseTailMs,
pttBoundKeyLabel: pttBoundKeyLabel,
pttLevel: pttLevel,
pttBackendId: pttBackendId,
pttBoundInputClass: pttBoundInputClass,
isTouchOnly: isTouchOnly,
onModeChanged: onModeChanged,
onReleaseTailChanged: onReleaseTailChanged,
);
},
);
}
class _VoiceSheetBody extends StatefulWidget {
const _VoiceSheetBody({
required this.audioStats,
required this.initialMode,
required this.initialReleaseTailMs,
required this.pttBoundKeyLabel,
required this.pttLevel,
required this.pttBackendId,
required this.pttBoundInputClass,
required this.isTouchOnly,
required this.onModeChanged,
required this.onReleaseTailChanged,
});
final rust.BridgeAudioStats? audioStats;
final rust.BridgeTransmitMode initialMode;
final int initialReleaseTailMs;
final String pttBoundKeyLabel;
final String pttLevel;
final String pttBackendId;
final String pttBoundInputClass;
final bool isTouchOnly;
final ValueChanged<rust.BridgeTransmitMode> onModeChanged;
final ValueChanged<int> onReleaseTailChanged;
@override
State<_VoiceSheetBody> createState() => _VoiceSheetBodyState();
}
class _VoiceSheetBodyState extends State<_VoiceSheetBody> {
late rust.BridgeTransmitMode _mode = widget.initialMode;
late int _tail = widget.initialReleaseTailMs;
void _setMode(rust.BridgeTransmitMode m) {
if (m == rust.BridgeTransmitMode.voiceActivity) {
// Coming-soon \u2014 disabled in UI; defensive guard.
return;
}
setState(() => _mode = m);
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 = widget.audioStats;
final levelActive = stats?.pttActive ?? false;
final isPtt = _mode == rust.BridgeTransmitMode.ptt;
// Route picker only meaningful on iOS + Android where the OS
// owns audio routing. Desktop hosts skip the tile entirely.
final showRoutePicker =
!kIsWeb && (Platform.isIOS || Platform.isAndroid);
return SafeArea(
child: SingleChildScrollView(
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} (${l10n.voiceModeComingSoon})',
icon: Icons.graphic_eq,
selected: false,
onTap: null,
),
// 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, 1000),
min: 0,
max: 1000,
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.
_LevelMeter(active: levelActive),
const SizedBox(height: 6),
if (stats != null)
Text(
l10n.audioStatsLine(
stats.framesSent,
stats.framesReceived,
stats.pttActive ? l10n.voiceMicOn : l10n.voiceMicOff,
),
style: theme.textTheme.bodySmall,
),
// 5) PTT capability badge \u2014 desktop-only.
if (isPtt && !widget.isTouchOnly) ...[
const SizedBox(height: 12),
PttCapabilityBadge(
level: widget.pttLevel,
backendId: widget.pttBackendId,
boundInputClass: widget.pttBoundInputClass,
),
],
],
),
),
);
}
}
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 InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
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> {
AVAudioSessionPortDescription? _activeOutput;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
@override
void initState() {
super.initState();
_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;
super.dispose();
}
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 {
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);
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,
),
],
),
),
);
}
}
/// '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);
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);
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);
debugPrint(
'chanora: audio output -> ${port.portName} (${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 color =
selected ? theme.colorScheme.primary : theme.colorScheme.onSurface;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
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),
),
),
),
);
}
}