[gnome] Add new extensions
This commit is contained in:
@ -0,0 +1,262 @@
|
||||
import GObject from "gi://GObject";
|
||||
import GLib from "gi://GLib";
|
||||
import Soup from "gi://Soup";
|
||||
|
||||
import * as Main from "resource:///org/gnome/shell/ui/main.js";
|
||||
import * as QuickSettings from "resource:///org/gnome/shell/ui/quickSettings.js";
|
||||
import { Extension } from "resource:///org/gnome/shell/extensions/extension.js";
|
||||
|
||||
function clamp01(x) {
|
||||
return Math.max(0, Math.min(1, x));
|
||||
}
|
||||
|
||||
function jsonRpc(method, params = {}) {
|
||||
return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
||||
}
|
||||
|
||||
async function postJson(session, url, bodyStr) {
|
||||
const msg = Soup.Message.new("POST", url);
|
||||
msg.request_headers.append("Content-Type", "application/json");
|
||||
|
||||
// Soup 3: request body as GLib.Bytes
|
||||
msg.set_request_body_from_bytes(
|
||||
"application/json",
|
||||
new GLib.Bytes(bodyStr),
|
||||
);
|
||||
|
||||
const bytes = await session.send_and_read_async(
|
||||
msg,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
);
|
||||
const text = new TextDecoder().decode(bytes.get_data());
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function connectedClients(group) {
|
||||
return (group?.clients ?? []).filter((c) => c?.connected);
|
||||
}
|
||||
|
||||
function connectedClientIds(group) {
|
||||
return connectedClients(group)
|
||||
.map((c) => c.id)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function avgConnectedVolumePercent(group) {
|
||||
const percents = connectedClients(group)
|
||||
.map((c) => c?.config?.volume?.percent)
|
||||
.filter((p) => typeof p === "number");
|
||||
|
||||
if (!percents.length) return null;
|
||||
|
||||
return percents.reduce((a, b) => a + b, 0) / percents.length;
|
||||
}
|
||||
|
||||
function groupLabel(group) {
|
||||
const base =
|
||||
group?.name && group.name.trim()
|
||||
? group.name.trim()
|
||||
: (group?.stream_id ?? "Group");
|
||||
|
||||
const connected = connectedClients(group).length;
|
||||
const total = group?.clients?.length ?? 0;
|
||||
|
||||
return `${base} (${connected}/${total})`;
|
||||
}
|
||||
|
||||
const SnapcastSlider = GObject.registerClass(
|
||||
class SnapcastSlider extends QuickSettings.QuickSlider {
|
||||
constructor(extension) {
|
||||
super({
|
||||
title: "Snapcast",
|
||||
iconName: "audio-volume-high-symbolic",
|
||||
});
|
||||
|
||||
this._ext = extension;
|
||||
this._settings = extension.getSettings();
|
||||
this._session = new Soup.Session();
|
||||
|
||||
this._pollId = 0;
|
||||
this._debounceId = 0;
|
||||
this._sliderChangedId = 0;
|
||||
this._settingsChangedId = 0;
|
||||
|
||||
this._isUpdatingFromServer = false;
|
||||
this._groupClientIds = [];
|
||||
|
||||
// Load settings
|
||||
this._reloadSettings();
|
||||
|
||||
// React to settings changes (server-url, group-id, poll-seconds)
|
||||
this._settingsChangedId = this._settings.connect("changed", () => {
|
||||
this._reloadSettings();
|
||||
this._restartPolling();
|
||||
this._pollOnce().catch(logError);
|
||||
});
|
||||
|
||||
// Slider change -> debounce -> set group volume
|
||||
this._sliderChangedId = this.slider.connect("notify::value", () => {
|
||||
if (this._isUpdatingFromServer) return;
|
||||
this._debouncedSetVolume();
|
||||
});
|
||||
|
||||
// Start polling + initial sync
|
||||
this._restartPolling();
|
||||
this._pollOnce().catch(logError);
|
||||
}
|
||||
|
||||
_reloadSettings() {
|
||||
// You’ll define these keys in your gschema:
|
||||
// - server-url (string)
|
||||
// - group-id (string)
|
||||
// - poll-seconds (uint)
|
||||
this._url = this._settings.get_string("server-url");
|
||||
this._groupId = this._settings.get_string("group-id");
|
||||
this._pollSeconds = this._settings.get_uint("poll-seconds");
|
||||
|
||||
// Sensible guards
|
||||
if (!this._pollSeconds || this._pollSeconds < 2)
|
||||
this._pollSeconds = 5;
|
||||
}
|
||||
|
||||
_restartPolling() {
|
||||
if (this._pollId) {
|
||||
GLib.source_remove(this._pollId);
|
||||
this._pollId = 0;
|
||||
}
|
||||
|
||||
this._pollId = GLib.timeout_add_seconds(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
this._pollSeconds,
|
||||
() => {
|
||||
this._pollOnce().catch(logError);
|
||||
return GLib.SOURCE_CONTINUE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_debouncedSetVolume() {
|
||||
if (this._debounceId) {
|
||||
GLib.source_remove(this._debounceId);
|
||||
this._debounceId = 0;
|
||||
}
|
||||
|
||||
// Wait a beat so dragging doesn’t fire 100 requests/sec
|
||||
this._debounceId = GLib.timeout_add(
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
150,
|
||||
() => {
|
||||
this._debounceId = 0;
|
||||
this._setGroupVolumeFromSlider().catch(logError);
|
||||
return GLib.SOURCE_REMOVE;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async _pollOnce() {
|
||||
// If not configured yet
|
||||
if (!this._url) {
|
||||
this.title = "Snapcast — set server URL";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
if (!this._groupId) {
|
||||
this.title = "Snapcast — select a group";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Pull all status in one request
|
||||
const resp = await postJson(
|
||||
this._session,
|
||||
this._url,
|
||||
jsonRpc("Server.GetStatus", {}),
|
||||
);
|
||||
const groups = resp?.result?.server?.groups ?? [];
|
||||
const group = groups.find((g) => g.id === this._groupId);
|
||||
|
||||
if (!group) {
|
||||
this.title = "Snapcast — group not found";
|
||||
this._groupClientIds = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Only connected clients
|
||||
this._groupClientIds = connectedClientIds(group);
|
||||
|
||||
// Update title with something human-readable
|
||||
this.title = `Snapcast — ${groupLabel(group)}`;
|
||||
|
||||
// Slider position = average of connected clients’ volumes
|
||||
const avg = avgConnectedVolumePercent(group);
|
||||
if (typeof avg === "number") {
|
||||
const v = clamp01(avg / 100);
|
||||
|
||||
this._isUpdatingFromServer = true;
|
||||
try {
|
||||
if (Math.abs(this.slider.value - v) > 0.01)
|
||||
this.slider.value = v;
|
||||
} finally {
|
||||
this._isUpdatingFromServer = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _setGroupVolumeFromSlider() {
|
||||
if (!this._groupClientIds.length) return;
|
||||
|
||||
const percent = Math.round(clamp01(this.slider.value) * 100);
|
||||
|
||||
// Fan out: set volume for each connected client in the group
|
||||
await Promise.allSettled(
|
||||
this._groupClientIds.map((id) => {
|
||||
const body = jsonRpc("Client.SetVolume", {
|
||||
id,
|
||||
volume: { percent },
|
||||
});
|
||||
return postJson(this._session, this._url, body);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._pollId) GLib.source_remove(this._pollId);
|
||||
if (this._debounceId) GLib.source_remove(this._debounceId);
|
||||
if (this._sliderChangedId)
|
||||
this.slider.disconnect(this._sliderChangedId);
|
||||
if (this._settingsChangedId)
|
||||
this._settings.disconnect(this._settingsChangedId);
|
||||
|
||||
super.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const SnapcastIndicator = GObject.registerClass(
|
||||
class SnapcastIndicator extends QuickSettings.SystemIndicator {
|
||||
constructor(extension) {
|
||||
super();
|
||||
this.quickSettingsItems.push(new SnapcastSlider(extension));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.quickSettingsItems.forEach((item) => item.destroy());
|
||||
super.destroy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default class SnapshellExtension extends Extension {
|
||||
enable() {
|
||||
this._indicator = new SnapcastIndicator(this);
|
||||
Main.panel.statusArea.quickSettings.addExternalIndicator(
|
||||
this._indicator,
|
||||
);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"uuid": "snapshell@unbl.ink",
|
||||
"name": "Snapshell",
|
||||
"description": "Quick Settings volume slider for a Snapcast group (connected clients only).",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50", "51"],
|
||||
"version": 1
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
import Adw from "gi://Adw";
|
||||
import Gio from "gi://Gio";
|
||||
import GLib from "gi://GLib";
|
||||
import Gtk from "gi://Gtk";
|
||||
import Soup from "gi://Soup";
|
||||
|
||||
import { ExtensionPreferences } from "resource:///org/gnome/shell/extensions/prefs.js";
|
||||
|
||||
function jsonRpc(method, params = {}) {
|
||||
return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
||||
}
|
||||
|
||||
async function postJson(session, url, bodyStr) {
|
||||
const msg = Soup.Message.new("POST", url);
|
||||
msg.request_headers.append("Content-Type", "application/json");
|
||||
msg.set_request_body_from_bytes(
|
||||
"application/json",
|
||||
new GLib.Bytes(bodyStr),
|
||||
);
|
||||
|
||||
const bytes = await session.send_and_read_async(
|
||||
msg,
|
||||
GLib.PRIORITY_DEFAULT,
|
||||
null,
|
||||
);
|
||||
const text = new TextDecoder().decode(bytes.get_data());
|
||||
return JSON.parse(text);
|
||||
}
|
||||
|
||||
function connectedCount(group) {
|
||||
return (group?.clients ?? []).filter((c) => c?.connected).length;
|
||||
}
|
||||
|
||||
function groupLabel(group) {
|
||||
const base =
|
||||
group?.name && group.name.trim()
|
||||
? group.name.trim()
|
||||
: (group?.stream_id ?? "Group");
|
||||
|
||||
const connected = connectedCount(group);
|
||||
const total = group?.clients?.length ?? 0;
|
||||
|
||||
return `${base} (${connected}/${total})`;
|
||||
}
|
||||
|
||||
export default class SnapshellPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings(
|
||||
"org.gnome.shell.extensions.snapshell",
|
||||
);
|
||||
const session = new Soup.Session();
|
||||
|
||||
// --- UI models ---
|
||||
const groupNames = new Gtk.StringList();
|
||||
let groupIds = []; // parallel array: index -> group id
|
||||
|
||||
// --- Page ---
|
||||
const page = new Adw.PreferencesPage({
|
||||
title: "Snapshell",
|
||||
icon_name: "audio-volume-high-symbolic",
|
||||
});
|
||||
window.add(page);
|
||||
|
||||
// --- Group: Connection ---
|
||||
const connectionGroup = new Adw.PreferencesGroup({
|
||||
title: "Connection",
|
||||
description: "Configure Snapserver JSON-RPC endpoint.",
|
||||
});
|
||||
page.add(connectionGroup);
|
||||
|
||||
const urlRow = new Adw.EntryRow({
|
||||
title: "Server URL",
|
||||
text: settings.get_string("server-url"),
|
||||
});
|
||||
urlRow.set_tooltip_text("Example: https://snap.unbl.ink/jsonrpc");
|
||||
connectionGroup.add(urlRow);
|
||||
|
||||
urlRow.connect("notify::text", () => {
|
||||
settings.set_string("server-url", urlRow.text.trim());
|
||||
});
|
||||
|
||||
// --- Group: Target Group ---
|
||||
const targetGroup = new Adw.PreferencesGroup({
|
||||
title: "Target group",
|
||||
description:
|
||||
"Choose which Snapcast group the Quick Settings slider controls.",
|
||||
});
|
||||
page.add(targetGroup);
|
||||
|
||||
const groupRow = new Adw.ComboRow({
|
||||
title: "Snapcast group",
|
||||
model: groupNames,
|
||||
});
|
||||
targetGroup.add(groupRow);
|
||||
|
||||
const refreshRow = new Adw.ActionRow({
|
||||
title: "Refresh groups",
|
||||
subtitle: "Fetch groups from Server.GetStatus",
|
||||
});
|
||||
const refreshBtn = new Gtk.Button({ label: "Refresh" });
|
||||
refreshRow.add_suffix(refreshBtn);
|
||||
refreshRow.activatable_widget = refreshBtn;
|
||||
targetGroup.add(refreshRow);
|
||||
|
||||
// --- Group: Behavior ---
|
||||
const behaviorGroup = new Adw.PreferencesGroup({
|
||||
title: "Behavior",
|
||||
});
|
||||
page.add(behaviorGroup);
|
||||
|
||||
const pollRow = new Adw.SpinRow({
|
||||
title: "Poll interval (seconds)",
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 2,
|
||||
upper: 60,
|
||||
step_increment: 1,
|
||||
page_increment: 5,
|
||||
value: settings.get_uint("poll-seconds") || 5,
|
||||
}),
|
||||
});
|
||||
behaviorGroup.add(pollRow);
|
||||
|
||||
pollRow.connect("notify::value", () => {
|
||||
settings.set_uint("poll-seconds", pollRow.value);
|
||||
});
|
||||
|
||||
// --- Helpers to load + sync groups ---
|
||||
const setStatus = (text) => {
|
||||
refreshRow.subtitle = text;
|
||||
};
|
||||
|
||||
const clearGroups = () => {
|
||||
// Remove all items from StringList
|
||||
while (groupNames.get_n_items() > 0) groupNames.remove(0);
|
||||
groupIds = [];
|
||||
};
|
||||
|
||||
const syncComboSelectionFromSettings = () => {
|
||||
const selectedId = settings.get_string("group-id");
|
||||
const idx = groupIds.indexOf(selectedId);
|
||||
groupRow.selected = idx >= 0 ? idx : Gtk.INVALID_LIST_POSITION;
|
||||
};
|
||||
|
||||
const fetchGroups = async () => {
|
||||
const url = settings.get_string("server-url").trim();
|
||||
if (!url) {
|
||||
clearGroups();
|
||||
setStatus("Set Server URL first.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Fetching…");
|
||||
try {
|
||||
const resp = await postJson(
|
||||
session,
|
||||
url,
|
||||
jsonRpc("Server.GetStatus", {}),
|
||||
);
|
||||
const groups = resp?.result?.server?.groups ?? [];
|
||||
|
||||
clearGroups();
|
||||
|
||||
// Populate list
|
||||
for (const g of groups) {
|
||||
groupNames.append(groupLabel(g));
|
||||
groupIds.push(g.id);
|
||||
}
|
||||
|
||||
setStatus(`Loaded ${groups.length} group(s).`);
|
||||
syncComboSelectionFromSettings();
|
||||
} catch (e) {
|
||||
clearGroups();
|
||||
setStatus(`Failed: ${e?.message ?? e}`);
|
||||
}
|
||||
};
|
||||
|
||||
// When user picks a row, store its group id
|
||||
groupRow.connect("notify::selected", () => {
|
||||
const idx = groupRow.selected;
|
||||
if (idx === Gtk.INVALID_LIST_POSITION) return;
|
||||
const id = groupIds[idx];
|
||||
if (id) settings.set_string("group-id", id);
|
||||
});
|
||||
|
||||
// Refresh button
|
||||
refreshBtn.connect("clicked", () => {
|
||||
fetchGroups();
|
||||
});
|
||||
|
||||
// If URL changes, offer fresh fetch (don’t auto-spam)
|
||||
urlRow.connect("notify::text", () => {
|
||||
setStatus("URL changed. Click Refresh.");
|
||||
clearGroups();
|
||||
syncComboSelectionFromSettings();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetchGroups();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist>
|
||||
<schema id="org.gnome.shell.extensions.snapshell"
|
||||
path="/org/gnome/shell/extensions/snapshell/">
|
||||
|
||||
<key name="server-url" type="s">
|
||||
<default>'https://snap.unbl.ink/jsonrpc'</default>
|
||||
<summary>Snapcast JSON-RPC URL</summary>
|
||||
<description>
|
||||
HTTP(S) endpoint for Snapserver JSON-RPC.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
<key name="group-id" type="s">
|
||||
<default>'a16be8ca-e36f-5498-08cd-3879f2025775'</default>
|
||||
<summary>Snapcast group id</summary>
|
||||
<description>
|
||||
UUID of the Snapcast group to control by default.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
<key name="poll-seconds" type="u">
|
||||
<default>5</default>
|
||||
<summary>Polling interval (seconds)</summary>
|
||||
<description>
|
||||
How often to refresh Server.GetStatus to update slider position.
|
||||
</description>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
</schemalist>
|
||||
Reference in New Issue
Block a user