aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md
blob: 35d22a9dc9fd023e85ca3f6db3a2e46fe164cad0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# OBS pill: desktop audio mute, webcam corners, scene grid

Extends the OBS pill from `2026-10-17-obs-pill-design.md`. Two new controls, a
refreshed panel, and a settings page for the scene icon map. This is a design
document; the plan follows.

## Intent

While OBS records, control two more things from the island pill: the desktop
audio (mute/unmute) and the webcam's on-canvas position (snap to a corner). At
the same time, make the panel's buttons icon-first so the panel is faster to
scan mid-recording.

## Goals

- Toggle desktop audio mute from the panel; state follows OBS.
- Snap the webcam to any of the 4 corners of the recorded canvas.
- Panel buttons become icon + short label.
- Scenes become an icon grid whose glyphs are mapped by name in a watched
  `~/.config` file, editable from the settings window, with a generic glyph for
  unmapped scenes.

## Non-goals / anti-goals

- Mic mute: the user has a physical button (unchanged from the original spec).
- Camera hardware settings, replay buffer, streaming, virtual camera.
- Scene creation or reordering from the pill.
- Persistent "current corner" state: corner placement is fire-and-forget.
- Echo cancellation: see "Mic bleed" below.
- A general source-transform editor: only the webcam is moved, only to corners.
- A glyph palette or font browser inside the settings page: glyphs are pasted
  from the Nerd Fonts cheat sheet.

## Mic bleed (setup, not code)

With desktop audio captured and the mic open, the mic also hears the speakers,
so the recording carries the app's audio twice. This is acoustic, not a routing
bug, and OBS cannot subtract the speaker signal from the mic. The chosen fix is
**headphones** while recording with the mic: no bleed, no pipeline change. The
pill does not touch the mic, and no code is added for this.

## Verified current state (2026-10-17, read from OBS's own files and live probes)

- Desktop audio is a **global** device, not a scene source: a
  `pulse_output_capture` of the monitor output, named `Desktop Audio`, kind
  `pulse_output_capture`, currently unmuted. `GetSpecialInputs.desktop1` is
  `Desktop Audio`, and `GetInputMute` resolves by that name.
- Base canvas is 1920x1080.
- Scenes present: `Desktop`, `Webcam Only`, `Window Only`, and `Intervallo`
  (Image Slide Show, the pause scene). `Intervallo` is the current program
  scene, so the panel opens without webcam controls until the user switches to
  `Desktop` (cosmetic; not a bug).
- Webcam sources: `camera` (Desktop) and `Camera` (Webcam Only), both
  `v4l2_input`. The existing per-scene webcam toggle already finds the first
  `v4l2_input`, so no name-based matching is needed.
- The live webcam item in `Desktop` is scaled 0.4656: source 640x360 renders as
  298x168 at position (1572, 861), `alignment: 5` (top-left).

## `Obs.qml` additions

New state:

- `desktopAudio`: string, the desktop-audio input name, `""` when none.
- `desktopMuted`: bool.
- `baseWidth`, `baseHeight`: real, the base canvas size.
- `camW`, `camH`: real, the webcam item's **rendered** size in the current
  scene.

On `Identified` (op 2), in addition to the existing `GetRecordStatus` and
`GetSceneList`:

- `GetSpecialInputs` -> `desktopAudio = desktop1` (the first non-empty desktop
  input name).
- `GetVideoSettings` -> `baseWidth` / `baseHeight`.
- If `desktopAudio` is non-empty, `GetInputMute { inputName: desktopAudio }` ->
  `desktopMuted`.

`identify()` must also subscribe to the Inputs event category:
`eventSubscriptions` becomes `4 | 8 | 64 | 128` = **204** (Scenes | Inputs |
Outputs | SceneItems). Without Inputs, `InputMuteStateChanged` never arrives and
`desktopMuted` would only ever be set by the initial `GetInputMute`.

New requests:

- `toggleAudio()`: if `desktopAudio`, `SetInputMute { inputName: desktopAudio,
  inputMuted: !desktopMuted }`. State is not set optimistically; it follows the
  `InputMuteStateChanged` event.
- `moveCam(corner)`: if `camId >= 0` and `baseWidth > 0`, compute the position
  and send `SetSceneItemTransform { sceneName: scene, sceneItemId: camId,
  sceneItemTransform: { positionX, positionY } }`. Send **position only**, never
  the full echoed transform: for an item with `boundsType:
  OBS_BOUNDS_NONE` (the live webcam), the echoed `boundsWidth: 0` fails the
  server's minimum of 1.

  Corners use a margin `m` (~16px) and the rendered size `camW`/`camH`:
  - `tl` `(m, m)`
  - `tr` `(baseWidth - camW - m, m)`
  - `bl` `(m, baseHeight - camH - m)`
  - `br` `(baseWidth - camW - m, baseHeight - camH - m)`

  The math assumes `alignment: 5` (top-left), which holds on the live webcam
  item; state that assumption in a code comment rather than relying on it
  silently.

