From f8a9c7a422820fed2dd0ddad307d53f9b837390a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Wed, 20 May 2026 16:26:13 +0900 Subject: [PATCH] fix(android): add output device picker --- .../lib/widgets/voice_compact.dart | 253 +++++++++++++++++- 1 file changed, 248 insertions(+), 5 deletions(-) diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 132f1dd..cea48bd 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -21,6 +21,7 @@ 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 '../l10n/generated/app_localizations.dart'; import '../main.dart' show PttCapabilityBadge; @@ -353,9 +354,9 @@ class _VoiceSheetBodyState extends State<_VoiceSheetBody> { final levelActive = stats?.pttActive ?? false; final isPtt = _mode == rust.BridgeTransmitMode.ptt; - // Route picker is iOS-only; the picker uses AVAudioSession-backed - // widgets that must not be constructed on Android. - final showRoutePicker = !kIsWeb && Platform.isIOS; + // 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( @@ -567,12 +568,28 @@ class _AudioOutputTile extends StatefulWidget { } class _AudioOutputTileState extends State<_AudioOutputTile> { + static const _androidOutputChannel = MethodChannel('app.audio_output'); + static const _androidOutputEvents = EventChannel('app.audio_output/events'); + AVAudioSessionPortDescription? _activeOutput; StreamSubscription? _routeSub; + StreamSubscription? _androidOutputSub; + List<_AndroidAudioOutputDevice> _androidDevices = const []; + bool _androidLoading = false; @override void initState() { super.initState(); + if (!kIsWeb && Platform.isAndroid) { + _refreshAndroidDevices(); + _androidOutputSub = _androidOutputEvents.receiveBroadcastStream().listen(( + _, + ) { + if (!mounted) return; + _refreshAndroidDevices(); + }); + return; + } _refresh(); // Live updates when the user plugs / unplugs / connects a // headset / BT device while the modal sheet is open. @@ -586,9 +603,35 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { void dispose() { _routeSub?.cancel(); _routeSub = null; + _androidOutputSub?.cancel(); + _androidOutputSub = null; super.dispose(); } + Future _refreshAndroidDevices() async { + if (_androidLoading) return; + setState(() => _androidLoading = true); + try { + final raw = + await _androidOutputChannel.invokeListMethod( + 'getOutputDevices', + ) ?? + const []; + final devices = raw + .whereType>() + .map(_AndroidAudioOutputDevice.fromMap) + .toList(); + if (!mounted) return; + setState(() { + _androidDevices = devices; + _androidLoading = false; + }); + } catch (_) { + if (!mounted) return; + setState(() => _androidLoading = false); + } + } + Future _refresh() async { try { final route = await AVAudioSession().currentRoute; @@ -658,6 +701,44 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { } Future _openPicker() async { + if (!kIsWeb && Platform.isAndroid) { + final selectedDeviceId = await showModalBottomSheet( + context: context, + showDragHandle: true, + isScrollControlled: true, + builder: (ctx) => _AndroidAudioOutputPickerSheet( + devices: _androidDevices, + loading: _androidLoading, + onRefresh: _refreshAndroidDevices, + ), + ); + if (selectedDeviceId == null) return; + try { + if (selectedDeviceId == 'auto') { + await _androidOutputChannel.invokeMethod( + 'clearCommunicationDevice', + ); + } else { + final changed = await _androidOutputChannel.invokeMethod( + 'setCommunicationDevice', + {'deviceId': selectedDeviceId}, + ); + if (changed != true && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('This output cannot be selected.')), + ); + } + } + await _refreshAndroidDevices(); + } catch (_) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not change audio output.')), + ); + } + return; + } + await showModalBottomSheet( context: context, showDragHandle: true, @@ -674,6 +755,52 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { 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 + ? 'System default' + : _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( @@ -713,6 +840,119 @@ class _AudioOutputTileState extends State<_AudioOutputTile> { ), ); } + + static String _androidDeviceLabel( + String type, + String fallback, + AppL10n l10n, + ) => switch (type) { + 'speaker' => l10n.audioRouteSpeaker, + 'earpiece' => 'Earpiece', + 'wiredHeadset' || 'wiredHeadphones' => l10n.audioRouteWiredHeadset, + 'bluetoothA2dp' || + 'bluetoothSco' || + 'bluetoothLe' => l10n.audioRouteBluetooth, + 'usbHeadset' => fallback.isEmpty ? 'USB Headset' : fallback, + 'hdmi' => l10n.audioRouteCarAudio, + _ => fallback.isEmpty ? 'Other Device' : fallback, + }; + + static IconData _androidDeviceIcon(String? type) => switch (type) { + 'speaker' => Icons.volume_up, + 'earpiece' => Icons.phone_in_talk, + 'wiredHeadset' || 'wiredHeadphones' || 'usbHeadset' => Icons.headset, + 'bluetoothA2dp' || 'bluetoothSco' || 'bluetoothLe' => Icons.bluetooth_audio, + 'hdmi' => Icons.tv, + _ => Icons.speaker, + }; +} + +class _AndroidAudioOutputPickerSheet extends StatelessWidget { + const _AndroidAudioOutputPickerSheet({ + required this.devices, + required this.loading, + required this.onRefresh, + }); + + final List<_AndroidAudioOutputDevice> devices; + final bool loading; + final Future Function() onRefresh; + + @override + Widget build(BuildContext context) { + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(l10n.audioOutputLabel, style: theme.textTheme.titleLarge), + const SizedBox(height: 16), + _PickerRow( + icon: Icons.speaker, + label: 'System default', + 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: const Text('Refresh audio devices'), + ) + else + for (final device in devices) + _PickerRow( + icon: _AudioOutputTileState._androidDeviceIcon(device.type), + label: _AudioOutputTileState._androidDeviceLabel( + device.type, + device.name, + l10n, + ), + selected: device.isSelected, + onTap: device.isAvailableForCommunication + ? () => Navigator.of(context).pop(device.id) + : null, + ), + ], + ), + ), + ); + } +} + +class _AndroidAudioOutputDevice { + const _AndroidAudioOutputDevice({ + required this.id, + required this.name, + required this.type, + required this.isSelected, + required this.isAvailableForCommunication, + }); + + final String id; + final String name; + final String type; + final bool isSelected; + final bool isAvailableForCommunication; + + factory _AndroidAudioOutputDevice.fromMap(Map map) { + return _AndroidAudioOutputDevice( + id: map['id']?.toString() ?? '', + name: map['name']?.toString() ?? '', + type: map['type']?.toString() ?? 'unknown', + isSelected: map['isSelected'] == true, + isAvailableForCommunication: map['isAvailableForCommunication'] == true, + ); + } } /// 'Choose audio' bottom sheet that lists every selectable route @@ -920,14 +1160,17 @@ class _PickerRow extends StatelessWidget { final IconData icon; final String label; final bool selected; - final VoidCallback onTap; + final VoidCallback? onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); + final enabled = onTap != null; final color = selected ? theme.colorScheme.primary - : theme.colorScheme.onSurface; + : enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant.withAlpha(130); return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8),