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
9 changes: 7 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ src/SystemMedia/
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 and the default sound devices
Variables/ deck variables for the current session and the default sound devices, and a
catalog with each known app's own (pushed while subscribed)
Platform/
IMediaPlatform.cs the interface every OS backend implements
IAudioDevices.cs the default sound devices, read and switched per OS
Expand All @@ -45,7 +46,8 @@ Look there instead of guessing.

**Public API.** These are persisted in users' widgets, templates and automations. Renaming one breaks them:

- instance ids (`InstanceIds.ForApp`, `InstanceIds.Current`; a test pins the exact output)
- instance ids (`InstanceIds.ForApp`, `InstanceIds.Current`; a test pins the exact output) and the per-app catalog
variable ids built from them (`system-media-<instance id>-<variable>`), which the host keeps for linked variables
- event ids, event parameter names and variable names (`system_media_*`)
- player app ids, `player:<Player.Id>` (`player:mpv`, `player:smplayer`)
- the data files `known-apps.json` (its `appUserModelId` field is the 1.x name), `settings.json` and
Expand Down Expand Up @@ -172,6 +174,9 @@ dotnet test SystemMedia.slnx -p:SystemMediaWindows=false # the non-Windows vari
make conformance # after changing capability shape, cancellation or the manifest
```

`make conformance` runs in demo mode: the suite runs actions and writes variables, which would otherwise
pause, skip, seek or switch whatever is playing on the machine.

Some tests run against the machine's real media sessions and only assert what holds whether or not
something plays. To see real apps, use `make run` while a browser or Spotify plays.

Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ screenshots:
pack:
$(PACK)

# Demo mode: the suite runs actions and writes variables, which would pause, skip or seek what is playing.
conformance:
macrodeck-plugin test --project $(PROJECT) --report markdown --output conformance.md
export SYSTEM_MEDIA_DEMO="$(CURDIR)/demo" && macrodeck-plugin test --project $(PROJECT) --report markdown --output conformance.md

update:
dotnet package update
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ 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` |

Every app listed as an instance also has its own copy of these, except the app name, so a "Now Playing" text
can follow one player while another plays too. They are in a folder per app in the variable picker, e.g.
*Spotify: Artist*, and named after the app, e.g. `system_media_vlc_media_player_artist`. Two apps with the
same name get a short code each, e.g. `system_media_firefox_1a2b3c4d_artist`.

### Sound devices

Whatever is playing, these name the system's default sound devices:
Expand Down
2 changes: 1 addition & 1 deletion src/SystemMedia/Core/InstanceIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal static string ForApp(string appUserModelId)
return slug.Length == 0 ? AppPrefix + hash : $"{AppPrefix}{slug}-{hash}";
}

