Files
dotfiles/gnome/.local/share/gnome-shell/extensions/snapshell@unbl.ink/extension.js

260 lines
7.8 KiB
JavaScript

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 });
}
// 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");
msg.set_request_body_from_bytes(
"application/json",
new GLib.Bytes(bodyStr),
);
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);
}
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();
// --- 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;
this._sliderChangedId = 0;
this._settingsChangedId = 0;
this._isUpdatingFromServer = false;
this._groupClientIds = [];
this._reloadSettings();
this._settingsChangedId = this._settings.connect("changed", () => {
this._reloadSettings();
this._restartPolling();
this._pollOnce().catch(logError);
});
this._sliderChangedId = this.slider.connect("notify::value", () => {
if (this._isUpdatingFromServer) return;
this._debouncedSetVolume();
});
this._restartPolling();
this._pollOnce().catch(logError);
}
_reloadSettings() {
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");
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;
}
this._debounceId = GLib.timeout_add(
GLib.PRIORITY_DEFAULT,
150,
() => {
this._debounceId = 0;
this._setGroupVolumeFromSlider().catch(logError);
return GLib.SOURCE_REMOVE;
},
);
}
async _pollOnce() {
if (!this._url) {
this.title = "Snapcast — set server URL";
this._groupClientIds = [];
return;
}
if (!this._groupId) {
this.title = "Snapcast — select a group";
this._groupClientIds = [];
return;
}
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;
}
this._groupClientIds = connectedClientIds(group);
this.title = `Snapcast — ${groupLabel(group)}`;
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);
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;
}
}