fix(android): add output device picker
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user