private static string Slug(string value)
internal static string Slug(string value)
{
var builder = new StringBuilder(Math.Min(value.Length, MaxSlugLength));
foreach (var character in value)
Expand Down
33 changes: 33 additions & 0 deletions src/SystemMedia/Localization/Strings.resx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,39 @@
<data name="Variables.RepeatMode" xml:space="preserve">
<value>Repeat mode</value>
</data>
<data name="Variables.App.TrackName" xml:space="preserve">
<value>{app}: Track name</value>
</data>
<data name="Variables.App.Artist" xml:space="preserve">
<value>{app}: Artist</value>
</data>
<data name="Variables.App.Album" xml:space="preserve">
<value>{app}: Album</value>
</data>
<data name="Variables.App.PlaybackState" xml:space="preserve">
<value>{app}: Playback state</value>
</data>
<data name="Variables.App.IsPlaying" xml:space="preserve">
<value>{app}: Is playing</value>
</data>
<data name="Variables.App.Position" xml:space="preserve">
<value>{app}: Position</value>
</data>
<data name="Variables.App.Duration" xml:space="preserve">
<value>{app}: Duration</value>
</data>
<data name="Variables.App.ProgressPercentage" xml:space="preserve">
<value>{app}: Progress</value>
</data>
<data name="Variables.App.Volume" xml:space="preserve">
<value>{app}: App volume</value>
</data>
<data name="Variables.App.ShuffleEnabled" xml:space="preserve">
<value>{app}: Shuffle enabled</value>
</data>
<data name="Variables.App.RepeatMode" xml:space="preserve">
<value>{app}: Repeat mode</value>
</data>
<data name="Variables.OutputDevice" xml:space="preserve">
<value>Default output device</value>
</data>
Expand Down
151 changes: 133 additions & 18 deletions src/SystemMedia/PluginIntegration.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Globalization;
using MacroDeck.Plugin.Hosting;
using MacroDeck.Plugin.Hosting.Integrations.HostApis;
using MacroDeck.Plugin.Protocol.Handshake;
Expand Down Expand Up @@ -56,6 +57,7 @@ public sealed class PluginIntegration
private readonly IReadOnlyList<ActionParameter> _currentOptions;
private readonly StateSnapshotCache _currentState;
private readonly MediaEventWatcher _events;
private readonly AppVariablePublisher _appVariables;
private readonly Lock _appsGate = new();

private volatile KnownApp[] _apps = [];
Expand All @@ -66,9 +68,11 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions<PluginHostOpti
_catalog = catalog;
_logger = logger.ForContext<PluginIntegration>();
var dataDirectory = hostOptions.Value.DataDirectory;
var demo = false;
#if DEBUG
// Made-up apps must not be remembered as real ones.
if (Environment.GetEnvironmentVariable(Demo.DemoMediaPlatform.EnvironmentVariable) is { Length: > 0 })
// Made-up apps must not be remembered as real ones, and conformance runs here must not control a real player.
demo = Environment.GetEnvironmentVariable(Demo.DemoMediaPlatform.EnvironmentVariable) is { Length: > 0 };
if (demo)
{
dataDirectory = null;
}
Expand All @@ -79,13 +83,12 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions<PluginHostOpti
_youTube = new YouTubePageCorrector(_youTubeLookup.LookUpAsync, TimeProvider.System, _logger);
_platform = new CompositeMediaPlatform(
MediaPlatformFactory.Create(_logger),
[
new PlayersSource(_logger, TimeProvider.System),
]);
demo ? [] : [new PlayersSource(_logger, TimeProvider.System)]);
_selection = new CurrentAppSelection(_platform, TimeProvider.System);
_current = CreatePlayer(appId: null);
_currentState = new StateSnapshotCache(_current.GetStateAsync, TimeProvider.System);
_events = new MediaEventWatcher(WatchedApps, CurrentAppInstanceId, _logger);
_appVariables = new AppVariablePublisher(ReadAppVariablesAsync, _logger);
_currentOptions =
[
ActionParameter.Toggle(
Expand Down Expand Up @@ -114,6 +117,14 @@ .. MusicPlayerActions.Common(ResolvePlayer, GetInstances),

public IReadOnlyList<VariableDefinition> Variables => SystemMediaVariables.All;

// Each known app's own variables, one folder per app. A catalog has no limit on how many it offers.
public bool SupportsCatalog => true;

public bool SupportsPush => true;

// Without search the host flattens the catalog and lists no folders.
public bool SupportsSearch => true;

public IReadOnlyList<EventDefinition> EventDefinitions => MediaEvents.All;

public bool RequiresConfiguration => false;
Expand All @@ -135,7 +146,7 @@ public async Task InitializeAsync(IIntegrationContext context)

if (RememberActiveApps() || changed)
{
_catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media apps discovered");
AppsChanged("media apps discovered");
}

_events.Start(context.Events);
Expand All @@ -148,12 +159,14 @@ public async Task ShutdownAsync()
{
_platform.SessionsChanged -= OnSessionsChanged;
await _events.StopAsync();
await _appVariables.StopAsync();
}

public void Dispose()
{
_platform.SessionsChanged -= OnSessionsChanged;
_events.Dispose();
_appVariables.Dispose();
_platform.Dispose();
_youTube.Dispose();
_youTubeLookup.Dispose();
Expand Down Expand Up @@ -181,19 +194,113 @@ public async ValueTask<VariableReading> ReadAsync(string localId, CancellationTo
: VariableReading.Unavailable;
}

if (FindAppVariable(localId) is var (app, currentId))
{
return SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken));
}

var state = await _currentState.GetAsync(cancellationToken);
return SystemMediaVariables.Read(localId, state);
}

public ValueTask<VariableWriteResult> SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) =>
SystemMediaVariables.DeviceRole(localId) is { } role
? SetDefaultAudioDeviceAsync(role, value, cancellationToken)
: localId switch
public ValueTask<VariableWriteResult> SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default)
{
if (SystemMediaVariables.DeviceRole(localId) is { } role)
{
return SetDefaultAudioDeviceAsync(role, value, cancellationToken);
}

IMusicPlayer player = _current;
if (FindAppVariable(localId) is var (app, currentId))
{
player = app.Player;
localId = currentId;
}

return localId switch
{
SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(player, value, cancellationToken),
SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(player, value, cancellationToken),
_ => ValueTask.FromResult(VariableWriteResult.NotWritable()),
};
}

