diff --git a/AGENTS.md b/AGENTS.md index 6fe9a6b..d1810d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,21 +13,23 @@ src/SystemMedia/ PluginIntegration.cs instances (current + one per known app, per-widget cycling), variables, events, issues, the optional settings page SettingsConfigFlow.cs the settings page (exact YouTube info) - ShowNextPlayingAppAction the plugin's own action + ShowNextPlayingAppAction switches "Any app" to the next playing app + SetDefaultAudioDevice... switches a default sound device (also done by writing a device variable) Core/ platform-neutral: the player, snapshot, state mapping, leftover filter and thumbnail history, "any app" picker, instance ids, remembered apps, settings, YouTube correction Events/ event definitions, change detection, the once-a-second watcher - Variables/ deck variables for the current session + Variables/ deck variables for the current session and the default sound devices Platform/ IMediaPlatform.cs the interface every OS backend implements + IAudioDevices.cs the default sound devices, read and switched per OS CompositeMediaPlatform the OS backend plus every IMediaSource, routed by app id - Windows/ GSMTC sessions, volume mixer (Audio/), app names (Shell/), icons, VLC add-on, + Windows/ GSMTC sessions, volume mixer and sound devices (Audio/), app names (Shell/), icons, VLC add-on, browser tabs (Browser/) Linux/ MPRIS over the D-Bus session bus (Tmds.DBus.Protocol), app icons from .desktop - files and the hicolor theme + files and the hicolor theme, sound devices through pactl MacOS/ Now Playing through the user's Homebrew media-control, app names/icons via - mdfind/sips + mdfind/sips, sound devices through Core Audio Players/ players the system does not see: the Player base class, MpvPlayer, SmPlayer, the watcher that polls each, local sockets and the Qt single-app message docs/ adding-a-player.md, the contributor guide for a new Player @@ -49,6 +51,9 @@ Look there instead of guessing. `thumbnail-history.json` - the "Any app" widget option names (`cycle`, `cycle-seconds`) and settings field names (`exact_youtube_info`), stored in users' widgets and config entry +- action ids, parameter names and choice values (`set-default-audio-device` with `role` and `device`), + and the device ids it stores: the Windows endpoint id, the macOS device UID, the PulseAudio sink or + source name **Instances.** The host only resolves instance ids `GetInstances()` currently lists and uses the first as the default. `current` stays first, and an app once seen stays listed (and remembered on disk). Names carry @@ -69,6 +74,11 @@ single-configuration integration to `default` and keeps only the first. - Linux has no system-wide current player; the one that started playing last stands in. MPRIS volume is the player's own, so a player without a `Volume` property gets no slider. An app id is the bus name after `org.mpris.MediaPlayer2.` without a `.instance...` suffix, so every instance is one app. +- Sound devices are an `IAudioDevices` behind `IMediaPlatform.AudioDevices`: Core Audio on Windows and + macOS, `pactl --format=json` on Linux (it ships with PulseAudio and `pipewire-pulse`, run with + `LC_NUMERIC=C` because some locales break its JSON). Only Windows has communication defaults; elsewhere + those roles are the plain defaults. Windows switches through the undocumented `IPolicyConfig`, as every + device switcher does; keep it confined to `WindowsAudioDevices`. - No third-party program is bundled or run unless the user installed it (Store guideline 2); managed NuGet libraries such as Tmds.DBus.Protocol are fine. macOS relies on the user installing `media-control` from Homebrew; its `stream --micros` output is what `NowPlayingState` parses. Its version is not pinned, diff --git a/README.md b/README.md index fe50f59..7df8dee 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,27 @@ For whatever is playing right now (the same as *Any app*): | `system_media_volume` | The app's volume in percent; can be set | | `system_media_shuffle_enabled`, `system_media_repeat_mode` | `repeat_mode` is `off`, `track` or `context` | +### Sound devices + +Whatever is playing, these name the system's default sound devices: + +| Variable | Value | +| ------------------------------------------ | ------------------------------------------------------ | +| `system_media_output_device` | The default output, e.g. `Speakers (Realtek(R) Audio)` | +| `system_media_input_device` | The default input, e.g. `Microphone (USB Audio)` | +| `system_media_communication_output_device` | The output Windows uses for calls | +| `system_media_communication_input_device` | The input Windows uses for calls | + +Setting one of them to a device's name makes that device the default. The **Set the default audio device** +action does the same with a device picked from a list. + +Only Windows keeps separate devices for calls. On macOS and Linux the communication variables show the +plain defaults, and setting one changes the plain default. + +On Linux the devices are read and switched with `pactl`, which comes with PulseAudio and with PipeWire's +PulseAudio support (`pipewire-pulse`). Without it the variables stay empty. On Windows, switching uses the +same undocumented interface as the Sound settings, because Windows has no public one. + ### Events Each event can be narrowed to one app with its **App** field and carries `app` and `appName`: @@ -136,11 +157,13 @@ Everything runs on your computer. The plugin sends nothing about you or your med - **SMPlayer and mpv** are read and controlled over their local IPC pipe or socket. A cover is a screenshot mpv writes to a temporary file, which is deleted right after it is read. - **macOS:** Now Playing is read by running your installed `media-control`; app names and icons come - from `mdfind`, `plutil` and `sips`. Nothing third-party ships with the plugin. + from `mdfind`, `plutil` and `sips`, sound devices from Core Audio. Nothing third-party ships with the + plugin. - **Linux:** players are read over the D-Bus session bus with the bundled [Tmds.DBus.Protocol](https://github.com/tmds/Tmds.DBus) library (MIT). A player that reports its cover as a web address (Spotify does) has the cover downloaded from there, as the desktop's own media controls - do. App icons come from the apps' `.desktop` files and the icon theme. + do. App icons come from the apps' `.desktop` files and the icon theme. Sound devices are read and + switched by running `pactl`. ## Development diff --git a/src/SystemMedia/Localization/Strings.resx b/src/SystemMedia/Localization/Strings.resx index 85aae60..eedeeb6 100644 --- a/src/SystemMedia/Localization/Strings.resx +++ b/src/SystemMedia/Localization/Strings.resx @@ -97,6 +97,18 @@ Repeat mode + + Default output device + + + Default input device + + + Default communication output device + + + Default communication input device + Track changed @@ -199,6 +211,45 @@ No other app is playing right now. + + Set the default audio device + + + Makes a speaker, headset or microphone the system's default. + + + Default for + + + Only Windows has separate defaults for calls; elsewhere they are the plain defaults. + + + Output + + + Input + + + Communication output + + + Communication input + + + Device + + + Audio devices cannot be changed on this system. + + + Choose a device first. + + + That device is not connected. + + + The default device could not be changed. + System Media settings diff --git a/src/SystemMedia/Platform/CompositeMediaPlatform.cs b/src/SystemMedia/Platform/CompositeMediaPlatform.cs index 537496a..34f0d8a 100644 --- a/src/SystemMedia/Platform/CompositeMediaPlatform.cs +++ b/src/SystemMedia/Platform/CompositeMediaPlatform.cs @@ -95,6 +95,8 @@ public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, Canc : _system.SetVolumePercentAsync(app, volumePercent, cancellationToken); } + public IAudioDevices? AudioDevices => _system.AudioDevices; + public IReadOnlyList GetIssues() => [.. _system.GetIssues(), .. _sources.SelectMany(source => source.GetIssues())]; diff --git a/src/SystemMedia/Platform/IAudioDevices.cs b/src/SystemMedia/Platform/IAudioDevices.cs new file mode 100644 index 0000000..57872ce --- /dev/null +++ b/src/SystemMedia/Platform/IAudioDevices.cs @@ -0,0 +1,41 @@ +namespace SystemMedia.Platform; + +// Only Windows keeps separate defaults for calls; elsewhere the communication roles are the plain defaults. +internal enum AudioDeviceRole +{ + Output, + Input, + CommunicationOutput, + CommunicationInput, +} + +internal sealed record AudioDevice(string Id, string Name); + +// Reads return no device when the system has none; only a platform-level failure throws. +internal interface IAudioDevices +{ + Task> GetDevicesAsync(bool input, CancellationToken cancellationToken); + + Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken); + + // false when no device has that id. + Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken); +} + +internal static class AudioDeviceRoles +{ + internal static bool IsInput(this AudioDeviceRole role) => + role is AudioDeviceRole.Input or AudioDeviceRole.CommunicationInput; + + internal static bool IsCommunication(this AudioDeviceRole role) => + role is AudioDeviceRole.CommunicationOutput or AudioDeviceRole.CommunicationInput; + + // A stored id first, then a name, so a variable can be set to what it shows. + internal static async Task FindAsync(this IAudioDevices devices, AudioDeviceRole role, string idOrName, + CancellationToken cancellationToken) + { + var candidates = await devices.GetDevicesAsync(role.IsInput(), cancellationToken); + return candidates.FirstOrDefault(device => string.Equals(device.Id, idOrName, StringComparison.Ordinal)) + ?? candidates.FirstOrDefault(device => string.Equals(device.Name, idOrName.Trim(), StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/SystemMedia/Platform/IMediaPlatform.cs b/src/SystemMedia/Platform/IMediaPlatform.cs index c81b9b5..198bde7 100644 --- a/src/SystemMedia/Platform/IMediaPlatform.cs +++ b/src/SystemMedia/Platform/IMediaPlatform.cs @@ -56,6 +56,9 @@ internal interface IMediaPlatform : IDisposable Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken); + // The system's default sound devices; null where the platform cannot reach them. + IAudioDevices? AudioDevices => null; + IReadOnlyList GetIssues(); Task ResolveIssueAsync(string issueId, CancellationToken cancellationToken); diff --git a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs index 3299322..ff431e6 100644 --- a/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs +++ b/src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs @@ -138,6 +138,8 @@ public async Task SetVolumePercentAsync(AppIdentity app, int volumePercent Find(app.AppId) is { } player && player.CanControl && player.Has("Volume") && await TryAsync(bus => bus.SetAsync(player.BusName, "Volume", Math.Clamp(volumePercent, 0, 100) / 100d), cancellationToken); + public IAudioDevices AudioDevices { get; } = new PulseAudioDevices(); + // Not "not started": the host asks for issues while the first start is still running. public IReadOnlyList GetIssues() => !_startFailed ? [] : diff --git a/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs b/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs new file mode 100644 index 0000000..861aad0 --- /dev/null +++ b/src/SystemMedia/Platform/Linux/PulseAudioDevices.cs @@ -0,0 +1,182 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace SystemMedia.Platform.Linux; + +// PulseAudio and PipeWire (through pipewire-pulse) both answer pactl, which ships with either. Its JSON output +// (PulseAudio 16 and later) is read rather than the text, which is translated. A device is a sink or source, +// identified by its name. +internal sealed class PulseAudioDevices : IAudioDevices +{ + private static readonly TimeSpan _commandTimeout = TimeSpan.FromSeconds(3); + + // Every variable asks on its own refresh; one reading serves them all. + private static readonly TimeSpan _cacheDuration = TimeSpan.FromSeconds(1); + + private readonly Lock _gate = new(); + private Task? _snapshot; + private long _snapshotTakenAt; + + public async Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) => + await GetSnapshotAsync() is { } snapshot ? (input ? snapshot.Sources : snapshot.Sinks) : []; + + // Linux has no separate defaults for calls. + public async Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) + { + if (await GetSnapshotAsync() is not { } snapshot) + { + return null; + } + + var (devices, name) = role.IsInput() ? (snapshot.Sources, snapshot.DefaultSource) : (snapshot.Sinks, snapshot.DefaultSink); + return devices.FirstOrDefault(device => string.Equals(device.Id, name, StringComparison.Ordinal)); + } + + public async Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken) + { + if (!(await GetDevicesAsync(role.IsInput(), cancellationToken)).Any(device => string.Equals(device.Id, deviceId, StringComparison.Ordinal))) + { + return false; + } + + var command = role.IsInput() ? "set-default-source" : "set-default-sink"; + _ = await RunAsync(cancellationToken, command, deviceId) + ?? throw new InvalidOperationException($"pactl {command} failed or pactl is not installed."); + lock (_gate) + { + _snapshot = null; + } + + return true; + } + + internal static IReadOnlyList ParseDevices(string json) + { + using var document = JsonDocument.Parse(json); + var devices = new List(); + foreach (var element in document.RootElement.EnumerateArray()) + { + // A sink's monitor is a source too, but nobody records from it by choice. + if (Text(element, "name") is not { } name || Text(element, "description") is not { } description || + Text(element, "monitor_of_sink") is { } monitored && monitored != "n/a" || + name.EndsWith(".monitor", StringComparison.Ordinal)) + { + continue; + } + + devices.Add(new AudioDevice(name, description)); + } + + return devices; + } + + internal static (string? Sink, string? Source) ParseDefaults(string json) + { + using var document = JsonDocument.Parse(json); + return (Text(document.RootElement, "default_sink_name"), Text(document.RootElement, "default_source_name")); + } + + private Task GetSnapshotAsync() + { + lock (_gate) + { + if (_snapshot is null || Stopwatch.GetElapsedTime(_snapshotTakenAt) > _cacheDuration) + { + _snapshot = ReadSnapshotAsync(); + _snapshotTakenAt = Stopwatch.GetTimestamp(); + } + + return _snapshot; + } + } + + // Not cancelled by a caller: the reading is shared. + private static async Task ReadSnapshotAsync() + { + try + { + var info = RunAsync(CancellationToken.None, "info"); + var sinks = RunAsync(CancellationToken.None, "list", "sinks"); + var sources = RunAsync(CancellationToken.None, "list", "sources"); + if (await info is not { } infoJson || await sinks is not { } sinksJson || await sources is not { } sourcesJson) + { + return null; + } + + var (defaultSink, defaultSource) = ParseDefaults(infoJson); + return new Snapshot(ParseDevices(sinksJson), ParseDevices(sourcesJson), defaultSink, defaultSource); + } + catch (JsonException) + { + return null; + } + } + + // null when pactl is missing, too old for JSON, or has no sound server to talk to. + private static async Task RunAsync(CancellationToken cancellationToken, params string[] arguments) + { + if (Locate() is not { } executable) + { + return null; + } + + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + start.ArgumentList.Add("--format=json"); + foreach (var argument in arguments) + { + start.ArgumentList.Add(argument); + } + + // Some locales put decimal commas into the JSON numbers. + start.Environment.Remove("LC_ALL"); + start.Environment["LC_NUMERIC"] = "C"; + + using var process = Process.Start(start); + if (process is null) + { + return null; + } + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(_commandTimeout); + try + { + var output = process.StandardOutput.ReadToEndAsync(timeout.Token); + _ = process.StandardError.ReadToEndAsync(timeout.Token); + await process.WaitForExitAsync(timeout.Token); + var text = await output; + return process.ExitCode == 0 ? text : null; + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + if (cancellationToken.IsCancellationRequested) + { + throw; + } + + return null; + } + } + + // A process started outside a login shell may have a bare PATH, so the usual folder is checked too. + private static string? Locate() => + (Environment.GetEnvironmentVariable("PATH") ?? "") + .Split(':', StringSplitOptions.RemoveEmptyEntries) + .Append("/usr/bin") + .Select(folder => Path.Combine(folder, "pactl")) + .FirstOrDefault(File.Exists); + + private static string? Text(JsonElement element, string property) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + + private sealed record Snapshot(IReadOnlyList Sinks, IReadOnlyList Sources, string? DefaultSink, string? DefaultSource); +} diff --git a/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs b/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs new file mode 100644 index 0000000..060050e --- /dev/null +++ b/src/SystemMedia/Platform/MacOS/CoreAudioDevices.cs @@ -0,0 +1,163 @@ +using System.Runtime.InteropServices; + +namespace SystemMedia.Platform.MacOS; + +// The Core Audio hardware properties (AudioHardware.h). A device is identified by its UID, which survives +// reconnects and restarts; its AudioObjectID does not. +internal sealed unsafe partial class CoreAudioDevices : IAudioDevices +{ + private const string CoreAudio = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio"; + private const string CoreFoundation = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; + + private const uint SystemObject = 1; + private const uint ElementMain = 0; + private static readonly uint _devices = FourCharCode("dev#"); + private static readonly uint _defaultOutput = FourCharCode("dOut"); + private static readonly uint _defaultInput = FourCharCode("dIn "); + private static readonly uint _name = FourCharCode("lnam"); + private static readonly uint _uid = FourCharCode("uid "); + private static readonly uint _streams = FourCharCode("stm#"); + private static readonly uint _scopeGlobal = FourCharCode("glob"); + private static readonly uint _scopeInput = FourCharCode("inpt"); + private static readonly uint _scopeOutput = FourCharCode("outp"); + + public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) => + Task.FromResult>([.. GetDevices(input).Select(device => device.Device)]); + + // macOS has no separate defaults for calls. + public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) + { + var objectId = GetDefaultObjectId(role.IsInput()); + return Task.FromResult(objectId == 0 ? null : Read(objectId)); + } + + public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken) + { + var match = GetDevices(role.IsInput()).FirstOrDefault(device => string.Equals(device.Device.Id, deviceId, StringComparison.Ordinal)); + if (match.Device is null) + { + return Task.FromResult(false); + } + + var objectId = match.ObjectId; + var address = new PropertyAddress(role.IsInput() ? _defaultInput : _defaultOutput, _scopeGlobal); + var status = AudioObjectSetPropertyData(SystemObject, &address, 0, null, sizeof(uint), &objectId); + return status == 0 + ? Task.FromResult(true) + : throw new InvalidOperationException($"Core Audio refused the default device change (status {status})."); + } + + private static List<(uint ObjectId, AudioDevice Device)> GetDevices(bool input) + { + var address = new PropertyAddress(_devices, _scopeGlobal); + uint size; + if (AudioObjectGetPropertyDataSize(SystemObject, &address, 0, null, &size) != 0 || size == 0) + { + return []; + } + + var objectIds = new uint[size / sizeof(uint)]; + fixed (uint* buffer = objectIds) + { + if (AudioObjectGetPropertyData(SystemObject, &address, 0, null, &size, buffer) != 0) + { + return []; + } + } + + var devices = new List<(uint, AudioDevice)>(); + foreach (var objectId in objectIds.AsSpan(0, (int)(size / sizeof(uint)))) + { + if (HasStreams(objectId, input) && Read(objectId) is { } device) + { + devices.Add((objectId, device)); + } + } + + return devices; + } + + private static uint GetDefaultObjectId(bool input) + { + var address = new PropertyAddress(input ? _defaultInput : _defaultOutput, _scopeGlobal); + uint objectId = 0; + var size = (uint)sizeof(uint); + return AudioObjectGetPropertyData(SystemObject, &address, 0, null, &size, &objectId) == 0 ? objectId : 0; + } + + private static bool HasStreams(uint objectId, bool input) + { + var address = new PropertyAddress(_streams, input ? _scopeInput : _scopeOutput); + uint size; + return AudioObjectGetPropertyDataSize(objectId, &address, 0, null, &size) == 0 && size > 0; + } + + private static AudioDevice? Read(uint objectId) => + ReadString(objectId, _uid) is { Length: > 0 } uid && ReadString(objectId, _name) is { Length: > 0 } name + ? new AudioDevice(uid, name) + : null; + + // The caller owns the returned CFString. + private static string? ReadString(uint objectId, uint selector) + { + var address = new PropertyAddress(selector, _scopeGlobal); + IntPtr text = 0; + var size = (uint)sizeof(IntPtr); + if (AudioObjectGetPropertyData(objectId, &address, 0, null, &size, &text) != 0 || text == 0) + { + return null; + } + + try + { + var length = CFStringGetLength(text); + var characters = new char[length]; + fixed (char* buffer = characters) + { + CFStringGetCharacters(text, new CFRange(0, length), buffer); + } + + return new string(characters); + } + finally + { + CFRelease(text); + } + } + + private static uint FourCharCode(string code) => + ((uint)code[0] << 24) | ((uint)code[1] << 16) | ((uint)code[2] << 8) | code[3]; + + [StructLayout(LayoutKind.Sequential)] + private readonly struct PropertyAddress(uint selector, uint scope) + { + public readonly uint Selector = selector; + public readonly uint Scope = scope; + public readonly uint Element = ElementMain; + } + + [StructLayout(LayoutKind.Sequential)] + private readonly struct CFRange(nint location, nint length) + { + public readonly nint Location = location; + public readonly nint Length = length; + } + + [LibraryImport(CoreAudio)] + private static partial int AudioObjectGetPropertyDataSize(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint* size); + + [LibraryImport(CoreAudio)] + private static partial int AudioObjectGetPropertyData(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint* size, void* data); + + [LibraryImport(CoreAudio)] + private static partial int AudioObjectSetPropertyData(uint objectId, PropertyAddress* address, uint qualifierSize, void* qualifier, uint size, void* data); + + [LibraryImport(CoreFoundation)] + private static partial nint CFStringGetLength(IntPtr text); + + [LibraryImport(CoreFoundation)] + private static partial void CFStringGetCharacters(IntPtr text, CFRange range, char* buffer); + + [LibraryImport(CoreFoundation)] + private static partial void CFRelease(IntPtr value); +} diff --git a/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs b/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs index abda9be..bf055b5 100644 --- a/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs +++ b/src/SystemMedia/Platform/MacOS/MacMediaPlatform.cs @@ -123,6 +123,8 @@ public Task SetRepeatModeAsync(string appId, RepeatMode mode, Cancellation public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken) => Task.FromResult(false); + public IAudioDevices AudioDevices { get; } = new CoreAudioDevices(); + // Only once a start failed or the stream broke: the host asks for issues while the first start still runs. public IReadOnlyList GetIssues() => !_broken ? [] diff --git a/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs b/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs index 7f05700..883d5a4 100644 --- a/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs +++ b/src/SystemMedia/Platform/Windows/Audio/CoreAudioNative.cs @@ -15,6 +15,7 @@ internal class MMDeviceEnumeratorComObject; internal interface IMMDeviceEnumerator { [PreserveSig] int EnumAudioEndpoints(int dataFlow, int stateMask, out IMMDeviceCollection devices); + [PreserveSig] int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice device); } [ComImport] @@ -32,6 +33,60 @@ internal interface IMMDeviceCollection internal interface IMMDevice { [PreserveSig] int Activate(ref Guid interfaceId, int classContext, IntPtr activationParams, [MarshalAs(UnmanagedType.IUnknown)] out object result); + [PreserveSig] int OpenPropertyStore(int access, out IPropertyStore properties); + [PreserveSig] int GetId([MarshalAs(UnmanagedType.LPWStr)] out string id); +} + +// Undocumented, but the only way to change a default device; the Sound settings and every switcher use it. +[ComImport] +[Guid("870AF99C-171D-4F9E-AF0D-E63DF40C2BC9")] +internal class PolicyConfigComObject; + +[ComImport] +[Guid("F8679F50-850A-41CF-9C72-430F290290C8")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IPolicyConfig +{ + [PreserveSig] int GetMixFormat(IntPtr deviceId, IntPtr format); + [PreserveSig] int GetDeviceFormat(IntPtr deviceId, int useDefault, IntPtr format); + [PreserveSig] int ResetDeviceFormat(IntPtr deviceId); + [PreserveSig] int SetDeviceFormat(IntPtr deviceId, IntPtr endpointFormat, IntPtr mixFormat); + [PreserveSig] int GetProcessingPeriod(IntPtr deviceId, int useDefault, IntPtr defaultPeriod, IntPtr minimumPeriod); + [PreserveSig] int SetProcessingPeriod(IntPtr deviceId, IntPtr period); + [PreserveSig] int GetShareMode(IntPtr deviceId, IntPtr mode); + [PreserveSig] int SetShareMode(IntPtr deviceId, IntPtr mode); + [PreserveSig] int GetPropertyValue(IntPtr deviceId, IntPtr key, IntPtr value); + [PreserveSig] int SetPropertyValue(IntPtr deviceId, IntPtr key, IntPtr value); + [PreserveSig] int SetDefaultEndpoint([MarshalAs(UnmanagedType.LPWStr)] string deviceId, int role); +} + +// propsys.h +[ComImport] +[Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] +[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] +internal interface IPropertyStore +{ + [PreserveSig] int GetCount(out int count); + [PreserveSig] int GetAt(int index, out PropertyKey key); + [PreserveSig] int GetValue(ref PropertyKey key, out PropVariant value); +} + +[StructLayout(LayoutKind.Sequential)] +internal struct PropertyKey(Guid formatId, int propertyId) +{ + public Guid FormatId = formatId; + public int PropertyId = propertyId; +} + +[StructLayout(LayoutKind.Sequential)] +internal struct PropVariant +{ + public ushort VariantType; + public ushort Reserved1; + public ushort Reserved2; + public ushort Reserved3; + public IntPtr Value; + public IntPtr Value2; } [ComImport] @@ -84,6 +139,12 @@ internal interface ISimpleAudioVolume internal static class CoreAudioNative { internal const int DataFlowRender = 0; + internal const int DataFlowCapture = 1; + internal const int RoleConsole = 0; + internal const int RoleMultimedia = 1; + internal const int RoleCommunications = 2; + internal const int StorageRead = 0; + internal const ushort VariantTypeString = 31; internal const int DeviceStateActive = 0x1; internal const int ClassContextAll = 0x17; internal const int SessionStateExpired = 2; diff --git a/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs b/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs new file mode 100644 index 0000000..a7b92c8 --- /dev/null +++ b/src/SystemMedia/Platform/Windows/Audio/WindowsAudioDevices.cs @@ -0,0 +1,160 @@ +using System.Runtime.InteropServices; + +namespace SystemMedia.Platform.Windows.Audio; + +internal sealed partial class WindowsAudioDevices : IAudioDevices +{ + // PKEY_Device_FriendlyName, the name the Sound settings show. + private static readonly Guid _friendlyNameFormat = new("A45C254E-DF1C-4EFD-8020-67D146A850E0"); + private const int FriendlyNameId = 14; + + public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) => + Task.FromResult>(GetDevices(input)); + + public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) => + Task.FromResult(GetDefault(role)); + + public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken) + { + if (!GetDevices(role.IsInput()).Any(device => string.Equals(device.Id, deviceId, StringComparison.Ordinal))) + { + return Task.FromResult(false); + } + + var policy = (IPolicyConfig)new PolicyConfigComObject(); + try + { + // The Sound settings' "default device" is both the console and the multimedia role. + int[] roles = role.IsCommunication() + ? [CoreAudioNative.RoleCommunications] + : [CoreAudioNative.RoleConsole, CoreAudioNative.RoleMultimedia]; + foreach (var windowsRole in roles) + { + Marshal.ThrowExceptionForHR(policy.SetDefaultEndpoint(deviceId, windowsRole)); + } + + return Task.FromResult(true); + } + finally + { + Marshal.ReleaseComObject(policy); + } + } + + private static List GetDevices(bool input) + { + var devices = new List(); + WithEnumerator(enumerator => + { + if (enumerator.EnumAudioEndpoints(DataFlow(input), CoreAudioNative.DeviceStateActive, out var collection) != 0) + { + return; + } + + try + { + if (collection.GetCount(out var count) != 0) + { + return; + } + + for (var i = 0; i < count; i++) + { + if (collection.Item(i, out var device) == 0 && Read(device) is { } read) + { + devices.Add(read); + } + } + } + finally + { + Marshal.ReleaseComObject(collection); + } + }); + + return devices; + } + + private static AudioDevice? GetDefault(AudioDeviceRole role) + { + AudioDevice? result = null; + var windowsRole = role.IsCommunication() ? CoreAudioNative.RoleCommunications : CoreAudioNative.RoleConsole; + WithEnumerator(enumerator => + { + // Fails when no device of that kind is connected. + if (enumerator.GetDefaultAudioEndpoint(DataFlow(role.IsInput()), windowsRole, out var device) == 0) + { + result = Read(device); + } + }); + + return result; + } + + private static int DataFlow(bool input) => input ? CoreAudioNative.DataFlowCapture : CoreAudioNative.DataFlowRender; + + private static void WithEnumerator(Action use) + { + IMMDeviceEnumerator enumerator; + try + { + enumerator = (IMMDeviceEnumerator)new MMDeviceEnumeratorComObject(); + } + catch (COMException) + { + // No audio service: no devices. + return; + } + + try + { + use(enumerator); + } + finally + { + Marshal.ReleaseComObject(enumerator); + } + } + + // Releases the device. + private static AudioDevice? Read(IMMDevice device) + { + IPropertyStore? properties = null; + try + { + if (device.GetId(out var id) != 0 || device.OpenPropertyStore(CoreAudioNative.StorageRead, out properties) != 0) + { + return null; + } + + var key = new PropertyKey(_friendlyNameFormat, FriendlyNameId); + if (properties.GetValue(ref key, out var value) != 0) + { + return null; + } + + try + { + return value.VariantType == CoreAudioNative.VariantTypeString && Marshal.PtrToStringUni(value.Value) is { Length: > 0 } name + ? new AudioDevice(id, name) + : null; + } + finally + { + _ = PropVariantClear(ref value); + } + } + finally + { + if (properties is not null) + { + Marshal.ReleaseComObject(properties); + } + + Marshal.ReleaseComObject(device); + } + } + + [LibraryImport("ole32.dll")] + private static partial int PropVariantClear(ref PropVariant value); +} diff --git a/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs b/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs index 7f1984a..38987ff 100644 --- a/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs +++ b/src/SystemMedia/Platform/Windows/WindowsMediaPlatform.cs @@ -223,6 +223,8 @@ public Task SetRepeatModeAsync(string appId, RepeatMode mode, Cancellation public Task SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken) => Task.FromResult(AppVolumeMixer.SetVolumePercent(app, volumePercent)); + public IAudioDevices AudioDevices { get; } = new WindowsAudioDevices(); + public IReadOnlyList GetIssues() { var issues = new List(); diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs index 51f5a8e..4b9f191 100644 --- a/src/SystemMedia/PluginIntegration.cs +++ b/src/SystemMedia/PluginIntegration.cs @@ -106,6 +106,7 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions _platform.AudioDevices, _logger), ]; } @@ -173,17 +174,43 @@ public void Dispose() public async ValueTask ReadAsync(string localId, CancellationToken cancellationToken = default) { + if (SystemMediaVariables.DeviceRole(localId) is { } role) + { + return _platform.AudioDevices is { } devices + ? VariableReading.Of((await devices.GetDefaultAsync(role, cancellationToken))?.Name) + : VariableReading.Unavailable; + } + var state = await _currentState.GetAsync(cancellationToken); return SystemMediaVariables.Read(localId, state); } public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) => - localId switch + SystemMediaVariables.DeviceRole(localId) is { } role + ? SetDefaultAudioDeviceAsync(role, value, cancellationToken) + : localId switch + { + SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken), + SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken), + _ => ValueTask.FromResult(VariableWriteResult.NotWritable()), + }; + + private async ValueTask SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken) + { + if (value?.ToString() is not { Length: > 0 } device) { - SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken), - SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken), - _ => ValueTask.FromResult(VariableWriteResult.NotWritable()), + return VariableWriteResult.InvalidValue(); + } + + return await SetDefaultAudioDeviceAction.SwitchAsync(_platform.AudioDevices, role, device, _logger, cancellationToken) switch + { + SetDefaultAudioDeviceAction.Outcome.Switched => VariableWriteResult.Applied(), + SetDefaultAudioDeviceAction.Outcome.NotSupported => VariableWriteResult.Unavailable(Strings.Actions.SetDefaultAudioDevice.NotSupported()), + // NotFound would claim the variable itself is missing. + SetDefaultAudioDeviceAction.Outcome.NotFound => VariableWriteResult.InvalidValue(Strings.Actions.SetDefaultAudioDevice.DeviceNotFound()), + _ => VariableWriteResult.Failed(Strings.Actions.SetDefaultAudioDevice.Failed()), }; + } public Task GetEventOptionsAsync(EventOptionsContext context, CancellationToken cancellationToken) { diff --git a/src/SystemMedia/SetDefaultAudioDeviceAction.cs b/src/SystemMedia/SetDefaultAudioDeviceAction.cs new file mode 100644 index 0000000..5943e65 --- /dev/null +++ b/src/SystemMedia/SetDefaultAudioDeviceAction.cs @@ -0,0 +1,133 @@ +using MacroDeck.Localization; +using MacroDeck.Sdk.Actions; +using Serilog; +using SystemMedia.Platform; + +namespace SystemMedia; + +internal sealed class SetDefaultAudioDeviceAction(Func devices, ILogger logger) : IDynamicOptionsActionDefinition +{ + internal const string ActionId = "set-default-audio-device"; + + // Parameter names and role values are stored in users' buttons. + internal const string RoleParameter = "role"; + internal const string DeviceParameter = "device"; + + private static readonly (string Value, AudioDeviceRole Role)[] _roles = + [ + ("output", AudioDeviceRole.Output), + ("input", AudioDeviceRole.Input), + ("communication-output", AudioDeviceRole.CommunicationOutput), + ("communication-input", AudioDeviceRole.CommunicationInput), + ]; + + internal enum Outcome + { + Switched, + NotSupported, + NotFound, + Failed, + } + + public string Id => ActionId; + + public LocalizedText Name => Strings.Actions.SetDefaultAudioDevice.Name(); + + public LocalizedText Description => Strings.Actions.SetDefaultAudioDevice.Description(); + + public IReadOnlyList Parameters { get; } = + [ + ActionParameter.Choice( + RoleParameter, + [ + new ActionParameterOption { Value = "output", Label = Strings.Actions.SetDefaultAudioDevice.Role.Output() }, + new ActionParameterOption { Value = "input", Label = Strings.Actions.SetDefaultAudioDevice.Role.Input() }, + new ActionParameterOption { Value = "communication-output", Label = Strings.Actions.SetDefaultAudioDevice.Role.CommunicationOutput() }, + new ActionParameterOption { Value = "communication-input", Label = Strings.Actions.SetDefaultAudioDevice.Role.CommunicationInput() }, + ], + Strings.Actions.SetDefaultAudioDevice.Role.Label(), + Strings.Actions.SetDefaultAudioDevice.Role.Description(), + defaultValue: "output", + required: true), + ActionParameter.DynamicChoice(DeviceParameter, Strings.Actions.SetDefaultAudioDevice.Device.Label(), required: true), + ]; + + public IActionExecutor CreateExecutor() => new Executor(devices, logger); + + public async Task GetDynamicOptionsAsync(DynamicOptionsContext context, CancellationToken cancellationToken) + { + if (!string.Equals(context.ParameterName, DeviceParameter, StringComparison.Ordinal)) + { + return new DynamicOptionsResult { Options = [] }; + } + + if (devices() is not { } audio) + { + return new DynamicOptionsResult { Options = [], Error = Strings.Actions.SetDefaultAudioDevice.NotSupported() }; + } + + var role = ParseRole(context.CurrentParameters.GetValueOrDefault(RoleParameter)) ?? AudioDeviceRole.Output; + var available = await audio.GetDevicesAsync(role.IsInput(), cancellationToken); + return new DynamicOptionsResult + { + Options = [.. available.Select(device => new ActionParameterOption { Value = device.Id, Label = device.Name })], + CacheSeconds = 2, + }; + } + + internal static AudioDeviceRole? ParseRole(object? value) + { + var text = value?.ToString(); + foreach (var (candidate, role) in _roles) + { + if (string.Equals(candidate, text, StringComparison.Ordinal)) + { + return role; + } + } + + return null; + } + + internal static async Task SwitchAsync(IAudioDevices? devices, AudioDeviceRole role, string idOrName, ILogger logger, + CancellationToken cancellationToken) + { + if (devices is null) + { + return Outcome.NotSupported; + } + + try + { + return await devices.FindAsync(role, idOrName, cancellationToken) is { } device && + await devices.SetDefaultAsync(role, device.Id, cancellationToken) + ? Outcome.Switched + : Outcome.NotFound; + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logger.Warning(exception, "Setting the default {Role} device to {Device} failed: {Message}", role, idOrName, exception.Message); + return Outcome.Failed; + } + } + + private sealed class Executor(Func devices, ILogger logger) : IActionExecutor + { + public async Task ExecuteAsync(ActionExecutionContext context) + { + var role = ParseRole(context.Parameters.GetValueOrDefault(RoleParameter) ?? "output"); + if (role is null || context.Parameters.GetValueOrDefault(DeviceParameter)?.ToString() is not { Length: > 0 } device) + { + return ActionResult.Failed(ActionErrorCodes.InvalidParameter, Strings.Actions.SetDefaultAudioDevice.NoDevice()); + } + + return await SwitchAsync(devices(), role.Value, device, logger, context.CancellationToken) switch + { + Outcome.Switched => ActionResult.Success(), + Outcome.NotSupported => ActionResult.Failed(ActionErrorCodes.Unavailable, Strings.Actions.SetDefaultAudioDevice.NotSupported()), + Outcome.NotFound => ActionResult.Failed(ActionErrorCodes.NotFound, Strings.Actions.SetDefaultAudioDevice.DeviceNotFound()), + _ => ActionResult.Failed(ActionErrorCodes.ProviderError, Strings.Actions.SetDefaultAudioDevice.Failed()), + }; + } + } +} diff --git a/src/SystemMedia/Variables/SystemMediaVariables.cs b/src/SystemMedia/Variables/SystemMediaVariables.cs index 2300c75..b0892e4 100644 --- a/src/SystemMedia/Variables/SystemMediaVariables.cs +++ b/src/SystemMedia/Variables/SystemMediaVariables.cs @@ -1,6 +1,7 @@ using MacroDeck.Localization; using MacroDeck.Sdk.MusicPlayer; using MacroDeck.Sdk.Variables; +using SystemMedia.Platform; namespace SystemMedia.Variables; @@ -53,7 +54,20 @@ internal static class SystemMediaVariables Text("system_media_repeat_mode", Strings.Variables.RepeatMode(), state => state.RepeatMode.ToString().ToLowerInvariant(), _slow), ]; - internal static IReadOnlyList All { get; } = [.. _entries.Select(entry => entry.Definition)]; + // The system's default devices, whatever app is playing. Setting one to a device's name switches to it. + private static readonly DeviceEntry[] _devices = + [ + Device("system_media_output_device", Strings.Variables.OutputDevice(), AudioDeviceRole.Output), + Device("system_media_input_device", Strings.Variables.InputDevice(), AudioDeviceRole.Input), + Device("system_media_communication_output_device", Strings.Variables.CommunicationOutputDevice(), AudioDeviceRole.CommunicationOutput), + Device("system_media_communication_input_device", Strings.Variables.CommunicationInputDevice(), AudioDeviceRole.CommunicationInput), + ]; + + internal static IReadOnlyList All { get; } = + [.. _entries.Select(entry => entry.Definition), .. _devices.Select(device => device.Definition)]; + + internal static AudioDeviceRole? DeviceRole(string localId) => + Array.Find(_devices, candidate => string.Equals(candidate.Definition.ResolvedId, localId, StringComparison.Ordinal))?.Role; internal static VariableReading Read(string localId, MusicPlayerState state) { @@ -81,4 +95,11 @@ private static Entry Text(string name, LocalizedText displayName, Func Read); + + private static DeviceEntry Device(string name, LocalizedText displayName, AudioDeviceRole role) => + new(VariableDefinition.Eager(name, VariableType.Text, refreshInterval: _slow) + with { DisplayName = displayName, Write = new VariableWriteCapability() }, + role); + + private sealed record DeviceEntry(VariableDefinition Definition, AudioDeviceRole Role); } diff --git a/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs b/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs new file mode 100644 index 0000000..f8ba67c --- /dev/null +++ b/tests/SystemMedia.Tests/Linux/PulseAudioDevicesTests.cs @@ -0,0 +1,44 @@ +using NUnit.Framework; +using SystemMedia.Platform; +using SystemMedia.Platform.Linux; + +namespace SystemMedia.Tests.Linux; + +[TestFixture] +public sealed class PulseAudioDevicesTests +{ + [Test] + public void Sinks_are_named_by_their_description() + { + const string json = """ + [{"index":56,"state":"RUNNING","name":"alsa_output.pci-0000_00_1f.3.analog-stereo","description":"Built-in Audio Analog Stereo","monitor_source":"alsa_output.pci-0000_00_1f.3.analog-stereo.monitor"}] + """; + + Assert.That(PulseAudioDevices.ParseDevices(json), + Is.EqualTo(new[] { new AudioDevice("alsa_output.pci-0000_00_1f.3.analog-stereo", "Built-in Audio Analog Stereo") })); + } + + [Test] + public void A_monitor_of_a_sink_is_no_input() + { + const string json = """ + [ + {"name":"alsa_output.usb.monitor","description":"Monitor of Headset","monitor_of_sink":"alsa_output.usb"}, + {"name":"alsa_input.usb","description":"Headset Microphone","monitor_of_sink":"n/a"}, + {"name":"other.monitor","description":"Monitor without the field"} + ] + """; + + Assert.That(PulseAudioDevices.ParseDevices(json).Single().Id, Is.EqualTo("alsa_input.usb")); + } + + [Test] + public void The_defaults_come_from_the_server_info() + { + const string json = """ + {"server_name":"PulseAudio (on PipeWire 1.2.7)","default_sink_name":"alsa_output.usb","default_source_name":"alsa_input.usb"} + """; + + Assert.That(PulseAudioDevices.ParseDefaults(json), Is.EqualTo(("alsa_output.usb", "alsa_input.usb"))); + } +} diff --git a/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs b/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs new file mode 100644 index 0000000..423dbdd --- /dev/null +++ b/tests/SystemMedia.Tests/SetDefaultAudioDeviceActionTests.cs @@ -0,0 +1,115 @@ +using MacroDeck.Sdk.Actions; +using NUnit.Framework; +using Serilog; +using SystemMedia.Platform; + +namespace SystemMedia.Tests; + +[TestFixture] +public sealed class SetDefaultAudioDeviceActionTests +{ + private static readonly ILogger _logger = Serilog.Core.Logger.None; + + [Test] + public async Task A_device_is_switched_to_by_its_id() + { + var devices = new FakeAudioDevices(); + + var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Output, "headset-out", _logger, CancellationToken.None); + + Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Switched)); + Assert.That(devices.Switched, Is.EqualTo((AudioDeviceRole.Output, "headset-out"))); + } + + [Test] + public async Task A_device_is_switched_to_by_the_name_a_variable_shows() + { + var devices = new FakeAudioDevices(); + + var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.CommunicationInput, " headset microphone ", _logger, CancellationToken.None); + + Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Switched)); + Assert.That(devices.Switched, Is.EqualTo((AudioDeviceRole.CommunicationInput, "headset-in"))); + } + + [Test] + public async Task An_output_is_not_found_among_the_inputs() + { + var devices = new FakeAudioDevices(); + + var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Input, "headset-out", _logger, CancellationToken.None); + + Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.NotFound)); + Assert.That(devices.Switched, Is.Null); + } + + [Test] + public async Task A_refused_switch_is_a_failure() + { + var devices = new FakeAudioDevices { Refuses = true }; + + var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(devices, AudioDeviceRole.Output, "speakers", _logger, CancellationToken.None); + + Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.Failed)); + } + + [Test] + public async Task A_platform_without_audio_devices_cannot_switch() + { + var outcome = await SetDefaultAudioDeviceAction.SwitchAsync(null, AudioDeviceRole.Output, "speakers", _logger, CancellationToken.None); + + Assert.That(outcome, Is.EqualTo(SetDefaultAudioDeviceAction.Outcome.NotSupported)); + } + + [Test] + public async Task The_device_options_follow_the_chosen_role() + { + var action = new SetDefaultAudioDeviceAction(() => new FakeAudioDevices(), _logger); + + var result = await action.GetDynamicOptionsAsync( + new DynamicOptionsContext + { + ParameterName = SetDefaultAudioDeviceAction.DeviceParameter, + CurrentParameters = new Dictionary { [SetDefaultAudioDeviceAction.RoleParameter] = "communication-input" }, + }, + CancellationToken.None); + + Assert.That(result.Options.Single().Value, Is.EqualTo("headset-in")); + } + + [TestCase("output", nameof(AudioDeviceRole.Output))] + [TestCase("input", nameof(AudioDeviceRole.Input))] + [TestCase("communication-output", nameof(AudioDeviceRole.CommunicationOutput))] + [TestCase("communication-input", nameof(AudioDeviceRole.CommunicationInput))] + public void The_stored_role_values_stay_the_same(string value, string role) + { + Assert.That(SetDefaultAudioDeviceAction.ParseRole(value).ToString(), Is.EqualTo(role)); + } + + private sealed class FakeAudioDevices : IAudioDevices + { + private static readonly AudioDevice[] _outputs = [new("speakers", "Speakers"), new("headset-out", "Headset")]; + private static readonly AudioDevice[] _inputs = [new("headset-in", "Headset Microphone")]; + + public bool Refuses { get; init; } + + public (AudioDeviceRole, string)? Switched { get; private set; } + + public Task> GetDevicesAsync(bool input, CancellationToken cancellationToken) => + Task.FromResult>(input ? _inputs : _outputs); + + public Task GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken) => + Task.FromResult(role.IsInput() ? _inputs[0] : _outputs[0]); + + public Task SetDefaultAsync(AudioDeviceRole role, string deviceId, CancellationToken cancellationToken) + { + if (Refuses) + { + throw new InvalidOperationException("Refused."); + } + + Switched = (role, deviceId); + return Task.FromResult(true); + } + } +} diff --git a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs index 48baf86..62cd3c5 100644 --- a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs +++ b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs @@ -1,5 +1,6 @@ using MacroDeck.Sdk.MusicPlayer; using NUnit.Framework; +using SystemMedia.Platform; using SystemMedia.Variables; namespace SystemMedia.Tests; @@ -43,6 +44,24 @@ public void A_variable_reads_its_value_from_the_current_state(string id, object Assert.That(SystemMediaVariables.Read(id, _playing).Value, Is.EqualTo(expected)); } + [TestCase("system-media-output-device", nameof(AudioDeviceRole.Output))] + [TestCase("system-media-input-device", nameof(AudioDeviceRole.Input))] + [TestCase("system-media-communication-output-device", nameof(AudioDeviceRole.CommunicationOutput))] + [TestCase("system-media-communication-input-device", nameof(AudioDeviceRole.CommunicationInput))] + public void A_device_variable_is_a_writable_default_device(string id, string role) + { + var variable = SystemMediaVariables.All.Single(candidate => candidate.ResolvedId == id); + + Assert.That(variable.CanWrite, Is.True); + Assert.That(SystemMediaVariables.DeviceRole(id).ToString(), Is.EqualTo(role)); + } + + [Test] + public void A_state_variable_is_no_device() + { + Assert.That(SystemMediaVariables.DeviceRole(SystemMediaVariables.VolumeId), Is.Null); + } + [Test] public void The_position_slider_ends_at_the_track_duration() { diff --git a/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs b/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs index 714a7c2..39dcb12 100644 --- a/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs +++ b/tests/SystemMedia.Tests/Windows/WindowsPluginIntegrationTests.cs @@ -2,6 +2,8 @@ using MacroDeck.Plugin.Testing; using NUnit.Framework; using Serilog; +using SystemMedia.Platform; +using SystemMedia.Platform.Windows.Audio; using SystemMedia.Platform.Windows; namespace SystemMedia.Tests.Windows; @@ -22,6 +24,34 @@ public async Task The_current_instance_reports_a_connected_state_even_when_nothi Assert.That(state!.IsConnected, Is.True); } + // A machine without a microphone or speakers has no default device, so only what holds either way is checked. + [TestCase(nameof(AudioDeviceRole.Output))] + [TestCase(nameof(AudioDeviceRole.Input))] + [TestCase(nameof(AudioDeviceRole.CommunicationOutput))] + [TestCase(nameof(AudioDeviceRole.CommunicationInput))] + public async Task A_default_device_is_one_of_the_connected_devices(string roleName) + { + var role = Enum.Parse(roleName); + var devices = new WindowsAudioDevices(); + + var connected = await devices.GetDevicesAsync(role.IsInput(), CancellationToken.None); + var current = await devices.GetDefaultAsync(role, CancellationToken.None); + + Assert.That(connected.Select(device => device.Name), Has.None.Empty); + if (current is not null) + { + Assert.That(connected, Has.Member(current)); + } + } + + [Test] + public async Task An_unknown_device_is_not_made_the_default() + { + var switched = await new WindowsAudioDevices().SetDefaultAsync(AudioDeviceRole.Output, "{0.0.0.00000000}.{unknown}", CancellationToken.None); + + Assert.That(switched, Is.False); + } + // The host asks for issues while the first start is still running. [Test] public void A_platform_that_has_not_started_yet_is_not_reported_unavailable()