feat: added widgets capture, music visualiser, bluetooth, notes

This commit is contained in:
2026-09-22 01:22:27 +02:00
parent 9b023a0993
commit d15bb4687c
25 changed files with 1675 additions and 55 deletions
+277 -23
View File
@@ -4,6 +4,8 @@ import Quickshell.Io
import qs.Common
import qs.Modules.Plugins
import qs.Services
import "./services"
import "./overlay"
PluginComponent {
id: root
@@ -22,7 +24,7 @@ PluginComponent {
"icon": "volume_up",
"width": 430,
"height": 470,
"x": 0.04,
"x": 0.03,
"y": 0.14,
"defaultVisible": true
},
@@ -32,18 +34,28 @@ PluginComponent {
"icon": "tune",
"width": 400,
"height": 360,
"x": 0.30,
"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.04,
"y": 0.56,
"x": 0.3,
"y": 0.52,
"defaultVisible": true
},
{
@@ -52,8 +64,38 @@ PluginComponent {
"icon": "home",
"width": 420,
"height": 420,
"x": 0.56,
"y": 0.50,
"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
},
{
@@ -62,8 +104,8 @@ PluginComponent {
"icon": "speed",
"width": 380,
"height": 330,
"x": 0.56,
"y": 0.14,
"x": 0.8,
"y": 0.45,
"defaultVisible": false
}
]
@@ -100,39 +142,185 @@ PluginComponent {
// ── 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";
return SettingsData.getScreenDisplayName(screen) || screen.name || "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 widgetPosition(screen, widgetId) {
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 saved;
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 def ? ({
"x": def.x,
"y": def.y
}) : ({
"x": 0.1,
"y": 0.15
});
return {
"x": (def ? def.x : 0.1) * panelWidth,
"y": (def ? def.y : 0.15) * panelHeight
};
}
function saveWidgetPosition(screen, widgetId, fractionX, fractionY) {
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": fractionX,
"y": fractionY
"x": Math.round(x),
"y": Math.round(y)
};
root.savePluginValue("layout", layout);
}
@@ -207,6 +395,52 @@ PluginComponent {
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)");
@@ -224,20 +458,40 @@ PluginComponent {
}
readonly property alias homeService: haService
readonly property alias captureService: capture
GameBarHomeService {
CaptureService {
id: capture
daemon: root
onNotice: message => {
if (typeof ToastService !== "undefined" && ToastService)
ToastService.showInfo(message);
}
}
HomeService {
id: haService
daemon: root
}
GameBarOverlay {
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;