510 lines
17 KiB
QML
510 lines
17 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": "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() {
|
|
if (overlay.shouldBeVisible)
|
|
return;
|
|
|
|
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 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
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|