From ef4fed7ad69522c9d90a13268548ac51987a77a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Mon, 19 Oct 2026 08:39:39 +0200 Subject: [PATCH 1/3] feat: add state variables per-app --- AGENTS.md | 3 +- README.md | 6 ++ src/SystemMedia/Core/InstanceIds.cs | 23 +++++-- src/SystemMedia/Localization/Strings.resx | 6 ++ src/SystemMedia/PluginIntegration.cs | 64 ++++++++++++++----- src/SystemMedia/Variables/AppVariables.cs | 37 +++++++++++ .../Variables/SystemMediaVariables.cs | 30 ++++++++- tests/SystemMedia.Tests/InstanceIdsTests.cs | 7 ++ .../SystemMediaVariablesTests.cs | 50 +++++++++++++++ 9 files changed, 204 insertions(+), 22 deletions(-) create mode 100644 src/SystemMedia/Variables/AppVariables.cs diff --git a/AGENTS.md b/AGENTS.md index 077ac9b..765d127 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,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`) and the app part of per-app variable names + (`InstanceIds.ForAppVariables`); tests pin the exact output - event ids, event parameter names and variable names (`system_media_*`) - player app ids, `player:` (`player:mpv`, `player:smplayer`) - the data files `known-apps.json` (its `appUserModelId` field is the 1.x name), `settings.json` and diff --git a/README.md b/README.md index 31a3860..2040006 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,12 @@ 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. The app's part of the name is its app id made safe, plus a +short code that keeps two apps apart, e.g. `system_media_vlc_exe_1a2b3c4d_artist`. Pick them from the +variable list rather than typing them. Only the first 24 apps get their own variables, because a plugin may +offer at most 256. + ### Sound devices Whatever is playing, these name the system's default sound devices: diff --git a/src/SystemMedia/Core/InstanceIds.cs b/src/SystemMedia/Core/InstanceIds.cs index acbd1eb..08a06b2 100644 --- a/src/SystemMedia/Core/InstanceIds.cs +++ b/src/SystemMedia/Core/InstanceIds.cs @@ -11,20 +11,33 @@ internal static class InstanceIds private const string AppPrefix = "app-"; private const int MaxSlugLength = 40; + // A variable id may only be 64 characters long, including its prefix and the longest variable name. + private const int MaxVariableSlugLength = 22; internal static string ForApp(string appUserModelId) { - var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(appUserModelId)).AsSpan(0, 4)); - var slug = Slug(appUserModelId); + var hash = Hash(appUserModelId); + var slug = Slug(appUserModelId, MaxSlugLength); return slug.Length == 0 ? AppPrefix + hash : $"{AppPrefix}{slug}-{hash}"; } - private static string Slug(string value) + // The app's part of its variables' ids. Templates persist the names, so this mapping must never change either. + internal static string ForAppVariables(string appUserModelId) { - var builder = new StringBuilder(Math.Min(value.Length, MaxSlugLength)); + var hash = Hash(appUserModelId); + var slug = Slug(appUserModelId, MaxVariableSlugLength); + return slug.Length == 0 ? hash : $"{slug}-{hash}"; + } + + private static string Hash(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)).AsSpan(0, 4)); + + private static string Slug(string value, int maxLength) + { + var builder = new StringBuilder(Math.Min(value.Length, maxLength)); foreach (var character in value) { - if (builder.Length >= MaxSlugLength) + if (builder.Length >= maxLength) { break; } diff --git a/src/SystemMedia/Localization/Strings.resx b/src/SystemMedia/Localization/Strings.resx index eedeeb6..1614a95 100644 --- a/src/SystemMedia/Localization/Strings.resx +++ b/src/SystemMedia/Localization/Strings.resx @@ -97,6 +97,12 @@ Repeat mode + + {app}: {variable} + + + App + Default output device diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs index 4b9f191..9a0ab0e 100644 --- a/src/SystemMedia/PluginIntegration.cs +++ b/src/SystemMedia/PluginIntegration.cs @@ -59,6 +59,7 @@ public sealed class PluginIntegration private readonly Lock _appsGate = new(); private volatile KnownApp[] _apps = []; + private volatile AppVariables _appVariables = AppVariables.None; private bool _rememberedAppsLoaded; public PluginIntegration(IPluginCatalogNotifier catalog, IOptions hostOptions, ILogger logger) @@ -112,7 +113,9 @@ .. MusicPlayerActions.Common(ResolvePlayer, GetInstances), public IReadOnlyList Actions { get; } - public IReadOnlyList Variables => SystemMediaVariables.All; + public IReadOnlyList Variables => [.. SystemMediaVariables.All, .. _appVariables.Definitions]; + + public IReadOnlyList DeclaredVariables => [.. SystemMediaVariables.All, .. SystemMediaVariables.AppTemplates]; public IReadOnlyList EventDefinitions => MediaEvents.All; @@ -135,7 +138,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); @@ -181,19 +184,38 @@ public async ValueTask ReadAsync(string localId, CancellationTo : VariableReading.Unavailable; } + if (_appVariables.Find(localId) is { } target) + { + return FindApp(target.AppId) is { } app + ? SystemMediaVariables.Read(target.CurrentId, await app.State.GetAsync(cancellationToken)) + : VariableReading.Unavailable; + } + var state = await _currentState.GetAsync(cancellationToken); return SystemMediaVariables.Read(localId, state); } - public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) => - 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()), - }; + public ValueTask SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) + { + if (SystemMediaVariables.DeviceRole(localId) is { } role) + { + return SetDefaultAudioDeviceAsync(role, value, cancellationToken); + } + + IMusicPlayer? player = _current; + if (_appVariables.Find(localId) is { } target) + { + player = FindApp(target.AppId)?.Player; + localId = target.CurrentId; + } + + return localId switch + { + SystemMediaVariables.VolumeId => MusicPlayerVariableWrites.SetVolumeAsync(player, value, cancellationToken), + SystemMediaVariables.PositionId => MusicPlayerVariableWrites.SeekAsync(player, value, cancellationToken), + _ => ValueTask.FromResult(VariableWriteResult.NotWritable()), + }; + } private async ValueTask SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken) { @@ -232,7 +254,7 @@ public async Task 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()); @@ -350,14 +372,23 @@ private IReadOnlyList WatchedApps() => private IMusicPlayer? ResolvePlayer(string? instanceId) => string.IsNullOrEmpty(instanceId) ? _current : GetPlayer(instanceId); + private KnownApp? FindApp(string appId) => + Array.Find(_apps, app => string.Equals(app.AppId, appId, StringComparison.Ordinal)); + 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); + _catalog.CatalogChanged(CapabilityKinds.Variables, reason: reason); + } + private async Task TryStartPlatformAsync(CancellationToken cancellationToken) { try @@ -424,17 +455,20 @@ private bool Merge(IReadOnlyList 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; } if (changed) { _apps = [.. merged]; + _appVariables = new AppVariables(merged.Select(known => (known.AppId, known.DisplayName))); } return changed; @@ -443,5 +477,5 @@ private bool Merge(IReadOnlyList 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); } diff --git a/src/SystemMedia/Variables/AppVariables.cs b/src/SystemMedia/Variables/AppVariables.cs new file mode 100644 index 0000000..b6a25d2 --- /dev/null +++ b/src/SystemMedia/Variables/AppVariables.cs @@ -0,0 +1,37 @@ +using MacroDeck.Sdk.Variables; + +namespace SystemMedia.Variables; + +// Every known app's own copy of the state variables, so a template can follow one player whatever else plays. +internal sealed class AppVariables +{ + // The host drops every eager variable past its limit, so the apps that would not fit get none. + internal static int MaxApps { get; } = + (VariableLimits.MaxEagerVariablesPerProvider - SystemMediaVariables.All.Count) / SystemMediaVariables.PerApp; + + private readonly Dictionary _targets; + + internal AppVariables(IEnumerable<(string AppId, string AppName)> apps) + { + var definitions = new List(); + _targets = new Dictionary(StringComparer.Ordinal); + foreach (var (appId, appName) in apps.Take(MaxApps)) + { + foreach (var (definition, currentId) in SystemMediaVariables.ForApp(appId, appName)) + { + definitions.Add(definition); + _targets[definition.ResolvedId!] = new Target(appId, currentId); + } + } + + Definitions = definitions; + } + + internal static AppVariables None { get; } = new([]); + + internal IReadOnlyList Definitions { get; } + + internal Target? Find(string localId) => _targets.GetValueOrDefault(localId); + + internal sealed record Target(string AppId, string CurrentId); +} diff --git a/src/SystemMedia/Variables/SystemMediaVariables.cs b/src/SystemMedia/Variables/SystemMediaVariables.cs index b0892e4..c829e8a 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.Core; using SystemMedia.Platform; namespace SystemMedia.Variables; @@ -11,13 +12,16 @@ internal static class SystemMediaVariables internal const string PositionId = "system-media-position"; internal const string VolumeId = "system-media-volume"; + private const string NamePrefix = "system_media_"; + private const string AppNameName = NamePrefix + "app_name"; + private static readonly TimeSpan _fast = TimeSpan.FromSeconds(1); private static readonly TimeSpan _normal = TimeSpan.FromSeconds(2); private static readonly TimeSpan _slow = TimeSpan.FromSeconds(5); private static readonly Entry[] _entries = [ - Text("system_media_app_name", Strings.Variables.AppName(), state => state.DeviceName), + Text(AppNameName, Strings.Variables.AppName(), state => state.DeviceName), Text("system_media_track_name", Strings.Variables.TrackName(), state => state.TrackName), Text("system_media_artist", Strings.Variables.Artist(), state => state.Artists.Count == 0 ? null : string.Join(", ", state.Artists)), Text("system_media_album", Strings.Variables.Album(), state => state.AlbumName), @@ -66,6 +70,23 @@ internal static class SystemMediaVariables internal static IReadOnlyList All { get; } = [.. _entries.Select(entry => entry.Definition), .. _devices.Select(device => device.Definition)]; + // An app's own variables need no app name: the app is in their name. + private static readonly Entry[] _appEntries = [.. _entries.Where(entry => entry.Definition.Name != AppNameName)]; + + internal static int PerApp => _appEntries.Length; + + internal static IReadOnlyList AppTemplates { get; } = + [ + .. _appEntries.Select(entry => ForApp(entry, VariableNameTemplate.Placeholder("app"), Strings.Variables.AppPlaceholder())), + ]; + + // Each one is paired with the id of the "any app" variable it reads like. + internal static IEnumerable<(VariableDefinition Definition, string CurrentId)> ForApp(string appId, string appName) + { + var key = InstanceIds.ForAppVariables(appId).Replace('-', '_'); + return _appEntries.Select(entry => (ForApp(entry, key, appName), entry.Definition.ResolvedId!)); + } + internal static AudioDeviceRole? DeviceRole(string localId) => Array.Find(_devices, candidate => string.Equals(candidate.Definition.ResolvedId, localId, StringComparison.Ordinal))?.Role; @@ -94,6 +115,13 @@ internal static VariableReading Read(string localId, MusicPlayerState state) private static Entry Text(string name, LocalizedText displayName, Func read, TimeSpan? refresh = null) => new(VariableDefinition.Eager(name, VariableType.Text, refreshInterval: refresh ?? _normal) with { DisplayName = displayName }, read); + private static VariableDefinition ForApp(Entry entry, string key, LocalizedText appName) => + entry.Definition with + { + Name = $"{NamePrefix}{key}_{entry.Definition.Name![NamePrefix.Length..]}", + DisplayName = Strings.Variables.ForApp(appName, entry.Definition.DisplayName), + }; + private sealed record Entry(VariableDefinition Definition, Func Read); private static DeviceEntry Device(string name, LocalizedText displayName, AudioDeviceRole role) => diff --git a/tests/SystemMedia.Tests/InstanceIdsTests.cs b/tests/SystemMedia.Tests/InstanceIdsTests.cs index 292a909..7c61fce 100644 --- a/tests/SystemMedia.Tests/InstanceIdsTests.cs +++ b/tests/SystemMedia.Tests/InstanceIdsTests.cs @@ -36,6 +36,13 @@ public void The_mapping_never_changes_so_a_bound_widget_survives_restarts_and_up Assert.That(InstanceIds.ForApp("Spotify.exe"), Is.EqualTo("app-spotify-exe-b527fa0a")); } + // Pinned: templates persist it. + [Test] + public void The_variable_mapping_never_changes_and_shares_the_instance_hash() + { + Assert.That(InstanceIds.ForAppVariables("Spotify.exe"), Is.EqualTo("spotify-exe-b527fa0a")); + } + [Test] public void Ids_that_slug_the_same_still_map_to_different_instances() { diff --git a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs index 62cd3c5..466df66 100644 --- a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs +++ b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs @@ -1,4 +1,5 @@ using MacroDeck.Sdk.MusicPlayer; +using MacroDeck.Sdk.Variables; using NUnit.Framework; using SystemMedia.Platform; using SystemMedia.Variables; @@ -70,6 +71,55 @@ public void The_position_slider_ends_at_the_track_duration() Assert.That(reading.Max, Is.EqualTo(256)); } + [TestCase("Spotify.exe")] + [TestCase("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic.Very.Long.Application.Identifier.That.Exceeds.Limits")] + [TestCase("---")] + public void Every_app_variable_has_a_valid_id_of_its_own(string appId) + { + var ids = SystemMediaVariables.ForApp(appId, "App").Select(pair => pair.Definition.ResolvedId).ToList(); + + Assert.That(ids, Has.All.Matches(InstanceIdsTests.LocalIdPattern).And.All.Length.LessThanOrEqualTo(64)); + Assert.That(ids, Is.Unique); + Assert.That(ids, Has.None.AnyOf(SystemMediaVariables.All.Select(variable => variable.ResolvedId).ToArray())); + } + + [Test] + public void An_app_variable_is_named_after_the_app_and_reads_like_its_any_app_variable() + { + var (definition, currentId) = SystemMediaVariables.ForApp("Spotify.exe", "Spotify").Single(pair => pair.Definition.Name!.EndsWith("_artist", StringComparison.Ordinal)); + + Assert.That(definition.Name, Is.EqualTo("system_media_spotify_exe_b527fa0a_artist")); + Assert.That(currentId, Is.EqualTo("system-media-artist")); + } + + [Test] + public void App_variables_are_declared_as_templates_without_an_id() + { + Assert.That(SystemMediaVariables.AppTemplates.Select(template => template.Name), Has.All.StartsWith("system_media__")); + Assert.That(SystemMediaVariables.AppTemplates.Select(template => template.ResolvedId), Has.All.Null); + Assert.That(SystemMediaVariables.AppTemplates, Has.Count.EqualTo(SystemMediaVariables.PerApp)); + } + + [Test] + public void Apps_past_the_eager_variable_limit_get_no_variables() + { + var apps = Enumerable.Range(0, AppVariables.MaxApps + 3).Select(index => ($"app{index}", $"App {index}")); + + var variables = new AppVariables(apps); + + Assert.That(variables.Definitions, Has.Count.EqualTo(AppVariables.MaxApps * SystemMediaVariables.PerApp)); + Assert.That(SystemMediaVariables.All.Count + variables.Definitions.Count, Is.LessThanOrEqualTo(VariableLimits.MaxEagerVariablesPerProvider)); + } + + [Test] + public void An_app_variable_id_leads_back_to_its_app() + { + var variables = new AppVariables([("Spotify.exe", "Spotify")]); + + Assert.That(variables.Find("system-media-spotify-exe-b527fa0a-volume"), Is.EqualTo(new AppVariables.Target("Spotify.exe", SystemMediaVariables.VolumeId))); + Assert.That(variables.Find(SystemMediaVariables.VolumeId), Is.Null); + } + [Test] public void A_stream_without_a_timeline_has_no_progress() { From 1962b851fecfa36deadfa9e97c262d16ed895d57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20R=C3=B6=C3=9Fler?= Date: Mon, 19 Oct 2026 19:03:23 +0200 Subject: [PATCH 2/3] chore: run conformance in demo mode so it never controls a real player --- AGENTS.md | 3 +++ Makefile | 3 ++- src/SystemMedia/PluginIntegration.cs | 10 +++++----- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 765d127..f41fff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,6 +173,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. diff --git a/Makefile b/Makefile index 03514b1..beebe7b 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs index 9a0ab0e..66041f6 100644 --- a/src/SystemMedia/PluginIntegration.cs +++ b/src/SystemMedia/PluginIntegration.cs @@ -67,9 +67,11 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions(); 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; } @@ -80,9 +82,7 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions Date: Mon, 19 Oct 2026 19:03:41 +0200 Subject: [PATCH 3/3] feat: offer per-app variables as a catalog with a folder per app --- AGENTS.md | 7 +- README.md | 7 +- src/SystemMedia/Core/InstanceIds.cs | 23 +-- src/SystemMedia/Localization/Strings.resx | 35 ++++- src/SystemMedia/PluginIntegration.cs | 113 ++++++++++++--- .../Variables/AppVariablePublisher.cs | 132 ++++++++++++++++++ src/SystemMedia/Variables/AppVariables.cs | 37 ----- .../Variables/SystemMediaVariables.cs | 96 ++++++++++--- tests/SystemMedia.Tests/InstanceIdsTests.cs | 7 - .../SystemMediaVariablesTests.cs | 84 ++++++++--- 10 files changed, 412 insertions(+), 129 deletions(-) create mode 100644 src/SystemMedia/Variables/AppVariablePublisher.cs delete mode 100644 src/SystemMedia/Variables/AppVariables.cs diff --git a/AGENTS.md b/AGENTS.md index f41fff8..dc74269 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -45,8 +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`) and the app part of per-app variable names - (`InstanceIds.ForAppVariables`); tests pin 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--`), which the host keeps for linked variables - event ids, event parameter names and variable names (`system_media_*`) - player app ids, `player:` (`player:mpv`, `player:smplayer`) - the data files `known-apps.json` (its `appUserModelId` field is the 1.x name), `settings.json` and diff --git a/README.md b/README.md index 2040006..7c33885 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,9 @@ For whatever is playing right now (the same as *Any app*): | `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. The app's part of the name is its app id made safe, plus a -short code that keeps two apps apart, e.g. `system_media_vlc_exe_1a2b3c4d_artist`. Pick them from the -variable list rather than typing them. Only the first 24 apps get their own variables, because a plugin may -offer at most 256. +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 diff --git a/src/SystemMedia/Core/InstanceIds.cs b/src/SystemMedia/Core/InstanceIds.cs index 08a06b2..c5c9a57 100644 --- a/src/SystemMedia/Core/InstanceIds.cs +++ b/src/SystemMedia/Core/InstanceIds.cs @@ -11,33 +11,20 @@ internal static class InstanceIds private const string AppPrefix = "app-"; private const int MaxSlugLength = 40; - // A variable id may only be 64 characters long, including its prefix and the longest variable name. - private const int MaxVariableSlugLength = 22; internal static string ForApp(string appUserModelId) { - var hash = Hash(appUserModelId); - var slug = Slug(appUserModelId, MaxSlugLength); + var hash = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(appUserModelId)).AsSpan(0, 4)); + var slug = Slug(appUserModelId); return slug.Length == 0 ? AppPrefix + hash : $"{AppPrefix}{slug}-{hash}"; } - // The app's part of its variables' ids. Templates persist the names, so this mapping must never change either. - internal static string ForAppVariables(string appUserModelId) + internal static string Slug(string value) { - var hash = Hash(appUserModelId); - var slug = Slug(appUserModelId, MaxVariableSlugLength); - return slug.Length == 0 ? hash : $"{slug}-{hash}"; - } - - private static string Hash(string value) => - Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value)).AsSpan(0, 4)); - - private static string Slug(string value, int maxLength) - { - var builder = new StringBuilder(Math.Min(value.Length, maxLength)); + var builder = new StringBuilder(Math.Min(value.Length, MaxSlugLength)); foreach (var character in value) { - if (builder.Length >= maxLength) + if (builder.Length >= MaxSlugLength) { break; } diff --git a/src/SystemMedia/Localization/Strings.resx b/src/SystemMedia/Localization/Strings.resx index 1614a95..6bbeab3 100644 --- a/src/SystemMedia/Localization/Strings.resx +++ b/src/SystemMedia/Localization/Strings.resx @@ -97,11 +97,38 @@ Repeat mode - - {app}: {variable} + + {app}: Track name - - App + + {app}: Artist + + + {app}: Album + + + {app}: Playback state + + + {app}: Is playing + + + {app}: Position + + + {app}: Duration + + + {app}: Progress + + + {app}: App volume + + + {app}: Shuffle enabled + + + {app}: Repeat mode Default output device diff --git a/src/SystemMedia/PluginIntegration.cs b/src/SystemMedia/PluginIntegration.cs index 66041f6..7d43087 100644 --- a/src/SystemMedia/PluginIntegration.cs +++ b/src/SystemMedia/PluginIntegration.cs @@ -1,3 +1,4 @@ +using System.Globalization; using MacroDeck.Plugin.Hosting; using MacroDeck.Plugin.Hosting.Integrations.HostApis; using MacroDeck.Plugin.Protocol.Handshake; @@ -56,10 +57,10 @@ public sealed class PluginIntegration private readonly IReadOnlyList _currentOptions; private readonly StateSnapshotCache _currentState; private readonly MediaEventWatcher _events; + private readonly AppVariablePublisher _appVariables; private readonly Lock _appsGate = new(); private volatile KnownApp[] _apps = []; - private volatile AppVariables _appVariables = AppVariables.None; private bool _rememberedAppsLoaded; public PluginIntegration(IPluginCatalogNotifier catalog, IOptions hostOptions, ILogger logger) @@ -87,6 +88,7 @@ public PluginIntegration(IPluginCatalogNotifier catalog, IOptions Actions { get; } - public IReadOnlyList Variables => [.. SystemMediaVariables.All, .. _appVariables.Definitions]; + public IReadOnlyList Variables => SystemMediaVariables.All; - public IReadOnlyList DeclaredVariables => [.. SystemMediaVariables.All, .. SystemMediaVariables.AppTemplates]; + // 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 EventDefinitions => MediaEvents.All; @@ -151,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(); @@ -184,11 +194,9 @@ public async ValueTask ReadAsync(string localId, CancellationTo : VariableReading.Unavailable; } - if (_appVariables.Find(localId) is { } target) + if (FindAppVariable(localId) is var (app, currentId)) { - return FindApp(target.AppId) is { } app - ? SystemMediaVariables.Read(target.CurrentId, await app.State.GetAsync(cancellationToken)) - : VariableReading.Unavailable; + return SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken)); } var state = await _currentState.GetAsync(cancellationToken); @@ -202,11 +210,11 @@ public ValueTask SetValueAsync(string localId, object? valu return SetDefaultAudioDeviceAsync(role, value, cancellationToken); } - IMusicPlayer? player = _current; - if (_appVariables.Find(localId) is { } target) + IMusicPlayer player = _current; + if (FindAppVariable(localId) is var (app, currentId)) { - player = FindApp(target.AppId)?.Player; - localId = target.CurrentId; + player = app.Player; + localId = currentId; } return localId switch @@ -217,6 +225,83 @@ public ValueTask SetValueAsync(string localId, object? valu }; } + public ValueTask DiscoverAsync(VariableCatalogQuery query, CancellationToken cancellationToken = default) + { + var apps = _apps; + IReadOnlyList 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 ResolveAsync(string localId, CancellationToken cancellationToken = default) + { + var apps = _apps; + return ValueTask.FromResult(apps + .SelectMany(app => (IEnumerable)[SystemMediaVariables.AppFolder(app.InstanceId, app.DisplayName), .. AppVariablesOf(apps, app)]) + .FirstOrDefault(definition => definition.Id == localId)); + } + + public async ValueTask> SubscribeAsync(IReadOnlyCollection 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> ReadAppVariablesAsync(IReadOnlyCollection localIds, CancellationToken cancellationToken) + { + var values = new List(); + foreach (var localId in localIds) + { + if (FindAppVariable(localId) is var (app, currentId)) + { + values.Add(VariableValue.Of(localId, SystemMediaVariables.Read(currentId, await app.State.GetAsync(cancellationToken)))); + } + } + + return values; + } + + private static IReadOnlyList 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 SetDefaultAudioDeviceAsync(AudioDeviceRole role, object? value, CancellationToken cancellationToken) { if (value?.ToString() is not { Length: > 0 } device) @@ -372,9 +457,6 @@ private IReadOnlyList WatchedApps() => private IMusicPlayer? ResolvePlayer(string? instanceId) => string.IsNullOrEmpty(instanceId) ? _current : GetPlayer(instanceId); - private KnownApp? FindApp(string appId) => - Array.Find(_apps, app => string.Equals(app.AppId, appId, StringComparison.Ordinal)); - private void OnSessionsChanged(object? sender, EventArgs e) { if (RememberActiveApps()) @@ -386,7 +468,7 @@ private void OnSessionsChanged(object? sender, EventArgs e) private void AppsChanged(string reason) { _catalog.CatalogChanged(CapabilityKinds.MusicPlayer, reason: reason); - _catalog.CatalogChanged(CapabilityKinds.Variables, reason: reason); + _ = _appVariables.Sink?.InvalidateCatalogAsync(); } private async Task TryStartPlatformAsync(CancellationToken cancellationToken) @@ -468,7 +550,6 @@ private bool Merge(IReadOnlyList apps, bool active) if (changed) { _apps = [.. merged]; - _appVariables = new AppVariables(merged.Select(known => (known.AppId, known.DisplayName))); } return changed; diff --git a/src/SystemMedia/Variables/AppVariablePublisher.cs b/src/SystemMedia/Variables/AppVariablePublisher.cs new file mode 100644 index 0000000..3ee38b2 --- /dev/null +++ b/src/SystemMedia/Variables/AppVariablePublisher.cs @@ -0,0 +1,132 @@ +using System.Collections.Concurrent; +using MacroDeck.Sdk.Logging; +using MacroDeck.Sdk.Variables; +using Serilog; + +namespace SystemMedia.Variables; + +// Catalog variables reach the host by push, so the subscribed ones are read every second and sent when they change. +internal sealed class AppVariablePublisher( + Func, CancellationToken, Task>> read, + ILogger logger) : IDisposable +{ + private static readonly TimeSpan _interval = TimeSpan.FromSeconds(1); + + private readonly ConcurrentDictionary _published = new(StringComparer.Ordinal); + private readonly FailureEpisodeTracker _failures = new(); + private readonly Lock _gate = new(); + + private volatile IVariableSink? _sink; + private volatile IReadOnlyCollection _subscribed = []; + private CancellationTokenSource? _stopping; + private Task? _loop; + + internal IVariableSink? Sink => _sink; + + internal void Start(IVariableSink sink) + { + _sink = sink; + lock (_gate) + { + if (_loop is not null) + { + return; + } + + _stopping = new CancellationTokenSource(); + _loop = Task.Run(() => RunAsync(_stopping.Token)); + } + } + + internal async Task StopAsync() + { + Task? loop; + lock (_gate) + { + loop = _loop; + _stopping?.Cancel(); + _loop = null; + } + + if (loop is not null) + { + await loop.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } + + public void Dispose() + { + lock (_gate) + { + _stopping?.Cancel(); + _stopping?.Dispose(); + _stopping = null; + } + } + + // The host receives these values with its subscription, so they count as published. + internal void Subscribe(IReadOnlyCollection ids, IReadOnlyList current) + { + _subscribed = ids; + foreach (var id in _published.Keys.Except(ids, StringComparer.Ordinal)) + { + _published.TryRemove(id, out _); + } + + foreach (var value in current) + { + _published[value.Id] = value.Reading; + } + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(_interval); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await TickAsync(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + + private async Task TickAsync(CancellationToken cancellationToken) + { + var ids = _subscribed; + if (ids.Count == 0 || _sink is not { } sink) + { + return; + } + + try + { + var changed = (await read(ids, cancellationToken)) + .Where(value => !_published.TryGetValue(value.Id, out var last) || last != value.Reading) + .ToList(); + if (changed.Count > 0) + { + await sink.PublishAsync(changed, cancellationToken); + foreach (var value in changed) + { + _published[value.Id] = value.Reading; + } + } + + if (_failures.RecordSuccess() is { } episode) + { + logger.Information("App variable publisher recovered after {Duration} ({Failures} failed rounds).", episode.Duration, episode.Failures); + } + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + if (_failures.RecordFailure(exception.Message).Kind == FailureEpisodeSignalKind.Onset) + { + logger.Warning(exception, "App variable publisher could not read the media sessions; app variables stay as they are until it can."); + } + } + } +} diff --git a/src/SystemMedia/Variables/AppVariables.cs b/src/SystemMedia/Variables/AppVariables.cs deleted file mode 100644 index b6a25d2..0000000 --- a/src/SystemMedia/Variables/AppVariables.cs +++ /dev/null @@ -1,37 +0,0 @@ -using MacroDeck.Sdk.Variables; - -namespace SystemMedia.Variables; - -// Every known app's own copy of the state variables, so a template can follow one player whatever else plays. -internal sealed class AppVariables -{ - // The host drops every eager variable past its limit, so the apps that would not fit get none. - internal static int MaxApps { get; } = - (VariableLimits.MaxEagerVariablesPerProvider - SystemMediaVariables.All.Count) / SystemMediaVariables.PerApp; - - private readonly Dictionary _targets; - - internal AppVariables(IEnumerable<(string AppId, string AppName)> apps) - { - var definitions = new List(); - _targets = new Dictionary(StringComparer.Ordinal); - foreach (var (appId, appName) in apps.Take(MaxApps)) - { - foreach (var (definition, currentId) in SystemMediaVariables.ForApp(appId, appName)) - { - definitions.Add(definition); - _targets[definition.ResolvedId!] = new Target(appId, currentId); - } - } - - Definitions = definitions; - } - - internal static AppVariables None { get; } = new([]); - - internal IReadOnlyList Definitions { get; } - - internal Target? Find(string localId) => _targets.GetValueOrDefault(localId); - - internal sealed record Target(string AppId, string CurrentId); -} diff --git a/src/SystemMedia/Variables/SystemMediaVariables.cs b/src/SystemMedia/Variables/SystemMediaVariables.cs index c829e8a..ced7635 100644 --- a/src/SystemMedia/Variables/SystemMediaVariables.cs +++ b/src/SystemMedia/Variables/SystemMediaVariables.cs @@ -12,8 +12,7 @@ internal static class SystemMediaVariables internal const string PositionId = "system-media-position"; internal const string VolumeId = "system-media-volume"; - private const string NamePrefix = "system_media_"; - private const string AppNameName = NamePrefix + "app_name"; + private const string IdPrefix = "system-media-"; private static readonly TimeSpan _fast = TimeSpan.FromSeconds(1); private static readonly TimeSpan _normal = TimeSpan.FromSeconds(2); @@ -21,7 +20,7 @@ internal static class SystemMediaVariables private static readonly Entry[] _entries = [ - Text(AppNameName, Strings.Variables.AppName(), state => state.DeviceName), + Text("system_media_app_name", Strings.Variables.AppName(), state => state.DeviceName), Text("system_media_track_name", Strings.Variables.TrackName(), state => state.TrackName), Text("system_media_artist", Strings.Variables.Artist(), state => state.Artists.Count == 0 ? null : string.Join(", ", state.Artists)), Text("system_media_album", Strings.Variables.Album(), state => state.AlbumName), @@ -70,21 +69,80 @@ internal static class SystemMediaVariables internal static IReadOnlyList All { get; } = [.. _entries.Select(entry => entry.Definition), .. _devices.Select(device => device.Definition)]; - // An app's own variables need no app name: the app is in their name. - private static readonly Entry[] _appEntries = [.. _entries.Where(entry => entry.Definition.Name != AppNameName)]; + // One string per variable: a localized argument inside another does not reach the host. No app name, the app is in the name. + private static readonly Dictionary> _appDisplayNames = new(StringComparer.Ordinal) + { + ["system_media_track_name"] = Strings.Variables.App.TrackName, + ["system_media_artist"] = Strings.Variables.App.Artist, + ["system_media_album"] = Strings.Variables.App.Album, + ["system_media_playback_state"] = Strings.Variables.App.PlaybackState, + ["system_media_is_playing"] = Strings.Variables.App.IsPlaying, + ["system_media_position"] = Strings.Variables.App.Position, + ["system_media_duration"] = Strings.Variables.App.Duration, + ["system_media_progress_percentage"] = Strings.Variables.App.ProgressPercentage, + ["system_media_volume"] = Strings.Variables.App.Volume, + ["system_media_shuffle_enabled"] = Strings.Variables.App.ShuffleEnabled, + ["system_media_repeat_mode"] = Strings.Variables.App.RepeatMode, + }; + + private static readonly Entry[] _appEntries = [.. _entries.Where(entry => _appDisplayNames.ContainsKey(entry.Definition.Name!))]; + + internal static string AppFolderId(string instanceId) => IdPrefix + instanceId; + + internal static VariableDefinition AppFolder(string instanceId, string appName) => + VariableDefinition.OnDemand(AppFolderId(instanceId), VariableType.Text) with + { + DisplayName = appName, + IsContainer = true, + IsBindable = false, + }; - internal static int PerApp => _appEntries.Length; + // The host keeps the id of a linked variable, not its name, so a name may take the hash only while two apps clash. + internal static IReadOnlyDictionary NameKeys(IEnumerable<(string InstanceId, string AppName)> apps) + { + var slugs = apps.Select(app => (app.InstanceId, Slug: InstanceIds.Slug(app.AppName).Replace('-', '_'))).ToList(); + var shared = slugs.GroupBy(app => app.Slug, StringComparer.Ordinal).Where(group => group.Count() > 1).Select(group => group.Key).ToHashSet(StringComparer.Ordinal); + return slugs.ToDictionary( + app => app.InstanceId, + app => app.Slug.Length > 0 && !shared.Contains(app.Slug) ? app.Slug : WithHash(app.Slug, app.InstanceId), + StringComparer.Ordinal); + } - internal static IReadOnlyList AppTemplates { get; } = + internal static IReadOnlyList ForApp(string instanceId, string appName, string nameKey) => [ - .. _appEntries.Select(entry => ForApp(entry, VariableNameTemplate.Placeholder("app"), Strings.Variables.AppPlaceholder())), + .. _appEntries.Select(entry => + { + var id = AppVariableId(instanceId, entry.Definition.ResolvedId!); + return entry.Definition with + { + Id = id, + Name = $"system_media_{nameKey}_{entry.Definition.Name!["system_media_".Length..]}", + Materialization = VariableMaterialization.OnDemand, + ParentId = AppFolderId(instanceId), + DisplayName = _appDisplayNames[entry.Definition.Name!](appName), + }; + }), ]; - // Each one is paired with the id of the "any app" variable it reads like. - internal static IEnumerable<(VariableDefinition Definition, string CurrentId)> ForApp(string appId, string appName) + // Every word typed has to appear in the variable's name or its app's name, so "vlc artist" finds VLC's artist. + internal static bool Matches(VariableDefinition variable, string appName, string search) + { + var text = $"{variable.Name} {appName}"; + return search.Split([' ', '_', '.'], StringSplitOptions.RemoveEmptyEntries) + .All(word => text.Contains(word, StringComparison.OrdinalIgnoreCase)); + } + + // The id of the "any app" variable that an app's variable reads like, or null if it is none of that app's. + internal static string? CurrentIdOf(string localId, string instanceId) { - var key = InstanceIds.ForAppVariables(appId).Replace('-', '_'); - return _appEntries.Select(entry => (ForApp(entry, key, appName), entry.Definition.ResolvedId!)); + var prefix = AppFolderId(instanceId) + "-"; + if (!localId.StartsWith(prefix, StringComparison.Ordinal)) + { + return null; + } + + var currentId = IdPrefix + localId[prefix.Length..]; + return Array.Exists(_appEntries, entry => entry.Definition.ResolvedId == currentId) ? currentId : null; } internal static AudioDeviceRole? DeviceRole(string localId) => @@ -115,12 +173,14 @@ internal static VariableReading Read(string localId, MusicPlayerState state) private static Entry Text(string name, LocalizedText displayName, Func read, TimeSpan? refresh = null) => new(VariableDefinition.Eager(name, VariableType.Text, refreshInterval: refresh ?? _normal) with { DisplayName = displayName }, read); - private static VariableDefinition ForApp(Entry entry, string key, LocalizedText appName) => - entry.Definition with - { - Name = $"{NamePrefix}{key}_{entry.Definition.Name![NamePrefix.Length..]}", - DisplayName = Strings.Variables.ForApp(appName, entry.Definition.DisplayName), - }; + private static string WithHash(string slug, string instanceId) + { + var hash = instanceId[(instanceId.LastIndexOf('-') + 1)..]; + return slug.Length == 0 ? hash : $"{slug}_{hash}"; + } + + private static string AppVariableId(string instanceId, string currentId) => + $"{AppFolderId(instanceId)}-{currentId[IdPrefix.Length..]}"; private sealed record Entry(VariableDefinition Definition, Func Read); diff --git a/tests/SystemMedia.Tests/InstanceIdsTests.cs b/tests/SystemMedia.Tests/InstanceIdsTests.cs index 7c61fce..292a909 100644 --- a/tests/SystemMedia.Tests/InstanceIdsTests.cs +++ b/tests/SystemMedia.Tests/InstanceIdsTests.cs @@ -36,13 +36,6 @@ public void The_mapping_never_changes_so_a_bound_widget_survives_restarts_and_up Assert.That(InstanceIds.ForApp("Spotify.exe"), Is.EqualTo("app-spotify-exe-b527fa0a")); } - // Pinned: templates persist it. - [Test] - public void The_variable_mapping_never_changes_and_shares_the_instance_hash() - { - Assert.That(InstanceIds.ForAppVariables("Spotify.exe"), Is.EqualTo("spotify-exe-b527fa0a")); - } - [Test] public void Ids_that_slug_the_same_still_map_to_different_instances() { diff --git a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs index 466df66..fc32e3f 100644 --- a/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs +++ b/tests/SystemMedia.Tests/SystemMediaVariablesTests.cs @@ -1,6 +1,8 @@ +using MacroDeck.Localization; using MacroDeck.Sdk.MusicPlayer; using MacroDeck.Sdk.Variables; using NUnit.Framework; +using SystemMedia.Core; using SystemMedia.Platform; using SystemMedia.Variables; @@ -74,50 +76,88 @@ public void The_position_slider_ends_at_the_track_duration() [TestCase("Spotify.exe")] [TestCase("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic.Very.Long.Application.Identifier.That.Exceeds.Limits")] [TestCase("---")] - public void Every_app_variable_has_a_valid_id_of_its_own(string appId) + public void Every_app_variable_is_an_on_demand_variable_in_its_app_folder(string appId) { - var ids = SystemMediaVariables.ForApp(appId, "App").Select(pair => pair.Definition.ResolvedId).ToList(); + var instanceId = InstanceIds.ForApp(appId); + var variables = SystemMediaVariables.ForApp(instanceId, "App", "app"); + + Assert.That(variables.Select(variable => variable.Id), Has.All.Matches(InstanceIdsTests.LocalIdPattern).And.All.Length.LessThanOrEqualTo(256)); + Assert.That(variables.Select(variable => variable.Id), Is.Unique); + Assert.That(variables.Select(variable => variable.Id), Has.None.AnyOf(SystemMediaVariables.All.Select(variable => variable.ResolvedId).ToArray())); + Assert.That(variables.Select(variable => variable.Materialization), Has.All.EqualTo(VariableMaterialization.OnDemand)); + Assert.That(variables.Select(variable => variable.ParentId), Has.All.EqualTo(SystemMediaVariables.AppFolderId(instanceId))); + } - Assert.That(ids, Has.All.Matches(InstanceIdsTests.LocalIdPattern).And.All.Length.LessThanOrEqualTo(64)); - Assert.That(ids, Is.Unique); - Assert.That(ids, Has.None.AnyOf(SystemMediaVariables.All.Select(variable => variable.ResolvedId).ToArray())); + // Pinned: the host keeps the id of every linked variable. + [Test] + public void An_app_variable_is_identified_by_the_app_instance_and_named_after_the_app() + { + var keys = SystemMediaVariables.NameKeys([("app-vlc-exe-2900ecd1", "VLC media player")]); + var artist = SystemMediaVariables.ForApp("app-vlc-exe-2900ecd1", "VLC media player", keys["app-vlc-exe-2900ecd1"]) + .Single(variable => variable.Id!.EndsWith("-artist", StringComparison.Ordinal)); + + Assert.That(artist.Id, Is.EqualTo("system-media-app-vlc-exe-2900ecd1-artist")); + Assert.That(artist.Name, Is.EqualTo("system_media_vlc_media_player_artist")); } [Test] - public void An_app_variable_is_named_after_the_app_and_reads_like_its_any_app_variable() + public void Apps_with_the_same_name_are_told_apart_by_their_hash() { - var (definition, currentId) = SystemMediaVariables.ForApp("Spotify.exe", "Spotify").Single(pair => pair.Definition.Name!.EndsWith("_artist", StringComparison.Ordinal)); + var keys = SystemMediaVariables.NameKeys([("app-firefox-11111111", "Firefox"), ("app-firefox-profile-22222222", "Firefox"), ("app-vlc-exe-33333333", "VLC")]); - Assert.That(definition.Name, Is.EqualTo("system_media_spotify_exe_b527fa0a_artist")); - Assert.That(currentId, Is.EqualTo("system-media-artist")); + Assert.That(keys["app-firefox-11111111"], Is.EqualTo("firefox_11111111")); + Assert.That(keys["app-firefox-profile-22222222"], Is.EqualTo("firefox_22222222")); + Assert.That(keys["app-vlc-exe-33333333"], Is.EqualTo("vlc")); } [Test] - public void App_variables_are_declared_as_templates_without_an_id() + public void An_app_name_without_letters_falls_back_to_the_hash() + { + Assert.That(SystemMediaVariables.NameKeys([("app-44444444", "音楽")])["app-44444444"], Is.EqualTo("44444444")); + } + + [TestCase("system-media-app-spotify-exe-b527fa0a-track-name", "system-media-track-name")] + [TestCase("system-media-app-spotify-exe-b527fa0a-volume", SystemMediaVariables.VolumeId)] + [TestCase("system-media-app-spotify-exe-b527fa0a", null)] + [TestCase("system-media-app-spotify-exe-b527fa0a-app-name", null)] + [TestCase("system-media-app-vlc-exe-0011aabb-artist", null)] + [TestCase(SystemMediaVariables.VolumeId, null)] + public void An_app_variable_reads_like_its_any_app_variable(string localId, string? currentId) { - Assert.That(SystemMediaVariables.AppTemplates.Select(template => template.Name), Has.All.StartsWith("system_media__")); - Assert.That(SystemMediaVariables.AppTemplates.Select(template => template.ResolvedId), Has.All.Null); - Assert.That(SystemMediaVariables.AppTemplates, Has.Count.EqualTo(SystemMediaVariables.PerApp)); + Assert.That(SystemMediaVariables.CurrentIdOf(localId, "app-spotify-exe-b527fa0a"), Is.EqualTo(currentId)); } + // The host resolves the text itself and drops a localized argument nested inside it. [Test] - public void Apps_past_the_eager_variable_limit_get_no_variables() + public void An_app_variable_name_carries_the_app_as_plain_text_only() { - var apps = Enumerable.Range(0, AppVariables.MaxApps + 3).Select(index => ($"app{index}", $"App {index}")); + var names = SystemMediaVariables.ForApp("app-spotify-exe-b527fa0a", "Spotify", "spotify").Select(variable => variable.DisplayName.Localized!.Value); - var variables = new AppVariables(apps); + Assert.That(names.Select(name => name.Arguments.Values.Single()), Has.All.EqualTo((LocalizedText)"Spotify")); + } + + [TestCase("vlc artist", true)] + [TestCase("VLC Media", true)] + [TestCase("system_media_vlc_media_player_artist", true)] + [TestCase("player", true)] + [TestCase("firefox artist", false)] + [TestCase("vlc album", false)] + public void A_search_finds_an_app_variable_by_its_name_or_its_app(string search, bool found) + { + var artist = SystemMediaVariables.ForApp("app-vlc-exe-2900ecd1", "VLC media player", "vlc_media_player") + .Single(variable => variable.Id!.EndsWith("-artist", StringComparison.Ordinal)); - Assert.That(variables.Definitions, Has.Count.EqualTo(AppVariables.MaxApps * SystemMediaVariables.PerApp)); - Assert.That(SystemMediaVariables.All.Count + variables.Definitions.Count, Is.LessThanOrEqualTo(VariableLimits.MaxEagerVariablesPerProvider)); + Assert.That(SystemMediaVariables.Matches(artist, "VLC media player", search), Is.EqualTo(found)); } [Test] - public void An_app_variable_id_leads_back_to_its_app() + public void An_app_folder_holds_variables_but_is_none() { - var variables = new AppVariables([("Spotify.exe", "Spotify")]); + var folder = SystemMediaVariables.AppFolder("app-spotify-exe-b527fa0a", "Spotify"); - Assert.That(variables.Find("system-media-spotify-exe-b527fa0a-volume"), Is.EqualTo(new AppVariables.Target("Spotify.exe", SystemMediaVariables.VolumeId))); - Assert.That(variables.Find(SystemMediaVariables.VolumeId), Is.Null); + Assert.That(folder.Id, Is.EqualTo("system-media-app-spotify-exe-b527fa0a")); + Assert.That(folder.IsContainer, Is.True); + Assert.That(folder.IsBindable, Is.False); } [Test]