@rocklobster42195/streamdeck-kit
v0.1.0-alpha.43
Published
(Alpha) Building blocks for Elgato Stream Deck plugins: key and touch-strip rendering, animation, Panorama effects across dials, and a framework-free property inspector kit.
Maintainers
Readme
@rocklobster42195/streamdeck-kit
Building blocks for Elgato Stream Deck plugins written against the official @elgato/streamdeck SDK:
- Rendering helpers for keys and Stream Deck + touch strips: exact Arial text widths, truncation and wrapping by measured width, a marquee, a title fader and a PNG encoder.
- Animation: a shared frame ticker that runs only while something moves, plus eased values.
- Panorama effects: animated backgrounds that span several neighbouring dials.
- A property inspector kit: a framework-free replacement for
sdpi-components, with a Stream Deck socket client, i18n, light-DOM components and a dark theme.
It is used by Sonos Controller, Music Assistant Controller and System Audio Controller, and by the plugins that follow them (Home Assistant, X-Air mixers).
Alpha. The API changes often and without notice, also between alpha versions. The kit is built for the plugins named above; if you use it in your own plugin, pin an exact version.
This is an unofficial community package. It is not affiliated with or endorsed by Elgato or Corsair.
Install
npm install @rocklobster42195/streamdeck-kit@alphaNode 20 or later (the Stream Deck plugin runtime). The package is ESM only.
Entry points
| Import | Runs in | Contents |
|---|---|---|
| @rocklobster42195/streamdeck-kit | Plugin (Node) | Rendering helpers, animation, Panorama effects, state and timing helpers. Safe to import in the plugin backend. |
| @rocklobster42195/streamdeck-kit/bridge | Plugin (Node) | piBridge, the plugin side of the property inspector. Imports @elgato/streamdeck (a peer dependency), which is why it has its own entry point. |
| @rocklobster42195/streamdeck-kit/bus | Plugin (Node) | DeckBus, the kit's implementation of deckbus, a local bus between plugins (see below). |
| @rocklobster42195/streamdeck-kit/mdi | Plugin (Node) | Search over all Material Design Icons (see below). |
| @rocklobster42195/streamdeck-kit/pi | Property inspector (browser) | Socket client, i18n, components. Bundle it into your PI script (e.g. with Rollup). |
| @rocklobster42195/streamdeck-kit/styles/pi-theme.css | Property inspector | The theme. Copy it next to your PI pages at build time. |
Rendering and animation
import { FrameTicker, MARQUEE_TICK_MS, truncateToWidth, wrapToWidth } from "@rocklobster42195/streamdeck-kit";
const frames = new FrameTicker(MARQUEE_TICK_MS); // one ticker for all animations of the plugin
const title = truncateToWidth("A rather long track title", 13, 180);
const lines = wrapToWidth("Two lines that fit the touch strip", 13, 180, 2);measureArialWidth,truncateToWidth,wrapToWidth,balanceLines: real Arial (regular and bold) glyph widths, because Stream Deck renders SVG text in Arial reliably.marqueeSvg,marqueeNeeded,marqueeOffset: time-based scrolling text.TitleFader: a title band that fades in, scrolls and fades out.AnimatedValue,FrameTicker: eased values and a frame loop that stops when nothing moves.frames: the plugin's sharedFrameTicker(80 ms). Everything below animates on it.glide(key, target, redraw): a value that eases toward its target, e.g. a volume ring.ColorFader: a color that fades to the next one, e.g. a ring following the cover color between tracks.loadCover,getCachedCover: fetch images once into a small LRU cache of data URIs.CoverFaderholds the last image while the next one loads, then crossfades.- Touch-strip helpers (200×100 SVG segments):
stripImage,text,mdi,image,progressBar,scrimDisc,scrimBox,fit,wrap,textWidth,escapeXml,formatTime. encodePngDataUri: raw RGBA into a PNG data URI, for raster images that must be built synchronously.
Gauges
import { arc, pie } from "@rocklobster42195/streamdeck-kit";
const ring = arc(72, 72, 54, 0.42, "#6cc4ff", { width: 14 }); // SVG parts: track + arc
const torte = pie(50, 50, 38, 0.42, "#cccccc"); // SVG parts: outline + wedgearc(cx, cy, r, value, color, { track, width }): a ring gauge with a round-capped arc from 12 o'clock, clockwise (a volume ring).pie(cx, cy, r, value, color, { inner, stroke }): an outline circle and a filled wedge, also from 12 o'clock (Sonos Controller's volume look). Without options, the wedge radius and stroke follow Sonos Controller's proportions: 7 and 1.5 for r 9, 30 and 6 for r 38.halfArc({ cx, cy, r, min, max, value, target, zones, width, track, color }): a half-circle gauge from 9 o'clock over the top to 3 o'clock, in the value's own unit (°C, W, %). Colour zones (Zones, as for meters) show as dim bands on the track, the value as a filled arc in its zone's colour, andtargetas a marker, e.g. a thermostat's setpoint.halfArcPositionandhalfArcPointgive positions for labels.
Both take value from 0 to 1 and return SVG fragments to put into a key or strip image.
Level meters
import { meterBar, meterScaleMarks, MeterBallistics, VuBallistics, vuMeter, dbfsToVu, METER_TICKS } from "@rocklobster42195/streamdeck-kit";
const left = new MeterBallistics(); // fast attack, 20 dB/s release, 1.5 s peak hold
const s = left.update(measuredDb, Date.now());
meterBar({ x: 8, y: 32, length: 184, thickness: 22, levels: [s.level], peaks: [s.peak] }); // mono: one bar, stereo: [l, r]
meterScaleMarks(8, 71, 184, METER_TICKS);
const needle = new VuBallistics(); // 300 ms VU inertia
vuMeter({ x: 40, y: 4, width: 120, height: 82, vu: needle.update(dbfsToVu(measuredDb), Date.now()), label: "MONO" });meterBar: segmented LED bars, lying or standing, one per channel. It has zone colours (green, yellow from -18 dB, red from -6 dB) and a peak line.scalePosition,METER_SCALEandzoneColor: the dBFS scale (-60 to 0, with more room near the top) and the colour zones. Both can be replaced.MeterBallistics(digital) andVuBallistics(analog): how the shown level follows the measured one.vuMeter: an analog VU meter with a cream face, arc scale from -20 to +3 VU, a red zone and a needle.dbfsToVusets 0 VU to -18 dBFS by default.
List dial
A smooth vertical list on a dial's touch strip: equal rows (cover, MDI icon or nothing on the left, title and a second line on the right), the row nearest the centre marked, the neighbours dimmed. One dial tick moves one row (two on a fast spin) and the list glides there; at the ends it gives a small rubber-band nudge. The active row (what is playing, the current device, the last scene) has its title in your accent colour and an icon on the right; folders get a chevron.
Three layers: ScrollList (the pure scroll model), listStrip() (the pure layout) and ListController, which drives both on the shared frame ticker, adds the marker crossfade, a short overlay (e.g. a breadcrumb) and a marquee for the marked row's title. Your dial action stays your own:
const list = new ListController({ id: ev.action.id, redraw: () => render() });
list.reset(items.length, currentIndex, "Favourites"); // new content (safe inside render)
list.rotate(ev.payload.ticks); // on dialRotate
const svg = list.render({ // in your render
row: (i) => ({ title: items[i].name, subtitle: items[i].artist, image: coverFor(i), active: i === currentIndex }),
accent: "#009FDF",
});
play(items[list.marked]); // on dialDownrow(i) is asked for only around the centre (two rows beyond the visible ones), so load covers there. state: "loading" draws placeholder rows, state: { message } one sentence. Call dispose() when the list leaves the strip.
Device frame
deviceFrame({ screenWidth, screenHeight, knobs: 4 }) draws a Stream Deck housing as SVG: a dark body, the screen well and, for the Stream Deck +, the knobs. It is meant for screenshots, READMEs and store pictures. Put your rendered deck at screenX/screenY on top:
const frame = deviceFrame({ screenWidth: deck.width, screenHeight: deck.height, knobs: 4 });
await sharp(Buffer.from(frame.svg)).composite([{ input: deckPng, left: frame.screenX, top: frame.screenY }]).png().toFile("deck.png");State and timing
OptimisticStore: short-lived local overrides of server state, so a key shows a change at once and reconciles when the device confirms it.LatestSender: sends only the latest value, at most once per interval. Use it for dial rotations.AlarmTimersandalarmTimers: countdowns that fire even while their key is not on screen, with catch-up and retry.computeFadeSteps(from, to, durationMs, minStepIntervalMs?): plans a volume ramp (0..100).
Logging
The kit reports problems, such as an image that failed to load or an effect that threw, to console by default. Route them to the plugin log once at startup:
import streamDeck from "@elgato/streamdeck";
import { setKitLogger } from "@rocklobster42195/streamdeck-kit";
setKitLogger(streamDeck.logger);deckbus (/bus)
Stream Deck plugins can't talk to each other. deckbus lets them, and any other local program, share state, stream data, send requests and broadcast, without a central server or master. Every peer is equal, and one that goes away never takes the others down. The protocol is open and documented in docs/deckbus-protocol.md, so programs without this kit can join too.
import { DeckBus, DuckLeases, serveDucking } from "@rocklobster42195/streamdeck-kit/bus";
const bus = new DeckBus({ id: "de.example.mixer-plugin", name: "Mixer", version: "1.0.0", caps: ["meters"] });
await bus.start(); // false (and logged) if it can't: the plugin works on without the bus
bus.setState("status", { online: true }); // soft state, sent again after every reconnect
bus.onPeers((peers) => console.log(peers.map((p) => p.name)));
bus.onSubscribers("meters/main", (n) => (n ? startMeters() : stopMeters())); // produce only while someone listens
bus.publish("meters/main", { l: -18.5, r: -20.1 });
// Ducking: leases per peer and target (deepest wins, ends on unduck, after maxMs or when the peer leaves)
const leases = new DuckLeases(({ target, db, rampMs }) => fadeTo(target, db, rampMs)); // db 0 = restore
serveDucking(bus, leases, { allow: (peer) => allowedPeers.includes(peer.id), has: (target) => target === "meters/main" });
await otherBus.request("de.example.mixer-plugin", "duck", { target: "meters/main", by: -12 });
bus.broadcast("alert", { text: "Doorbell" });- Addresses: 16 slots, named pipes on Windows and Unix sockets elsewhere. The peer in the higher slot connects to the lower one.
- Access: a per-user key file keeps other users out.
- Versions: negotiated per pair of peers.
- Stream data: the latest value wins when a peer is slow; nothing piles up.
- Tools in the repository:
tools/bus-monitor.mjsshows who is on the bus and what they send;tools/bus-fake-meters.mjsis a fake meter source for testing.
Players
Media keys that work with any player on the deck: every plugin publishes what it can play (speakers, apps), and a key picks a player or "the active player", not a plugin. One device known to two plugins (a Sonos speaker seen directly and through Music Assistant) is one player: the plugin that talks to it directly says whether it plays and takes volume and mute; the one whose media plays gives title, cover, colour and position and takes play, pause, skip and seek.
import { PlayerBoard, SeekStepper, positionNow, resolveKeyColor } from "@rocklobster42195/streamdeck-kit";
const players = new PlayerBoard("Sonos");
players.connect(bus, { allow: () => settings.othersMayControl !== false }); // "transport" is on by default
players.serve(({ player, command, value }) => speakers.get(player)?.run(command, value)); // own players, from own keys and other plugins
players.publish([{ player: "RINCON_…", device: "RINCON_…", name: "Bad", kind: "speaker", direct: true, playing: true, since: Date.now(), color: "#d9643a", can: ["play-pause", "next", "seek"] }]);
const p = players.resolve(keySettings.player ?? "active"); // "active", "active:all", "device:…", "app:Spotify", "Sonos/Kitchen"
await players.send(p, "play-pause");
// Next/Previous key: a long press switches to seeking, quick taps add up to one seek
const seek = new SeekStepper({ position: () => positionNow(p), duration: () => p.duration, seek: (s) => players.send(p, "seek", s), onChange: redraw });
const icon = resolveKeyColor(keySettings.color, { cover: p?.color, row: rowColour }); // "grey" | "cover" | "row" | "#RRGGBB"Transport keys
The same Play/Pause key in every plugin: PlayPauseKeys does what all of a plugin's Play/Pause keys show and send, on the PlayerBoard; the plugin keeps a thin action class (its own UUID, its old settings migrated). The key shows the cover, dimmed with a play symbol while paused, an optional scrolling title and progress pill, and two corners the user fills (topLeft, topRight: none, marker, source or battery). A press sends an explicit play or pause by what the key shows (a peer that only knows play-pause gets that), and the key shows the new state at once until the device confirms it. With multiTap on in the key's settings (PI: <pi-multi-tap>), a double tap sends next and a triple tap previous (skip()), shown for a moment on the key; a single tap then waits tapSpeed (250/350/500 ms). TapCounter is the reusable counter behind it.
import { PlayPauseKeys, type PlayPauseKeySettings } from "@rocklobster42195/streamdeck-kit";
const keys = new PlayPauseKeys({ board: players, draw: (id, image, title) => { const a = actions.get(id); void a?.setImage(image); void a?.setTitle(title); }, markerPath: (name) => mdiPath(name), rowColor: (id) => rows.rowColor(deviceOf(id)) });
keys.show(id, settings as PlayPauseKeySettings); // willAppear / settings changed
keys.hide(id); // willDisappear
await keys.press(id); // keyDown; rejects without a player → showAlert()With the Stream Deck SDK, PlayPauseKeyAction from /keys is the whole action; the plugin gives it its UUID and players:
import { action } from "@elgato/streamdeck";
import { PlayPauseKeyAction } from "@rocklobster42195/streamdeck-kit/keys";
@action({ UUID: "com.example.my-plugin.play-pause-key" })
export class PlayPauseKey extends PlayPauseKeyAction {
constructor() {
super({ board: players, markerPath: (name) => mdiPath(name), rowColor: (deviceId) => rows.rowColor(deviceId), migrate: fromOldSettings });
}
}The Volume key too: VolumeKeyAction (/keys) with VolumeKeys; settings command (up, down, mute, preset), step, preset, showVolume, gauge (ring, pie, open), player, keyColor, marker.
The Playback Control key works the same way: PlaybackControlKeyAction (/keys) with PlaybackKeys behind it; settings command (next, previous, shuffle, repeat, crossfade, dont_stop), seekStep, player, keyColor, marker.
In the settings panel, <pi-corners> picks the two corners (and when the battery shows); the player, colour and marker use <pi-select source="players">, <pi-key-color> and <pi-icon-picker>. Player entries carry source, sourceIcon, battery and charging for the corners.
Icon catalog (/mdi)
@rocklobster42195/streamdeck-kit/mdi provides searchMdi(query), mdiPath(name) and mdiLabel(name) over all Material Design Icons, so users can pick any icon for a key. It imports the whole icon set, which adds about 3 MB to the plugin bundle. That is why it has its own entry point.
Panorama effects
A Panorama effect is one animated picture that spans several dials of a Stream Deck +. Each dial renders its own slice, and all dials move in sync.
PanoramaEngineruns the effects, andPanoramaOrchestratorgroups neighbouring dials.effectRegistry,listEffects()andwithEffectDefaults()cover the built-in effects: particles, matrix rain, boing ball, boing globe and blank.EffectDefinitionis the interface for writing your own effect.- Audio-reactive:
engine.setLevel(display, level)with a level from 0 to 1 (e.g. mixer meters from deckbus) makes the effects move with the music. A group follows its loudest display, andundefinedswitches it off. Your own effect takes part by implementingsetLevel(level).
Open ring
openRing({ cx, cy, r, min, max, value, dot, zones, handle, dim }) draws a ring open at the bottom (270°), as Home Assistant's thermostat and light cards: filled up to value in its zone's colour, an optional handle on the value (what a dial sets) and a dot for a second value (a thermostat's room temperature). Text inside is up to you. It joins the other gauges: arc (ring), pie (Sonos Controller's Torte), halfArc.
Shared Panorama
new SharedPanorama(engine, actionsState) wraps your PanoramaEngine with the same API (join, leave, renderSlice, …). Call connect(bus) once deckbus runs: adjacent dials of different plugins with the same effect then share one Panorama. The plugin with the leftmost dial leads and streams the others their slices; role(context) tells a dial whether it runs locally, leads, or follows (and whom), e.g. for "effect from the left neighbour" in its settings. Pass your ActionsState, so other plugins see which effect each dial shows.
Key feedback
Instead of Stream Deck's generic green check (showOk()) and yellow triangle (showAlert()), keys can confirm in their own colour:
import { isFeedbackPlaying, playFeedback } from "@rocklobster42195/streamdeck-kit";
playFeedback(action.id, action, "ok", { color: "#F7A600", background: "#111111", restore: () => redraw() });
// in your render(): if (isFeedbackPlaying(action.id)) return;A disc in the colour grows from the centre and a white check mark draws itself in it ("alert": an exclamation mark on Monza red); after about 0.7 s restore() brings the key's own image back. feedbackFrame(kind, p, opts) draws a single frame (0 ≤ p ≤ 1). For a dial, pass width: 200, height: 100 and an adapter that calls setFeedback({ "full-canvas": image }). Defaults come from the standard colours: Yellow Sea for "ok", Monza for "alert".
Property inspector kit
import { definePiComponents, initConditionalVisibility, sd, translateDom } from "@rocklobster42195/streamdeck-kit/pi";
sd.onReady(() => {
translateDom();
definePiComponents();
initConditionalVisibility();
});<pi-section title="pi.display">
<pi-toggle setting="showProgress" label="pi.show_progress" hint="pi.show_progress_hint"></pi-toggle>
<pi-range setting="preset" min="0" max="100" step="1" default="20" label="pi.preset" unit="%"></pi-range>
<pi-color setting="color" default="#87AE73" label="pi.color"></pi-color>
<pi-field setting="serverUrl" global label="pi.server_url" placeholder="pi.server_url_placeholder"></pi-field>
<div data-show-when="command=up,down">Shown only for these commands</div>
</pi-section>sd: the PI socket client. It covers action settings and global settings (setSetting,setGlobalSetting),sendToPlugin,onMessageandopenUrl.Components:
<pi-section>: a titled card<pi-toggle>: a switch<pi-field>: a text or password field<pi-range>: a slider<pi-choice>: tiles for choosing one option<pi-color>: a colour setting with the kit's colour picker: a popover with the standard colours (17, around the colour wheel and the greys), the 8 colours picked last (kept in the PI's localStorage), a colour wheel (hue = angle, saturation = distance from the centre) with a brightness slider, and hex and R/G/B fields. Dragging previews, letting go picks. Addautoto offer "automatic" (stored as"auto").<pi-swatch value="#F7A600" [auto]>: just the swatch that opens the picker. It stands in for<input type="color">(samevalue, sameinputandchangeevents), so lists of colours (e.g. states, colour ranges) can use the picker without other changes.openColorPopover(anchor, { value, auto, onInput, onChange })opens the popover from anything else;STANDARD_COLORSand the hex/RGB/HSV helpers (normalizeHex,hexToHsv,hsvToHex, …) are exported too.<pi-key-color setting="keyColor">: a key's colour as four tiles, Grey (the default), Cover (the key's player), Like Panorama (the row colour of the key's Stream Deck; left out on a Stream Deck without dials (Stream Deck, Mini, XL, Mobile, Neo, Pedal, Corsair, SCUF), unless the key already uses it) and Fixed (with the colour picker). Stored asgrey,cover,rowor#RRGGBB; the plugin turns it into the icon colour withresolveKeyColor().<pi-select source="…">: a dropdown whose entries the plugin provides (see the PI bridge below).with="player"sends parameters that the PI registered withregisterSelectParams("player", () => ({ playerId })), and the list reloads when they change.label-settingalso stores the chosen label.refreshPiSelects()andreloadPiSelects()update all lists after the plugin pushed new state. Labels starting withkit.are the kit's own texts and shown translated (e.g.kit.player_active). For a key's player, the plugin answers withplayerOptions(players.players()).<pi-icon-picker setting="icon" default-icon="mdiBullhorn">: search and pick any Material Design Icon. For optional icons (e.g. a marker), setnone-labelandreset-label.<pi-thresholds setting="thresholds" base-setting="color" unit="°C" min-setting="min" max-setting="max">(orunit-setting="unit"for the unit the user typed): colour ranges for a value: a base colour and "from … : colour" rows, with a bar that previews them between min and max. Stored as[{ from, color }], sorted, ready to use asZonesafter the base colour.
Add
globalto bind a component to global settings instead of the action's settings.data-show-when="setting=a,b"shows an element only for those values. Prefix the key withglobal:to check a global setting.t(key, vars)andtranslateDom()handle i18n. Components treatlabel,hintandplaceholderas translation keys. Translations come fromwindow.PI_LOCALES({ en: {...}, de: {...} }), which your build generates from theLocalizationblocks of your plugin's<lang>.jsonfiles. Nested groups work the same way as in the plugin SDK:pi.playerlooks upLocalization.pi.player.The kit brings its own texts (
kit.*, English and German), such as "Choose" or the icon search placeholder.t()uses them when your locale files don't have the key, so you can override any of them in your<lang>.json.nextRequestId(): ids for your own requests to the plugin. All kit components share the counter, so replies never reach the wrong component.
PI bridge (plugin side)
piBridge is the plugin side of the property inspector. It knows which PI is open, answers the kit components' requests, and pushes your state to the open PI. Unchanged messages are not sent again.
import { piBridge } from "@rocklobster42195/streamdeck-kit/bridge";
import { mdiOptions } from "@rocklobster42195/streamdeck-kit/mdi";
piBridge.init();
piBridge.registerOptions("mdi-icons", mdiOptions); // <pi-icon-picker>
piBridge.registerOptions("inputs", ({ playerId }) => inputsOf(playerId)); // <pi-select source="inputs" with="player">
piBridge.handle("reconnect", () => connection.reconnect()); // your own requests
piBridge.addPusher(() => [{ event: "status", status: currentStatus() }]); // your own state
connection.onChange(() => piBridge.schedulePush());updatePreview(actionId, preview)forwards what a key shows to its open PI ({ event: "preview" }).reply(msg)answers a request.- The kit's messages are typed in
KitPiRequestandKitPiPush, and list entries inOptionItem. @elgato/streamdeckis a peer dependency. When you link the kit from a local checkout, tell your bundler to use one copy (Rollup:nodeResolve({ dedupe: ["@elgato/streamdeck", "@elgato/utils"] })), or the bridge talks through a second SDK instance that never connects.
Examples
A Play/Pause key with double and triple tap
One action class, the kit does the rest (cover, paused look, corners, status badge, tap counting). The key reacts at once while multiTap is off; with it on, one tap plays or pauses, two taps skip to the next track and three go back.
// plugin/src/actions/play-pause-key.ts
import { action } from "@elgato/streamdeck";
import { PlayPauseKeyAction } from "@rocklobster42195/streamdeck-kit/keys";
import { players, rows, mdiPath } from "../deck.js"; // your PlayerBoard, PanoramaRows and icon lookup
@action({ UUID: "com.example.my-plugin.play-pause-key" })
export class PlayPauseKey extends PlayPauseKeyAction {
constructor() {
super({
board: players,
markerPath: (name) => mdiPath(name),
rowColor: (deviceId) => rows.rowColor(deviceId),
});
}
}<!-- plugin/…sdPlugin/ui/play-pause-key.html -->
<pi-section title="pi.player">
<pi-select setting="player" source="players" placeholder="kit.player_active"></pi-select>
</pi-section>
<pi-section title="pi.display">
<pi-toggle setting="showCover" label="pi.show_cover" data-default="true"></pi-toggle>
<pi-toggle setting="showTitle" label="pi.show_title"></pi-toggle>
</pi-section>
<pi-section title="pi.tapping">
<pi-multi-tap></pi-multi-tap> <!-- switch "Double and triple tap" and, while on, the tap speed -->
</pi-section>
<pi-section title="kit.corners_title">
<pi-corners default-left="marker" default-right="source"></pi-corners>
</pi-section>
<pi-section title="kit.key_color">
<pi-key-color setting="keyColor" label=""></pi-key-color>
</pi-section>The settings the key stores are the fields of PlayPauseKeySettings: multiTap (boolean, off by default) and tapSpeed ("fast" 250 ms, "normal" 350 ms, "slow" 500 ms). <pi-multi-tap> writes them, and tapWindow(settings) tells you the window a key uses (undefined while the key doesn't count taps).
Counting taps on your own key
TapCounter is the part of this that doesn't need the Stream Deck SDK. tap() is called on every release; done(count) runs once when the burst is final, either after the window or at once at the third tap.
import { TapCounter } from "@rocklobster42195/streamdeck-kit";
import type { KeyUpEvent, WillDisappearEvent } from "@elgato/streamdeck";
const taps = new TapCounter({ max: 3 });
// in your action
override onKeyUp(ev: KeyUpEvent): void {
taps.tap(ev.action.id, 300, (count) => {
if (count === 1) toggle();
else if (count === 2) next();
else previous();
});
}
override onWillDisappear(ev: WillDisappearEvent): void {
taps.cancel(ev.action.id); // drop a burst that never finished
}A Volume key
The same shape for volume: louder, quieter, mute or a preset, for any player on the deck. A long press on a key that has a preset sets the preset volume.
import { action } from "@elgato/streamdeck";
import { VolumeKeyAction } from "@rocklobster42195/streamdeck-kit/keys";
@action({ UUID: "com.example.my-plugin.volume-key" })
export class VolumeKey extends VolumeKeyAction {
constructor() {
super({ board: players, markerPath: (name) => mdiPath(name) });
}
}Settings: command ("up", "down", "mute", "preset"), step (percent per press, default 5), preset, showVolume, gauge ("ring", "pie", "open"), player, keyColor, marker.
Copying the theme into your plugin
The property inspector's look is one CSS file. Copy it next to your PI pages when you build (a Rollup plugin; any build tool can do the same):
// rollup.config.mjs
import { createRequire } from "node:module";
import fs from "node:fs";
const require = createRequire(import.meta.url);
const copyPiTheme = {
name: "copy-pi-theme",
buildStart() {
const theme = require.resolve("@rocklobster42195/streamdeck-kit/styles/pi-theme.css");
this.addWatchFile(theme);
fs.copyFileSync(theme, "com.example.my-plugin.sdPlugin/ui/pi-theme.css");
},
};The family
The kit exists so that a small family of Stream Deck plugins looks and feels the same and shares what is worth sharing. They are all built on it:
| | Plugin | For | Status |
|---|---|---|---|
| | Sonos Controller | Sonos speakers | on the Marketplace, 0.5.0 in beta |
|
| Music Assistant Controller | your Music Assistant server and its players | alpha |
|
| System Audio Controller | your computer's sound: volume, apps, microphone | beta |
|
| X-Air Controller | Behringer X-Air and Midas mixers | alpha |
|
| Home Assistant Controller | your smart home, also over MQTT | alpha |
What the kit gives them in common:
- The same look and feel. Keys and dials are drawn by the same code: one symbol size, the same colours, errors always in the same place, and a settings window in one style with diagnostics, in English, German and Spanish.
- One Panorama across plugins. Dials of different plugins in one row share an effect (particles, a picture, a webcam, …) as if they were one display.
- They find each other. Through deckbus the plugins share players and colours without any setup, on the same computer only.
- Reusable. The kit is open, MIT licensed and on npm. If you build Stream Deck plugins yourself, take what helps.
Ideas and feedback
Ideas from outside are very welcome: they show what I stopped seeing on my own deck. Please open an issue with your idea or problem. Small fixes as pull requests are welcome too; for bigger things, please start with an issue. The kit is alpha and changes often: if you use it in your own plugin, pin an exact version.
Support
The kit started as the foundation of my own Stream Deck plugins. It is free and MIT licensed, and everyone is welcome to use it. If you like it, a coffee and a beer are always welcome: ko-fi.com/rocklobster42195.
License
MIT © Boris Kemper
