aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-17 12:54:03 +0200
committerDanilo M. <danix@danix.xyz>2026-10-17 13:11:21 +0200
commit611fb6b05f5820d8acae823cbcddd19310f46c74 (patch)
tree5d964715dc1d873eedb9adcd1ede2cadf8929c22 /docs
parente3bd8e9f637dc6bed732170153cb09a281b842e1 (diff)
downloadquickshell-611fb6b05f5820d8acae823cbcddd19310f46c74.tar.gz
quickshell-611fb6b05f5820d8acae823cbcddd19310f46c74.zip
docs: OBS pill enhancements design
Diffstat (limited to 'docs')
-rw-r--r--docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md279
1 files changed, 279 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md b/docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md
new file mode 100644
index 0000000..35d22a9
--- /dev/null
+++ b/docs/superpowers/specs/2026-10-17-obs-pill-enhancements-design.md
@@ -0,0 +1,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.