feat(ui,ios): replace audio_router with audio_session + custom VoIP-style picker

User reported: the 'output device' picker only listed AirPlay
destinations (other iPhones / AirPlay speakers / AppleTV) and not
the speaker / iPhone receiver / AirPods / wired headset choices.

Root cause: audio_router 1.1.1's iOS path uses AVRoutePickerView,
which is Apple's **AirPlay** picker UI \u2014 by design it only lists
AirPlay-eligible output destinations, NOT the input/output route
choices we need (speaker vs receiver vs Bluetooth HFP vs wired).
AVRoutePickerView is the right UI for 'cast audio elsewhere'; for
'pick how I hear / talk' (VoIP) the right primitive is direct
AVAudioSession calls.

Fix: replace audio_router with audio_session 0.2.3 (Ryan Heise,
verified publisher, 865k downloads, MIT). audio_session exposes:

  * AVAudioSession.availableInputs \u2014 enumerate every real input
    port: builtInMic, bluetoothHfp, bluetoothA2dp, headsetMic
    (wired), usbAudio, carAudio, airPlay.
  * AVAudioSession.currentRoute \u2014 .inputs + .outputs of the
    active route.
  * AVAudioSession.setPreferredInput(port) \u2014 switch the input
    (HFP / wired / USB / car audio also move output to themselves).
  * AVAudioSession.overrideOutputAudioPort(.speaker | .none) \u2014
    toggle built-in speakerphone vs receiver/earpiece.
  * AVAudioSession.routeChangeStream \u2014 live notifications when
    the user plugs / unplugs / connects a device while the picker
    is open.

This is exactly the same primitive Discord, WhatsApp, FaceTime
use for their VoIP audio chooser. No native UI plugin needed.

