Music players
An integration exposes a music player by implementing IMusicPlayerProvider. It lists one
MusicPlayerInstance per configured account and resolves each one to an IMusicPlayer. Macro Deck
reads the state and sends the commands; how you talk to the player stays inside your integration.
Quick start
Section titled “Quick start”using MacroDeck.Sdk;using MacroDeck.Sdk.Actions;using MacroDeck.Sdk.MusicPlayer;using MacroDeck.Sdk.MusicPlayer.Actions;
public sealed class JukeboxIntegration : IPluginIntegration, IMusicPlayerProvider{ private const string InstanceId = "default";
private readonly JukeboxPlayer _player = new(new JukeboxClient("http://localhost:9090"));
public JukeboxIntegration() { // Play, pause, toggle, next, previous, refresh, volume, seek, shuffle and repeat. Actions = MusicPlayerActions.Common(ResolvePlayer, GetInstances); }
public IReadOnlyList<IActionDefinition> Actions { get; }
public IReadOnlyList<MusicPlayerInstance> GetInstances() => [new MusicPlayerInstance(InstanceId, "Jukebox")];
public IMusicPlayer? GetPlayer(string instanceId) => instanceId == InstanceId ? _player : null;
// An empty id means "first available player" - the actions' default. private IMusicPlayer? ResolvePlayer(string? instanceId) => GetPlayer(string.IsNullOrEmpty(instanceId) ? InstanceId : instanceId);
// IPluginIntegration members omitted.}The user can now pick “Jukebox” in the Music Player widget, which shows the track, artists and artwork, and control it with the ten standard music-player actions.
Things to know:
- The interface lights up the widget, not the actions. Actions come from
MusicPlayerActions, and variables such as{{ vars.jukebox_track }}from your ownIVariableProvider. ImplementingIMusicPlayerProvideralone gives you neither. - Instance ids are local. Return
"default", not a qualified id. The host stores it asintegrationId::default, and it ends up in saved widgets, so keep it stable. - The host polls. It calls
GetStateAsyncregularly for each instance, so keep it cheap. You never push state.
Implementing the player
Section titled “Implementing the player”using MacroDeck.Sdk.MusicPlayer;
internal sealed class JukeboxPlayer(JukeboxClient client) : IMusicPlayer{ public async Task<MusicPlayerState> GetStateAsync(CancellationToken cancellationToken = default) { if (!client.IsConfigured) { return MusicPlayerState.Disconnected; }
JukeboxStatus status; try { status = await client.GetStatusAsync(cancellationToken); } catch (HttpRequestException) { return MusicPlayerState.Unavailable("Jukebox offline"); }
return new MusicPlayerState { IsConnected = true, PlaybackState = status.IsPlaying ? PlaybackState.Playing : PlaybackState.Paused, TrackName = status.Title, Artists = status.Artists, AlbumName = status.Album, ArtworkId = status.CoverHash, Position = TimeSpan.FromSeconds(status.PositionSeconds), Duration = TimeSpan.FromSeconds(status.LengthSeconds), VolumePercent = status.Volume, ShuffleEnabled = status.Shuffle, RepeatMode = status.RepeatOne ? RepeatMode.Track : RepeatMode.Off }; }
public async Task<MusicPlayerArtwork?> GetArtworkAsync(string artworkId, CancellationToken cancellationToken = default) { var bytes = await client.GetCoverAsync(artworkId, cancellationToken); return bytes is null ? null : new MusicPlayerArtwork(bytes, "image/jpeg"); }
public Task PlayAsync(CancellationToken cancellationToken = default) => client.SendAsync("play", cancellationToken);
public Task PauseAsync(CancellationToken cancellationToken = default) => client.SendAsync("pause", cancellationToken);
public Task TogglePlayPauseAsync(CancellationToken cancellationToken = default) => client.SendAsync("toggle", cancellationToken);
public Task NextAsync(CancellationToken cancellationToken = default) => client.SendAsync("next", cancellationToken);
public Task PreviousAsync(CancellationToken cancellationToken = default) => client.SendAsync("previous", cancellationToken);
public Task SeekAsync(TimeSpan position, CancellationToken cancellationToken = default) => client.SeekAsync(position, cancellationToken);
public Task SetVolumeAsync(int volumePercent, CancellationToken cancellationToken = default) => client.SetVolumeAsync(volumePercent, cancellationToken);
public Task SetShuffleAsync(bool enabled, CancellationToken cancellationToken = default) => client.SetShuffleAsync(enabled, cancellationToken);
public Task SetRepeatModeAsync(RepeatMode mode, CancellationToken cancellationToken = default) => client.SetRepeatAsync(mode != RepeatMode.Off, cancellationToken);}Return a state, don’t throw one. MusicPlayerState.Disconnected means there is nothing to reach (not
set up, signed out). MusicPlayerState.Unavailable(message) means the player is set up but can’t answer
right now (rate limit, outage). The widget shows the message in place of the artist line, so keep it to a
few words. The two look different on purpose: an outage that reads “Not connected” looks as if the player
had gone.
ArtworkId is an opaque key that the host passes back to GetArtworkAsync, never a URL. The UI only
ever asks the host for artwork.
Source and badge
Section titled “Source and badge”The widget’s header shows where playback is happening on a second line under the player’s name, and a short badge of yours at the right, beside the playback icon:
return new MusicPlayerState{ IsConnected = true, PlaybackState = PlaybackState.Playing, TrackName = track.Title, Artists = [track.Artist], DeviceName = session.AppName, // "Firefox" Badge = $"{index + 1}/{sessions.Count}" // "2/3"};- Both show only while
IsConnectedis true. A disconnected or unavailable state shows neither. DeviceNameis left out when the player’s name in the header already contains it as a whole word, so an instance named “SinusBot (Kitchen)” with the device “Kitchen” does not say it twice. A name that comes fromProviderNameas a localized string is never compared.- Keep
Badgeto a few characters. The widget reserves room for about 8, then shrinks and cuts off longer text.nullor blank shows nothing. - Users can hide both with the widget’s Source option. The now-playing screen saver never shows them.
- A host older than
Badgeignores it, so setting it is safe on every host. Don’t put the badge intoArtistsorTrackNameas a fallback: it would be read as part of the metadata by variables and triggers.
Showing the cover in your own UI
Section titled “Showing the cover in your own UI”The deck’s music player widget fetches artwork from the host on its own. When your plugin also draws the
cover in a tree of its own, GetArtworkAsUiResourceAsync resolves the artwork and registers it as a
UI resource in one call:
UiResource? cover = await player.GetArtworkAsUiResourceAsync( context.UiResources, resourceName: "now-playing-cover", artworkId: state.ArtworkId, cancellationToken);- It returns
nullwhenartworkIdis null or empty orGetArtworkAsynchas none. Nothing is registered or removed then, so the name keeps the previous cover until you drop it from the tree. - Use one fixed name per place the cover appears, not one per track. Registering the name again replaces its bytes, while a name per track keeps every cover in your quota for the whole session.
- The registry’s rules apply to the artwork. An invalid name, empty data, more than 2 MiB, or a media type
Macro Deck does not accept is an
ArgumentException. Only PNG, JPEG, WebP and GIF are accepted, so SVG is refused. The deck widget copes with some of these by re-encoding on the host, so a cover that shows there can still be refused here. - Exceptions from your own
GetArtworkAsync, including anOperationCanceledException, reach the caller unchanged. - Await one call before starting the next for the same name. Two overlapping calls can finish out of order, and the older cover would then replace the newer one.
- An in-process integration has no
UiResources; the call throwsUiResourceExceptionwithUnsupported.
Showing another player’s cover
Section titled “Showing another player’s cover”GetArtworkAsUiResourceAsync needs an IMusicPlayer of your own. To show the cover of a player that
belongs to another integration, ask Macro Deck to register it for you with UiResources.RegisterMusicPlayerArtworkAsync:
UiResource? cover = await context.UiResources.RegisterMusicPlayerArtworkAsync( name: "now-playing-cover", instanceId: "net.example.jukebox::default", artworkId: "c0ffee", cancellationToken);- Where the two ids come from. Nothing in the SDK lists other integrations’ players. Both ids appear in
the URL that a player’s
*-album-art-urlvariable holds, for example/api/music-player/artwork/c0ffee?instanceId=net.example.jukebox%3A%3Adefault: the last path segment is theartworkIdand theinstanceIdquery value is the qualified instance id, both URL-encoded, so decode them first. A plugin that parses text the user writes, with such a variable in it, reads the pair from there. For an integration that allows only one configuration,integrationId::defaultresolves to its first player. - What you get. The handle of the current artwork registered under
name, as if you had registered the bytes yourself: it counts against the quota, is released with the session, and registering the name again replaces it. Macro Deck may re-encode the image, so the media type can differ from the player’s. Use one fixed name per place the cover is shown. nullwhen Macro Deck knows no such player or the player has no artwork for that id. Nothing is registered or removed then, so the name keeps its previous picture.- Errors.
ArgumentExceptionfor an invalid name, before anything is sent.UiResourceExceptionwithQuotaExceeded;RateLimited, because this call has a tighter budget than other registrations and at most four run at once;Unsupportedon a Macro Deck that predates it or an in-process integration; orFailed.Failedcovers a player that does not answer within 20 seconds, where retrying can succeed, and artwork larger thanmaxUiResourceBytesor of a media type Macro Deck does not accept, where it cannot. - Any plugin can ask for the artwork of any player, the same images the deck shows to every client. Listing players is not part of this call.
In tests, FakeUiResourceRegistry.AddMusicPlayerArtwork(instanceId, artworkId, bytes, mediaType) seeds what
the call finds, and MacroDeckTestHost answers it over the wire as a Macro Deck without other players.
MusicPlayerState
Section titled “MusicPlayerState”| Property | Meaning |
|---|---|
IsConnected |
There is a usable session. |
IsUnavailable, StatusMessage |
Configured but unreachable right now, and a short reason. |
PlaybackState |
Stopped, Playing or Paused. |
TrackName, Artists, AlbumName |
What is playing. Artists defaults to empty. |
ArtworkId |
Opaque id resolved through GetArtworkAsync. |
Position, Duration |
Playback position and track length. null when unknown. |
VolumePercent |
0-100, or null when the player does not report it. |
ShuffleEnabled, RepeatMode |
RepeatMode is Off, Track or Context (album, playlist or queue). |
DeviceName, DeviceType |
Where playback is happening. The widget shows DeviceName as the source. |
Badge |
Short text beside the playback badge, such as 2/3. See Source and badge. |
Actions
Section titled “Actions”Actions =[ .. MusicPlayerActions.Common(ResolvePlayer, GetInstances), MusicPlayerActions.PlayTrack(IntegrationId, ResolvePlayer, GetInstances), MusicPlayerActions.PlayPlaylist(IntegrationId, ResolvePlayer, GetInstances), MusicPlayerActions.PlayOnDevice(IntegrationId, ResolvePlayer, GetInstances), MusicPlayerActions.TransferPlayback(IntegrationId, ResolvePlayer, GetInstances)];Every action has an instance parameter (MusicPlayerActions.InstanceParameterName) filled from
GetInstances. Left empty, the action gets the first available player through your resolver. If the
command throws, the action reports a failed result and the rest of the flow keeps running.
IntegrationId is your integration’s id, which for a plugin is the id in
manifest.json. The item and device actions use it to build the qualified
instance id when they ask the triggering client to pick a track, playlist or device at run time.
Browsing and playing the library
Section titled “Browsing and playing the library”internal sealed class JukeboxPlayer(JukeboxClient client) : ICatalogMusicPlayer{ public async Task<IReadOnlyList<MusicPlayerCatalogItem>> GetCatalogAsync( string instanceId, MusicPlayerCatalogItemKind kind, string? filter, CancellationToken cancellationToken) { // Throws on failure: the picker shows "could not load, retry" instead of "no tracks". var songs = await client.SearchAsync(kind == MusicPlayerCatalogItemKind.Playlist, filter, cancellationToken);
return songs .Select(s => new MusicPlayerCatalogItem(s.Id, s.Title, kind, Subtitle: s.Artist, ArtworkId: s.CoverHash)) .ToList(); }
public async Task PlayItemAsync(MusicPlayerCatalogItem item, CancellationToken cancellationToken = default) { try { await client.PlayAsync(item.Id, cancellationToken); } catch (HttpRequestException ex) { _logger.Warning(ex, "Could not play {ItemId}", item.Id); } }
// IMusicPlayer members as above.}ICatalogMusicPlayer is IMusicPlayer plus IMusicPlayerCatalogProvider. It powers the
Play Track and Play Playlist action pickers when the user configures an action, and the pick
dialog when the action runs with nothing selected. The host finds it by casting the object GetPlayer
returns.
Reads and commands fail in opposite ways:
| Member | On failure | Why |
|---|---|---|
GetCatalogAsync |
Throw. Return empty only for an empty library. | Empty renders “no tracks”, a throw renders “retry”. |
PlayItemAsync |
Log and return. | A command failure must not abort the user’s action flow. |
Let OperationCanceledException propagate from both. GetCatalogAsync is only called from a REST
request and may take as long as the library needs, but its token is the only thing that ends a request
the remote service never answers.
Switching playback devices
Section titled “Switching playback devices”internal sealed class JukeboxPlayer(JukeboxClient client) : IMusicPlayer, IMusicPlayerDeviceProvider{ public async Task<IReadOnlyList<MusicPlayerDevice>> GetDevicesAsync(CancellationToken cancellationToken) => (await client.GetOutputsAsync(cancellationToken)) .Select(o => new MusicPlayerDevice(o.Id, o.Name, Type: "Speaker", IsActive: o.IsCurrent)) .ToList();
public async Task TransferPlaybackAsync(string deviceId, bool startPlayback, CancellationToken cancellationToken) { try { await client.SwitchOutputAsync(deviceId, startPlayback, cancellationToken); } catch (HttpRequestException ex) { _logger.Warning(ex, "Could not switch to {DeviceId}", deviceId); } }
// IMusicPlayer members as above.}IMusicPlayerDeviceProvider powers Play on Device and Transfer Playback. It takes no instance id
because the object is already one instance. MusicPlayerDevice.Type is a free-form display string
(“Computer”, “Speaker”), not an enum. The failure rules are the same as for the library: GetDevicesAsync
throws on failure, and TransferPlaybackAsync logs and returns. A player without this interface fails
the device actions with Unavailable.
Per-widget options
Section titled “Per-widget options”An instance can declare options that each Music Player widget sets for itself. One widget can then cycle through the apps that are playing every 10 seconds while another widget on the same deck follows the app the system calls current, without a second instance cluttering the player picker.
using MacroDeck.Sdk.Actions;using MacroDeck.Sdk.MusicPlayer;
public IReadOnlyList<MusicPlayerInstance> GetInstances() => [ new MusicPlayerInstance("any", "Any app") { Options = [ ActionParameter.Toggle("cycle", "Cycle between apps", defaultValue: false), ActionParameter.Number("cycleSeconds", "Cycle every (seconds)", min: 5, max: 60, defaultValue: 10) ] }, .. _sessions.Select(app => new MusicPlayerInstance(app.Id, app.Name)) ];
public IMusicPlayer? GetPlayerWithOptions(MusicPlayerOptionsRequest request){ if (request.InstanceId != "any" || request.Options["cycle"] is not true) { return GetPlayer(request.InstanceId); }
var seconds = (double)request.Options["cycleSeconds"]; return new CyclingPlayer(_sessions, TimeSpan.FromSeconds(seconds));}The widget editor shows the options of the picked player below the player picker, and each widget stores
its own values. The host resolves the player with GetPlayerWithOptions and polls it separately from the
plain instance, so two widgets with different values show different tracks and covers.
- Supported kinds.
String,Number(withMin,Max,Stepand the slider),Boolean, andChoicewith staticOptions. Any other kind, aChoicewith dynamic options, an invalid name or a duplicate name is skipped with a warning in the host log. A kind the host does not know at all, from a newer SDK, is skipped as well. - Names. 1 to 64 letters, digits, hyphens or underscores, starting with a letter or digit. The name is the key in the widget’s stored data, so keep it stable.
- Labels.
LabelandDescriptionare shown, localized like any otherLocalizedText. Set a label: without one, the editor shows the raw name.VisibleWhen,RequiredandPlaceholderare ignored. - Values.
request.Optionsalways has one entry per supported option: the widget’s value, or your default when the widget stored none or an invalid one. Values arestringfor String and Choice,doublefor Number (clamped toMinandMax), andboolfor Boolean. A Choice value is always one of your option values. - Only for instances with options.
GetPlayerWithOptionsis called only for an instance that declares options, and only for a widget that picked that instance. A widget set to “Active player” usesGetPlayer. The Now Playing screen saver has no option fields and shows the instance with your defaults. - Keep it cheap. The host may call
GetPlayerWithOptionsfor the same values again and again, and it never tells you when a set of values is no longer shown. Return a cached or lightweight object, and derive time-based behaviour such as cycling from the clock whenGetStateAsyncruns, not from a timer per set of values. - Display only. The host reads state and artwork from this player and sends it no commands. Actions in
a widget’s flows keep addressing the instance through their own
instanceparameter, so a Next action on a cycling widget acts on the plain “Any app” player. - Artwork ids. The host caches covers by instance and artwork id, shared across option values. An artwork id must name the same image whichever values produced it.
- Default.
GetPlayerWithOptionsdefaults toGetPlayer(request.InstanceId). Providers without options do not implement it. - Older hosts ignore
Optionsand show the plain instance.
Volume and position variables
Section titled “Volume and position variables”VariableDefinition.Eager("jukebox_volume", VariableType.Numeric) with{ Id = "volume", Unit = "%", SemanticKind = VariableSemanticKinds.Percentage, Write = MusicPlayerVariableWrites.Volume},VariableDefinition.Eager("jukebox_position", VariableType.Numeric, refreshInterval: TimeSpan.FromSeconds(1)) with{ Id = "position", SemanticKind = VariableSemanticKinds.Duration, Write = MusicPlayerVariableWrites.Position}
public ValueTask<VariableWriteResult> SetValueAsync(string localId, object? value, CancellationToken cancellationToken = default) => localId switch { "volume" => MusicPlayerVariableWrites.SetVolumeAsync(GetPlayer(InstanceId), value, cancellationToken), "position" => MusicPlayerVariableWrites.SeekAsync(GetPlayer(InstanceId), value, cancellationToken), _ => ValueTask.FromResult(VariableWriteResult.NotWritable()) };MusicPlayerVariableWrites gives a bound Slider the same write behaviour as the built-in players:
volume streams while the user drags, position commits on release, values are clamped, and a missing
player answers Unavailable. See Writable variables.
Edge cases
Section titled “Edge cases”- An instance disappears. Drop it from
GetInstancesand returnnullfromGetPlayer. Widgets pointing at it show as disconnected, and they come back if the id returns. - Invalid or duplicate ids from
GetInstancesare skipped and logged. ProviderNameis optional. Leave it out and the integration’s name (the manifest name for a plugin) is used.- A disabled integration has no instances.
- Options change. Widgets pick up a changed
Optionslist on their own; an editor that is already open shows the new fields when it is opened again. Values a widget stored for an option that no longer exists are ignored.
Over the plugin protocol
Section titled “Over the plugin protocol”Music players are fully supported out of process (capability kind music-player). The instance list is
a snapshot, so after GetInstances changes (an account was added in your config flow, or an instance’s
Options changed), call CatalogChanged(CapabilityKinds.MusicPlayer) on an injected IPluginCatalogNotifier.
Options travel as optional fields within music-player version 1: the instance list carries them, and
only the state and artwork operations carry a widget’s values. State reads from
an unreachable plugin degrade to unavailable. Library and device failures stay real failures, so an
unreachable plugin never looks like an empty library. See
Capability parity and
the WebSocket reference.
See also
Section titled “See also”- Variables - track, artist and playback variables.
- Setup flows - adding accounts, and so instances.
- Actions
- Virtual profiles - ship a ready-made layout with a Music Player widget.
- Testing