OBS Stream Deck Controller

@rebind / obs-scene-switcherv0.1.4Public

Hotkey control for OBS scenes, audio mute, streaming, recording, and camera via built-in OBS WebSocket.

Files

Older release. Installs get 0.1.4.

main.luau7.9 KB

-- OBS Stream Deck Controller: hotkeys for scenes, mic mute, streaming,
-- recording, replays and the virtual camera over OBS's built-in WebSocket.

local cfg = UI.Schema({
  -- Tab: Scenes
  scene_a_btn = UI.Keybind("Mouse4", { label = "Scene A Key", tab = "Scenes" }),
  scene_a_name = UI.Text("Starting Soon", { label = "Scene A Name", tab = "Scenes" }),

  scene_b_btn = UI.Keybind("Mouse5", { label = "Scene B Key", tab = "Scenes" }),
  scene_b_name = UI.Text("Gameplay", { label = "Scene B Name", tab = "Scenes" }),

  scene_c_btn = UI.Keybind("F1", { label = "Scene C Key", tab = "Scenes" }),
  scene_c_name = UI.Text("Just Chatting", { label = "Scene C Name", tab = "Scenes" }),

  scene_d_btn = UI.Keybind("F2", { label = "Scene D Key", tab = "Scenes" }),
  scene_d_name = UI.Text("BRB", { label = "Scene D Name", tab = "Scenes" }),

  scene_e_btn = UI.Keybind("F3", { label = "Scene E Key", tab = "Scenes" }),
  scene_e_name = UI.Text("Ending", { label = "Scene E Name", tab = "Scenes" }),

  -- Tab: Controls
  mute_btn = UI.Keybind("F9", { label = "Toggle Mic Mute Key", group = "Audio", tab = "Controls" }),
  mic_source = UI.Text("Mic/Aux", { label = "Mic Source Name", group = "Audio", tab = "Controls" }),

  stream_start_btn = UI.Keybind("F5", { label = "Start Stream Key", group = "Stream", tab = "Controls" }),
  stream_stop_btn = UI.Keybind("F6", { label = "Stop Stream Key", group = "Stream", tab = "Controls" }),
  stream_toggle_btn = UI.Keybind("F10", { label = "Toggle Stream Key", group = "Stream", tab = "Controls" }),
  record_toggle_btn = UI.Keybind("F11", { label = "Toggle Record Key", group = "Output", tab = "Controls" }),
  replay_save_btn = UI.Keybind("F8", { label = "Save Replay Buffer Key", group = "Output", tab = "Controls" }),

  cam_toggle_btn = UI.Keybind("F7", { label = "Toggle Virtual Cam Key", group = "Camera", tab = "Controls" }),

  -- Tab: Connection
  ws_url = UI.Text("ws://127.0.0.1:4455", { label = "OBS WebSocket URL", tab = "Connection" }),
  password = UI.Text("", { label = "Server Password (Optional)", tab = "Connection" }),
  beep = UI.Toggle(true, { label = "Audio Feedback on Actions", tab = "Connection" }),
})

local ws_conn = nil
local is_ready = false
local reconnect_timer = nil
local req_counter = 0
-- when the current connection attempt started; OBS gets time to finish the
-- handshake before another attempt replaces it
local connecting_since = nil
local HANDSHAKE_MS = 10000

local function needs_connect()
  if is_ready then
    return false
  end
  if ws_conn and connecting_since and os.clock() * 1000 - connecting_since < HANDSHAKE_MS then
    return false
  end
  return true
end

local function play_feedback()
  if cfg.beep then
    Audio.Beep()
  end
end

local function connect()
  if ws_conn then
    pcall(ws_conn.Close, ws_conn)
    ws_conn = nil
  end
  is_ready = false
  connecting_since = os.clock() * 1000

  local conn = nil
  local ok, err = pcall(function()
    conn = Net.WSConnect(cfg.ws_url, {
      OnOpen = function()
        Log.Info("Connecting to OBS WebSocket at " .. cfg.ws_url)
      end,

      OnMessage = function(payload, is_binary)
        if is_binary then return end
        local msg = JSON.Parse(payload)
        if not msg or type(msg.op) ~= "number" then return end

        local client = conn or ws_conn
        if not client then return end

        if msg.op == 0 then
          -- OpCode 0: Hello from OBS
          local d = msg.d or {}
          local auth_data = d.authentication
          local identify_payload = {
            rpcVersion = 1,
          }

          if auth_data and auth_data.challenge and auth_data.salt then
            local pw = cfg.password or ""
            local secret = Codec.Base64(Codec.HexDecode(Hash.SHA256(pw .. auth_data.salt)))
            local auth_str = Codec.Base64(Codec.HexDecode(Hash.SHA256(secret .. auth_data.challenge)))
            identify_payload.authentication = auth_str
          end

          local send_ok, send_err = pcall(function()
            client:Send(JSON.Stringify({
              op = 1,
              d = identify_payload,
            }))
          end)
          if not send_ok then
            Log.Error("Failed to send Identify to OBS: " .. tostring(send_err))
          end
        elseif msg.op == 2 then
          -- OpCode 2: Identified (ready to receive commands)
          is_ready = true
          Log.Info("OBS WebSocket authenticated and ready")
          UI.Notify("Connected to OBS", "success")
        elseif msg.op == 7 then
          -- OpCode 7: RequestResponse
          local d = msg.d or {}
          local status = d.requestStatus or {}
          if status.result then
            play_feedback()
            UI.Notify("OBS: " .. (d.requestType or "Command") .. " OK", "info")
          else
            UI.Notify("OBS Error: " .. (status.comment or "Request failed"), "warning")
          end
        end
      end,

      OnClose = function()
        -- a late close from a replaced connection must not drop the new one
        if ws_conn and ws_conn ~= conn then
          return
        end
        if is_ready then
          Log.Warn("OBS WebSocket disconnected")
          UI.Notify("OBS Disconnected", "warning")
        else
          Log.Warn("OBS WebSocket closed before identification")
        end
        is_ready = false
        ws_conn = nil
      end,
    })
  end)

  if ok and conn then
    ws_conn = conn
  else
    ws_conn = nil
    is_ready = false
    if not ok then
      Log.Error("Failed to connect to OBS WebSocket: " .. tostring(err))
    end
  end
end

local function send_command(request_type, request_data)
  if not ws_conn or not is_ready then
    if needs_connect() then
      connect()
    end
    UI.Notify("Connecting to OBS... Try again in a moment.", "warning")
    return
  end

  req_counter = req_counter + 1
  local msg = {
    op = 6,
    d = {
      requestType = request_type,
      requestId = tostring(req_counter),
      requestData = request_data,
    },
  }

  local ok = pcall(function()
    ws_conn:Send(JSON.Stringify(msg))
  end)

  if not ok then
    UI.Notify("Failed to send command to OBS", "error")
  end
end

function OnStart()
  Log.Info("OBS Stream Deck Controller starting")
  connect()

  -- Check connection health and auto-reconnect if OBS starts or restarts
  reconnect_timer = Timer.Every(5000, function()
    if needs_connect() then
      connect()
    end
  end)
end

function OnStop()
  if reconnect_timer then
    reconnect_timer:Cancel()
    reconnect_timer = nil
  end

  if ws_conn then
    pcall(ws_conn.Close, ws_conn)
    ws_conn = nil
  end

  is_ready = false
  Audio.StopAll()
end

-- Scenes A through E
Bind(cfg.scene_a_btn, function()
  send_command("SetCurrentProgramScene", { sceneName = cfg.scene_a_name })
end)

Bind(cfg.scene_b_btn, function()
  send_command("SetCurrentProgramScene", { sceneName = cfg.scene_b_name })
end)

Bind(cfg.scene_c_btn, function()
  send_command("SetCurrentProgramScene", { sceneName = cfg.scene_c_name })
end)

Bind(cfg.scene_d_btn, function()
  send_command("SetCurrentProgramScene", { sceneName = cfg.scene_d_name })
end)

Bind(cfg.scene_e_btn, function()
  send_command("SetCurrentProgramScene", { sceneName = cfg.scene_e_name })
end)

-- Audio Control
Bind(cfg.mute_btn, function()
  send_command("ToggleInputMute", { inputName = cfg.mic_source })
end)

-- Stream & Recording
Bind(cfg.stream_start_btn, function()
  send_command("StartStream")
end)

Bind(cfg.stream_stop_btn, function()
  send_command("StopStream")
end)

Bind(cfg.stream_toggle_btn, function()
  send_command("ToggleStream")
end)

Bind(cfg.record_toggle_btn, function()
  send_command("ToggleRecord")
end)

Bind(cfg.replay_save_btn, function()
  send_command("SaveReplayBuffer")
end)

-- Virtual Camera Toggle
Bind(cfg.cam_toggle_btn, function()
  send_command("ToggleVirtualCam")
end)

3 files, 10.3 KB.