263 lines
8.1 KiB
JavaScript
263 lines
8.1 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 });
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|