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; } }