streamdeck_flutter

A Flutter framework for building Elgato Stream Deck plugins. Renders Flutter UI offscreen, slices frames into key-sized tiles and touchscreen strips, and pushes them as base64 PNG via the Stream Deck WebSocket protocol. Input events from the Stream Deck are injected back into Flutter's gesture system.

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.3.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();
  runApp(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,
      actionBuilder: (context) {
        final action = StreamDeck.actionOf(context)!;
        return switch (action.action) {
          '$_uuid.counter' => const CounterAction(),
          _ => const Placeholder(),
        };
      },
      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')],
          ),
        ],
        os: const [ManifestOS(platform: 'mac', minimumVersion: '10.15')],
        software: const ManifestSoftware(minimumVersion: '6.9'),
      ),
    );
  }
}

3. Create an action widget

The actionBuilder receives a BuildContext with the action info 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, action routing
    |-- Client                -> connects, registers, sends setImage/setFeedback
    |-- Raster                -> captures UI via CaptureBoundary, change detection
    '-- StreamDeckAction      -> maps SD events to Flutter callbacks

The app runs headless (hidden macOS window). Full frames are captured per action context, and only changed tiles are sent (FNV hash comparison) to minimize WebSocket traffic. Always captures at 2.0x for Retina Stream Deck displays.

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.

Libraries

streamdeck_flutter