From 29f5dc00c45b84af085710a4af687a580ba73a41 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 16 Feb 2026 21:56:22 +0100 Subject: [PATCH] feat: populate audio output device menu and group by backend --- lib/mpv/player/player_base.dart | 37 ++++++- lib/mpv/player/player_native.dart | 2 + .../sheets/video_settings_sheet.dart | 96 +++++++++++++++---- 3 files changed, 118 insertions(+), 17 deletions(-) diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index dd8c60fb..a27efe3c 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -109,7 +109,7 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { void _handleEvent(dynamic event) { if (event is List && event.length == 2) { - final name = _propIdToName[event[0]]; + final name = _propIdToName[event[0] as int]; if (name != null) { handlePropertyChange(name, event[1]); } @@ -217,6 +217,41 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { case 'sid': updateSelectedSubtitleTrack(value); break; + + case 'audio-device-list': + List? deviceList; + if (value is List) { + deviceList = value; + } else if (value is String && value.isNotEmpty) { + try { + final parsed = jsonDecode(value); + if (parsed is List) deviceList = parsed; + } catch (_) {} + } + if (deviceList != null) { + final devices = deviceList + .whereType() + .map((d) => AudioDevice( + name: d['name'] as String? ?? '', + description: d['description'] as String? ?? '', + )) + .toList(); + _state = _state.copyWith(audioDevices: devices); + audioDevicesController.add(devices); + } + break; + + case 'audio-device': + if (value is String && value.isNotEmpty) { + final device = _state.audioDevices.cast().firstWhere( + (d) => d?.name == value, + orElse: () => AudioDevice(name: value), + ) ?? + AudioDevice(name: value); + _state = _state.copyWith(audioDevice: device); + audioDeviceController.add(device); + } + break; } } diff --git a/lib/mpv/player/player_native.dart b/lib/mpv/player/player_native.dart index e982064d..b46cecdb 100644 --- a/lib/mpv/player/player_native.dart +++ b/lib/mpv/player/player_native.dart @@ -52,6 +52,8 @@ class PlayerNative extends PlayerBase { await observeProperty('speed', 'double'); await observeProperty('aid', 'string'); await observeProperty('sid', 'string'); + await observeProperty('audio-device-list', (Platform.isAndroid || Platform.isWindows) ? 'string' : 'node'); + await observeProperty('audio-device', 'string'); } catch (e) { errorController.add('Initialization failed: $e'); rethrow; diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 93e2d1ba..cd6dd658 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -347,7 +347,7 @@ class _VideoSettingsSheetState extends State { 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 { ); } + /// 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>( stream: widget.player.streams.audioDevices, @@ -475,22 +496,44 @@ class _VideoSettingsSheetState extends State { 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 = {}; + 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 = []; + final groups = >{}; + 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 { ); } + Widget _buildFlatDeviceList(List 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();