New widgets in voice_compact.dart:

  * _AudioOutputTile: shows the active output port name (Speaker /
    iPhone / AirPods / 'Phil's Wired Headset' / etc.) with the
    matching icon. Subscribes to routeChangeStream for live
    updates. Tap opens _AudioOutputPickerSheet.

  * _AudioOutputPickerSheet: bottom sheet with 'Choose audio' title
    and a Discord-style list:
      - Speaker          (volume_up)
      - iPhone           (phone_in_talk; the receiver/earpiece)
      - <BT name>        (bluetooth_audio)
      - <Wired headset>  (headset)
      - <USB / Car>      (usb / directions_car)
    Selected row is highlighted + has a check mark. Tap routes:
      - Speaker  -> overrideOutputAudioPort(.speaker)
      - iPhone   -> overrideOutputAudioPort(.none) + setPreferredInput(builtInMic)
      - External -> overrideOutputAudioPort(.none) + setPreferredInput(port)

  * _PickerRow: shared row widget with selected/check styling.

AppDelegate.swift is unchanged: the manual AVAudioSession
.setCategory(playAndRecord / .voiceChat) we already do at launch
(0466000 / 4ee2b38) is fully compatible with audio_session \u2014 the
plugin only adds Dart-side accessors over the same underlying
AVAudioSession singleton.

Removed l10n keys not used anymore (audioRouteUsb was already gone).
Kept audioRouteSpeaker / Receiver / Bluetooth / WiredHeadset /
CarAudio / Airplay / Unknown \u2014 all still used by the new picker.

flutter analyze: 6 pre-existing Radio deprecation infos (unchanged).
flutter build ios --release --no-codesign: 54.9 s, Runner.app
30.4 MB (+200 KB vs audio_router build).
This commit is contained in:
EdisonJwa
2026-05-16 20:53:00 +08:00
parent 0c1fd1c2e3
commit f0016155aa
4 changed files with 345 additions and 72 deletions
+5 -5
View File
@@ -1,5 +1,5 @@
PODS:
- audio_router (1.1.1):
- audio_session (0.0.1):
- Flutter
- chanora_bridge (1.0.0)
- connectivity_plus (0.0.1):
@@ -7,14 +7,14 @@ PODS:
- Flutter (1.0.0)
DEPENDENCIES:
- audio_router (from `.symlinks/plugins/audio_router/ios`)
- audio_session (from `.symlinks/plugins/audio_session/ios`)
- chanora_bridge (from `.`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
EXTERNAL SOURCES:
audio_router:
:path: ".symlinks/plugins/audio_router/ios"
audio_session:
:path: ".symlinks/plugins/audio_session/ios"
chanora_bridge:
:path: "."
connectivity_plus:
@@ -23,7 +23,7 @@ EXTERNAL SOURCES:
:path: Flutter
SPEC CHECKSUMS:
audio_router: ab44b34ec1da33105ff9dc3ee1ba029f7733a4cc
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
chanora_bridge: af821d2c0507cb3199c91be12996bf0eb6b8bf5d
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
@@ -2,19 +2,23 @@
// AppBar mutes + status chip with 2-line live readout + wide bottom-
// anchored PTT button + modal sheet for non-essential controls).
//
// rc.8 follow-up (post-iPhone-test feedback): the AppBar gear icon
// was removed; the modal sheet is now the **single** voice-controls
// surface on mobile. Mode + release-tail are reached via an
// "Adjust mode & release tail" button inside the modal that opens
// the existing [VoiceSettingsDialog]. Audio output route picker is
// new — driven by the `audio_router` plugin, which renders the
// native AVRoutePickerView on iOS and a Material 3 device list on
// Android.
// 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_router/audio_router.dart';
import 'package:audio_router/audio_router_platform_interface.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
@@ -538,6 +542,23 @@ class _ModeRow extends StatelessWidget {
/// 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();
@@ -546,89 +567,120 @@ class _AudioOutputTile extends StatefulWidget {
}
class _AudioOutputTileState extends State<_AudioOutputTile> {
final AudioRouter _router = AudioRouter();
AudioDevice? _device;
AVAudioSessionPortDescription? _activeOutput;
StreamSubscription<AVAudioSessionRouteChange>? _routeSub;
@override
void initState() {
super.initState();
// Query the current route once on mount so the tile renders the
// real device (Speaker / Receiver / AirPods / etc.) immediately,
// before currentDeviceStream fires its first delta event. Without
// this, the tile shows 'Unknown' until the user changes routes.
_refreshCurrent();
_router.currentDeviceStream.listen((dev) {
_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;
setState(() => _device = dev);
_refresh();
});
}
Future<void> _refreshCurrent() async {
@override
void dispose() {
_routeSub?.cancel();
_routeSub = null;
super.dispose();
}
Future<void> _refresh() async {
try {
final dev = await AudioRouterPlatform.instance.getCurrentDevice();
final route = await AVAudioSession().currentRoute;
if (!mounted) return;
setState(() => _device = dev);
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 (_) {
// Ignore \u2014 we'll fall back to the stream. Plugin can throw on
// first call before AVAudioSession is fully active.
// Suppress \u2014 AVAudioSession may transiently throw on first
// call before the session is active.
}
}
String _deviceLabel(AudioSourceType? type, AppL10n l10n) {
static String _portLabel(AVAudioSessionPort? type, String fallback,
AppL10n l10n) {
switch (type) {
case AudioSourceType.builtinSpeaker:
case AVAudioSessionPort.builtInSpeaker:
return l10n.audioRouteSpeaker;
case AudioSourceType.builtinReceiver:
case AVAudioSessionPort.builtInReceiver:
return l10n.audioRouteReceiver;
case AudioSourceType.bluetooth:
return l10n.audioRouteBluetooth;
case AudioSourceType.wiredHeadset:
return l10n.audioRouteWiredHeadset;
case AudioSourceType.carAudio:
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 AudioSourceType.airplay:
case AVAudioSessionPort.airPlay:
return l10n.audioRouteAirplay;
case AudioSourceType.unknown:
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:
return l10n.audioRouteUnknown;
default:
return fallback.isEmpty ? l10n.audioRouteUnknown : fallback;
}
}
IconData _deviceIcon(AudioSourceType? type) {
static IconData _portIcon(AVAudioSessionPort? type) {
switch (type) {
case AudioSourceType.builtinSpeaker:
case AVAudioSessionPort.builtInSpeaker:
return Icons.volume_up;
case AudioSourceType.builtinReceiver:
case AVAudioSessionPort.builtInReceiver:
return Icons.phone_in_talk;
case AudioSourceType.bluetooth:
case AVAudioSessionPort.bluetoothHfp:
case AVAudioSessionPort.bluetoothA2dp:
case AVAudioSessionPort.bluetoothLe:
return Icons.bluetooth_audio;
case AudioSourceType.wiredHeadset:
case AVAudioSessionPort.headphones:
case AVAudioSessionPort.headsetMic:
return Icons.headset;
case AudioSourceType.carAudio:
case AVAudioSessionPort.carAudio:
return Icons.directions_car;
case AudioSourceType.airplay:
case AVAudioSessionPort.airPlay:
return Icons.airplay;
case AudioSourceType.unknown:
case null:
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: () => _router.showAudioRoutePicker(context),
onTap: _openPicker,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4),
child: Row(
children: [
Icon(
_deviceIcon(_device?.type),
color: theme.colorScheme.primary,
),
Icon(_portIcon(port?.portType), color: theme.colorScheme.primary),
const SizedBox(width: 14),
Expanded(
child: Column(
@@ -641,7 +693,7 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
),
),
Text(
_deviceLabel(_device?.type, l10n),
label,
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
@@ -660,6 +712,211 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
}
}
/// '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 {
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker);
} catch (_) {/* ignore */}
if (!mounted) return;
Navigator.of(context).pop();
}
Future<void> _selectReceiver() async {
try {
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
// Also clear any preferred input so it falls back to built-in.
// We do that by selecting the built-in mic if available.
final builtIn = _availableInputs
.where((p) => p.portType == AVAudioSessionPort.builtInMic)
.toList();
if (builtIn.isNotEmpty) {
await AVAudioSession().setPreferredInput(builtIn.first);
}
} catch (_) {/* ignore */}
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 move output to themselves).
await AVAudioSession()
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
await AVAudioSession().setPreferredInput(port);
} catch (_) {/* ignore */}
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});
+12 -4
View File
@@ -33,14 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.1"
audio_router:
audio_session:
dependency: "direct main"
description:
name: audio_router
sha256: "4dd8b65870f915ef1e7a08ca97898b9ac12c44d3e236ac272bb8f1f68065f48f"
name: audio_session
sha256: "7217b229db57cc4dc577a8abb56b7429a5a212b978517a5be578704bfe5e568b"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "0.2.3"
boolean_selector:
dependency: transitive
description:
@@ -581,6 +581,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shelf:
dependency: transitive
description:
+21 -13
View File
@@ -41,20 +41,28 @@ dependencies:
freezed_annotation: ^3.1.0
connectivity_plus: ^6.1.0
path_provider: ^2.1.4
# audio_router 1.1.1 (MIT, supports Android + iOS) drives the
# "Audio output" device picker on mobile. iOS displays the native
# AVRoutePickerView (AirPlay/Bluetooth/Speaker/Receiver). Android
# gets a Material 3 dialog backed by AudioManager.setCommunicationDevice.
# No-op on desktop \u2014 we only import the symbols and gate use behind
# Platform.isIOS / Platform.isAndroid at the call site.
# audio_session 0.2.3 (MIT, Ryan Heise, 865k downloads) gives us
# programmatic access to AVAudioSession on iOS + AudioManager on
# Android. We use:
#
# Prerequisite the plugin documents: the audio session must be
# configured *before* the picker is shown. We already set
# AVAudioSession to .playAndRecord/.voiceChat in AppDelegate.swift
# (iOS 0466000), so the plugin's iOS path is satisfied. Android
# has no audio session yet \u2014 will be addressed when we wire up
# the Android target post-rc.8.
audio_router: ^1.1.1
# * AVAudioSession.availableInputs \u2014 enumerate real input ports
# (Built-in Mic, Bluetooth HFP, wired headset, USB).
# * AVAudioSession.currentRoute \u2014 inputs + outputs of the
# active route.
# * AVAudioSession.setPreferredInput(port) \u2014 switch input
# (also drives matching output for HFP devices).
# * AVAudioSession.overrideOutputAudioPort(speaker|none) \u2014
# toggle the built-in receiver/earpiece vs speakerphone.
# * AVAudioSession.routeChangeStream \u2014 live notifications when
# the user plugs / unplugs / connects a device.
#
# This is the right primitive for a VoIP-style 'pick speaker /
# receiver / AirPods / wired' picker. Discord, WhatsApp, FaceTime
# all do exactly this. We previously tried audio_router 1.1.1
# whose iOS path is AVRoutePickerView (the AirPlay button) \u2014 the
# wrong UI: only lists AirPlay output destinations, not the
# speaker/receiver/Bluetooth choices we actually want.
audio_session: ^0.2.3
dev_dependencies:
flutter_test: