// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. pragma Singleton import Quickshell import Quickshell.Io import QtQuick import QtWebSockets import "Logic.js" as Logic // OBS Studio over obs-websocket v5. Connected means OBS is running with // its server enabled, so nothing checks for the process. State follows // OBS's events; actions only send requests and never set state // optimistically. Singleton { id: root property bool connected: false property bool recording: false property bool paused: false property real duration: 0 property var scenes: [] property string scene: "" // The webcam: the first v4l2_input item in the current scene, -1 for none. // ponytail: a webcam inside a group or nested scene is not found; walk // GetGroupSceneItemList if one ever is. property int camId: -1 property bool camOn: false property real camW: 0 property real camH: 0 // The global "Desktop Audio" input's name, "" when none is configured. property string desktopAudio: "" property bool desktopMuted: false property real baseWidth: 0 property real baseHeight: 0 function toggleRecord() { request("ToggleRecord"); } function togglePause() { request("ToggleRecordPause"); } function setScene(name) { if (name) request("SetCurrentProgramScene", { sceneName: name }); } function toggleCam() { if (camId >= 0) request("SetSceneItemEnabled", { sceneName: scene, sceneItemId: camId, sceneItemEnabled: !camOn }); } function toggleAudio() { if (desktopAudio) request("SetInputMute", { inputName: desktopAudio, inputMuted: !desktopMuted }); } // The corner math assumes the webcam item is top-left aligned (alignment 5). function moveCam(corner) { if (camId < 0 || baseWidth <= 0 || camW <= 0 || camH <= 0) return; const p = Logic.cornerPos(corner, baseWidth, baseHeight, camW, camH, 16); request("SetSceneItemTransform", { sceneName: scene, sceneItemId: camId, sceneItemTransform: { positionX: p.x, positionY: p.y } }); } // --- config ----------------------------------------------------------- // OBS's own file, so no copy of the password exists anywhere else. FileView { id: cfg path: `${Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config"}/obs-studio/plugin_config/obs-websocket/config.json` watchChanges: true onFileChanged: reload() } function config() { try { return JSON.parse(cfg.text()); } catch (e) { return null; } } // --- socket ----------------------------------------------------------- property bool warned: false property bool wasOpen: false WebSocket { id: sock onStatusChanged: { if (status === WebSocket.Open) { root.wasOpen = true; return; } if (status === WebSocket.Connecting) return; // QML's WebSocket hides the close code, so a wrong password // (4009) and OBS quitting look the same. Say it once per run. if (root.wasOpen && !root.connected && !root.warned) { console.warn("obs: closed before Identified, wrong password?"); root.warned = true; } root.reset(); } onTextMessageReceived: message => { try { root.receive(JSON.parse(message)); } catch (e) { console.warn(`obs: bad message: ${e}`); } } } function reset() { connected = false; wasOpen = false; recording = false; paused = false; duration = 0; scenes = []; scene = ""; camId = -1; camOn = false; camW = 0; camH = 0; desktopAudio = ""; desktopMuted = false; baseWidth = 0; baseHeight = 0; } // Retry while not connected; a closed or failed socket is reopened by // toggling active. Timer { interval: 3000 repeat: true running: !root.connected triggeredOnStart: true onTriggered: { if (sock.status === WebSocket.Open || sock.status === WebSocket.Connecting) return; const c = root.config(); if (!c || !c.server_enabled) return; sock.active = false; sock.url = `ws://127.0.0.1:${c.server_port}`; sock.active = true; } } // --- protocol ----------------------------------------------------------- property int seq: 0 function send(op, d) { sock.sendTextMessage(JSON.stringify({ op: op, d: d })); } function request(type, data) { if (!connected) return; send(6, { requestType: type, requestId: String(++seq), requestData: data || {} }); } function identify(token) { const d = { rpcVersion: 1, eventSubscriptions: 4 | 8 | 64 | 128 }; // Scenes | Inputs | Outputs | SceneItems if (token) d.authentication = token; send(1, d); } Process { id: auth command: ["sh", `${Quickshell.shellDir}/island/obs-auth.sh`] stdinEnabled: true property string data: "" onStarted: { auth.write(auth.data); auth.stdinEnabled = false; } stdout: StdioCollector { onStreamFinished: root.identify(text.trim()) } } function receive(m) { switch (m.op) { case 0: { // Hello const a = m.d.authentication; if (!a) { identify(""); break; } const c = config(); auth.data = `${c ? c.server_password : ""}${a.salt}\n${a.challenge}\n`; auth.stdinEnabled = true; auth.running = false; auth.running = true; break; } case 2: // Identified connected = true; warned = false; request("GetRecordStatus"); request("GetSceneList"); request("GetSpecialInputs"); request("GetVideoSettings"); break; case 5: event(m.d.eventType, m.d.eventData || {}); break; case 7: response(m.d); break; } } function event(type, d) { switch (type) { case "RecordStateChanged": recording = d.outputActive; paused = d.outputState === "OBS_WEBSOCKET_OUTPUT_PAUSED"; if (!recording) duration = 0; break; case "CurrentProgramSceneChanged": scene = d.sceneName; request("GetSceneItemList", { sceneName: scene }); break; case "SceneListChanged": case "SceneItemCreated": case "SceneItemRemoved": request("GetSceneList"); break; case "SceneItemEnableStateChanged": if (d.sceneName === scene && d.sceneItemId === camId) camOn = d.sceneItemEnabled; break; case "InputMuteStateChanged": if (d.inputName === desktopAudio) desktopMuted = d.inputMuted; break; } } function response(d) { if (!d.requestStatus.result) { console.warn(`obs: ${d.requestType} failed: ${d.requestStatus.comment ?? d.requestStatus.code}`); return; } const r = d.responseData || {}; switch (d.requestType) { case "GetRecordStatus": recording = r.outputActive; paused = r.outputPaused; duration = r.outputDuration; break; case "GetSceneList": // obs-websocket lists scenes bottom-up; reversed is OBS's own order. scenes = r.scenes.slice().reverse().map(s => s.sceneName); scene = r.currentProgramSceneName; request("GetSceneItemList", { sceneName: scene }); break; case "GetSceneItemList": { const it = r.sceneItems.find(i => i.inputKind === "v4l2_input"); camId = it ? it.sceneItemId : -1; camOn = !!it && it.sceneItemEnabled; camW = it ? it.sceneItemTransform.width : 0; camH = it ? it.sceneItemTransform.height : 0; break; } case "GetSpecialInputs": desktopAudio = r.desktop1 || ""; if (desktopAudio) request("GetInputMute", { inputName: desktopAudio }); break; case "GetVideoSettings": baseWidth = r.baseWidth; baseHeight = r.baseHeight; break; case "GetInputMute": desktopMuted = r.inputMuted; break; } } // OBS owns the clock, so pausing freezes it with no local bookkeeping. // ponytail: one local request a second, only while recording. Timer { interval: 1000 repeat: true running: root.recording && !root.paused onTriggered: root.request("GetRecordStatus") } }