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

263 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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() {
// 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._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 doesnt 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;
}
}