feat: populate audio output device menu and group by backend

This commit is contained in:
edde746
2026-02-16 21:56:22 +01:00
parent 9d04833b3e
commit 29f5dc00c4
3 changed files with 118 additions and 17 deletions
@@ -347,7 +347,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
final deviceLabel = currentDevice.description.isEmpty
? currentDevice.name
: '${currentDevice.name} · ${currentDevice.description}';
: currentDevice.description;
return _SettingsMenuItem(
icon: Symbols.speaker_rounded,
@@ -462,6 +462,27 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
/// Extract the audio backend name from a device name (e.g. "coreaudio" from "coreaudio/BuiltIn").
static String _audioBackend(String name) {
final slash = name.indexOf('/');
return slash > 0 ? name.substring(0, slash) : name;
}
/// Pretty-print a backend identifier.
static String _formatBackend(String backend) {
const labels = {
'coreaudio': 'CoreAudio',
'avfoundation': 'AVFoundation',
'wasapi': 'WASAPI',
'pulse': 'PulseAudio',
'pipewire': 'PipeWire',
'alsa': 'ALSA',
'jack': 'JACK',
'oss': 'OSS',
};
return labels[backend] ?? backend;
}
Widget _buildAudioDeviceView() {
return StreamBuilder<List<AudioDevice>>(
stream: widget.player.streams.audioDevices,
@@ -475,22 +496,44 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
builder: (context, selectedSnapshot) {
final currentDevice = selectedSnapshot.data ?? widget.player.state.audioDevice;
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) {
final device = devices[index];
final isSelected = device.name == currentDevice.name;
final label = device.description.isEmpty ? device.name : device.description;
// Check for duplicate descriptions (same physical device across multiple backends).
final descCounts = <String, int>{};
for (final d in devices) {
final desc = d.description.isEmpty ? d.name : d.description;
descCounts[desc] = (descCounts[desc] ?? 0) + 1;
}
final hasDuplicates = descCounts.values.any((c) => c > 1);
return ListTile(
title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : Colors.white)),
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
onTap: () {
widget.player.setAudioDevice(device);
Navigator.pop(context); // Close sheet after selection
},
);
},
if (!hasDuplicates) {
return _buildFlatDeviceList(devices, currentDevice);
}
// Group devices by backend, keeping "auto" at the top ungrouped.
final ungrouped = <AudioDevice>[];
final groups = <String, List<AudioDevice>>{};
for (final d in devices) {
final backend = _audioBackend(d.name);
if (!d.name.contains('/')) {
ungrouped.add(d);
} else {
(groups[backend] ??= []).add(d);
}
}
return ListView(
children: [
for (final d in ungrouped) _buildDeviceTile(d, currentDevice),
for (final entry in groups.entries) ...[
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
_formatBackend(entry.key),
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 12, fontWeight: FontWeight.w600),
),
),
for (final d in entry.value) _buildDeviceTile(d, currentDevice),
],
],
);
},
);
@@ -498,6 +541,27 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
);
}
Widget _buildFlatDeviceList(List<AudioDevice> devices, AudioDevice currentDevice) {
return ListView.builder(
itemCount: devices.length,
itemBuilder: (context, index) => _buildDeviceTile(devices[index], currentDevice),
);
}
Widget _buildDeviceTile(AudioDevice device, AudioDevice currentDevice) {
final isSelected = device.name == currentDevice.name;
final label = device.description.isEmpty ? device.name : device.description;
return ListTile(
title: Text(label, style: TextStyle(color: isSelected ? Colors.blue : Colors.white)),
trailing: isSelected ? const AppIcon(Symbols.check_rounded, fill: 1, color: Colors.blue) : null,
onTap: () {
widget.player.setAudioDevice(device);
Navigator.pop(context);
},
);
}
Widget _buildShaderView() {
if (widget.shaderService == null) return const SizedBox.shrink();