public ValueTask<VariableCatalogPage> DiscoverAsync(VariableCatalogQuery query, CancellationToken cancellationToken = default)
{
var apps = _apps;
IReadOnlyList<VariableDefinition> items = query switch
{
{ ParentId: null, Search: { Length: > 0 } search } =>
[.. apps.SelectMany(app => AppVariablesOf(apps, app).Where(variable => SystemMediaVariables.Matches(variable, app.DisplayName, search)))],
{ ParentId: null } => [.. apps.Select(app => SystemMediaVariables.AppFolder(app.InstanceId, app.DisplayName))],
_ => Array.Find(apps, app => SystemMediaVariables.AppFolderId(app.InstanceId) == query.ParentId) is { } parent
? AppVariablesOf(apps, parent)
: [],
};

var offset = int.TryParse(query.ContinuationToken, CultureInfo.InvariantCulture, out var token) ? Math.Max(token, 0) : 0;
var next = offset + query.PageSize;
return ValueTask.FromResult(new VariableCatalogPage
{
Items = [.. items.Skip(offset).Take(query.PageSize)],
ContinuationToken = next < items.Count ? next.ToString(CultureInfo.InvariantCulture) : null,
});
}

public ValueTask<VariableDefinition?> ResolveAsync(string localId, CancellationToken cancellationToken = default)
{
var apps = _apps;
return ValueTask.FromResult(apps
.SelectMany(app => (IEnumerable<VariableDefinition>)[SystemMediaVariables.AppFolder(app.InstanceId, app.DisplayName), .. AppVariablesOf(apps, app)])
.FirstOrDefault(definition => definition.Id == localId));
}

public async ValueTask<IReadOnlyList<VariableValue>> SubscribeAsync(IReadOnlyCollection<string> localIds, CancellationToken cancellationToken = default)
{
var values = await ReadAppVariablesAsync(localIds, cancellationToken);
_appVariables.Subscribe(localIds, values);
return values;
}

// The host keeps the catalog it browsed last, even across a plugin restart, until told to reload it.
public Task OnAttachedAsync(IVariableSink sink, CancellationToken cancellationToken = default)
{
_appVariables.Start(sink);
return sink.InvalidateCatalogAsync(cancellationToken);
}

private async Task<IReadOnlyList<VariableValue>> ReadAppVariablesAsync(IReadOnlyCollection<string> localIds, CancellationToken cancellationToken)
{
var values = new List<VariableValue>();
foreach (var localId in localIds)
{
if (FindAppVariable(localId) is var (app, currentId))
{
SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(_current, value, cancellationToken),
SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(_current, value, cancellationToken),
_ => ValueTask.FromResult(VariableWriteResult.NotWritable()),
};
values.Add(VariableValue.Of(localId, SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken))));
}
}

return values;
}

private static IReadOnlyList<VariableDefinition> AppVariablesOf(KnownApp[] apps, KnownApp app) =>
SystemMediaVariables.ForApp(
app.InstanceId,
app.DisplayName,
SystemMediaVariables.NameKeys(apps.Select(known => (known.InstanceId, known.DisplayName)))[app.InstanceId]);

private (KnownApp App, string CurrentId)? FindAppVariable(string localId)
{
foreach (var app in _apps)
{
if (SystemMediaVariables.CurrentIdOf(localId, app.InstanceId) is { } currentId)
{
return (app, currentId);
}
}

return null;
}

private async ValueTask<VariableWriteResult> SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -232,7 +339,7 @@ public async Task<IssueResolution> ResolveIssueAsync(string issueId, Cancellatio
var resolution = await _platform.ResolveIssueAsync(issueId, cancellationToken);
if (resolution is { Success: true } && RememberActiveApps())
{
_catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media issue resolved");
AppsChanged("media issue resolved");
}

return resolution ?? IssueResolution.Failed(Strings.Issues.Unknown());
Expand Down Expand Up @@ -354,10 +461,16 @@ private void OnSessionsChanged(object? sender, EventArgs e)
{
if (RememberActiveApps())
{
_catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: "media apps changed");
AppsChanged("media apps changed");
}
}

private void AppsChanged(string reason)
{
_catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: reason);
_ = _appVariables.Sink?.InvalidateCatalogAsync();
}

private async Task TryStartPlatformAsync(CancellationToken cancellationToken)
{
try
Expand Down Expand Up @@ -424,11 +537,13 @@ private bool Merge(IReadOnlyList<RememberedApp> apps, bool active)
continue;
}

var player = CreatePlayer(app.AppId);
merged.Add(new KnownApp(
InstanceIds.ForApp(app.AppId),
app.AppId,
name ?? app.Name ?? _platform.ResolveApp(app.AppId).DisplayName,
CreatePlayer(app.AppId)));
player,
new StateSnapshotCache(player.GetStateAsync, TimeProvider.System)));
changed = true;
}

Expand All @@ -443,5 +558,5 @@ private bool Merge(IReadOnlyList<RememberedApp> apps, bool active)
private SessionMusicPlayer CreatePlayer(string? appId, TimeSpan cycleInterval = default) =>
new(_platform, _selection, appId, TimeProvider.System, _youTube, _thumbnails, cycleInterval);

private sealed record KnownApp(string InstanceId, string AppId, string DisplayName, SessionMusicPlayer Player);
private sealed record KnownApp(string InstanceId, string AppId, string DisplayName, SessionMusicPlayer Player, StateSnapshotCache State);
}
Loading
Loading