diff options
Diffstat (limited to 'docs/superpowers/specs/2026-10-17-obs-pill-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-10-17-obs-pill-design.md | 125 |
1 files changed, 125 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-17-obs-pill-design.md b/docs/superpowers/specs/2026-10-17-obs-pill-design.md new file mode 100644 index 0000000..3331bba --- /dev/null +++ b/docs/superpowers/specs/2026-10-17-obs-pill-design.md @@ -0,0 +1,125 @@ +# OBS pill + +A pill on the island for screen recording with OBS Studio. It exists only +while OBS is running and controls the running instance: start and stop +recording, pause and resume, switch scene, show or hide the webcam. The +always-visible sliver shows recording state. + +## Out of scope + +- Mic mute: the user has a physical button for it. +- Camera hardware settings (zoom, focus, brightness through `v4l2-ctl`). +- Replay buffer, streaming, virtual camera. + +## Transport + +obs-websocket (protocol v5), built into OBS 28+, spoken directly from QML +with `QtWebSockets`' `WebSocket`, which is installed. Events are pushed, so +nothing polls OBS except the elapsed time while recording (below). + +Rejected: a Python bridge (the stdlib has no websocket client, so either a +hand-rolled framing layer or a new dependency) and `obs-cmd` (a new +dependency, polled, laggy state). + +### One-time setup + +Enable the server in OBS: Tools > WebSocket Server Settings. Authentication +stays on: obs-websocket listens on every interface and has no localhost-only +option, so turning it off would expose recording control to the LAN. + +### Password + +Read from OBS's own `~/.config/obs-studio/plugin_config/obs-websocket/config.json` +(`server_password`, `server_port`) with a `FileView`. No copy is stored +anywhere, and a password changed in OBS is picked up on the next connect. + +### Handshake + +1. Server sends `Hello` (op 0). With auth it carries `challenge` and `salt`. +2. Auth string: `secret = base64(sha256(password + salt))`, + `auth = base64(sha256(secret + challenge))`. QML has no SHA256, so one + `Process` per connect runs `openssl dgst -sha256 -binary | base64` twice, + with the inputs on stdin, never on the command line. +3. Client sends `Identify` (op 1) with `rpcVersion: 1`, `authentication`, and + `eventSubscriptions` = Scenes (4) | Outputs (64) | SceneItems (128) = 196. +4. `Identified` (op 2): connected. Request the initial state. + +## `Obs.qml` singleton (`desktop/island/`) + +State: + +- `connected`: true after `Identified`. This doubles as "OBS is running": + no process check. +- `recording`, `paused` +- `duration`: milliseconds, from `GetRecordStatus.outputDuration` +- `scenes`: names, in OBS order; `scene`: current program scene +- `camId`, `camOn`: the scene item in the current scene whose `inputKind` is + `v4l2_input`, found automatically; `camId` is -1 when the scene has none + +Requests sent: + +| Action | Request | +| --- | --- | +| initial / on change | `GetRecordStatus`, `GetSceneList`, `GetSceneItemList {sceneName}` | +| start/stop | `ToggleRecord` | +| pause/resume | `ToggleRecordPause` | +| scene | `SetCurrentProgramScene {sceneName}` | +| webcam | `SetSceneItemEnabled {sceneName, sceneItemId, sceneItemEnabled}` | + +Events handled: `RecordStateChanged` (sets `recording`/`paused` from +`outputState`), `CurrentProgramSceneChanged` and `SceneListChanged` (re-read +scenes and the webcam item), `SceneItemEnableStateChanged` (updates `camOn` +when the id matches). + +Responses are matched by `requestId`; the request type prefixes the id, so a +plain switch on it routes the reply. + +Elapsed time: `GetRecordStatus` every 1s while `recording`. OBS owns the +clock, so pause freezes it with no local bookkeeping. ponytail: one local +request per second, only while recording. + +Reconnect: a `Timer` every 3s while not connected sets `active` on the +`WebSocket`. A closed or errored socket clears all state, which hides the +pill. An auth failure (close code 4009) logs once and keeps retrying, since a +password change in OBS fixes it without a restart. + +## The pill (`ObsPill.qml`) + +Right of the status pill, slides with the others, exists only while +`Obs.connected`. + +Left to right: + +- **Record**: idle icon, or a red dot and `mm:ss` (`h:mm:ss` past an hour) + while recording. Click toggles recording. +- **Pause**: only while recording. Click toggles pause; the dot dims while + paused. +- **Scene**: current scene name. Click steps to the next, wheel steps + previous (up) and next (down), wrapping. Hidden with fewer than two scenes. +- **Webcam**: camera icon, struck while hidden. Click toggles. Absent when + `camId` is -1. + +The pill names its own panels (AGENTS.md): it opens none, so it never +expands. + +## Sliver + +While recording, the sliver pulses red (opacity animation), drawn over the +privacy segments; paused, it holds steady at reduced opacity. Red is +already "screen capture" in the privacy colours, and OBS's screen source +likely lights that segment whenever OBS is open, so the pulse, not the +colour, is what says "recording". Not recording: the sliver is unchanged. + +## Pure logic and its check + +In `Logic.js`, covered by `test_logic.js`: + +- `obsElapsed(ms)`: `0` -> `00:00`, `61000` -> `01:01`, `3661000` -> `1:01:01` +- `obsStep(list, cur, d)`: next/previous scene with wrap, `cur` missing -> first + +## Errors + +- Server disabled or OBS closed: connect fails, pill absent, retry loop. +- Auth failure: logged, retry loop. +- A failed request (`requestStatus.result` false): logged with its comment; + state is not changed optimistically, it follows OBS's events. |
