fix(android): add output device picker

This commit is contained in:
Edison Jwa
2026-05-20 16:26:13 +09:00
parent f4db27b565
commit f8a9c7a422
@@ -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<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.
@@ -586,9 +603,35 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
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;
@@ -658,6 +701,44 @@ class _AudioOutputTileState extends State<_AudioOutputTile> {
}
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(
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<void>(
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<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: '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<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
@@ -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),