[gnome] Updating extensions, back to Forge

This commit is contained in:
2026-02-18 11:13:53 -05:00
parent 9d4dd2863c
commit ede0687e11
107 changed files with 9856 additions and 4216 deletions

View File

@ -14,21 +14,30 @@ function jsonRpc(method, params = {}) {
return JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
}
// Soup 3: callback-based async -> Promise wrapper
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 bytes = await new Promise((resolve, reject) => {
session.send_and_read_async(
msg,
GLib.PRIORITY_DEFAULT,
null,
(sess, res) => {
try {
resolve(sess.send_and_read_finish(res));
} catch (e) {
reject(e);
}
},
);
});
const text = new TextDecoder().decode(bytes.get_data());
return JSON.parse(text);
}
@ -75,7 +84,13 @@ const SnapcastSlider = GObject.registerClass(
this._ext = extension;
this._settings = extension.getSettings();
this._session = new Soup.Session();
// --- Soup session with timeouts (seconds) ---
this._session = new Soup.Session({
timeout: 5, // total request timeout
idle_timeout: 5, // idle timeout
user_agent: "Snapshell/1",
});
this._pollId = 0;
this._debounceId = 0;
@ -85,37 +100,28 @@ const SnapcastSlider = GObject.registerClass(
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() {
// Youll 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._url = this._settings.get_string("server-url").trim();
this._groupId = this._settings.get_string("group-id").trim();
this._pollSeconds = this._settings.get_uint("poll-seconds");
// Sensible guards
if (!this._pollSeconds || this._pollSeconds < 2)
this._pollSeconds = 5;
}
@ -142,7 +148,6 @@ const SnapcastSlider = GObject.registerClass(
this._debounceId = 0;
}
// Wait a beat so dragging doesnt fire 100 requests/sec
this._debounceId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
150,
@ -155,7 +160,6 @@ const SnapcastSlider = GObject.registerClass(
}
async _pollOnce() {
// If not configured yet
if (!this._url) {
this.title = "Snapcast — set server URL";
this._groupClientIds = [];
@ -167,7 +171,6 @@ const SnapcastSlider = GObject.registerClass(
return;
}
// Pull all status in one request
const resp = await postJson(
this._session,
this._url,
@ -182,13 +185,9 @@ const SnapcastSlider = GObject.registerClass(
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);
@ -208,7 +207,6 @@ const SnapcastSlider = GObject.registerClass(
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", {
@ -227,7 +225,6 @@ const SnapcastSlider = GObject.registerClass(
this.slider.disconnect(this._sliderChangedId);
if (this._settingsChangedId)
this._settings.disconnect(this._settingsChangedId);
super.destroy();
}
},

View File

@ -1,7 +1,8 @@
{
"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
"description": "Quick Settings volume slider for a Snapcast group.",
"shell-version": ["45", "46", "47", "48", "49", "50", "51", "52"],
"settings-schema": "org.gnome.shell.extensions.snapshell",
"version": 2
}

View File

@ -1,67 +1,130 @@
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})`;
}
import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js";
export default class SnapshellPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
const settings = this.getSettings(
"org.gnome.shell.extensions.snapshell",
);
const session = new Soup.Session();
const settings = this.getSettings();
const session = new Soup.Session({
timeout: 5,
idle_timeout: 5,
user_agent: "Snapshell/1",
});
// --- UI models ---
const groupNames = new Gtk.StringList();
let groupIds = []; // parallel array: index -> group id
let groupIds = [];
// --- Page ---
function jsonRpc(method, params) {
return JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: method,
params: params || {},
});
}
function postJson(url, bodyStr) {
return new Promise((resolve, reject) => {
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),
);
session.send_and_read_async(
msg,
GLib.PRIORITY_DEFAULT,
null,
(sess, res) => {
try {
const bytes = sess.send_and_read_finish(res);
const text = new TextDecoder().decode(
bytes.get_data(),
);
resolve(JSON.parse(text));
} catch (e) {
reject(e);
}
},
);
});
}
function labelForGroup(g) {
const clients = g && g.clients ? g.clients : [];
let connected = 0;
for (const c of clients) {
if (c && c.connected) connected++;
}
const total = clients.length;
let base = "Group";
if (g && g.name && String(g.name).trim().length > 0)
base = String(g.name).trim();
else if (g && g.stream_id) base = String(g.stream_id);
return `${base} (${connected}/${total})`;
}
function clearGroups() {
while (groupNames.get_n_items() > 0) groupNames.remove(0);
groupIds = [];
}
function syncSelectionFromSettings(groupRow) {
const selectedId = settings.get_string("group-id");
const idx = groupIds.indexOf(selectedId);
groupRow.selected = idx >= 0 ? idx : Gtk.INVALID_LIST_POSITION;
}
async function fetchGroups(statusRow, groupRow) {
const url = settings.get_string("server-url").trim();
if (!url) {
clearGroups();
statusRow.subtitle = "Set Server URL first.";
return;
}
statusRow.subtitle = "Fetching…";
try {
const resp = await postJson(
url,
jsonRpc("Server.GetStatus", {}),
);
const groups =
resp &&
resp.result &&
resp.result.server &&
resp.result.server.groups
? resp.result.server.groups
: [];
clearGroups();
for (const g of groups) {
groupNames.append(labelForGroup(g));
groupIds.push(g.id);
}
statusRow.subtitle = `Loaded ${groups.length} group(s).`;
syncSelectionFromSettings(groupRow);
} catch (e) {
clearGroups();
statusRow.subtitle = `Failed: ${e && e.message ? e.message : e}`;
}
}
// ----- UI -----
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.",
@ -72,14 +135,12 @@ export default class SnapshellPreferences extends ExtensionPreferences {
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:
@ -93,19 +154,16 @@ export default class SnapshellPreferences extends ExtensionPreferences {
});
targetGroup.add(groupRow);
const refreshRow = new Adw.ActionRow({
const statusRow = 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);
statusRow.add_suffix(refreshBtn);
statusRow.activatable_widget = refreshBtn;
targetGroup.add(statusRow);
// --- Group: Behavior ---
const behaviorGroup = new Adw.PreferencesGroup({
title: "Behavior",
});
const behaviorGroup = new Adw.PreferencesGroup({ title: "Behavior" });
page.add(behaviorGroup);
const pollRow = new Adw.SpinRow({
@ -124,57 +182,6 @@ export default class SnapshellPreferences extends ExtensionPreferences {
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;
@ -182,19 +189,17 @@ export default class SnapshellPreferences extends ExtensionPreferences {
if (id) settings.set_string("group-id", id);
});
// Refresh button
refreshBtn.connect("clicked", () => {
fetchGroups();
fetchGroups(statusRow, groupRow);
});
// If URL changes, offer fresh fetch (dont auto-spam)
urlRow.connect("notify::text", () => {
setStatus("URL changed. Click Refresh.");
statusRow.subtitle = "URL changed. Click Refresh.";
clearGroups();
syncComboSelectionFromSettings();
syncSelectionFromSettings(groupRow);
});
// Initial load
fetchGroups();
fetchGroups(statusRow, groupRow);
}
}