Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
27 changes: 25 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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

Expand Down
51 changes: 51 additions & 0 deletions src/SystemMedia/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,18 @@
<data name="Variables.RepeatMode" xml:space="preserve">
<value>Repeat mode</value>
</data>
<data name="Variables.OutputDevice" xml:space="preserve">
<value>Default output device</value>
</data>
<data name="Variables.InputDevice" xml:space="preserve">
<value>Default input device</value>
</data>
<data name="Variables.CommunicationOutputDevice" xml:space="preserve">
<value>Default communication output device</value>
</data>
<data name="Variables.CommunicationInputDevice" xml:space="preserve">
<value>Default communication input device</value>
</data>
<data name="Events.TrackChanged.Name" xml:space="preserve">
<value>Track changed</value>
</data>
Expand Down Expand Up @@ -199,6 +211,45 @@
<data name="Actions.ShowNextPlayingApp.NothingElsePlaying" xml:space="preserve">
<value>No other app is playing right now.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Name" xml:space="preserve">
<value>Set the default audio device</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Description" xml:space="preserve">
<value>Makes a speaker, headset or microphone the system's default.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.Label" xml:space="preserve">
<value>Default for</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.Description" xml:space="preserve">
<value>Only Windows has separate defaults for calls; elsewhere they are the plain defaults.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.Output" xml:space="preserve">
<value>Output</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.Input" xml:space="preserve">
<value>Input</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.CommunicationOutput" xml:space="preserve">
<value>Communication output</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Role.CommunicationInput" xml:space="preserve">
<value>Communication input</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Device.Label" xml:space="preserve">
<value>Device</value>
</data>
<data name="Actions.SetDefaultAudioDevice.NotSupported" xml:space="preserve">
<value>Audio devices cannot be changed on this system.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.NoDevice" xml:space="preserve">
<value>Choose a device first.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.DeviceNotFound" xml:space="preserve">
<value>That device is not connected.</value>
</data>
<data name="Actions.SetDefaultAudioDevice.Failed" xml:space="preserve">
<value>The default device could not be changed.</value>
</data>
<data name="ConfigFlow.Settings.Title" xml:space="preserve">
<value>System Media settings</value>
</data>
Expand Down
2 changes: 2 additions & 0 deletions src/SystemMedia/Platform/CompositeMediaPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ public Task<bool> SetVolumePercentAsync(AppIdentity app, int volumePercent, Canc
: _system.SetVolumePercentAsync(app, volumePercent, cancellationToken);
}

public IAudioDevices? AudioDevices => _system.AudioDevices;

public IReadOnlyList<IntegrationIssue> GetIssues() =>
[.. _system.GetIssues(), .. _sources.SelectMany(source => source.GetIssues())];

Expand Down
41 changes: 41 additions & 0 deletions src/SystemMedia/Platform/IAudioDevices.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<AudioDevice>> GetDevicesAsync(bool input, CancellationToken cancellationToken);

Task<AudioDevice?> GetDefaultAsync(AudioDeviceRole role, CancellationToken cancellationToken);

// false when no device has that id.
Task<bool> 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<AudioDevice?> 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));
}
}
3 changes: 3 additions & 0 deletions src/SystemMedia/Platform/IMediaPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ internal interface IMediaPlatform : IDisposable

Task<bool> SetVolumePercentAsync(AppIdentity app, int volumePercent, CancellationToken cancellationToken);

// The system's default sound devices; null where the platform cannot reach them.
IAudioDevices? AudioDevices => null;

IReadOnlyList<IntegrationIssue> GetIssues();

Task<IssueResolution?> ResolveIssueAsync(string issueId, CancellationToken cancellationToken);
Expand Down
2 changes: 2 additions & 0 deletions src/SystemMedia/Platform/Linux/LinuxMediaPlatform.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ public async Task<bool> 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<IntegrationIssue> GetIssues() =>
!_startFailed ? [] :
Expand Down
Loading
Loading