aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/island
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/island')
-rw-r--r--desktop/island/Obs.qml217
1 files changed, 217 insertions, 0 deletions
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. <danix@danix.xyz>
+//
+// 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")
+ }
+}