New event:

- `InputMuteStateChanged`: if `d.inputName === desktopAudio`, set
  `desktopMuted = d.inputMuted`.

In the `GetSceneItemList` response (already handled), set `camW`/`camH` from the
webcam item's `sceneItemTransform.width` / `.height` (rendered, 298x168 on the
live item). Never use `sourceWidth`/`sourceHeight` (640x360); the item is scaled
and the corner math would land about 340px off-canvas.

## `ObsPill.qml` changes

Buttons switch from text-only to icon + short label:

- Record / Stop: record-circle / stop-circle glyph + "Record"/"Stop".
- Pause / Resume (visible only while recording): pause / play glyph +
  "Pause"/"Resume".
- Webcam (visible when `camId >= 0`): video / video-off glyph + "Webcam".
- Audio (visible when `desktopAudio !== ""`): volume-high / volume-off glyph +
  "Audio".
- Corner move: four icon-only corner-arrow buttons, visible when `camId >= 0`,
  calling `Obs.moveCam("tl" | "tr" | "bl" | "br")`. Icon-only because the arrow
  glyph is the label.

Unchanged:

- Status line (`Not recording`, `Recording 03:21`, `Paused 03:21`).
- At-rest pill dot and elapsed time.

### Scene grid

Scenes are square icon tiles in a `Grid` with `columns: 4`, reserved at the
bottom of the panel and always visible (not collapsible). Up to 8 tiles fit in
two rows; more scroll inside a fixed-height `Flickable`. Selection is click-only,
so scrolling never switches a scene. The current scene is highlighted as now.

Each scene's glyph comes from a config file,
`$XDG_CONFIG_HOME/quickshell/obs-scene-icons.json` (default
`~/.config/quickshell/obs-scene-icons.json`), watched and reloaded on change:

```json
{
  "generic": "\udb80\uded7",
  "scenes": {
    "Desktop": "\udb80\udf79",
    "Webcam Only": "\udb81\udd67",
    "Window Only": "\uf2d0",
    "Intervallo": "\udb80\udd76"
  }
}
```

(The `md-*` glyphs are supplementary-plane, so their JSON/JS form is a surrogate
pair; `JSON.stringify` also writes the glyph itself, which is equivalent.)

That starter map is also the built-in default in
`desktop/island/SceneIcons.js` (`pragma library`), used when the file is missing
or unreadable, so the pill works before the settings page is ever opened.
`SceneIcons.js` holds the pure `defaults`, `parse(text)`, `serialize(map)`,
`iconFor(map, name)` and the one-code-point check; a small `SceneIcons.qml`
singleton owns the watched `FileView`, exposes `iconFor(name)` and `save(map)`.
Unmapped scenes, including any added in OBS later, show `generic`. A missing or
unparseable file yields the starter map; a valid file is used as-is and is not
merged with the defaults, so deleting a key makes that scene `generic` again.

The file is user-editable and the settings page writes it; there is no new state
in `Obs.qml`.

Tiles are about 36px square: 4 across is ~168px with gaps, so the current 260px
panel width holds them without horizontal growth. The control-button row is the
width driver; the builder may widen the panel or wrap that row.

Panel height follows `controls.implicitHeight` as before. The `Flickable` has a
fixed two-row height, so the panel does not grow as scenes are added.

### Webcam Only

The corner buttons also appear in `Webcam Only`, where the webcam fills the
canvas; moving it there shifts it slightly off-centre. That is harmless and
accepted rather than adding scene-name or display-source detection.

## Settings page: OBS scenes

A new module `desktop/modules/obs/ObsScenesModule.qml`, `settings: true`, group
"Desktop", label "OBS scenes", so it joins the settings window's sidebar and
search. It imports `../../island` for `Obs` (the live scene list) and
`SceneIcons` (read and save). Its page lists one row per scene: the scene name
and a paste field holding that scene's glyph, rendered with `Theme.iconFamily`.
A final "Default for unmapped scenes" row edits `generic`.

Picking a glyph is paste-only: the user finds an icon on the Nerd Fonts cheat
sheet and pastes the character into the field (e.g. U+F02DC, `md-home`). No
palette and no built-in font browser; the cheat sheet already is one.

