Skip to content

Button icons

An icon-provider action supplies the image an action button currently shows, such as album artwork, an avatar or a weather symbol. It overrides the button’s configured icon while it answers, and hands it back when it doesn’t.

using MacroDeck.Sdk.Actions;
internal sealed class NowPlayingAction(PlayerClient player) : IActionDefinition, IIconProviderActionDefinition
{
public string Id => "now-playing";
public Task<ActionIconSnapshot?> GetActionIconAsync(
IReadOnlyDictionary<string, object?> parameters,
CancellationToken cancellationToken)
{
var artwork = player.CurrentArtwork; // already held - never fetch here
ActionIconSnapshot? snapshot = !player.IsConnected ? null
: artwork is null ? new ActionIconSnapshot { NoIcon = true }
: new ActionIconSnapshot { Version = artwork.TrackId, MediaType = artwork.MediaType };
return Task.FromResult(snapshot);
}
public Task<ActionIconContent?> GetActionIconContentAsync(
IReadOnlyDictionary<string, object?> parameters,
string version,
CancellationToken cancellationToken)
{
var artwork = player.CurrentArtwork;
return Task.FromResult(artwork?.TrackId == version
? new ActionIconContent(artwork.Bytes, artwork.MediaType)
: null);
}
// Name, Description, Parameters and CreateExecutor omitted - see Actions.
}

A button running Now playing shows the current track’s cover. Bytes are fetched once per track, and the button’s own icon comes back when the player is disconnected.

Things to know:

  • Three answers. null means “I can’t answer”, and the button’s configured icon is shown. NoIcon means “deliberately blank”. A Version means “this image”.
  • Bytes follow Version. The host calls GetActionIconContentAsync only when Version changes, which keeps polling cheap.
  • Render-time only. The button’s configured icon is never rewritten, and it reappears the moment the provider is removed or disabled.
  • Independent of states. It is free-standing (it does not extend IActionDefinition) and separate from IStateProviderActionDefinition. Enabling one never enables the other.
// Can't answer right now - the widget shows its configured icon.
return Task.FromResult<ActionIconSnapshot?>(null);
// Working, and deliberately showing nothing.
return Task.FromResult<ActionIconSnapshot?>(new ActionIconSnapshot { NoIcon = true });
// An icon the host already has - no bytes needed.
return Task.FromResult<ActionIconSnapshot?>(new ActionIconSnapshot
{
Version = condition,
Reference = ActionIconReference.IconPack(WeatherIcons.For(condition))
});
// Your own bytes - fetched through GetActionIconContentAsync when Version changes.
return Task.FromResult<ActionIconSnapshot?>(new ActionIconSnapshot { Version = avatarHash, MediaType = "image/png" });
ActionIconSnapshot
Version Stable identity of the image. The host refetches bytes only when it changes. Empty with NoIcon.
Reference A host-resolvable icon: ActionIconReference.IconPack(id) or ActionIconReference.PluginIcon(key, name). Opaque, and never a URL - the host never fetches one on a provider’s say-so.
MediaType Media type of the bytes, when there is no Reference.
NoIcon Render nothing.

GetActionIconContentAsync returns the bytes for the version asked for. Return null when that version has already moved on, and the host keeps the image it already holds rather than blanking the button. It is never called for a snapshot with a Reference or NoIcon. The default implementation returns null, so a reference-only provider does not override it.

A plugin that bundles icon packs can point at one of its own icons by the pack’s key and the icon’s name, with no bytes and no pack id:

return Task.FromResult<ActionIconSnapshot?>(new ActionIconSnapshot
{
Version = "spotify",
Reference = ActionIconReference.PluginIcon("logos", "spotify")
});
  • Your packs only. The host resolves the reference against the calling plugin’s own bundled packs, so the same key and name from another plugin never reaches yours, and yours never reaches theirs.
  • Unknown means no icon. A key or name your packs do not hold renders no icon, the same as an unknown icon-pack id. PluginIcon itself throws ArgumentException for a key that is not a valid pack key (lowercase letters, digits and inner hyphens) and for a blank name or one containing /.
  • Older hosts. A host that predates bundled icon packs does not know the plugin-icon reference type and renders no icon.
  • Updates keep it. Replacing a pack in an update keeps its icons by name, so the reference goes on resolving to the new artwork.

The rules match reading state:

  • Answer for the configured instance, from parameters. The same action on several buttons answers for each separately. The user adopts at most one instance as a button’s icon provider, from the control the button editor shows on each such action or from the offer when one is added, and the host resolves it once per button - you own the icon currently rendered, not one icon per state.
  • Tolerate a half-filled draft. It is called once per settled editor draft while the user configures the action, so a missing value must not throw.
  • Side-effect free. Answer from state you already hold, honour the token, never connect or authenticate, and never route through IActionExecutor.

IconPollInterval (default five seconds) is a request, clamped like StatePollInterval, and the host reads less often while nothing displays the button.

public async Task InitializeAsync(IIntegrationContext context)
{
_player.TrackChanged += async (_, _) => await context.Widgets.InvalidateIconAsync("now-playing");
// ...
}

InvalidateIconAsync makes every button following that action re-read its icon now, instead of at the next poll. Pass the action’s declared local id, not a configured instance: the host qualifies it with your integration, so you can only invalidate your own actions. It is a hint - the host still compares Version before refetching. It does not replace polling, and against a host that predates it the call does nothing.

A null snapshot, a timeout, an exception, or a missing or disabled block or integration all fall back to the button’s own configured icon. This is the opposite of a state provider, which holds its last state. An icon has a meaningful default to return to, so a button never shows a stale image.

The Set Icon action fails on a button with an assigned icon provider, the same way it fails on a state-controlled one. This holds even when the provider can’t answer right now. In a patch that changes several properties, only the icon is dropped and the rest still applies.

An action button can draw its icon in one colour: every pixel takes the colour and keeps its own transparency, so a white icon on a transparent background turns into an icon of that colour. The user sets it in the button editor, and a flow sets it with the Set Icon Color action.

await context.Widgets.ApplyAsync(new WidgetAppearanceRequest
{
WidgetId = widgetId,
Patch = new WidgetAppearancePatch { IconColor = "#ef4444" },
});

IconColor takes #rrggbb. To return the icon to its own colours, send ClearProperties = [WidgetAppearanceProperty.IconColor] or an empty string. In state mode the colour belongs to the targeted state, like the icon itself.

  • Artwork from an icon provider is never tinted. On a button with a provider the call still succeeds and stores the colour, but nothing visible changes until the button falls back to its own icon.
  • A host that predates IconColor ignores the field, so a patch that changes only the icon colour makes ApplyAsync return false there. WidgetTargetInfo.AppearanceProperties lists IconColor for a button on a host that supports it.
Operation SDK member
icon GetActionIconAsync
icon.content GetActionIconContentAsync

Test both with PluginTestHarness.Actions.GetActionIconAsync(localId, parameters) and GetActionIconContentAsync(localId, version, parameters).