Audit date: 2026-06-30. Compatibility recheck: 2026-07-10.
This compares the native Stream Deck plugin implementation in plugin/ against the current local VDO.Ninja runtime. The plugin still stays inside vdo-streamdeck/; no VDO.Ninja source files were changed for this audit.
Repeated source rechecks on 2026-06-30 confirmed the plugin should continue treating VDO.Ninja state as page-derived live state, not as a durable room model:
getDetails over &api maps to getDetailedState(value) in lib.js; there is no checked getDetailsState or getStreamDetails command.getDetailedState can return only the local page, only selected inbound streams, or a director room snapshot depending on page mode and optional value.getGuestList is a director DOM ordering helper only. Empty getGuestList is valid for push links, plain view links, clean/scene outputs, and roomless pages.data-scene strings. Numeric scene aliases are conveniences, not the whole model.details, newViewConnection, endViewConnection, and positionChange updates. The plugin must refresh state after these events instead of assuming a target stayed in the same room or slot.getDetails; and custom commands preserve string targets/values for stream IDs, room names, and custom scenes.../../lib.js
getDetailedStategetGuestListpokeAPIoscClienttargetGuestprocessMessageCommands../../main.js
getStreamIDsgetStreamInfogetDetailedStategetGuestList../../webrtc.js
newViewConnectionendViewConnectiondetails pushesThe native plugin is correct to use the &api WebSocket as the primary surface:
&api=KEY connects to session.apiserver.{ "join": "KEY" }.update:{
"update": {
"action": "details",
"streamID": "localStreamId",
"value": {
"localStreamId": {}
}
}
}
The value comes from getDetailedState(session.streamID), so it is often a partial page-local snapshot, not a full room snapshot.
Incoming plugin commands use:
{
"action": "mic",
"target": null,
"value": "toggle",
"get": "request-id"
}
processMessage routes commands with a non-null target through targetGuest(...); otherwise it calls local Commands[action]. When a response is non-null, the page returns:
{
"callback": {
"action": "mic",
"value": "toggle",
"get": "request-id",
"result": false
}
}
Because the original command object is reused, callback.get, callback.target, callback.value, and callback.value2 echo the normalized request where present. callback.result is the function return value, not a guaranteed durable state snapshot.
The reference HTTP relay resolves successful HTTP requests with only callback.result. It returns plain failed when no VDO.Ninja page is joined to the API key and plain timeout when a page does not return a callback in time. The plugin treats those relay strings as errors, not successful command results.
HTTP fallback note: VDO.Ninja’s relay POST path always adds a get ID and waits for a callback internally. Even plugin actions configured as “do not wait” may still wait when they fall back to HTTP, because HTTP has no fire-and-forget route in the reference relay.
Implementation rule: HTTP fallback responses are normalized into the same callback path as WebSocket callbacks so getDetails, getGuestList, and command feedback update the Stream Deck state store consistently.
Custom command parsing rule: the plugin preserves numeric-looking text values such as 01 as strings. VDO.Ninja itself parses numeric command values where needed, while stream IDs, push/view IDs, and custom scene keys can be numeric-looking strings that must not be coerced.
API host rule: default secure WebSocket remains wss://api.vdo.ninja:443. Custom hosts with explicit ports are preserved instead of blindly appending :443, and non-TLS WebSocket mode defaults to port 80.
Important API-key scope: the relay broadcasts commands to every WebSocket client joined to the same API key. The callback does not include a stable page identity. The MVP plugin therefore treats one API key as one controlled VDO.Ninja page/session for deterministic state feedback. Reusing the same API key across a director page plus scene/view/push pages can produce multiple callbacks and ambiguous local state; use separate API keys until a multi-page state model exists.
Connection-state rule: opening the WebSocket to the relay is not enough to show “connected” in Stream Deck. The plugin should only mark a VDO page as connected after a VDO.Ninja page sends an update or returns a callback. A relay connection with no answering page becomes no-page after the getDetails timeout.
The initial plugin implementation treated every details payload as a complete replacement. That is wrong for current VDO.Ninja because several pokeAPI("details", ...) calls are partial:
getDetailedState(session.streamID).msg.info update: getDetailedState(remoteStreamID).session.syncState.The plugin now does this:
getDetails callback without value: replace the current detailed state.getDetails callback with value: merge it as a targeted stream result.update.action == "details": merge it.remoteMuted, remoteVideoMuted, directorMuted, and directorVideoHide: patch the affected stream ID.endViewConnection: remove the affected stream from update.value, because update.streamID is the reporting local page.positionChange: patch stream positions immediately.codirector: patch local director feedback and preserve a separate codirector flag, because VDO.Ninja reports co-director approval separately.getDetails; join/leave/position also refresh getGuestList.getDetails polling uses the configured detailsPollMs as a backstop because the detailed state is live DOM/session-derived and not every future UI path is guaranteed to push a complete update.{ sid: { sid: streamState } } are unwrapped before normalization.failed and timeout relay responses raise errors and set no-page/timeout state.record defaults to true for generic toggle buttons because current VDO.Ninja record only handles explicit true and false.Regression coverage was added in plugin/src/state/session-store.test.ts for these cases.
getDetailedState is the main state source for the native plugin. It is built on demand from live session objects and the current DOM. It is not a persistent room or scene database.
Remote stream entries can include:
streamID, label, groupmiscellaneous from stats.infolayout, slotfeaturediframeSrclocalStream: falsemuted, videoMutedactiveSpeaker, defaultSpeakerdirectorpositionscenesothersLocal stream entries can include:
label, meta, group, groupView, scenestreamID, iframeSrcdirectorlocalStream and deprecated localstreamseedingmuted, videoMuted, speakerMutedThe plugin cannot assume that an API key controls a director room.
Expected state:
getDetails has local director state and remote guest entries.getGuestList usually returns visible #guestFeeds positions.scenes, others, slots, and queue/hand state.Best plugin behavior:
getGuestList for user-facing slot order.getDetails for stable stream IDs, labels, custom scenes, queue/hand flags, and guest feedback.Expected state:
localStream entry.#guestFeeds.getGuestList can be empty.Best plugin behavior:
Expected state:
session.rpcs entries from the viewed stream IDs.getGuestList can be empty or incomplete.Best plugin behavior:
getDetails as well as getGuestList.Expected state:
session.scene can be "0" or any sanitized custom scene string.getGuestList depends on whether the page has #guestFeeds.Best plugin behavior:
0 through 8.&api, keep their keys separate from the director key unless broadcast control is intended.Expected state:
directMigrate/directMigrateIssue and can change the guest room, queue type, and publishing state.endViewConnection, newViewConnection, details, and position changes, not a durable transfer-room object.Best plugin behavior:
Current VDO.Ninja scene state is dynamic:
getDetailedState reads scene controls with [data-action-type="addToScene"][data-scene].data-scene contains.targetGuest("addScene", target, value) uses value as the scene ID/name unless it is missing, null, or "toggle".addScene2 through addScene8 are only convenience aliases.addScene toggles the matching scene button when value2 is missing.addScene with value2=true/false, or setScene, to force custom scene membership without relying on a toggle.Plugin rule:
details[streamID].scenes, not from a fixed 0-8 list.getDetails.scenes state and a single legacy toggle only when needed.There is no current iframe call named getStreamDetails in the local runtime.
Closest calls:
getDetails / iframe getDetailedState: broad local plus remote stream state.getStreamInfo: remote-only map keyed by internal UUID with { label, streamID, info }.getStreamIDs: remote-only map of streamID to label.getStats: lightweight local/inbound/outbound stats.requestStats: heavier diagnostic stats with pcs and rpcs.getGuestList: director UI position map.For a native Marketplace plugin, getDetails is the best general state call over &api. getStreamInfo, device APIs, loudness, video frames, and some P2P data helpers remain iframe-only unless a future VDO.Ninja &api bridge is added. Queue activation is now covered by activateQueuedGuest.
Naming note: there is no getDetailsState call in the checked source. Use getDetails for &api and getDetailedState for iframe/source references.
Implemented now:
value2 and realtime commands.Still planned:
&api with activateQueuedGuest plus removeQueue / removeQueuedGuest aliases. Older self-hosted VDO.Ninja pages without that addition can still detect queued/held state but cannot activate it through native &api.getGuestList is not a universal stream list. The plugin must continue using getDetails as the fallback stream source.scenes may be absent even when the page is viewing a scene.false for missing targets, and some runtime functions can effectively return no useful result. Button feedback should use callbacks for acknowledgement and details/updates for durable state.addScene with value2=true/false, or setScene, but the plugin deliberately uses legacy scene aliases and state-aware toggles so pre-v30.1 pages remain compatible.