Rows are the union of the file's `scenes` keys and the scenes `Obs` currently
reports, so the page works with OBS closed (configured rows) and offers newly
created scenes once OBS is connected. A scene that exists in neither cannot be
added from the page; add it in OBS, or add a key to the JSON by hand.

Each field accepts exactly one code point, surrogate pairs included (a
supplementary-plane glyph such as U+F02DC is two UTF-16 units). An empty field
means the scene falls back to `generic`. The `generic` row itself must also hold
one code point; empty falls back to the built-in `md-help_circle` default, so
there is always a visible tile. Invalid input (zero or more than one code
point) gets a red border and blocks Save. Edits are a draft behind the page's
`SaveBar`, as with the other settings pages; Save serialises `{generic, scenes}`
through `SceneIcons.save()` and writes the file atomically. A glyph missing from
the installed font renders as tofu in the field, which is the visible sign to
pick another.

## Icons

Codepoints verified 2026-10-17 against the installed Inconsolata Nerd Font
Regular's cmap (all present). The control glyphs and the default scene glyphs
below are fixed in code; the scene glyphs actually rendered are user-supplied
from the config file. Re-check at build time if the font changes:

| Use | Glyph | U+ | JS/JSON escape |
| --- | --- | --- | --- |
| Record | `md-record_circle` | U+F0EC2 | `\udb83\udec2` |
| Stop | `md-stop_circle` | U+F0666 | `\udb81\ude66` |
| Pause | `md-pause` | U+F03E4 | `\udb80\udfe4` |
| Resume | `md-play` | U+F040A | `\udb81\udc0a` |
| Webcam on | `md-video` | U+F0567 | `\udb81\udd67` |
| Webcam off | `md-video_off` | U+F0568 | `\udb81\udd68` |
| Audio on | `md-volume_high` | U+F057E | `\udb81\udd7e` |
| Audio off | `md-volume_off` | U+F0581 | `\udb81\udd81` |
| Corner TL | `md-arrow_top_left` | U+F005B | `\udb80\udc5b` |
| Corner TR | `md-arrow_top_right` | U+F005C | `\udb80\udc5c` |
| Corner BL | `md-arrow_bottom_left` | U+F0042 | `\udb80\udc42` |
| Corner BR | `md-arrow_bottom_right` | U+F0043 | `\udb80\udc43` |
| Scene: Desktop | `md-monitor` | U+F0379 | `\udb80\udf79` |
| Scene: Webcam Only | `md-video` | U+F0567 | `\udb81\udd67` |
| Scene: Window Only | `fa-window_maximize` | U+F2D0 | `\uf2d0` |
| Scene: Intervallo | `md-coffee` | U+F0176 | `\udb80\udd76` |
| Scene: unmapped | `md-help_circle` | U+F02D7 | `\udb80\uded7` |

The `md-*` codepoints are supplementary-plane: their `\u` form is a surrogate
pair, never a five-digit escape. `\uf0ec2` would parse as `\uf0ec` + `"2"`, a
different glyph plus a stray digit.

All render with `Theme.iconFamily`.

## Errors and edge cases

- `desktop1` empty (no desktop audio configured): hide the Audio button.
- Audio toggle semantics: muting `Desktop Audio` removes system audio from the
  recording; it does not affect the user's live speakers.
- `camId === -1` (no webcam in the current scene): hide Webcam and corner
  buttons (existing gate).
- A failed request is already logged by `response()`; state follows events, so a
  refused mute or transform does not desynchronise the pill.

## Verification (no state-changing probes)

Do not script state changes against the live OBS during development: a corner
move is written into the saved scene collection, and a mute during a take is not
restorable. Verify by hand instead: move the webcam to each corner and confirm
the framing, mute and unmute and confirm the pill follows (including a mute
triggered from OBS's own mixer), click scene tiles to switch, and with more than
eight scenes confirm scrolling the grid never changes the scene.

For the icon map: paste a glyph into a settings row, Save, and confirm the pill's
tile changes without a restart; edit the JSON by hand and confirm the pill
follows; remove the file and confirm the starter defaults return; create a scene
in OBS and confirm its tile is `generic` until mapped.

## Open items to verify at build time

1. `SetSceneItemTransform` with position only (settled by live probe; confirm
   once by hand).
2. `GetSpecialInputs.desktop1` round-trips through `SetInputMute` (settled;
   confirm with the hand check above).
3. `SceneIcons.js` `parse`/`serialize`/one-code-point check covered in node
   before the QML that uses them is written.