Files
dms-gamebar/GameBarDaemon.qml
T

545 lines
18 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Modules.Plugins
import qs.Services
import "./services"
import "./overlay"
PluginComponent {
id: root
pluginId: "gameBar"
pluginService: PluginService
readonly property bool overlayOpen: overlay.shouldBeVisible
// Widget catalogue. `id` is the persistence key, the rest drives the top bar
// toggles and the default placement (fractions of the screen size).
readonly property var widgetDefs: [
{
"id": "audio",
"title": "Audio",
"icon": "volume_up",
"width": 430,
"height": 470,
"x": 0.03,
"y": 0.14,
"defaultVisible": true
},
{
"id": "apps",
"title": "Apps",
"icon": "tune",
"width": 400,
"height": 360,
"x": 0.3,
"y": 0.14,
"defaultVisible": true
},
{
"id": "timer",
"title": "Timer",
"icon": "timer",
"width": 380,
"height": 320,
"x": 0.57,
"y": 0.58,
"defaultVisible": false
},
{
"id": "capture",
"title": "Capture",
"icon": "videocam",
"width": 380,
"height": 230,
"x": 0.03,
"y": 0.62,
"defaultVisible": false
},
{
"id": "media",
"title": "Media",
"icon": "music_note",
"width": 430,
"height": 300,
"x": 0.3,
"y": 0.52,
"defaultVisible": true
},
{
"id": "home",
"title": "Home",
"icon": "home",
"width": 420,
"height": 420,
"x": 0.57,
"y": 0.14,
"defaultVisible": false
},
{
"id": "visualizer",
"title": "Visualiser",
"icon": "graphic_eq",
"width": 360,
"height": 180,
"x": 0.3,
"y": 0.79,
"defaultVisible": false
},
{
"id": "bluetooth",
"title": "Bluetooth",
"icon": "bluetooth",
"width": 380,
"height": 300,
"x": 0.8,
"y": 0.14,
"defaultVisible": false
},
{
"id": "notepad",
"title": "Notes",
"icon": "edit_note",
"width": 400,
"height": 320,
"x": 0.57,
"y": 0.58,
"defaultVisible": false
},
{
"id": "system",
"title": "Performance",
"icon": "speed",
"width": 380,
"height": 330,
"x": 0.8,
"y": 0.45,
"defaultVisible": false
}
]
function widgetDef(widgetId) {
return widgetDefs.find(def => def.id === widgetId) || null;
}
// ── Visibility ───────────────────────────────────────────────────────────
function savePluginValue(key, value) {
if (pluginService)
pluginService.savePluginData(pluginId, key, value);
}
function isWidgetVisible(widgetId) {
const stored = pluginData.visibleWidgets;
if (stored && stored[widgetId] !== undefined)
return stored[widgetId] === true;
const def = widgetDef(widgetId);
return def ? def.defaultVisible === true : false;
}
function setWidgetVisible(widgetId, visible) {
const stored = Object.assign({}, pluginData.visibleWidgets || {});
stored[widgetId] = visible === true;
root.savePluginValue("visibleWidgets", stored);
}
function toggleWidget(widgetId) {
root.setWidgetVisible(widgetId, !root.isWidgetVisible(widgetId));
}
// ── Positions ────────────────────────────────────────────────────────────
// Stored per screen as fractions of the screen size, so the same layout
// survives monitors of different resolutions (and is re-clamped on load).
// ── Brightness device mapping ────────────────────────────────────────────
// DisplayService only knows DDC devices as i2c bus ids, and the shell maps
// them to screens through manual pins. "ddcutil detect" reports the DRM
// connector for each bus, which is the Wayland output name, so the mapping
// can be worked out once and cached.
property var brightnessMap: ({})
property bool brightnessMapScanning: false
// Picks the cached map up as soon as plugin data arrives; surfaces are
// created before the daemon registers, so they cannot drive this.
readonly property var storedBrightnessMap: (pluginData && pluginData.brightnessMap && typeof pluginData.brightnessMap === "object") ? pluginData.brightnessMap : ({})
onStoredBrightnessMapChanged: {
if (Object.keys(root.storedBrightnessMap).length > 0)
root.brightnessMap = root.storedBrightnessMap;
root.ensureBrightnessMap();
}
function brightnessDeviceForScreen(screenName) {
if (!screenName)
return "";
// A pin set in the shell's own UI always wins.
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
if (pins[pinKey] && DisplayService.devices.some(d => d.id === pins[pinKey]))
return pins[pinKey];
const mapped = root.brightnessMap[screenName];
if (mapped && DisplayService.devices.some(d => d.id === mapped))
return mapped;
const backlight = DisplayService.devices.find(d => d.class === "backlight");
if (backlight)
return backlight.id;
const ddc = DisplayService.devices.find(d => d.class === "ddc");
return ddc ? ddc.id : "";
}
// The shell's own Control Center brightness slider resolves its device from
// per-screen pins, so publishing the detected map there makes the built-in
// slider (and its icon menu) follow whichever monitor the panel is on.
function syncBrightnessPins() {
if (pluginData.autoPinBrightness === false)
return;
if (typeof CacheData === "undefined" || !CacheData)
return;
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
let changed = false;
for (const screen of Quickshell.screens) {
const device = root.brightnessMap[screen.name];
if (!device)
continue;
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
// Never overwrite a pin the user set by hand.
if (pins[key])
continue;
pins[key] = device;
changed = true;
}
if (changed)
CacheData.set("brightnessDevicePins", pins);
}
function ensureBrightnessMap() {
if (root.brightnessMapScanning)
return;
if (Object.keys(root.brightnessMap).length === 0 && Object.keys(root.storedBrightnessMap).length > 0)
root.brightnessMap = root.storedBrightnessMap;
const screens = Quickshell.screens.map(s => s.name);
const missing = screens.filter(name => !root.brightnessMap[name]);
if (missing.length === 0) {
root.syncBrightnessPins();
return;
}
root.brightnessMapScanning = true;
Proc.runCommand("gameBar.ddcDetect", ["ddcutil", "detect", "--brief"], (stdout, exitCode) => {
root.brightnessMapScanning = false;
if (exitCode !== 0)
return;
const map = {};
let bus = "";
for (const line of String(stdout || "").split("\n")) {
const busMatch = line.match(/I2C bus:\s*\/dev\/i2c-([0-9]+)/);
if (busMatch) {
bus = "ddc:i2c-" + busMatch[1];
continue;
}
const connectorMatch = line.match(/DRM connector:\s*card[0-9]+-(.+)$/);
if (connectorMatch && bus !== "") {
map[connectorMatch[1].trim()] = bus;
bus = "";
}
}
if (Object.keys(map).length === 0)
return;
root.brightnessMap = map;
root.savePluginValue("brightnessMap", map);
root.syncBrightnessPins();
}, 0, 20000);
}
function focusedMonitorName() {
const screen = CompositorService.getFocusedScreen();
return (screen && screen.name) ? screen.name : "focused";
}
// Positions are stored per monitor *and* per resolution: a layout arranged at
// 4K means nothing at 1440p, and switching back must restore the original
// arrangement rather than a rescaled approximation of the other one.
function screenKey(screen) {
if (!screen)
return "default";
const base = SettingsData.getScreenDisplayName(screen) || screen.name || "default";
const w = Math.round(screen.width);
const h = Math.round(screen.height);
return (w > 0 && h > 0) ? base + "@" + w + "x" + h : base;
}
function legacyScreenKey(screen) {
if (!screen)
return "";
return SettingsData.getScreenDisplayName(screen) || screen.name || "";
}
// Returns absolute pixel coordinates for the given panel size.
function widgetPosition(screen, widgetId, panelWidth, panelHeight) {
const layout = pluginData.layout || {};
const perScreen = layout[root.screenKey(screen)];
const saved = perScreen ? perScreen[widgetId] : null;
if (saved && saved.x !== undefined && saved.y !== undefined)
return {
"x": saved.x,
"y": saved.y
};
// Entries written before 2.5.0 were fractions keyed by screen name only.
const legacyPerScreen = layout[root.legacyScreenKey(screen)];
const legacy = legacyPerScreen ? legacyPerScreen[widgetId] : null;
if (legacy && legacy.x !== undefined && legacy.y !== undefined && legacy.x <= 1 && legacy.y <= 1)
return {
"x": legacy.x * panelWidth,
"y": legacy.y * panelHeight
};
const def = widgetDef(widgetId);
return {
"x": (def ? def.x : 0.1) * panelWidth,
"y": (def ? def.y : 0.15) * panelHeight
};
}
function saveWidgetPosition(screen, widgetId, x, y) {
const layout = JSON.parse(JSON.stringify(pluginData.layout || {}));
const key = root.screenKey(screen);
if (!layout[key])
layout[key] = {};
layout[key][widgetId] = {
"x": Math.round(x),
"y": Math.round(y)
};
root.savePluginValue("layout", layout);
}
function resetLayout() {
root.savePluginValue("layout", {});
}
// ── Overlay ──────────────────────────────────────────────────────────────
function openOverlay() {
// No early return on shouldBeVisible: the flag can survive a shell
// restart with no window mapped, which used to make open() a no-op.
if (typeof PopoutService !== "undefined" && PopoutService)
PopoutService.closeControlCenter();
overlay.shouldBeVisible = true;
overlay.open();
}
function closeOverlay() {
if (!overlay.shouldBeVisible)
return;
overlay.shouldBeVisible = false;
overlay.close();
}
function toggleOverlay() {
if (overlay.shouldBeVisible)
root.closeOverlay();
else
root.openOverlay();
}
IpcHandler {
target: "gameBar"
enabled: true
function toggle() : string {
root.toggleOverlay();
return "SUCCESS";
}
function open() : string {
root.openOverlay();
return "SUCCESS";
}
function close() : string {
root.closeOverlay();
return "SUCCESS";
}
function widget(name: string) : string {
if (!root.widgetDef(name))
return "UNKNOWN_WIDGET: " + name;
root.toggleWidget(name);
return "SUCCESS";
}
function homeToggle(entityId: string) : string {
if (!entityId)
return "USAGE: homeToggle <entity_id>";
const domain = haService.domainOf(entityId);
if (["scene", "script", "button", "input_button"].indexOf(domain) !== -1)
haService.press(entityId);
else
haService.toggle(entityId);
return "SUCCESS";
}
function record() : string {
// The recorder stops asynchronously, so report the action taken.
const wasRecording = capture.recording;
capture.toggleRecording(root.focusedMonitorName());
return wasRecording ? "STOPPED" : "RECORDING";
}
function replay() : string {
const wasArmed = capture.replayArmed;
capture.toggleReplay(root.focusedMonitorName());
return wasArmed ? "STOPPED" : "ARMED";
}
function saveReplay() : string {
if (!capture.replayArmed)
return "REPLAY_NOT_ARMED";
capture.saveReplay();
return "SUCCESS";
}
function timer(seconds: string, label: string) : string {
const value = parseInt(seconds, 10);
if (isNaN(value) || value <= 0)
return "USAGE: timer <seconds> [label]";
timers.resetTimer();
timers.startTimer(value * 1000, label || "");
return "STARTED " + timers.formatDuration(value * 1000);
}
function timerStop() : string {
timers.resetTimer();
return "SUCCESS";
}
function timerStatus() : string {
return "timer=" + (timers.timerRunning ? "running" : (timers.timerFinished ? "finished" : "idle")) + " remaining=" + timers.formatDuration(timers.remainingMs) + " label=" + (timers.timerLabel || "(none)") + " stopwatch=" + (timers.stopwatchRunning ? "running" : "idle") + " elapsed=" + timers.formatStopwatch(timers.stopwatchElapsedMs);
}
function layoutStatus() : string {
const lines = Quickshell.screens.map(screen => {
const key = root.screenKey(screen);
const parts = root.widgetDefs.map(def => {
const pos = root.widgetPosition(screen, def.id, screen.width, screen.height);
return def.id + "(" + Math.round(pos.x) + "," + Math.round(pos.y) + ")";
});
return key + ": " + parts.join(" ");
});
return lines.join(" | ");
}
function brightnessStatus() : string {
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
const perScreen = Quickshell.screens.map(screen => {
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
return screen.name + "[key=" + key + " device=" + root.brightnessDeviceForScreen(screen.name) + " value=" + DisplayService.getDeviceBrightness(root.brightnessDeviceForScreen(screen.name)) + "]";
});
return "map=" + JSON.stringify(root.brightnessMap) + " pins=" + JSON.stringify(pins) + " screens=" + perScreen.join(" ") + " currentDevice=" + DisplayService.currentDevice;
}
function captureStatus() : string {
return "mode=" + (capture.mode || "idle") + " monitor=" + (capture.targetMonitor || "-") + " elapsed=" + capture.elapsedSeconds + "s dir=" + capture.saveDirectory + " lastFile=" + (capture.lastFile || "(none)") + " error=" + (capture.lastError || "(none)");
}
function homeStatus() : string {
const picked = Array.isArray(root.pluginData.haEntities) ? root.pluginData.haEntities.length : 0;
return "configured=" + haService.configured + " url=" + (haService.baseUrl || "(none)") + " tokenChars=" + haService.token.length + " http=" + haService.lastStatus + " entities=" + haService.entityList.length + " picked=" + picked + " error=" + (haService.lastError || "(none)");
}
function homeRefresh() : string {
haService.refresh();
return "SUCCESS";
}
function resetLayout() : string {
root.resetLayout();
return "SUCCESS";
}
}
readonly property alias homeService: haService
readonly property alias captureService: capture
readonly property alias timerService: timers
TimerService {
id: timers
daemon: root
}
CaptureService {
id: capture
daemon: root
onNotice: message => {
if (typeof ToastService !== "undefined" && ToastService)
ToastService.showInfo(message);
}
}
HomeService {
id: haService
daemon: root
}
Connections {
target: Quickshell
function onScreensChanged() {
root.ensureBrightnessMap();
}
}
OverlayModal {
id: overlay
daemon: root
}
Component.onCompleted: {
root.ensureBrightnessMap();
if (pluginService && pluginId) {
const instances = Object.assign({}, pluginService.pluginInstances);
instances[pluginId] = root;
pluginService.pluginInstances = instances;
}
}
Component.onDestruction: {
if (pluginService && pluginService.pluginInstances[pluginId] === root) {
const instances = Object.assign({}, pluginService.pluginInstances);
delete instances[pluginId];
pluginService.pluginInstances = instances;
}
}
}