From f0016155aabefb1895e08578cd778e3008ef0c0a Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 20:53:00 +0800 Subject: [PATCH] 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) - (bluetooth_audio) - (headset) - (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). --- apps/chanora_flutter/ios/Podfile.lock | 10 +- .../lib/widgets/voice_compact.dart | 357 +++++++++++++++--- apps/chanora_flutter/pubspec.lock | 16 +- apps/chanora_flutter/pubspec.yaml | 34 +- 4 files changed, 345 insertions(+), 72 deletions(-) diff --git a/apps/chanora_flutter/ios/Podfile.lock b/apps/chanora_flutter/ios/Podfile.lock index 374e187..68c6fe0 100644 --- a/apps/chanora_flutter/ios/Podfile.lock +++ b/apps/chanora_flutter/ios/Podfile.lock @@ -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 diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 6138704..f687c42 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -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? _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 _refreshCurrent() async { + @override + void dispose() { + _routeSub?.cancel(); + _routeSub = null; + super.dispose(); + } + + Future _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 _openPicker() async { + await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) { + return const _AudioOutputPickerSheet(); + }, + ); + // Refresh after the picker closes (user may have changed route). + await _refresh(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final l10n = AppL10n.of(context); + 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 _availableInputs = const {}; + AVAudioSessionRouteDescription? _route; + StreamSubscription? _routeSub; + bool _loading = true; + + @override + void initState() { + super.initState(); + _refresh(); + _routeSub = AVAudioSession().routeChangeStream.listen((_) { + if (!mounted) return; + _refresh(); + }); + } + + @override + void dispose() { + _routeSub?.cancel(); + _routeSub = null; + super.dispose(); + } + + Future _refresh() async { + try { + final session = AVAudioSession(); + final inputs = await session.availableInputs; + final route = await session.currentRoute; + if (!mounted) return; + setState(() { + _availableInputs = inputs; + _route = route; + _loading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _loading = false); + } + } + + Future _selectSpeaker() async { + try { + await AVAudioSession() + .overrideOutputAudioPort(AVAudioSessionPortOverride.speaker); + } catch (_) {/* ignore */} + if (!mounted) return; + Navigator.of(context).pop(); + } + + Future _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 _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}); diff --git a/apps/chanora_flutter/pubspec.lock b/apps/chanora_flutter/pubspec.lock index 6681d64..ab7a1bc 100644 --- a/apps/chanora_flutter/pubspec.lock +++ b/apps/chanora_flutter/pubspec.lock @@ -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: diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index b8e4564..cfdd87f 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -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: