streamdeck_flutter
A Flutter framework for building Elgato Stream Deck plugins. Renders Flutter UI headless (no on-screen window),
captures each action's region as a key-sized tile or touchscreen strip, and pushes them as base64 JPEG via the Stream
Deck WebSocket protocol. Input events from the Stream Deck arrive as a typed event stream, surfaced through
StreamDeckAction callbacks.
Platform: macOS only.
Requirements
macOS App Sandbox must be disabled. The plugin connects to the Stream Deck app via a local WebSocket, which is blocked by the default sandbox. Disable it in your entitlements:
<key>com.apple.security.app-sandbox</key>
<false/>
Or add the network client entitlement:
<key>com.apple.security.network.client</key>
<true/>
Quick Start
1. Add the dependency
dependencies:
streamdeck_flutter: ^0.4.0
2. Create your plugin
import 'package:flutter/material.dart';
import 'package:streamdeck_flutter/streamdeck_flutter.dart';
const _binary = 'my_plugin';
const _uuid = 'com.example.my-plugin';
void main(List<String> args) {
HeadlessBinding.ensureInitialized();
runHeadless(MyPlugin(connection: ConnectionInfo.fromArgs(args)));
}
class MyPlugin extends StatelessWidget {
const MyPlugin({super.key, required this.connection});
final ConnectionInfo connection;
@override
Widget build(BuildContext context) {
return StreamDeckPlugin(
connection: connection,
manifest: Manifest(
uuid: _uuid,
name: 'My Plugin',
author: 'Your Name',
description: 'A Stream Deck plugin built with Flutter.',
icon: 'assets/imgs/plugin-icon',
version: '1.0.0.0',
codePath: '$_binary.app/Contents/MacOS/$_binary',
actions: [
ManifestAction(
id: 'counter',
name: 'Counter',
tooltip: 'A simple tap counter.',
icon: 'assets/imgs/action-icon',
states: const [ManifestState(image: 'assets/imgs/action-icon')],
build: (context) => const CounterAction(),
),
],
os: const [ManifestOS(platform: 'mac', minimumVersion: '10.15')],
software: const ManifestSoftware(minimumVersion: '6.9'),
),
);
}
}
3. Create an action widget
Each ManifestAction.build is the WidgetBuilder for that action — the Stream Deck event for an action context is
dispatched to its build. Inside, the action info is available via StreamDeck.actionOf(context). Use
StreamDeckAction to wrap your UI — it handles routing to Keypad or Dial based on the controller type.
class CounterAction extends StatefulWidget {
const CounterAction({super.key});
@override
State<CounterAction> createState() => _CounterActionState();
}
class _CounterActionState extends State<CounterAction> {
int _count = 0;
@override
Widget build(BuildContext context) {
return StreamDeckAction(
onKeyDown: () => setState(() => _count++),
onDialRotate: (ticks) => setState(() => _count += ticks),
onDialDown: () => setState(() => _count = 0),
builder: (context) {
final isEncoder = StreamDeck.actionOf(context)?.payload.controller == ActionType.encoder;
return Container(
color: Colors.black,
child: Center(
child: Text(
isEncoder ? '$_count taps' : '$_count',
style: TextStyle(color: Colors.white, fontSize: isEncoder ? 20 : 32),
),
),
);
},
);
}
}
4. Build and install
flutter pub run streamdeck_flutter build -i com.example.my-plugin
Builds the app, bundles the plugin, links it, and restarts the Stream Deck app.
Action Types
Keypad + Encoder (default)
ManifestAction(
id: 'my-action',
name: 'My Action',
states: const [ManifestState(image: 'assets/imgs/icon')],
encoder: const ManifestEncoder(
triggerDescription: ManifestTriggerDescription(
rotate: 'Adjust value',
push: 'Confirm',
touch: 'Toggle',
),
),
)
Keypad only
ManifestAction.keypad(
id: 'key-action',
name: 'Key Action',
states: const [ManifestState(image: 'assets/imgs/icon')],
)
Encoder only
ManifestAction.encoder(
id: 'dial-action',
name: 'Dial Action',
states: const [ManifestState(image: 'assets/imgs/icon')],
)
StreamDeckAction Widget
Wrap your action UI in StreamDeckAction. It provides a single builder that renders for both keypad and encoder —
check StreamDeck.actionOf(context)?.payload.controller to differentiate:
StreamDeckAction(
onKeyDown: () { /* key pressed */ },
onKeyUp: () { /* key released */ },
onDialRotate: (ticks) { /* dial rotated */ },
onDialDown: () { /* dial pressed */ },
onDialUp: () { /* dial released */ },
onTouchTap: (position) { /* touchscreen tapped */ },
onSettings: (json) { /* settings changed */ },
builder: (context) => MyWidget(),
)
Settings
Access action settings:
final action = StreamDeck.actionOf(context)!;
final color = action.payload.setting<String>('color') ?? '#FFFFFF';
Persist settings:
final client = StreamDeck.clientOf(context);
client.setSettings(settings: {'color': '#FF0000'});
CLI Commands
# Build and install (profile mode)
flutter pub run streamdeck_flutter build -i com.example.my-plugin
# Build in release mode
flutter pub run streamdeck_flutter build -i com.example.my-plugin -m release
# Build and attach for development (debug mode + hot reload)
flutter pub run streamdeck_flutter run -i com.example.my-plugin
# Unlink before building (clean install)
flutter pub run streamdeck_flutter run -i com.example.my-plugin -u
Architecture
Stream Deck App (WS Server)
| WebSocket JSON
Flutter macOS App (WS Client / Plugin)
|-- StreamDeckPlugin -> top-level widget, manifest, per-action build dispatch
|-- Client -> connects, registers, sends setImage/setFeedback
|-- Raster -> captures each RepaintBoundary, change detection, JPEG encode
'-- StreamDeckAction -> maps the SD event stream to Flutter callbacks
runHeadless mounts the plugin in a detached render tree (its own RenderView/PipelineOwner) flushed every frame,
so the macOS window is never shown. Each action's RepaintBoundary is captured independently at the device's reported
pixel ratio (the Stream Deck + reports 1.0). Only changed tiles are sent (FNV hash comparison) to minimize WebSocket
traffic, and JPEG encoding runs on a background isolate to keep the heavy work off the render thread.
The manifest.json is generated automatically by the plugin on startup from the Manifest Dart object. Action UUIDs
use short IDs (e.g. 'counter') that are auto-prefixed with the plugin UUID (e.g. com.example.my-plugin.counter).
Encoder Layout (canvas.json)
The framework renders encoder touchscreen strips as pixel data using a canvas.json layout file bundled as a package
asset. The manifest encoder block is generated automatically with the correct path. You do not need to call
setFeedbackLayout at runtime — the Stream Deck app reads the layout from the manifest.
Assets (icons, layouts, UI files) are discovered automatically from the built app's AssetManifest.bin and symlinked
into the plugin bundle. No manual asset copying is needed.