From 2148f660256b93d3ff6efde70be11d19b0f823ae Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 17 Oct 2026 12:16:17 +0200 Subject: island: Obs singleton, obs-websocket v5 client --- desktop/island/Obs.qml | 217 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 desktop/island/Obs.qml (limited to 'desktop') diff --git a/desktop/island/Obs.qml b/desktop/island/Obs.qml new file mode 100644 index 0000000..5addca4 --- /dev/null +++ b/desktop/island/Obs.qml @@ -0,0 +1,217 @@ +// 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 + +// 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 + + 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 }); + } + + // --- 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; + } + + // 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 | 64 | 128 }; // Scenes | 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"); + 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; + } + } + + 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; + 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") + } +} -- cgit v1.2.3