diff --git a/plugins/goxlr/.gitignore b/plugins/goxlr/.gitignore new file mode 100644 index 0000000..b8eb3e3 --- /dev/null +++ b/plugins/goxlr/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +*.log +*.tmp diff --git a/plugins/goxlr/AGENTS.md b/plugins/goxlr/AGENTS.md new file mode 100644 index 0000000..793fa88 --- /dev/null +++ b/plugins/goxlr/AGENTS.md @@ -0,0 +1,84 @@ +# Agent guide for the goxlr plugin + +This folder is a Ryoku shell plugin. If you are an AI agent editing it, follow +every rule below. They are the same rules `ryoku plugin validate` enforces, so +work that breaks one will not install. This file is self-contained: you do not +need any other document to work here. + +## The rules + +R1 Place. A plugin is one folder named after its id. Author it under +`$(xdg-user-dir DOCUMENTS)/ryoku-plugins//` (fallback `~/ryoku-plugins//`); +`ryoku plugin new ` creates it there. Never write into +`~/.local/share/ryoku/plugins/` (the store's install root, receipt-owned) or +`~/.config/quickshell/` (the shipped shell). + +R2 Shape. `manifest.json` at the root; `service/Main.qml` (logic, no UI); +`content/Widget.qml` (the one view); optional `content/Panel.qml` (a bar panel); +`README.md`; `LICENSE`; `assets/preview-widget.png` (a real capture); scripts +only under `bin/` with a shebang and the exec bit. Every extra file listed in +manifest `files`. + +R3 Id. Lowercase `[a-z0-9][a-z0-9-]*`, unique, not a built-in widget id. + +R4 Imports. Only `QtQuick*`, `Quickshell*`, `Ryoku.PluginKit`, +`Ryoku.PluginKit.Singletons`, and files inside the plugin folder. Never +`shell.*`, `Ryoku.Ui*` internals, or a relative import that climbs out of the +folder. + +R5 Settings. Declared in `metadata.settings` (types the bar renders: toggle, +choice, multi, int, text); read through `pluginApi.pluginSettings` behind a +default; written only through `pluginApi.saveSetting(key, value)`. Never edit +`shell.json` or `plugins.json` directly. + +R6 Commands. Every external program the plugin runs is either in its own `bin/` +or listed in `dependencies.commands`. No `sudo`, `doas`, `su`. A privileged +action is allowed only through `pkexec`, only on an explicit click, only when +listed in manifest `capabilities.privileged` (exact command strings) and +explained in README. + +R7 Network. Every host the plugin talks to is listed in +`capabilities.network`. No `curl … | sh`, no downloading and running code. + +R8 Files. Write only under `pluginApi.stateDir` +(`$XDG_STATE_HOME/ryoku/plugins/`), `$XDG_CACHE_HOME/ryoku/plugins/`, or +a temp dir. Never touch `~/.ssh`, `/etc`, shell rc files, or another plugin's +folder. + +R9 Shell. No `sh -c` with a string built from settings or program output +(injection). Pass argv arrays. + +R10 Secrets and binaries. No tokens, keys or credentials in the tree; no +compiled binaries (ELF/Mach-O/.so); scripts only. + +R11 Honesty. `official` is never true for a community plugin. `author` is +`Name `. README says what runs, what it reads, what it writes, and every +privileged/network capability. + +## The workflow + +1. `ryoku plugin new ` scaffolds this folder (already done). +2. Edit: `service/Main.qml` for logic, `content/Widget.qml` for the view, and + `content/Panel.qml` for the bar panel. Capture `assets/preview-widget.png`. +3. `ryoku plugin validate ` and fix every blocking finding (warnings are + advisory but read them). +4. `ryoku plugin add --bar --yes` to install it, then verify it on the bar + and under **QS Bar Settings > Community**. +5. `ryoku plugin share ` ONLY when the user asks to publish. Never share a + plugin unless asked. + +## What the host sets + +Every entry point receives, from the host: `pluginApi` (see below), `s` (a scale +factor), `active`, `density`, and `widthBudget`. Read them; never assign them. + +`pluginApi` exposes: + +- `mainInstance`: the live `service/Main.qml` instance every view shares. +- `pluginSettings`: the resolved settings object; read behind a default. +- `pluginDir`: this plugin's folder. +- `stateDir`: `$XDG_STATE_HOME/ryoku/plugins/`; the only place to write. +- `saveSetting(key, value)`: persist one setting. +- `panelOpen` (bool), `openPanel()`, `closePanel()`, `togglePanel()`: the bar + panel controls (present but inert off the bar). The sanctioned widget click is + `onClicked: pluginApi.togglePanel()`. diff --git a/plugins/goxlr/CLAUDE.md b/plugins/goxlr/CLAUDE.md new file mode 100644 index 0000000..3c35c32 --- /dev/null +++ b/plugins/goxlr/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +Read `AGENTS.md` in this folder before editing anything here. +It holds every rule this plugin must follow and the full authoring workflow. diff --git a/plugins/goxlr/LICENSE b/plugins/goxlr/LICENSE new file mode 100644 index 0000000..4eb437a --- /dev/null +++ b/plugins/goxlr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 josh + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/goxlr/README.md b/plugins/goxlr/README.md new file mode 100644 index 0000000..cb48a36 --- /dev/null +++ b/plugins/goxlr/README.md @@ -0,0 +1,104 @@ +# GoXLR + +A Ryoku shell plugin (`goxlr`) that ports the core of the Noctalia GoXLR +plugin: a bar capsule showing one channel's volume, a mic-mute toggle, a +flash-style notification when any channel changes, and per-channel volume +controls in the panel. It talks to the [goxlr-utility](https://github.com/GoXLR-on-Linux/goxlr-utility) +daemon's local HTTP API — the same daemon the official GoXLR app and the +Noctalia plugin use. + +## Requirements + +- `goxlr-utility`'s `goxlr-daemon` running and reachable (default + `localhost:14564`). +- `curl` and `jq` on `PATH` (listed in `dependencies.commands`). + +## What it does (and deliberately does not) + +Ported: + +- **Bar capsule**: shows one configured channel's volume (icon, value, or + both), coloured to reflect connection/mute state. +- **Flash as OSD substitute**: Ryoku bar plugins don't get an OSD surface the + way Noctalia widgets can, so instead of a popup, the capsule itself + temporarily switches to show whichever channel just changed — glyph, name, + and new value — for `flashTimeoutMs`, then reverts to the configured + channel. This happens for changes from any source: the GoXLR hardware + itself, the vendor app, or another copy of this plugin on a second + monitor. +- **Mic mute**: right-click the capsule to toggle the mic's cough/mute. +- **Per-channel volumes**: left-click opens the panel, which lists every + channel with a slider and shows live mute state. + +Not ported (out of scope for this port): routing matrix, effects/FX +presets, sampler, lighting, and profile switching. These are higher-risk, +higher-surface-area features that don't fit a single bar capsule + panel; +they're better served by the full GoXLR app for now. + +## Settings + +| key | type | default | description | +| ---------------- | ------ | -------------- | ------------------------------------------------------------ | +| host | text | `localhost` | Daemon host | +| port | int | `14564` | Daemon port | +| refreshMs | int | `1000` | Poll interval while the panel is closed (ms) | +| volumeDisplay | choice | `percent` | Show volumes as percent or raw (0-255) | +| channel | choice | `Mic` | Channel shown on the bar | +| display | choice | `glyph_value` | Bar display: icon only, icon + value, or value only | +| glyph | text | `◆` | Bar icon: a literal character/emoji, or (with `glyphStyle: icon`) a [Material Symbols](https://fonts.google.com/icons) ligature name like `mic`; the mute icon stays `✖` | +| glyphStyle | choice | `text` | Interpret `glyph` as literal text/emoji, or as a Material Symbols icon name | +| showChannelName | toggle | `false` | Prefix the bar value with the channel name | +| scrollStep | int | `2` | Volume change per scroll notch on the capsule (%) | +| muteIndicator | toggle | `true` | Change colour while the shown channel is muted | +| hideWhenAbsent | toggle | `false` | Hide the capsule entirely when no GoXLR is connected | +| flashEnabled | toggle | `true` | Flash the capsule when any channel's volume or mute changes | +| flashTimeoutMs | int | `1500` | How long the flash stays up (ms) | +| flashPollMs | int | `200` | Poll interval while watching for changes to flash (ms) | + +## Usage + +- **Left click** the capsule: open/close the panel (per-channel volume + sliders and mute states). +- **Right click** the capsule: toggle mic mute. +- **Scroll** over the capsule: adjust the configured channel's volume by + `scrollStep`. + +## `bin/` scripts (for compositor keybinds) + +Two standalone scripts talk to the daemon directly over HTTP, independent of +the plugin runtime, for binding to Hyprland/compositor keybinds: + +- `bin/mic-mute-toggle` — toggles the mic's cough/mute. +- `bin/volume-step ` — nudges a channel's volume by a + signed percent (defaults: `Mic 2`). + +Both honour `GOXLR_HOST`/`GOXLR_PORT` env vars (default +`localhost:14564`). `ryoku plugin validate` flags an `undeclared-host` +warning on both — expected, since the host comes from a shell variable the +static analyzer can't resolve to a literal; it is non-blocking. + +## What it reads and writes + +Reads: GoXLR channel volumes and mute state from the daemon's HTTP API only +(`localhost`/`127.0.0.1` per `capabilities.network`). Writes: volume/mute +commands back to the same daemon API, and plugin settings via +`pluginApi.saveSetting`. No files, no privileged actions, no other hosts. + +## Preview + +See `assets/preview-widget.png`. + +## Build, check, install + +``` +ryoku plugin validate . +ryoku plugin add . --bar --yes +``` + +It lists under **Community** in QS Bar Settings. Publish it only when you +want to share it: `ryoku plugin share goxlr`. + +## Author + +josh : this plugin is community-made (`official` is +false). diff --git a/plugins/goxlr/assets/preview-panel.png b/plugins/goxlr/assets/preview-panel.png new file mode 100644 index 0000000..69828ac Binary files /dev/null and b/plugins/goxlr/assets/preview-panel.png differ diff --git a/plugins/goxlr/assets/preview-widget.png b/plugins/goxlr/assets/preview-widget.png new file mode 100644 index 0000000..ee7026f Binary files /dev/null and b/plugins/goxlr/assets/preview-widget.png differ diff --git a/plugins/goxlr/bin/mic-mute-toggle b/plugins/goxlr/bin/mic-mute-toggle new file mode 100755 index 0000000..2fa8b69 --- /dev/null +++ b/plugins/goxlr/bin/mic-mute-toggle @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Toggles the GoXLR's mic mute (the cough button) directly against the +# goxlr-daemon HTTP API, independent of the Quickshell plugin process. Meant +# to be bound to a key, e.g. in Hyprland: +# bind = , XF86AudioMicMute, exec, ~/.local/share/ryoku/plugins/goxlr/bin/mic-mute-toggle +# +# Reads GOXLR_HOST / GOXLR_PORT from the environment, defaulting to the +# daemon's own defaults (localhost:14564). Requires curl and jq. +set -euo pipefail + +host="${GOXLR_HOST:-localhost}" +port="${GOXLR_PORT:-14564}" +url="http://${host}:${port}/api/command" + +status=$(curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary '"GetStatus"') +serial=$(printf '%s' "$status" | jq -r '.Status.mixers | keys[0] // empty') +if [ -z "$serial" ]; then + echo "goxlr: no device connected" >&2 + exit 1 +fi + +state=$(printf '%s' "$status" | jq -r --arg s "$serial" '.Status.mixers[$s].cough_button.state // "Unmuted"') +if [ "$state" = "Unmuted" ]; then + target="MutedToAll" +else + target="Unmuted" +fi + +body=$(jq -nc --arg s "$serial" --arg t "$target" '{"Command":[$s,{"SetCoughMuteState":$t}]}') +curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary "$body" >/dev/null +echo "goxlr: mic ${target}" diff --git a/plugins/goxlr/bin/volume-step b/plugins/goxlr/bin/volume-step new file mode 100755 index 0000000..d8792aa --- /dev/null +++ b/plugins/goxlr/bin/volume-step @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Nudges one channel's volume directly against the goxlr-daemon HTTP API, +# independent of the Quickshell plugin process. Meant to be bound to a key, +# e.g. in Hyprland: +# bind = , XF86AudioRaiseVolume, exec, ~/.local/share/ryoku/plugins/goxlr/bin/volume-step Mic 5 +# bind = , XF86AudioLowerVolume, exec, ~/.local/share/ryoku/plugins/goxlr/bin/volume-step Mic -5 +# +# Args: , both optional (defaults: Mic, 2). is +# the signed change in percent, e.g. "5" raises, "-5" lowers. is a +# daemon channel name: Mic, LineIn, Console, System, Game, Chat, Sample, +# Music, Headphones, MicMonitor, LineOut. +# +# Reads GOXLR_HOST / GOXLR_PORT from the environment, defaulting to the +# daemon's own defaults (localhost:14564). Requires curl and jq. +set -euo pipefail + +channel="${1:-Mic}" +percent="${2:-2}" +host="${GOXLR_HOST:-localhost}" +port="${GOXLR_PORT:-14564}" +url="http://${host}:${port}/api/command" + +status=$(curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary '"GetStatus"') +serial=$(printf '%s' "$status" | jq -r '.Status.mixers | keys[0] // empty') +if [ -z "$serial" ]; then + echo "goxlr: no device connected" >&2 + exit 1 +fi + +current=$(printf '%s' "$status" | jq -r --arg s "$serial" --arg c "$channel" '.Status.mixers[$s].levels.volumes[$c] // 0') +delta=$(( percent * 255 / 100 )) +target=$(( current + delta )) +if [ "$target" -lt 0 ]; then target=0; fi +if [ "$target" -gt 255 ]; then target=255; fi + +body=$(jq -nc --arg s "$serial" --arg c "$channel" --argjson v "$target" '{"Command":[$s,{"SetVolume":[$c,$v]}]}') +curl -fs --max-time 3 -X POST "$url" -H 'Content-Type: application/json' --data-binary "$body" >/dev/null +echo "goxlr: ${channel} -> ${target}" diff --git a/plugins/goxlr/content/Panel.qml b/plugins/goxlr/content/Panel.qml new file mode 100644 index 0000000..c7a2f55 --- /dev/null +++ b/plugins/goxlr/content/Panel.qml @@ -0,0 +1,238 @@ +import QtQuick +import Ryoku.PluginKit.Singletons + +// content/Panel.qml: the bar panel. A compact per-channel volume mixer plus +// mic mute, scoped to what fits a 360px-wide dropdown. Routing, effects, the +// sampler, lighting and profile switching stay in the GoXLR Utility window; +// this plugin only ever sends SetVolume, SetFaderMuteState and +// SetCoughMuteState, the same requests its own web UI makes. +Item { + id: root + + property var pluginApi + property string density: "full" + property real s: 1 + property real widthBudget: 360 + property bool active: false + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var channels: service ? service.channelList : [] + readonly property bool connected: service ? service.connected : false + + function channelLabel(name) { + const labels = { + "Mic": "Mic", + "LineIn": "Line In", + "Console": "Console", + "System": "System", + "Game": "Game", + "Chat": "Chat", + "Sample": "Sample", + "Music": "Music", + "Headphones": "Headphones", + "MicMonitor": "Mic Monitor", + "LineOut": "Line Out" + }; + return labels[name] || name; + } + + width: root.widthBudget + implicitWidth: root.widthBudget + implicitHeight: col.implicitHeight + 24 * root.s + + Column { + id: col + x: 12 * root.s + y: 12 * root.s + width: root.width - 24 * root.s + spacing: 10 * root.s + + Text { + text: "GoXLR" + color: Theme.bright + font.family: Theme.display + font.pixelSize: 16 * root.s + } + + Text { + visible: !root.connected + width: parent.width + wrapMode: Text.WordWrap + text: (root.service && root.service.lastError === "unreachable") ? "Daemon unreachable \u2014 is goxlr-daemon running?" : "No GoXLR connected." + color: Theme.dim + font.family: Theme.font + font.pixelSize: 12 * root.s + } + + Text { + visible: root.connected + width: parent.width + elide: Text.ElideRight + text: [root.service ? root.service.deviceType : "", root.service ? root.service.profileName : ""].filter(t => t && t.length > 0).join(" \u00b7 ") + color: Theme.dim + font.family: Theme.font + font.pixelSize: 11 * root.s + } + + // Mic mute gets its own large target: the one action worth reaching + // without scrolling to the Mic row below. + Rectangle { + id: micButton + visible: root.connected + width: parent.width + height: micLabel.implicitHeight + 16 * root.s + radius: Theme.radius + readonly property bool muted: root.service ? root.service.channelMuted("Mic") : false + color: micArea.pressed ? Theme.vermDeep : (muted ? Theme.vermDeep : Theme.tileBg) + + Text { + id: micLabel + anchors.centerIn: parent + text: micButton.muted ? "Mic muted \u2014 click to unmute" : "Mute mic" + color: Theme.bright + font.family: Theme.font + font.pixelSize: 13 * root.s + } + + MouseArea { + id: micArea + anchors.fill: parent + onClicked: if (root.service) + root.service.toggleMicMute() + } + } + + Flickable { + id: flick + visible: root.connected + width: parent.width + height: Math.min(channelColumn.implicitHeight, 360 * root.s) + contentWidth: width + contentHeight: channelColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: channelColumn + width: flick.width + spacing: 10 * root.s + + Repeater { + model: root.channels + + delegate: Item { + id: chRow + required property string modelData + readonly property string channelName: modelData + readonly property var raw: (root.service && root.service.volumes) ? root.service.volumes[channelName] : undefined + readonly property bool muted: root.service ? root.service.channelMuted(channelName) : false + readonly property bool muteable: channelName === "Mic" || channelName === "MicMonitor" || (root.service ? root.service.faderFor(channelName) !== null : false) + + width: channelColumn.width + height: 26 * root.s + + Text { + id: nameLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 72 * root.s + elide: Text.ElideRight + text: root.channelLabel(chRow.channelName) + color: chRow.muted ? Theme.vermDeep : Theme.bright + font.family: Theme.font + font.pixelSize: 12 * root.s + } + + Item { + id: track + anchors.left: nameLabel.right + anchors.leftMargin: 8 * root.s + anchors.right: valueLabel.left + anchors.rightMargin: 8 * root.s + anchors.verticalCenter: parent.verticalCenter + height: 6 * root.s + + Rectangle { + anchors.fill: parent + radius: height / 2 + color: Theme.hair + } + + Rectangle { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + height: parent.height + radius: height / 2 + width: parent.width * Math.max(0, Math.min(1, (chRow.raw || 0) / 255)) + color: chRow.muted ? Theme.vermDeep : Theme.accent + } + + MouseArea { + anchors.fill: parent + anchors.margins: -8 * root.s + onPressed: mouse => trackSlide(mouse.x) + onPositionChanged: mouse => { + if (pressed) + trackSlide(mouse.x); + } + function trackSlide(x) { + var ratio = Math.max(0, Math.min(1, (x + 8 * root.s) / track.width)); + if (root.service) + root.service.setVolume(chRow.channelName, Math.round(ratio * 255)); + } + } + } + + Text { + id: valueLabel + anchors.right: muteBtn.visible ? muteBtn.left : parent.right + anchors.rightMargin: muteBtn.visible ? 8 * root.s : 0 + anchors.verticalCenter: parent.verticalCenter + width: 40 * root.s + horizontalAlignment: Text.AlignRight + text: (root.service && chRow.raw !== undefined) ? root.service.volumeText(chRow.raw) : "--" + color: Theme.dim + font.family: Theme.font + font.pixelSize: 12 * root.s + } + + Rectangle { + id: muteBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 22 * root.s + height: 22 * root.s + radius: Theme.radius + visible: chRow.muteable + color: muteArea.pressed ? Theme.vermDeep : (chRow.muted ? Theme.vermDeep : Theme.tileBg) + + Text { + anchors.centerIn: parent + text: "\u2716" + color: Theme.bright + font.family: Theme.mono + font.pixelSize: 10 * root.s + } + + MouseArea { + id: muteArea + anchors.fill: parent + onClicked: if (root.service) + root.service.toggleChannelMute(chRow.channelName) + } + } + } + } + } + } + + Text { + width: parent.width + wrapMode: Text.WordWrap + text: "Routing, effects, the sampler, lighting and profiles aren't covered here \u2014 open the GoXLR Utility for those." + color: Theme.faint + font.family: Theme.font + font.pixelSize: 10 * root.s + } + } +} diff --git a/plugins/goxlr/content/Widget.qml b/plugins/goxlr/content/Widget.qml new file mode 100644 index 0000000..df08ef5 --- /dev/null +++ b/plugins/goxlr/content/Widget.qml @@ -0,0 +1,120 @@ +import QtQuick +import Ryoku.PluginKit.Singletons + +// content/Widget.qml: the bar capsule. Shows the configured channel's volume +// and scrolls it; a left click opens the panel, a right click mutes the mic. +// A channel that changes elsewhere (the device itself, another monitor's +// copy of this plugin) flashes its name and new level here briefly, since a +// bar plugin owns one panel and it is reserved for the user's own click. +Item { + id: root + + property var pluginApi + property var screen + property bool active: false + property string density: "glyph" + property real s: 1 + property real widthBudget: 0 + + readonly property var service: pluginApi ? pluginApi.mainInstance : null + readonly property var settings: pluginApi ? pluginApi.pluginSettings : null + + readonly property string channel: (settings && settings.channel) ? settings.channel : "Mic" + readonly property string displayMode: (settings && settings.display) ? settings.display : "glyph_value" + readonly property string glyph: (settings && settings.glyph) ? settings.glyph : "\u25C6" + readonly property string glyphStyle: (settings && settings.glyphStyle) ? settings.glyphStyle : "text" + readonly property string glyphFont: root.glyphStyle === "icon" ? "Material Symbols Rounded" : Theme.mono + readonly property bool showChannelName: settings ? settings.showChannelName === true : false + readonly property bool muteIndicatorOn: settings ? settings.muteIndicator !== false : true + readonly property bool hideWhenAbsent: settings ? settings.hideWhenAbsent === true : false + readonly property int scrollStep: (settings && settings.scrollStep) ? settings.scrollStep : 2 + + readonly property bool connected: service ? service.connected : false + readonly property bool showingFlash: service ? (service.flashVisible && service.flashChannel !== "") : false + readonly property string effectiveChannel: showingFlash ? service.flashChannel : root.channel + readonly property var rawVolume: (service && service.volumes) ? service.volumes[root.effectiveChannel] : undefined + readonly property bool muted: service ? service.channelMuted(root.effectiveChannel) : false + + function channelLabel(name) { + const labels = { + "Mic": "Mic", + "LineIn": "Line In", + "Console": "Console", + "System": "System", + "Game": "Game", + "Chat": "Chat", + "Sample": "Sample", + "Music": "Music", + "Headphones": "Headphones", + "MicMonitor": "Mic Monitor", + "LineOut": "Line Out" + }; + return labels[name] || name; + } + + function valueText() { + if (!root.connected || root.rawVolume === undefined) + return "--"; + var text = root.service.volumeText(root.rawVolume); + if (root.showChannelName || root.showingFlash) + text = root.channelLabel(root.effectiveChannel) + " " + text; + return text; + } + + visible: !(root.hideWhenAbsent && !root.connected) + implicitWidth: root.visible ? row.implicitWidth : 0 + implicitHeight: Math.max(row.implicitHeight, 18 * root.s) + + Row { + id: row + anchors.verticalCenter: parent.verticalCenter + spacing: 6 * root.s + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.displayMode !== "value" + text: root.muted && root.muteIndicatorOn ? "\u2716" : root.glyph + color: { + if (!root.connected) + return Theme.dim; + if (root.showingFlash) + return Theme.accent; + if (root.muted && root.muteIndicatorOn) + return Theme.vermDeep; + return root.active ? Theme.accent : Theme.dim; + } + font.family: root.glyphFont + font.pixelSize: 13 * root.s + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: root.displayMode !== "glyph" + text: root.valueText() + color: root.showingFlash ? Theme.accent : (root.muted && root.muteIndicatorOn ? Theme.vermDeep : Theme.bright) + font.family: Theme.font + font.pixelSize: 13 * root.s + elide: Text.ElideRight + width: root.widthBudget > 0 ? Math.min(implicitWidth, root.widthBudget) : implicitWidth + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton | Qt.RightButton + onClicked: mouse => { + if (mouse.button === Qt.RightButton) { + if (root.service) + root.service.toggleMicMute(); + } else if (root.pluginApi) { + root.pluginApi.togglePanel(); + } + } + onWheel: wheel => { + if (!root.service) + return; + var delta = wheel.angleDelta.y > 0 ? root.scrollStep : -root.scrollStep; + root.service.adjustVolume(root.channel, delta); + } + } +} diff --git a/plugins/goxlr/manifest.json b/plugins/goxlr/manifest.json new file mode 100644 index 0000000..6a521aa --- /dev/null +++ b/plugins/goxlr/manifest.json @@ -0,0 +1,192 @@ +{ + "id": "goxlr", + "name": "GoXLR", + "version": "1.0.0", + "author": "dajax", + "description": "Core GoXLR / GoXLR Mini control from the bar: a volume capsule with a quick flash on change, mic mute, and per-channel volumes in the panel. Backed by the goxlr-utility daemon.", + "license": "MIT", + "official": false, + "hosts": [ + "topbarGlyph" + ], + "entryPoints": { + "main": "service/Main.qml", + "content": "content/Widget.qml", + "panel": "content/Panel.qml" + }, + "panel": { + "width": 360 + }, + "files": [ + "bin/mic-mute-toggle", + "bin/volume-step", + "assets/preview-widget.png" + ], + "capabilities": { + "densities": [ + "glyph", + "compact" + ], + "network": [ + "localhost", + "127.0.0.1" + ] + }, + "defaults": { + "host": "topbarGlyph", + "label": "GoXLR" + }, + "dependencies": { + "commands": [ + "curl", + "jq" + ] + }, + "metadata": { + "settings": [ + { + "key": "host", + "type": "text", + "label": "Daemon host", + "group": "Connection", + "default": "localhost", + "placeholder": "localhost" + }, + { + "key": "port", + "type": "int", + "label": "Daemon port", + "group": "Connection", + "default": 14564, + "min": 1, + "max": 65535 + }, + { + "key": "refreshMs", + "type": "int", + "label": "Poll interval while the panel is closed (ms)", + "group": "Connection", + "default": 1000, + "min": 250, + "max": 10000 + }, + { + "key": "volumeDisplay", + "type": "choice", + "label": "Show volumes as", + "group": "Connection", + "default": "percent", + "options": [ + { "value": "percent", "label": "Percent" }, + { "value": "raw", "label": "Raw (0-255)" } + ] + }, + { + "key": "channel", + "type": "choice", + "label": "Channel shown on the bar", + "group": "Widget", + "default": "Mic", + "options": [ + { "value": "Mic", "label": "Mic" }, + { "value": "LineIn", "label": "Line In" }, + { "value": "Console", "label": "Console" }, + { "value": "System", "label": "System" }, + { "value": "Game", "label": "Game" }, + { "value": "Chat", "label": "Chat" }, + { "value": "Sample", "label": "Sample" }, + { "value": "Music", "label": "Music" }, + { "value": "Headphones", "label": "Headphones" }, + { "value": "MicMonitor", "label": "Mic Monitor" }, + { "value": "LineOut", "label": "Line Out" } + ] + }, + { + "key": "display", + "type": "choice", + "label": "Bar display", + "group": "Widget", + "default": "glyph_value", + "options": [ + { "value": "glyph", "label": "Icon only" }, + { "value": "glyph_value", "label": "Icon and volume" }, + { "value": "value", "label": "Volume only" } + ] + }, + { + "key": "glyphStyle", + "type": "choice", + "label": "Bar icon style", + "group": "Widget", + "default": "text", + "options": [ + { "value": "text", "label": "Text / emoji character" }, + { "value": "icon", "label": "Material Symbols icon name" } + ] + }, + { + "key": "glyph", + "type": "text", + "label": "Bar icon", + "group": "Widget", + "default": "\u25C6", + "placeholder": "\u25C6 or, in icon style, a Material Symbols name like mic" + }, + { + "key": "showChannelName", + "type": "toggle", + "label": "Prefix the volume with the channel name", + "group": "Widget", + "default": false + }, + { + "key": "scrollStep", + "type": "int", + "label": "Volume change per scroll notch (%)", + "group": "Widget", + "default": 2, + "min": 1, + "max": 25 + }, + { + "key": "muteIndicator", + "type": "toggle", + "label": "Change colour while the channel is muted", + "group": "Widget", + "default": true + }, + { + "key": "hideWhenAbsent", + "type": "toggle", + "label": "Hide the capsule when no GoXLR is connected", + "group": "Widget", + "default": false + }, + { + "key": "flashEnabled", + "type": "toggle", + "label": "Flash the bar capsule when any channel's volume or mute changes", + "group": "Flash", + "default": true + }, + { + "key": "flashTimeoutMs", + "type": "int", + "label": "How long the flash stays up (ms)", + "group": "Flash", + "default": 1500, + "min": 500, + "max": 5000 + }, + { + "key": "flashPollMs", + "type": "int", + "label": "Poll interval while watching for changes to flash (ms)", + "group": "Flash", + "default": 200, + "min": 100, + "max": 1000 + } + ] + } +} diff --git a/plugins/goxlr/product-manifest.json b/plugins/goxlr/product-manifest.json new file mode 100644 index 0000000..fa89848 --- /dev/null +++ b/plugins/goxlr/product-manifest.json @@ -0,0 +1,113 @@ +{ + "schema": 1, + "id": "goxlr", + "category": "plugins", + "version": "1.0.0", + "destination": "ryoku/plugins/goxlr", + "files": [ + { + "source": ".gitignore", + "destination": ".gitignore", + "sha256": "379dfa9273952c08fa44042de676ed4addc26a7c4d4c90235d6ff2c73dffb810", + "mode": "0644", + "size": 22, + "install": true + }, + { + "source": "AGENTS.md", + "destination": "AGENTS.md", + "sha256": "069ca9810e1f7ab3d4053db9e9cfad2af6ef457874f8390e968471858e5e203a", + "mode": "0644", + "size": 3954, + "install": true + }, + { + "source": "CLAUDE.md", + "destination": "CLAUDE.md", + "sha256": "09ffb8ebdb8adf50bf4bde26dfe2109b4af3a4a8cc3f30cc536be42bb912c033", + "mode": "0644", + "size": 152, + "install": true + }, + { + "source": "LICENSE", + "destination": "LICENSE", + "sha256": "ed31c58623a08b80bf05d3575e03c1c957547da68ba2ade41c6cf81338e50a56", + "mode": "0644", + "size": 1061, + "install": false + }, + { + "source": "README.md", + "destination": "README.md", + "sha256": "83d27b9d36c281a62e501c8c03e21b79f28a57a96d03a5acb33528068716e25c", + "mode": "0644", + "size": 5270, + "install": false + }, + { + "source": "assets/preview-panel.png", + "destination": "assets/preview-panel.png", + "sha256": "45541f977411c5f1237dfaf010ad1983afcbc0ed38e7246dc24a44bc6770917c", + "mode": "0644", + "size": 108273, + "install": false + }, + { + "source": "assets/preview-widget.png", + "destination": "assets/preview-widget.png", + "sha256": "c65ca853699b3443f9efa1d24e797feae0fa36f398182a040e91c421a0018969", + "mode": "0644", + "size": 1289, + "install": false + }, + { + "source": "bin/mic-mute-toggle", + "destination": "bin/mic-mute-toggle", + "sha256": "2cd11be84be8b7a25f81c0efce0e4f8008c55691b698d0f397e476c3e4f390e9", + "mode": "0755", + "size": 1251, + "install": true + }, + { + "source": "bin/volume-step", + "destination": "bin/volume-step", + "sha256": "821dc027597264fd46d6793841007776b40b5e5a64d3bec383ba1ab6a3d10886", + "mode": "0755", + "size": 1755, + "install": true + }, + { + "source": "content/Panel.qml", + "destination": "content/Panel.qml", + "sha256": "260a2cdf171eb451bd2db517fd236194fbaa9c8c9ece012acfed902e6231bf9a", + "mode": "0644", + "size": 9734, + "install": true + }, + { + "source": "content/Widget.qml", + "destination": "content/Widget.qml", + "sha256": "fa847ea6ad44a117218c0e325c82bdd99abc7fe1bf9551087fb807d3b022c682", + "mode": "0644", + "size": 5046, + "install": true + }, + { + "source": "manifest.json", + "destination": "manifest.json", + "sha256": "6d4969c1040578ed0f7d99ba11c3623676ca73e7fdb1430e70dfa81a7ce4c3ce", + "mode": "0644", + "size": 5013, + "install": true + }, + { + "source": "service/Main.qml", + "destination": "service/Main.qml", + "sha256": "969d2d955f2cc5c5e5e2fe9ad75aa0151a7c66959779f6b6f58640c513ebb7cf", + "mode": "0644", + "size": 17474, + "install": true + } + ] +} diff --git a/plugins/goxlr/service/Main.qml b/plugins/goxlr/service/Main.qml new file mode 100644 index 0000000..7a75ab4 --- /dev/null +++ b/plugins/goxlr/service/Main.qml @@ -0,0 +1,487 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Io + +// service/Main.qml: the plugin's logic, no UI. One instance per bar copy of +// this plugin polls the goxlr-utility daemon's HTTP API (the same interface +// its own web UI uses) over loopback with curl, and every view +// (content/Widget.qml, content/Panel.qml) reads the live state published +// here through pluginApi.mainInstance. The daemon stays the only owner of the +// hardware; this never touches the device directly. +Item { + id: svc + + // Set by the host after this loads. Settings are declared in + // manifest.json metadata.settings and read behind a default (R5); they + // are written only through pluginApi.saveSetting, which this plugin never + // calls since it has no editable panel state of its own to persist. + property var pluginApi + readonly property var settings: pluginApi ? pluginApi.pluginSettings : null + + readonly property string host: (settings && settings.host && String(settings.host).trim() !== "") ? String(settings.host).trim() : "localhost" + readonly property int port: (settings && settings.port) ? Math.floor(settings.port) : 14564 + readonly property string baseUrl: "http://" + svc.host + ":" + svc.port + + readonly property var channelList: ["Mic", "LineIn", "Console", "System", "Game", "Chat", "Sample", "Music", "Headphones", "MicMonitor", "LineOut"] + readonly property var faderList: ["A", "B", "C", "D"] + + // ── Live state, published for the widget and the panel ───────────────── + property bool connected: false + property string serial: "" + property string deviceType: "" + property var volumes: ({}) + property var faders: ({}) + property var cough: ({}) + property string profileName: "" + property string micProfileName: "" + property string lastError: "" + + // A change worth flashing on the bar: the channel that moved, its new + // level, and whether it is muted. Mirrors the original plugin's OSD, but + // rendered inside the bar capsule itself since a bar plugin only owns one + // panel and it is reserved for the user's own click. + property string flashChannel: "" + property int flashVolume: 0 + property bool flashMuted: false + property bool flashVisible: false + property double flashUntil: 0 + + readonly property bool panelOpen: pluginApi ? pluginApi.panelOpen : false + + property bool _inFlight: false + property bool _pendingRefresh: false + property var _lastVolumes: null + property var _lastMutes: null + property string _lastSerial: "" + property double _lastStart: 0 + + // A burst wider than this is a profile load rewriting every level at + // once, not a deliberate change worth flashing. + readonly property int _flashBurstLimit: 3 + readonly property int _minGapMs: 120 + + onPanelOpenChanged: { + pollTimer.interval = svc.pollInterval(); + svc.poll(); + } + onSettingsChanged: { + pollTimer.interval = svc.pollInterval(); + svc.poll(); + } + + Component.onCompleted: { + pollTimer.interval = svc.pollInterval(); + svc.poll(); + } + + // ── Presentation helpers ──────────────────────────────────────────────── + + function volumeIsRaw() { + return settings && settings.volumeDisplay === "raw"; + } + + function volumePercent(raw) { + var clamped = Math.max(0, Math.min(255, Number(raw) || 0)); + return Math.round(clamped / 255 * 100); + } + + function percentToVolume(pct) { + var clamped = Math.max(0, Math.min(100, Number(pct) || 0)); + return Math.round(clamped / 100 * 255); + } + + function volumeText(raw) { + if (svc.volumeIsRaw()) + return String(Math.round(Number(raw) || 0)); + return svc.volumePercent(raw) + "%"; + } + + // Mic mute lives on the cough button; every other channel is muted + // through the fader it is assigned to, and a channel on no fader cannot + // be muted at all. + function channelMuted(channel) { + if (!svc.connected) + return false; + if (channel === "Mic" || channel === "MicMonitor") + return svc.cough && svc.cough.state !== undefined && svc.cough.state !== "Unmuted"; + var fader = svc.faderFor(channel); + if (fader === null) + return false; + var entry = svc.faders[fader]; + return entry ? entry.mute_state !== "Unmuted" : false; + } + + function faderFor(channel) { + for (var i = 0; i < svc.faderList.length; i++) { + var f = svc.faderList[i]; + var entry = svc.faders[f]; + if (entry && entry.channel === channel) + return f; + } + return null; + } + + // ── Transport ──────────────────────────────────────────────────────────── + // One throwaway curl process per request, run through an argv array (R9: + // never a shell string built from settings or daemon output). + + Component { + id: curlFactory + Process { + id: proc + property var onDone: null + stdout: StdioCollector { + id: outCollector + waitForEnd: true + } + onExited: (code, status) => { + var text = outCollector.text; + var cb = proc.onDone; + proc.destroy(); + if (cb) + cb(code === 0 ? text : null); + } + } + } + + function runCurl(body, onDone) { + var obj = curlFactory.createObject(svc, { + "command": ["curl", "-s", "-S", "--max-time", "3", "-X", "POST", svc.baseUrl + "/api/command", "-H", "Content-Type: application/json", "--data-binary", body], + "onDone": onDone + }); + if (!obj) { + if (onDone) + onDone(null); + return; + } + obj.running = true; + } + + // Every reply is JSON; a transport failure and a non-zero curl exit both + // arrive here as a null value plus a code the callers translate. + function post(body, onResult) { + svc.runCurl(body, function (text) { + if (!onResult) + return; + if (text === null || text === undefined || text === "") { + onResult(null, "unreachable"); + return; + } + var decoded = null; + try { + decoded = JSON.parse(text); + } catch (e) { + onResult(null, "bad_response"); + return; + } + onResult(decoded, null); + }); + } + + function errorText(decoded) { + if (decoded && typeof decoded === "object" && typeof decoded.Error === "string") + return decoded.Error; + return null; + } + + function status(onResult) { + svc.post(JSON.stringify("GetStatus"), function (decoded, err) { + if (decoded === null) { + onResult(null, err); + return; + } + if (decoded && decoded.Status !== undefined) { + onResult(decoded.Status, null); + return; + } + onResult(null, svc.errorText(decoded) || "bad_response"); + }); + } + + // A GoXLRCommand variant, encoded by arity the daemon expects: no args is + // an empty array, one arg is the bare value, two or more is an array. + function encodeCommand(name, args) { + var o = {}; + if (args.length === 0) + o[name] = []; + else if (args.length === 1) + o[name] = args[0]; + else + o[name] = args; + return o; + } + + // A device command: svc.send(serial, "SetVolume", "Music", 200). + function send(serial, name) { + if (!serial) + return; + var args = Array.prototype.slice.call(arguments, 2); + var body = JSON.stringify({ + "Command": [serial, svc.encodeCommand(name, args)] + }); + svc.post(body, null); + } + + // ── Polling ────────────────────────────────────────────────────────────── + // The daemon is local and answers in single-digit milliseconds, so a slow + // poll is about not spinning the CPU rather than about cost. The cadence + // tightens while the panel is open, and again while watching for a flash. + + function pollInterval() { + if (svc.panelOpen) + return 300; + var ms = Math.max(250, Math.min(10000, Number(svc.settings ? svc.settings.refreshMs : 1000) || 1000)); + if (svc.flashEnabled()) { + var detect = Math.max(100, Math.min(1000, Number(svc.settings ? svc.settings.flashPollMs : 200) || 200)); + ms = Math.min(ms, detect); + } + return ms; + } + + function flashEnabled() { + return !(svc.settings && svc.settings.flashEnabled === false); + } + + function flashTimeoutMs() { + return Math.max(500, Math.min(5000, Number(svc.settings ? svc.settings.flashTimeoutMs : 1500) || 1500)); + } + + function serials(status) { + var list = []; + if (!status || !status.mixers) + return list; + for (var key in status.mixers) + list.push(key); + list.sort(); + return list; + } + + function applyStatus(status) { + var list = svc.serials(status); + if (list.length === 0) { + svc.connected = false; + svc.serial = ""; + return; + } + var serial = list[0]; + var mixer = status.mixers[serial]; + var levels = mixer.levels || {}; + var faders = {}; + for (var i = 0; i < svc.faderList.length; i++) { + var f = svc.faderList[i]; + var entry = (mixer.fader_status || {})[f]; + if (entry) + faders[f] = { + "channel": entry.channel, + "mute_state": entry.mute_state, + "mute_type": entry.mute_type + }; + } + svc.connected = true; + svc.serial = serial; + svc.deviceType = (mixer.hardware || {}).device_type || ""; + svc.volumes = levels.volumes || {}; + svc.faders = faders; + svc.cough = mixer.cough_button || {}; + svc.profileName = mixer.profile_name || ""; + svc.micProfileName = mixer.mic_profile_name || ""; + } + + function muteSnapshot() { + var m = {}; + for (var i = 0; i < svc.channelList.length; i++) { + var c = svc.channelList[i]; + m[c] = svc.channelMuted(c); + } + return m; + } + + // Which single change to flash. A volume that moved wins over a mute + // that flipped; among several volumes the largest move wins. Ties fall + // back to channel order, which puts Mic ahead of MicMonitor when the + // cough button mutes both at once. + function changedChannel(volumes, mutes) { + if (!svc._lastVolumes || !svc._lastMutes) + return null; + var moved = null, movedDelta = -1, muteFlipped = null, count = 0; + for (var i = 0; i < svc.channelList.length; i++) { + var c = svc.channelList[i]; + var value = volumes[c], previous = svc._lastVolumes[c]; + var changed = false; + if (typeof previous === "number" && typeof value === "number" && previous !== value) { + changed = true; + var delta = Math.abs(value - previous); + if (delta > movedDelta) { + moved = c; + movedDelta = delta; + } + } + if (mutes[c] !== svc._lastMutes[c]) { + changed = true; + if (muteFlipped === null) + muteFlipped = c; + } + if (changed) + count += 1; + } + if (count === 0 || count > svc._flashBurstLimit) + return null; + return moved || muteFlipped; + } + + function showFlash(channel) { + svc.flashChannel = channel; + svc.flashVolume = (svc.volumes || {})[channel] || 0; + svc.flashMuted = svc.channelMuted(channel); + svc.flashUntil = Date.now() + svc.flashTimeoutMs(); + svc.flashVisible = true; + } + + function trackChanges() { + var volumes = svc.volumes || {}; + var mutes = svc.muteSnapshot(); + + // A different device, or one that just came back, has no history + // worth diffing against: everything would read as a change. + if (svc.serial !== svc._lastSerial) { + svc._lastSerial = svc.serial; + svc._lastVolumes = Object.assign({}, volumes); + svc._lastMutes = mutes; + return; + } + + // Nothing to flash while the panel is open: the control that + // changed is already on screen. + if (svc.flashEnabled() && !svc.panelOpen) { + var channel = svc.changedChannel(volumes, mutes); + if (channel !== null) + svc.showFlash(channel); + } + + svc._lastVolumes = Object.assign({}, volumes); + svc._lastMutes = mutes; + } + + function clearTracking() { + svc._lastVolumes = null; + svc._lastMutes = null; + svc._lastSerial = ""; + svc.flashVisible = false; + } + + function queueRefresh() { + svc._pendingRefresh = true; + } + + function poll() { + if (svc._inFlight) { + svc.queueRefresh(); + return; + } + var now = Date.now(); + if (svc._minGapMs - (now - svc._lastStart) > 0) { + svc.queueRefresh(); + return; + } + + svc._lastStart = now; + svc._pendingRefresh = false; + svc._inFlight = true; + pollTimer.interval = svc.pollInterval(); + + svc.status(function (status, err) { + svc._inFlight = false; + if (status === null) { + svc.connected = false; + svc.lastError = err === "unreachable" ? "unreachable" : (err || "failed"); + svc.clearTracking(); + } else { + svc.applyStatus(status); + svc.lastError = ""; + if (svc.connected) + svc.trackChanges(); + else + svc.clearTracking(); + } + if (svc._pendingRefresh) + svc.poll(); + }); + } + + Timer { + id: pollTimer + interval: 1000 + running: true + repeat: true + onTriggered: if (!svc._inFlight) + svc.poll() + } + + // The flash's lifetime rides on a plain timer rather than the poll tick, + // so it clears promptly even if the daemon is slow to answer. + Timer { + interval: 150 + running: svc.flashVisible + repeat: true + onTriggered: if (svc.flashVisible && Date.now() >= svc.flashUntil) + svc.flashVisible = false + } + + // ── Actions ────────────────────────────────────────────────────────────── + + // Mic mute is the cough button on both device families. Toggling asks + // for the opposite of the state the last poll saw, so a hold-to-talk + // profile still ends up where the user expects. + function toggleMicMute() { + if (!svc.connected) + return; + var muted = svc.cough && svc.cough.state !== "Unmuted"; + svc.send(svc.serial, "SetCoughMuteState", muted ? "Unmuted" : "MutedToAll"); + svc.poll(); + } + + // Mutes any other channel through the fader it is currently assigned to. + // A channel on no fader cannot be muted, matching the device itself. + function toggleChannelMute(channel) { + if (!svc.connected) + return; + if (channel === "Mic" || channel === "MicMonitor") { + svc.toggleMicMute(); + return; + } + var fader = svc.faderFor(channel); + if (fader === null) + return; + var entry = svc.faders[fader]; + var muted = entry ? entry.mute_state !== "Unmuted" : false; + svc.send(svc.serial, "SetFaderMuteState", fader, muted ? "Unmuted" : "MutedToX"); + svc.poll(); + } + + function adjustVolume(channel, deltaPercent) { + var current = (svc.volumes || {})[channel]; + if (typeof current !== "number") + return; + var target = Math.max(0, Math.min(255, Math.round(current + deltaPercent / 100 * 255))); + svc.setVolume(channel, target); + } + + function setVolume(channel, rawTarget) { + if (!svc.connected) + return; + var current = (svc.volumes || {})[channel]; + var target = Math.max(0, Math.min(255, Math.round(rawTarget))); + if (target === current) + return; + svc.send(svc.serial, "SetVolume", channel, target); + + // Publish the new level immediately so the UI tracks the wheel or + // slider instead of waiting a poll; the next poll confirms or + // corrects it. + var volumes = Object.assign({}, svc.volumes || {}); + volumes[channel] = target; + svc.volumes = volumes; + svc.poll(); + } +} diff --git a/plugins/registry.json b/plugins/registry.json index 15382ab..ee1011f 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -1621,6 +1621,36 @@ "topbarGlyph" ], "lastUpdated": "2026-10-01" + }, + { + "id": "goxlr", + "name": "GoXLR", + "version": "1.0.0", + "path": "plugins/goxlr", + "author": "dajax", + "upstream": "https://github.com/millerjryan/ryoku-goxlr", + "summary": "Core GoXLR / GoXLR Mini control from the bar: a volume capsule with a quick flash on change, mic mute, and per-channel volumes in the panel.", + "description": "Core GoXLR / GoXLR Mini control from the bar: a volume capsule with a quick flash on change, mic mute, and per-channel volumes in the panel. Backed by the goxlr-utility daemon over its local HTTP API; routing, effects, sampler, lighting, and profiles stay in the GoXLR app.", + "tags": [ + "bar-widget", + "audio" + ], + "accent": "#8f8378", + "surface": "#101010", + "preview": "assets/preview-panel.png", + "screenshots": [ + "assets/preview-panel.png", + "assets/preview-widget.png" + ], + "manifest": "product-manifest.json", + "manifestSha256": "c50fb4fd2ac01fb6d8db7edefe28d29c2392623a73181c66863b2614be81c114", + "official": false, + "tagline": "Core GoXLR / GoXLR Mini control from the bar: a volume capsule with a quick flash on change, mic mute, and per-channel volumes in the panel.", + "icon": "", + "hosts": [ + "topbarGlyph" + ], + "lastUpdated": "2026-10-07" } ] }