Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15bb4687c
|
||
|
|
9b023a0993
|
||
|
|
e0a27e59b4
|
@@ -0,0 +1,200 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import qs.Common
|
||||||
|
import qs.Modules.Plugins
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
import "./components"
|
||||||
|
|
||||||
|
PluginComponent {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var popoutService: null
|
||||||
|
|
||||||
|
readonly property var daemon: (typeof PluginService !== "undefined" && PluginService.pluginInstances) ? PluginService.pluginInstances["gameBar"] : null
|
||||||
|
// "focused" follows whichever monitor has focus, "own" sticks to the screen
|
||||||
|
// this particular bar instance lives on.
|
||||||
|
readonly property string targetMode: String(pluginData.barBrightnessTarget || "focused")
|
||||||
|
|
||||||
|
property var resolvedScreen: null
|
||||||
|
|
||||||
|
readonly property string screenName: {
|
||||||
|
const screen = root.resolvedScreen || root.parentScreen;
|
||||||
|
return (screen && screen.name) ? screen.name : "";
|
||||||
|
}
|
||||||
|
readonly property string deviceId: root.daemon ? root.daemon.brightnessDeviceForScreen(root.screenName) : ""
|
||||||
|
readonly property var device: DisplayService.devices ? (DisplayService.devices.find(d => d.id === root.deviceId) || null) : null
|
||||||
|
readonly property bool usable: DisplayService.brightnessAvailable && root.deviceId !== ""
|
||||||
|
readonly property int brightness: {
|
||||||
|
DisplayService.brightnessVersion;
|
||||||
|
return root.deviceId !== "" ? DisplayService.getDeviceBrightness(root.deviceId) : 0;
|
||||||
|
}
|
||||||
|
readonly property string brightnessIcon: {
|
||||||
|
if (!root.usable)
|
||||||
|
return "brightness_low";
|
||||||
|
if (root.brightness <= 33)
|
||||||
|
return "brightness_low";
|
||||||
|
if (root.brightness <= 66)
|
||||||
|
return "brightness_medium";
|
||||||
|
return "brightness_high";
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScreen() {
|
||||||
|
if (root.targetMode === "own") {
|
||||||
|
root.resolvedScreen = root.parentScreen;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.resolvedScreen = CompositorService.getFocusedScreen() || root.parentScreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Control Center pill click follows the shell's own convention for
|
||||||
|
// non-boolean widgets (see DisplayProfilesWidget): step through presets.
|
||||||
|
readonly property var brightnessPresets: [10, 25, 50, 75, 100]
|
||||||
|
|
||||||
|
function cyclePreset() {
|
||||||
|
if (!root.usable)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const current = root.brightness;
|
||||||
|
for (let i = 0; i < root.brightnessPresets.length; i++) {
|
||||||
|
if (root.brightnessPresets[i] > current + 1) {
|
||||||
|
DisplayService.setBrightness(root.brightnessPresets[i], root.deviceId, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisplayService.setBrightness(root.brightnessPresets[0], root.deviceId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjust(delta) {
|
||||||
|
if (!root.usable)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const next = Math.max(1, Math.min(100, root.brightness + delta));
|
||||||
|
DisplayService.setBrightness(next, root.deviceId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginId: "gameBar"
|
||||||
|
pluginService: PluginService
|
||||||
|
popoutWidth: 320
|
||||||
|
popoutHeight: 150
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
root.updateScreen();
|
||||||
|
if (root.daemon)
|
||||||
|
root.daemon.ensureBrightnessMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
onTargetModeChanged: root.updateScreen()
|
||||||
|
|
||||||
|
// Focus usually moves with the active toplevel; the timer covers moves to an
|
||||||
|
// empty workspace on another monitor, which raise no toplevel change.
|
||||||
|
Connections {
|
||||||
|
target: ToplevelManager
|
||||||
|
|
||||||
|
function onActiveToplevelChanged() {
|
||||||
|
root.updateScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
running: root.targetMode === "focused"
|
||||||
|
interval: 2000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: root.updateScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
horizontalBarPill: Component {
|
||||||
|
Item {
|
||||||
|
implicitWidth: pillRow.implicitWidth + Theme.spacingS * 2
|
||||||
|
height: parent.widgetThickness
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: pillRow
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: root.brightnessIcon
|
||||||
|
size: root.iconSize
|
||||||
|
color: root.usable ? Theme.surfaceText : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: root.usable ? root.brightness + "%" : "--"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WheelHandler {
|
||||||
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
|
onWheel: event => root.adjust(event.angleDelta.y > 0 ? 5 : -5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
verticalBarPill: Component {
|
||||||
|
Item {
|
||||||
|
width: parent.widgetThickness
|
||||||
|
implicitHeight: verticalColumn.implicitHeight + Theme.spacingS * 2
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: verticalColumn
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: root.brightnessIcon
|
||||||
|
size: root.iconSize
|
||||||
|
color: root.usable ? Theme.surfaceText : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: root.usable ? root.brightness : "--"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WheelHandler {
|
||||||
|
acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
|
||||||
|
onWheel: event => root.adjust(event.angleDelta.y > 0 ? 5 : -5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popoutContent: Component {
|
||||||
|
PopoutComponent {
|
||||||
|
headerText: "Brightness"
|
||||||
|
detailsText: root.screenName !== "" ? root.screenName + (root.deviceId !== "" ? " · " + root.deviceId : "") : "No display detected"
|
||||||
|
showCloseButton: true
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
BrightnessControl {
|
||||||
|
width: parent.width
|
||||||
|
daemon: root.daemon
|
||||||
|
screenName: root.screenName
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: root.targetMode === "own" ? "Controls this monitor" : "Follows the focused monitor"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+322
-18
@@ -4,6 +4,8 @@ import Quickshell.Io
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Modules.Plugins
|
import qs.Modules.Plugins
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
import "./services"
|
||||||
|
import "./overlay"
|
||||||
|
|
||||||
PluginComponent {
|
PluginComponent {
|
||||||
id: root
|
id: root
|
||||||
@@ -22,7 +24,7 @@ PluginComponent {
|
|||||||
"icon": "volume_up",
|
"icon": "volume_up",
|
||||||
"width": 430,
|
"width": 430,
|
||||||
"height": 470,
|
"height": 470,
|
||||||
"x": 0.04,
|
"x": 0.03,
|
||||||
"y": 0.14,
|
"y": 0.14,
|
||||||
"defaultVisible": true
|
"defaultVisible": true
|
||||||
},
|
},
|
||||||
@@ -32,18 +34,78 @@ PluginComponent {
|
|||||||
"icon": "tune",
|
"icon": "tune",
|
||||||
"width": 400,
|
"width": 400,
|
||||||
"height": 360,
|
"height": 360,
|
||||||
"x": 0.30,
|
"x": 0.3,
|
||||||
"y": 0.14,
|
"y": 0.14,
|
||||||
"defaultVisible": true
|
"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",
|
"id": "system",
|
||||||
"title": "Performance",
|
"title": "Performance",
|
||||||
"icon": "speed",
|
"icon": "speed",
|
||||||
"width": 380,
|
"width": 380,
|
||||||
"height": 330,
|
"height": 330,
|
||||||
"x": 0.56,
|
"x": 0.8,
|
||||||
"y": 0.14,
|
"y": 0.45,
|
||||||
"defaultVisible": false
|
"defaultVisible": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -80,39 +142,185 @@ PluginComponent {
|
|||||||
// ── Positions ────────────────────────────────────────────────────────────
|
// ── Positions ────────────────────────────────────────────────────────────
|
||||||
// Stored per screen as fractions of the screen size, so the same layout
|
// Stored per screen as fractions of the screen size, so the same layout
|
||||||
// survives monitors of different resolutions (and is re-clamped on load).
|
// 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) {
|
function screenKey(screen) {
|
||||||
if (!screen)
|
if (!screen)
|
||||||
return "default";
|
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 layout = pluginData.layout || {};
|
||||||
const perScreen = layout[root.screenKey(screen)];
|
const perScreen = layout[root.screenKey(screen)];
|
||||||
const saved = perScreen ? perScreen[widgetId] : null;
|
const saved = perScreen ? perScreen[widgetId] : null;
|
||||||
if (saved && saved.x !== undefined && saved.y !== undefined)
|
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);
|
const def = widgetDef(widgetId);
|
||||||
return def ? ({
|
return {
|
||||||
"x": def.x,
|
"x": (def ? def.x : 0.1) * panelWidth,
|
||||||
"y": def.y
|
"y": (def ? def.y : 0.15) * panelHeight
|
||||||
}) : ({
|
};
|
||||||
"x": 0.1,
|
|
||||||
"y": 0.15
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveWidgetPosition(screen, widgetId, fractionX, fractionY) {
|
function saveWidgetPosition(screen, widgetId, x, y) {
|
||||||
const layout = JSON.parse(JSON.stringify(pluginData.layout || {}));
|
const layout = JSON.parse(JSON.stringify(pluginData.layout || {}));
|
||||||
const key = root.screenKey(screen);
|
const key = root.screenKey(screen);
|
||||||
if (!layout[key])
|
if (!layout[key])
|
||||||
layout[key] = {};
|
layout[key] = {};
|
||||||
|
|
||||||
layout[key][widgetId] = {
|
layout[key][widgetId] = {
|
||||||
"x": fractionX,
|
"x": Math.round(x),
|
||||||
"y": fractionY
|
"y": Math.round(y)
|
||||||
};
|
};
|
||||||
root.savePluginValue("layout", layout);
|
root.savePluginValue("layout", layout);
|
||||||
}
|
}
|
||||||
@@ -175,19 +383,115 @@ PluginComponent {
|
|||||||
return "SUCCESS";
|
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 {
|
function resetLayout() : string {
|
||||||
root.resetLayout();
|
root.resetLayout();
|
||||||
return "SUCCESS";
|
return "SUCCESS";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarOverlay {
|
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
|
id: overlay
|
||||||
|
|
||||||
daemon: root
|
daemon: root
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
|
root.ensureBrightnessMap();
|
||||||
if (pluginService && pluginId) {
|
if (pluginService && pluginId) {
|
||||||
const instances = Object.assign({}, pluginService.pluginInstances);
|
const instances = Object.assign({}, pluginService.pluginInstances);
|
||||||
instances[pluginId] = root;
|
instances[pluginId] = root;
|
||||||
|
|||||||
+120
-1
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Modules.Plugins
|
import qs.Modules.Plugins
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
import "./settings"
|
||||||
|
|
||||||
PluginSettings {
|
PluginSettings {
|
||||||
id: settings
|
id: settings
|
||||||
@@ -34,7 +35,7 @@ PluginSettings {
|
|||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
text: "Commands: dms ipc call gameBar toggle / open / close / widget <audio|apps|system> / resetLayout"
|
text: "Commands: dms ipc call gameBar toggle / open / close / widget <name> / resetLayout"
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.family: Theme.monoFontFamily
|
font.family: Theme.monoFontFamily
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
@@ -55,6 +56,124 @@ PluginSettings {
|
|||||||
rightIcon: "dark_mode"
|
rightIcon: "dark_mode"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ToggleSetting {
|
||||||
|
settingKey: "autoPinBrightness"
|
||||||
|
label: "Match brightness devices to monitors"
|
||||||
|
description: "Detects which DDC device belongs to which display and pins it, so the shell's own brightness slider follows the panel's monitor"
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "barBrightnessTarget"
|
||||||
|
label: "Bar brightness widget controls"
|
||||||
|
description: "Which display the DankBar brightness pill adjusts"
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
"label": "Focused monitor",
|
||||||
|
"value": "focused"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Its own monitor",
|
||||||
|
"value": "own"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
defaultValue: "focused"
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "captureDirectory"
|
||||||
|
label: "Capture folder"
|
||||||
|
description: "Where recordings and replays are written"
|
||||||
|
defaultValue: "~/Videos/GameBar"
|
||||||
|
placeholder: "~/Videos/GameBar"
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "captureFps"
|
||||||
|
label: "Recording frame rate"
|
||||||
|
defaultValue: 60
|
||||||
|
minimum: 24
|
||||||
|
maximum: 144
|
||||||
|
unit: " fps"
|
||||||
|
leftIcon: "slow_motion_video"
|
||||||
|
rightIcon: "speed"
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "captureReplaySeconds"
|
||||||
|
label: "Replay buffer length"
|
||||||
|
description: "How much of the past the replay buffer keeps"
|
||||||
|
defaultValue: 30
|
||||||
|
minimum: 5
|
||||||
|
maximum: 300
|
||||||
|
unit: "s"
|
||||||
|
leftIcon: "timer"
|
||||||
|
rightIcon: "history"
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectionSetting {
|
||||||
|
settingKey: "captureQuality"
|
||||||
|
label: "Recording quality"
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
"label": "Medium",
|
||||||
|
"value": "medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "High",
|
||||||
|
"value": "high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Very high",
|
||||||
|
"value": "very_high"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ultra",
|
||||||
|
"value": "ultra"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
defaultValue: "very_high"
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "captureAudio"
|
||||||
|
label: "Recorded audio source"
|
||||||
|
description: "gpu-screen-recorder device, e.g. default_output"
|
||||||
|
defaultValue: "default_output"
|
||||||
|
placeholder: "default_output"
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "haUrl"
|
||||||
|
label: "Home Assistant URL"
|
||||||
|
description: "e.g. http://homeassistant.local:8123"
|
||||||
|
placeholder: "http://homeassistant.local:8123"
|
||||||
|
}
|
||||||
|
|
||||||
|
StringSetting {
|
||||||
|
settingKey: "haToken"
|
||||||
|
label: "Long-lived access token"
|
||||||
|
description: "Home Assistant profile page, bottom of Security tab"
|
||||||
|
placeholder: "eyJhbGciOi..."
|
||||||
|
}
|
||||||
|
|
||||||
|
SliderSetting {
|
||||||
|
settingKey: "haPollInterval"
|
||||||
|
label: "Home Assistant refresh interval"
|
||||||
|
description: "How often entity states are polled while the Home widget is open"
|
||||||
|
defaultValue: 5
|
||||||
|
minimum: 2
|
||||||
|
maximum: 30
|
||||||
|
unit: "s"
|
||||||
|
leftIcon: "speed"
|
||||||
|
rightIcon: "schedule"
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeSettings {
|
||||||
|
width: parent.width
|
||||||
|
settings: settings
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: resetColumn.implicitHeight
|
height: resetColumn.implicitHeight
|
||||||
|
|||||||
@@ -8,7 +8,34 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Audio** | Master output volume + mute, output device switching, microphone volume + mute, input device switching |
|
| **Audio** | Master output volume + mute, output device switching, microphone volume + mute, input device switching |
|
||||||
| **Apps** | Per-application volume sliders with individual mute, app icon and what is playing |
|
| **Apps** | Per-application volume sliders with individual mute, app icon and what is playing |
|
||||||
|
| **Media** | Now playing with album art, seek bar, previous/play/next, and a chip per MPRIS source to switch between players |
|
||||||
|
| **Capture** | Record the focused monitor, arm a replay buffer and save the last N seconds, or hand a screenshot to the quickCapture plugin (needs `gpu-screen-recorder`) |
|
||||||
|
| **Visualiser** | Bars driven by whatever is actually playing out of this machine (needs `cava`) |
|
||||||
|
| **Bluetooth** | Paired devices with battery, connect/disconnect, adapter and scan toggles |
|
||||||
|
| **Notes** | The shell's notepad scratch tab, editable in place; file-backed tabs are shown read-only |
|
||||||
| **Performance** | CPU (usage, temperature), memory (used / total), network (rx/tx), disk (root usage, read/write) |
|
| **Performance** | CPU (usage, temperature), memory (used / total), network (rx/tx), disk (root usage, read/write) |
|
||||||
|
| **Home** | Home Assistant entities you pick: lights (toggle + brightness), numbers (slider over the entity's own min/max/step), switches/fans, media players (volume, mute, play/pause), covers, scenes and scripts, plus read-only sensors |
|
||||||
|
|
||||||
|
## Home Assistant
|
||||||
|
|
||||||
|
Settings → Plugins → Game Bar:
|
||||||
|
|
||||||
|
1. **Home Assistant URL**, e.g. `http://homeassistant.local:8123`
|
||||||
|
2. **Long-lived access token** — HA profile page → *Security* → *Create token*.
|
||||||
|
It is stored in `plugin_settings.json`, which is world-readable by default
|
||||||
|
(`chmod 600` it if that matters to you).
|
||||||
|
3. **Refresh**, then tick the entities you want in the Home widget.
|
||||||
|
|
||||||
|
State is polled (default every 5 s) only while the Home widget is open. The token is handed to
|
||||||
|
`curl` through the process environment, so it never shows up in the process list.
|
||||||
|
|
||||||
|
If a refresh fails, `dms ipc call gameBar homeStatus` shows what the plugin actually sent and got
|
||||||
|
back. A `401` means Home Assistant rejected the token; compare with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer <token>" https://your-ha/api/
|
||||||
|
```
|
||||||
|
Note that editing `plugin_settings.json` by hand does not reach a running shell.
|
||||||
|
|
||||||
### IPC commands
|
### IPC commands
|
||||||
|
|
||||||
@@ -17,7 +44,16 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/
|
|||||||
| `dms ipc call gameBar toggle` | Toggle the overlay |
|
| `dms ipc call gameBar toggle` | Toggle the overlay |
|
||||||
| `dms ipc call gameBar open` | Open the overlay |
|
| `dms ipc call gameBar open` | Open the overlay |
|
||||||
| `dms ipc call gameBar close` | Close the overlay |
|
| `dms ipc call gameBar close` | Close the overlay |
|
||||||
| `dms ipc call gameBar widget audio\|apps\|system` | Show/hide one widget |
|
| `dms ipc call gameBar widget audio\|apps\|media\|home\|system` | Show/hide one widget |
|
||||||
|
| `dms ipc call gameBar homeToggle <entity_id>` | Toggle an entity (activates a scene/script/button) |
|
||||||
|
| `dms ipc call gameBar homeRefresh` | Re-poll Home Assistant now |
|
||||||
|
| `dms ipc call gameBar homeStatus` | Print URL, token length, HTTP status, entity/picked counts and last error |
|
||||||
|
| `dms ipc call gameBar record` | Start/stop recording the focused monitor |
|
||||||
|
| `dms ipc call gameBar replay` | Arm/disarm the replay buffer |
|
||||||
|
| `dms ipc call gameBar saveReplay` | Write the last N seconds to disk |
|
||||||
|
| `dms ipc call gameBar captureStatus` | Print recorder mode, monitor, elapsed time and last file |
|
||||||
|
| `dms ipc call gameBar brightnessStatus` | Print the display→DDC map, pins and resolved brightness per screen |
|
||||||
|
| `dms ipc call gameBar layoutStatus` | Print each screen's layout key and where every widget resolves |
|
||||||
| `dms ipc call gameBar resetLayout` | Forget all saved widget positions |
|
| `dms ipc call gameBar resetLayout` | Forget all saved widget positions |
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
@@ -25,6 +61,9 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/
|
|||||||
Settings → Plugins → Game Bar (gear icon):
|
Settings → Plugins → Game Bar (gear icon):
|
||||||
|
|
||||||
- **Background dimming** — how dark the desktop behind the overlay gets (0–95%).
|
- **Background dimming** — how dark the desktop behind the overlay gets (0–95%).
|
||||||
|
- **Capture** — folder, frame rate, quality, replay buffer length and the recorded audio source
|
||||||
|
(a `gpu-screen-recorder` device name, e.g. `default_output`; `gpu-screen-recorder --list-audio-devices`).
|
||||||
|
- **Home Assistant** — URL, access token, refresh interval, and the entity picker.
|
||||||
- **Reset widget positions** — back to the default arrangement on every monitor.
|
- **Reset widget positions** — back to the default arrangement on every monitor.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: control
|
||||||
|
|
||||||
|
property var daemon: null
|
||||||
|
property string screenName: ""
|
||||||
|
readonly property string deviceId: daemon ? daemon.brightnessDeviceForScreen(screenName) : ""
|
||||||
|
readonly property var device: DisplayService.devices ? (DisplayService.devices.find(d => d.id === control.deviceId) || null) : null
|
||||||
|
readonly property bool usable: DisplayService.brightnessAvailable && control.deviceId !== ""
|
||||||
|
|
||||||
|
readonly property int currentBrightness: {
|
||||||
|
DisplayService.brightnessVersion;
|
||||||
|
return control.deviceId !== "" ? DisplayService.getDeviceBrightness(control.deviceId) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int sliderMinimum: {
|
||||||
|
if (!control.device)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
const exponential = SessionData.getBrightnessExponential(control.device.id);
|
||||||
|
if (exponential)
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return (control.device.class === "backlight" || control.device.class === "ddc") ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property int sliderMaximum: {
|
||||||
|
if (!control.device)
|
||||||
|
return 100;
|
||||||
|
|
||||||
|
const exponential = SessionData.getBrightnessExponential(control.device.id);
|
||||||
|
if (exponential)
|
||||||
|
return 100;
|
||||||
|
|
||||||
|
return control.device.displayMax || 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: 190
|
||||||
|
height: 38
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (daemon)
|
||||||
|
daemon.ensureBrightnessMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
id: icon
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: {
|
||||||
|
if (!control.usable)
|
||||||
|
return "brightness_low";
|
||||||
|
if (control.currentBrightness <= 33)
|
||||||
|
return "brightness_low";
|
||||||
|
if (control.currentBrightness <= 66)
|
||||||
|
return "brightness_medium";
|
||||||
|
return "brightness_high";
|
||||||
|
}
|
||||||
|
size: Theme.iconSize - 4
|
||||||
|
color: control.usable ? Theme.primary : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
DankSlider {
|
||||||
|
id: slider
|
||||||
|
|
||||||
|
anchors.left: icon.right
|
||||||
|
anchors.leftMargin: Theme.spacingXS
|
||||||
|
anchors.right: valueLabel.left
|
||||||
|
anchors.rightMargin: Theme.spacingXS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
enabled: control.usable
|
||||||
|
minimum: control.sliderMinimum
|
||||||
|
maximum: control.sliderMaximum
|
||||||
|
showValue: false
|
||||||
|
onSliderValueChanged: newValue => {
|
||||||
|
if (control.usable)
|
||||||
|
DisplayService.setBrightness(newValue, control.deviceId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The slider writes its own value while dragging, so only follow the
|
||||||
|
// service when the user is not holding it.
|
||||||
|
Binding on value {
|
||||||
|
value: control.currentBrightness
|
||||||
|
when: !slider.isDragging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: valueLabel
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 34
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
text: control.usable ? control.currentBrightness + (control.device && control.device.class === "ddc" && !SessionData.getBrightnessExponential(control.device.id) ? "" : "%") : "n/a"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
DankTooltipV2 {
|
||||||
|
id: tooltip
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: icon
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
onEntered: tooltip.show(control.deviceId !== "" ? control.screenName + " · " + control.deviceId : "No brightness device", icon, 0, 0, "bottom")
|
||||||
|
onExited: tooltip.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ DankModal {
|
|||||||
modalHeight: availableHeight
|
modalHeight: availableHeight
|
||||||
|
|
||||||
content: Component {
|
content: Component {
|
||||||
GameBarPanel {
|
Panel {
|
||||||
overlayRef: overlay
|
overlayRef: overlay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell.Services.Pipewire
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
import "../widgets"
|
||||||
|
|
||||||
FocusScope {
|
FocusScope {
|
||||||
id: root
|
id: root
|
||||||
@@ -14,6 +15,9 @@ FocusScope {
|
|||||||
readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({})
|
readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({})
|
||||||
readonly property var targetScreen: overlayRef ? overlayRef.effectiveScreen : null
|
readonly property var targetScreen: overlayRef ? overlayRef.effectiveScreen : null
|
||||||
readonly property real edgeMargin: Theme.spacingM
|
readonly property real edgeMargin: Theme.spacingM
|
||||||
|
// Changes when the overlay lands on a different monitor (or that monitor
|
||||||
|
// changes resolution); widgets re-read their saved spot when it does.
|
||||||
|
readonly property string screenKey: daemon ? daemon.screenKey(root.targetScreen) : ""
|
||||||
readonly property real topLimit: topBar.y + topBar.height + Theme.spacingM
|
readonly property real topLimit: topBar.y + topBar.height + Theme.spacingM
|
||||||
|
|
||||||
property int windowZCounter: 1
|
property int windowZCounter: 1
|
||||||
@@ -39,17 +43,17 @@ FocusScope {
|
|||||||
|
|
||||||
function widgetPosition(widgetId) {
|
function widgetPosition(widgetId) {
|
||||||
if (daemon)
|
if (daemon)
|
||||||
return daemon.widgetPosition(root.targetScreen, widgetId);
|
return daemon.widgetPosition(root.targetScreen, widgetId, root.width, root.height);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"x": 0.1,
|
"x": root.width * 0.1,
|
||||||
"y": 0.15
|
"y": root.height * 0.15
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveWidgetPosition(widgetId, fractionX, fractionY) {
|
function saveWidgetPosition(widgetId, x, y) {
|
||||||
if (daemon)
|
if (daemon)
|
||||||
daemon.saveWidgetPosition(root.targetScreen, widgetId, fractionX, fractionY);
|
daemon.saveWidgetPosition(root.targetScreen, widgetId, x, y);
|
||||||
}
|
}
|
||||||
|
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
@@ -75,7 +79,7 @@ FocusScope {
|
|||||||
onClicked: root.requestClose()
|
onClicked: root.requestClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarTopBar {
|
TopBar {
|
||||||
id: topBar
|
id: topBar
|
||||||
|
|
||||||
panel: root
|
panel: root
|
||||||
@@ -87,7 +91,7 @@ FocusScope {
|
|||||||
Repeater {
|
Repeater {
|
||||||
model: root.daemon ? root.daemon.widgetDefs : []
|
model: root.daemon ? root.daemon.widgetDefs : []
|
||||||
|
|
||||||
delegate: GameBarWindow {
|
delegate: WidgetWindow {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
panel: root
|
panel: root
|
||||||
@@ -104,6 +108,18 @@ FocusScope {
|
|||||||
return audioWidgetComponent;
|
return audioWidgetComponent;
|
||||||
case "apps":
|
case "apps":
|
||||||
return appsWidgetComponent;
|
return appsWidgetComponent;
|
||||||
|
case "capture":
|
||||||
|
return captureWidgetComponent;
|
||||||
|
case "media":
|
||||||
|
return mediaWidgetComponent;
|
||||||
|
case "visualizer":
|
||||||
|
return visualizerWidgetComponent;
|
||||||
|
case "bluetooth":
|
||||||
|
return bluetoothWidgetComponent;
|
||||||
|
case "notepad":
|
||||||
|
return notepadWidgetComponent;
|
||||||
|
case "home":
|
||||||
|
return homeWidgetComponent;
|
||||||
case "system":
|
case "system":
|
||||||
return systemWidgetComponent;
|
return systemWidgetComponent;
|
||||||
}
|
}
|
||||||
@@ -115,18 +131,59 @@ FocusScope {
|
|||||||
Component {
|
Component {
|
||||||
id: audioWidgetComponent
|
id: audioWidgetComponent
|
||||||
|
|
||||||
GameBarAudioWidget {}
|
AudioWidget {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: appsWidgetComponent
|
id: appsWidgetComponent
|
||||||
|
|
||||||
GameBarAppMixer {}
|
AppMixer {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: captureWidgetComponent
|
||||||
|
|
||||||
|
CaptureWidget {
|
||||||
|
service: root.daemon ? root.daemon.captureService : null
|
||||||
|
panel: root
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: visualizerWidgetComponent
|
||||||
|
|
||||||
|
VisualizerWidget {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: bluetoothWidgetComponent
|
||||||
|
|
||||||
|
BluetoothWidget {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: notepadWidgetComponent
|
||||||
|
|
||||||
|
NotepadWidget {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: mediaWidgetComponent
|
||||||
|
|
||||||
|
MediaWidget {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: homeWidgetComponent
|
||||||
|
|
||||||
|
HomeWidget {
|
||||||
|
service: root.daemon ? root.daemon.homeService : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: systemWidgetComponent
|
id: systemWidgetComponent
|
||||||
|
|
||||||
GameBarSystemWidget {}
|
SystemWidget {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,7 @@ import QtQuick
|
|||||||
import Quickshell
|
import Quickshell
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
import "../components"
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: bar
|
id: bar
|
||||||
@@ -9,6 +10,9 @@ Rectangle {
|
|||||||
property var panel: null
|
property var panel: null
|
||||||
|
|
||||||
readonly property var widgetDefs: (panel && panel.daemon) ? panel.daemon.widgetDefs : []
|
readonly property var widgetDefs: (panel && panel.daemon) ? panel.daemon.widgetDefs : []
|
||||||
|
// Labelled chips stop fitting once there are a lot of widgets on a narrow
|
||||||
|
// panel, so fall back to icon-only toggles.
|
||||||
|
readonly property bool compact: (panel ? panel.width : 1920) < widgetDefs.length * 110 + 560
|
||||||
|
|
||||||
width: barRow.width + Theme.spacingL * 2
|
width: barRow.width + Theme.spacingL * 2
|
||||||
height: 56
|
height: 56
|
||||||
@@ -68,6 +72,14 @@ Rectangle {
|
|||||||
|
|
||||||
BarSeparator {}
|
BarSeparator {}
|
||||||
|
|
||||||
|
BrightnessControl {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
daemon: bar.panel ? bar.panel.daemon : null
|
||||||
|
screenName: (bar.panel && bar.panel.targetScreen && bar.panel.targetScreen.name) ? bar.panel.targetScreen.name : ""
|
||||||
|
}
|
||||||
|
|
||||||
|
BarSeparator {}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: Theme.spacingXS
|
spacing: Theme.spacingXS
|
||||||
@@ -82,7 +94,7 @@ Rectangle {
|
|||||||
|
|
||||||
readonly property bool active: bar.panel ? bar.panel.isWidgetVisible(modelData.id) : false
|
readonly property bool active: bar.panel ? bar.panel.isWidgetVisible(modelData.id) : false
|
||||||
|
|
||||||
width: toggleRow.width + Theme.spacingM * 2
|
width: bar.compact ? 40 : toggleRow.width + Theme.spacingM * 2
|
||||||
height: 38
|
height: 38
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: active ? Theme.primarySelected : (toggleArea.containsMouse ? Theme.surfaceHover : "transparent")
|
color: active ? Theme.primarySelected : (toggleArea.containsMouse ? Theme.surfaceHover : "transparent")
|
||||||
@@ -108,6 +120,7 @@ Rectangle {
|
|||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: toggleButton.active ? Font.Medium : Font.Normal
|
font.weight: toggleButton.active ? Font.Medium : Font.Normal
|
||||||
color: toggleButton.active ? Theme.primary : Theme.surfaceText
|
color: toggleButton.active ? Theme.primary : Theme.surfaceText
|
||||||
|
visible: !bar.compact
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +18,10 @@ Item {
|
|||||||
readonly property real margin: panel ? panel.edgeMargin : 12
|
readonly property real margin: panel ? panel.edgeMargin : 12
|
||||||
readonly property real panelWidth: panel ? panel.width : 0
|
readonly property real panelWidth: panel ? panel.width : 0
|
||||||
readonly property real panelHeight: panel ? panel.height : 0
|
readonly property real panelHeight: panel ? panel.height : 0
|
||||||
|
readonly property string screenKey: panel ? panel.screenKey : ""
|
||||||
|
// Which monitor the current x/y were resolved for; guards against saving a
|
||||||
|
// position under a screen it was never arranged on.
|
||||||
|
property string appliedKey: ""
|
||||||
readonly property real maxX: Math.max(margin, (panel ? panel.width : width) - width - margin)
|
readonly property real maxX: Math.max(margin, (panel ? panel.width : width) - width - margin)
|
||||||
readonly property real maxY: Math.max(minimumY, (panel ? panel.height : height) - height - margin)
|
readonly property real maxY: Math.max(minimumY, (panel ? panel.height : height) - height - margin)
|
||||||
|
|
||||||
@@ -29,15 +33,17 @@ Item {
|
|||||||
return Math.max(minimumY, Math.min(maxY, value));
|
return Math.max(minimumY, Math.min(maxY, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Positions are stored as fractions of the screen, so a layout made on a 4K
|
// Each monitor keeps its own absolute arrangement, so moving between a 4K
|
||||||
// display still lands somewhere sensible on a 1440p one (and vice versa).
|
// and a 1440p screen restores what was arranged there rather than a scaled
|
||||||
|
// copy of the other one.
|
||||||
function applyStoredPosition() {
|
function applyStoredPosition() {
|
||||||
if (!panel || panel.width <= 0 || panel.height <= 0)
|
if (!panel || panel.width <= 0 || panel.height <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const stored = panel.widgetPosition(win.widgetId);
|
const stored = panel.widgetPosition(win.widgetId);
|
||||||
win.x = win.clampX(stored.x * panel.width);
|
win.x = win.clampX(stored.x);
|
||||||
win.y = win.clampY(stored.y * panel.height);
|
win.y = win.clampY(stored.y);
|
||||||
|
win.appliedKey = win.screenKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
function raise() {
|
function raise() {
|
||||||
@@ -49,7 +55,12 @@ Item {
|
|||||||
if (!panel || panel.width <= 0 || panel.height <= 0)
|
if (!panel || panel.width <= 0 || panel.height <= 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
panel.saveWidgetPosition(win.widgetId, win.x / panel.width, win.y / panel.height);
|
// Mid-transition to another monitor: the position on screen does not
|
||||||
|
// belong to the new screen yet.
|
||||||
|
if (win.screenKey === "" || win.screenKey !== win.appliedKey)
|
||||||
|
return;
|
||||||
|
|
||||||
|
panel.saveWidgetPosition(win.widgetId, win.x, win.y);
|
||||||
}
|
}
|
||||||
|
|
||||||
width: widgetWidth
|
width: widgetWidth
|
||||||
@@ -71,10 +82,12 @@ Item {
|
|||||||
win.raise();
|
win.raise();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The panel has no size yet while the delegate is being created, so the
|
// The panel has neither size nor a resolved screen while the delegate is
|
||||||
// stored fraction has to be re-applied once the screen size is known.
|
// created, and the two settle independently - the screen can change without
|
||||||
|
// a resize (same resolution) and vice versa, so react to both.
|
||||||
onPanelWidthChanged: win.applyStoredPosition()
|
onPanelWidthChanged: win.applyStoredPosition()
|
||||||
onPanelHeightChanged: win.applyStoredPosition()
|
onPanelHeightChanged: win.applyStoredPosition()
|
||||||
|
onScreenKeyChanged: win.applyStoredPosition()
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: frame
|
id: frame
|
||||||
+14
-7
@@ -1,22 +1,29 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://github.com/AvengeMedia/DankMaterialShell/raw/refs/heads/master/PLUGINS/plugin-schema.json",
|
"$schema": "https://raw.githubusercontent.com/AvengeMedia/DankMaterialShell/master/quickshell/PLUGINS/plugin-schema.json",
|
||||||
"id": "gameBar",
|
"id": "gameBar",
|
||||||
"name": "Game Bar",
|
"name": "Game Bar",
|
||||||
"description": "Game Bar style overlay with quick actions and system monitoring",
|
"description": "Game Bar style overlay with movable widgets for quick settings and controls",
|
||||||
"version": "1.0.0",
|
"version": "2.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "keule",
|
"author": "keule2",
|
||||||
"icon": "sports_esports",
|
"icon": "sports_esports",
|
||||||
"type": "daemon",
|
"type": "composite",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"daemon",
|
"daemon",
|
||||||
|
"dankbar-widget",
|
||||||
|
"control-center",
|
||||||
"ipc"
|
"ipc"
|
||||||
],
|
],
|
||||||
"component": "./GameBarDaemon.qml",
|
"components": {
|
||||||
|
"daemon": "./GameBarDaemon.qml",
|
||||||
|
"widget": "./GameBarBarWidget.qml"
|
||||||
|
},
|
||||||
"settings": "./GameBarSettings.qml",
|
"settings": "./GameBarSettings.qml",
|
||||||
"requires_dms": ">=1.5.0",
|
"requires_dms": ">=1.5.0",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"settings_read",
|
"settings_read",
|
||||||
"settings_write"
|
"settings_write",
|
||||||
|
"process",
|
||||||
|
"network"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import qs.Common
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: service
|
||||||
|
|
||||||
|
property var daemon: null
|
||||||
|
|
||||||
|
readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({})
|
||||||
|
readonly property string saveDirectory: String(pluginData.captureDirectory || "~/Videos/GameBar").trim()
|
||||||
|
readonly property int fps: Math.max(10, pluginData.captureFps || 60)
|
||||||
|
readonly property int replaySeconds: Math.max(5, pluginData.captureReplaySeconds || 30)
|
||||||
|
readonly property string audioSource: String(pluginData.captureAudio || "default_output").trim()
|
||||||
|
readonly property string quality: String(pluginData.captureQuality || "very_high").trim()
|
||||||
|
|
||||||
|
// "" | "recording" | "replay"
|
||||||
|
property string mode: ""
|
||||||
|
property string targetMonitor: ""
|
||||||
|
property string lastFile: ""
|
||||||
|
property string lastError: ""
|
||||||
|
property double startedAt: 0
|
||||||
|
property int elapsedSeconds: 0
|
||||||
|
|
||||||
|
readonly property bool recording: mode === "recording"
|
||||||
|
readonly property bool replayArmed: mode === "replay"
|
||||||
|
readonly property bool busy: mode !== ""
|
||||||
|
|
||||||
|
signal notice(string message)
|
||||||
|
|
||||||
|
function expandedDirectory() {
|
||||||
|
const home = Quickshell.env("HOME") || "";
|
||||||
|
if (service.saveDirectory.startsWith("~/"))
|
||||||
|
return home + service.saveDirectory.substring(1);
|
||||||
|
|
||||||
|
return service.saveDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampedFile() {
|
||||||
|
const now = new Date();
|
||||||
|
const stamp = Qt.formatDateTime(now, "yyyy-MM-dd_HH-mm-ss");
|
||||||
|
return service.expandedDirectory() + "/GameBar_" + stamp + ".mp4";
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseArgs(monitor) {
|
||||||
|
return ["-w", monitor, "-f", String(service.fps), "-a", service.audioSource, "-q", service.quality, "-cursor", "no"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRecording(monitor) {
|
||||||
|
if (service.busy)
|
||||||
|
return;
|
||||||
|
|
||||||
|
service.targetMonitor = monitor || "focused";
|
||||||
|
service.lastFile = service.timestampedFile();
|
||||||
|
service.lastError = "";
|
||||||
|
recorder.command = ["sh", "-c", 'mkdir -p "$1" && exec gpu-screen-recorder "${@:2}"', "gameBar", service.expandedDirectory()].concat(service.baseArgs(service.targetMonitor)).concat(["-o", service.lastFile]);
|
||||||
|
recorder.running = true;
|
||||||
|
service.mode = "recording";
|
||||||
|
service.startedAt = Date.now();
|
||||||
|
service.elapsedSeconds = 0;
|
||||||
|
service.notice("Recording started");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReplay(monitor) {
|
||||||
|
if (service.busy)
|
||||||
|
return;
|
||||||
|
|
||||||
|
service.targetMonitor = monitor || "focused";
|
||||||
|
service.lastError = "";
|
||||||
|
recorder.command = ["sh", "-c", 'mkdir -p "$1" && exec gpu-screen-recorder "${@:2}"', "gameBar", service.expandedDirectory()].concat(service.baseArgs(service.targetMonitor)).concat(["-c", "mp4", "-r", String(service.replaySeconds), "-o", service.expandedDirectory()]);
|
||||||
|
recorder.running = true;
|
||||||
|
service.mode = "replay";
|
||||||
|
service.startedAt = Date.now();
|
||||||
|
service.elapsedSeconds = 0;
|
||||||
|
service.notice("Replay buffer armed (" + service.replaySeconds + "s)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGINT stops and saves a recording; in replay mode it just stops.
|
||||||
|
function stop() {
|
||||||
|
if (!service.busy)
|
||||||
|
return;
|
||||||
|
|
||||||
|
recorder.signal(2);
|
||||||
|
service.notice(service.recording ? "Recording saved" : "Replay buffer stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
// SIGUSR1 writes the last <replaySeconds> to disk, replay mode only.
|
||||||
|
function saveReplay() {
|
||||||
|
if (!service.replayArmed)
|
||||||
|
return;
|
||||||
|
|
||||||
|
recorder.signal(10);
|
||||||
|
service.notice("Replay saved");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRecording(monitor) {
|
||||||
|
if (service.recording)
|
||||||
|
service.stop();
|
||||||
|
else if (!service.busy)
|
||||||
|
service.startRecording(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleReplay(monitor) {
|
||||||
|
if (service.replayArmed)
|
||||||
|
service.stop();
|
||||||
|
else if (!service.busy)
|
||||||
|
service.startReplay(monitor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function screenshot(mode) {
|
||||||
|
// Delegate to the quickCapture plugin when it is installed.
|
||||||
|
Quickshell.execDetached(["dms", "ipc", "call", "quickCapture", "screenshot", mode || "region", "edit"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: recorder
|
||||||
|
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (raw !== "")
|
||||||
|
service.lastError = raw.split("\n").pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: exitCode => {
|
||||||
|
service.mode = "";
|
||||||
|
service.elapsedSeconds = 0;
|
||||||
|
// A clean exit still prints encoder chatter on stderr; only keep it
|
||||||
|
// around when the recorder actually failed.
|
||||||
|
if (exitCode === 0 || exitCode === 130) {
|
||||||
|
service.lastError = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.lastError !== "")
|
||||||
|
service.notice("Recorder: " + service.lastError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
running: service.busy
|
||||||
|
interval: 1000
|
||||||
|
repeat: true
|
||||||
|
onTriggered: service.elapsedSeconds = Math.floor((Date.now() - service.startedAt) / 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: service
|
||||||
|
|
||||||
|
property var daemon: null
|
||||||
|
|
||||||
|
readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({})
|
||||||
|
readonly property string baseUrl: String(pluginData.haUrl || "").trim().replace(/\/+$/, "")
|
||||||
|
readonly property string token: String(pluginData.haToken || "").trim()
|
||||||
|
readonly property int pollInterval: Math.max(2, pluginData.haPollInterval || 5) * 1000
|
||||||
|
readonly property bool configured: baseUrl !== "" && token !== ""
|
||||||
|
|
||||||
|
// entity_id -> state object, plus a sorted array for pickers.
|
||||||
|
property var entities: ({})
|
||||||
|
property var entityList: []
|
||||||
|
property string lastError: ""
|
||||||
|
property bool loading: false
|
||||||
|
property bool everLoaded: false
|
||||||
|
property int lastStatus: 0
|
||||||
|
|
||||||
|
// Polling only runs while something is actually showing HA data.
|
||||||
|
property int consumers: 0
|
||||||
|
|
||||||
|
signal refreshed
|
||||||
|
signal callFailed(string message)
|
||||||
|
|
||||||
|
function addConsumer() {
|
||||||
|
service.consumers = service.consumers + 1;
|
||||||
|
if (service.consumers === 1)
|
||||||
|
service.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeConsumer() {
|
||||||
|
service.consumers = Math.max(0, service.consumers - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entityState(entityId) {
|
||||||
|
return service.entities[entityId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyName(entityId) {
|
||||||
|
const state = service.entityState(entityId);
|
||||||
|
if (state && state.attributes && state.attributes.friendly_name)
|
||||||
|
return state.attributes.friendly_name;
|
||||||
|
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function domainOf(entityId) {
|
||||||
|
const parts = String(entityId || "").split(".");
|
||||||
|
return parts.length > 1 ? parts[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A changed URL or token invalidates whatever the last attempt reported.
|
||||||
|
onTokenChanged: service.lastError = ""
|
||||||
|
onBaseUrlChanged: service.lastError = ""
|
||||||
|
|
||||||
|
// The token is handed to curl through the environment, so it never shows up
|
||||||
|
// in the process arguments (and there is no stdin race to lose it to).
|
||||||
|
readonly property string curlGetScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -w "\n%{http_code}" -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" "$1"'
|
||||||
|
readonly property string curlPostScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" -X POST -d "$2" "$1"'
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
if (!service.configured) {
|
||||||
|
service.lastError = "Not configured";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (service.loading)
|
||||||
|
return;
|
||||||
|
|
||||||
|
service.loading = true;
|
||||||
|
statesProc.environment = {
|
||||||
|
"GAMEBAR_HA_TOKEN": service.token
|
||||||
|
};
|
||||||
|
statesProc.command = ["sh", "-c", service.curlGetScript, "gameBar", service.baseUrl + "/api/states"];
|
||||||
|
statesProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function callService(domain, method, data) {
|
||||||
|
if (!service.configured)
|
||||||
|
return;
|
||||||
|
|
||||||
|
service.callQueue.push({
|
||||||
|
"url": service.baseUrl + "/api/services/" + domain + "/" + method,
|
||||||
|
"body": JSON.stringify(data || {})
|
||||||
|
});
|
||||||
|
service.drainQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
property var callQueue: []
|
||||||
|
|
||||||
|
function drainQueue() {
|
||||||
|
if (callProc.running || service.callQueue.length === 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const next = service.callQueue.shift();
|
||||||
|
callProc.environment = {
|
||||||
|
"GAMEBAR_HA_TOKEN": service.token
|
||||||
|
};
|
||||||
|
callProc.command = ["sh", "-c", service.curlPostScript, "gameBar", next.url, next.body];
|
||||||
|
callProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(entityId) {
|
||||||
|
const domain = service.domainOf(entityId);
|
||||||
|
const toggleDomains = ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "media_player", "automation"];
|
||||||
|
const target = toggleDomains.indexOf(domain) !== -1 ? domain : "homeassistant";
|
||||||
|
service.callService(target, "toggle", {
|
||||||
|
"entity_id": entityId
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrightness(entityId, percent) {
|
||||||
|
service.callService("light", "turn_on", {
|
||||||
|
"entity_id": entityId,
|
||||||
|
"brightness_pct": Math.round(percent)
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVolume(entityId, percent) {
|
||||||
|
service.callService("media_player", "volume_set", {
|
||||||
|
"entity_id": entityId,
|
||||||
|
"volume_level": Math.max(0, Math.min(1, percent / 100))
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMuted(entityId, muted) {
|
||||||
|
service.callService("media_player", "volume_mute", {
|
||||||
|
"entity_id": entityId,
|
||||||
|
"is_volume_muted": muted === true
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNumber(entityId, value) {
|
||||||
|
service.callService(service.domainOf(entityId), "set_value", {
|
||||||
|
"entity_id": entityId,
|
||||||
|
"value": value
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function press(entityId) {
|
||||||
|
const domain = service.domainOf(entityId);
|
||||||
|
if (domain === "scene" || domain === "script")
|
||||||
|
service.callService(domain, "turn_on", {
|
||||||
|
"entity_id": entityId
|
||||||
|
});
|
||||||
|
else
|
||||||
|
service.callService(domain, "press", {
|
||||||
|
"entity_id": entityId
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cover(entityId, action) {
|
||||||
|
service.callService("cover", action, {
|
||||||
|
"entity_id": entityId
|
||||||
|
});
|
||||||
|
service.scheduleRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh() {
|
||||||
|
followUpTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: statesProc
|
||||||
|
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stdout: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
service.loading = false;
|
||||||
|
let raw = String(text || "").trim();
|
||||||
|
if (raw === "")
|
||||||
|
return;
|
||||||
|
|
||||||
|
// curl -w appends the HTTP status on its own line.
|
||||||
|
const split = raw.lastIndexOf("\n");
|
||||||
|
if (split !== -1) {
|
||||||
|
const tail = raw.substring(split + 1).trim();
|
||||||
|
if (/^[0-9]{3}$/.test(tail)) {
|
||||||
|
service.lastStatus = parseInt(tail, 10);
|
||||||
|
raw = raw.substring(0, split).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (service.lastStatus === 401) {
|
||||||
|
service.lastError = "401 Unauthorized - check the access token";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
service.lastError = parsed.message || "Unexpected response";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = {};
|
||||||
|
for (const item of parsed)
|
||||||
|
map[item.entity_id] = item;
|
||||||
|
|
||||||
|
service.entities = map;
|
||||||
|
service.entityList = parsed.slice().sort((a, b) => a.entity_id.localeCompare(b.entity_id));
|
||||||
|
service.lastError = "";
|
||||||
|
service.everLoaded = true;
|
||||||
|
service.refreshed();
|
||||||
|
} catch (e) {
|
||||||
|
service.lastError = "Invalid response from Home Assistant";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (raw !== "")
|
||||||
|
service.lastError = raw.split("\n")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: exitCode => {
|
||||||
|
service.loading = false;
|
||||||
|
if (exitCode !== 0 && service.lastError === "")
|
||||||
|
service.lastError = "Request failed (curl " + exitCode + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: callProc
|
||||||
|
|
||||||
|
running: false
|
||||||
|
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (raw !== "")
|
||||||
|
service.callFailed(raw.split("\n")[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: exitCode => {
|
||||||
|
if (exitCode !== 0)
|
||||||
|
service.callFailed("Service call failed (curl " + exitCode + ")");
|
||||||
|
|
||||||
|
service.drainQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: pollTimer
|
||||||
|
|
||||||
|
interval: service.pollInterval
|
||||||
|
repeat: true
|
||||||
|
running: service.consumers > 0 && service.configured
|
||||||
|
onTriggered: service.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home Assistant needs a moment to apply a service call before it shows up
|
||||||
|
// in /api/states.
|
||||||
|
Timer {
|
||||||
|
id: followUpTimer
|
||||||
|
|
||||||
|
interval: 400
|
||||||
|
repeat: false
|
||||||
|
onTriggered: service.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: haSettings
|
||||||
|
|
||||||
|
property var settings: null
|
||||||
|
|
||||||
|
readonly property var daemon: (typeof PluginService !== "undefined" && PluginService.pluginInstances) ? PluginService.pluginInstances["gameBar"] : null
|
||||||
|
readonly property var service: daemon ? daemon.homeService : null
|
||||||
|
readonly property var entityList: service ? service.entityList : []
|
||||||
|
|
||||||
|
property string filterText: ""
|
||||||
|
|
||||||
|
// Read straight from the daemon's (reactive) plugin data. Caching this in a
|
||||||
|
// local list loses the saved picks, because PluginSettings.loadValue() still
|
||||||
|
// returns the default while pluginService is being injected - and the next
|
||||||
|
// pick would then save that empty list over the stored one.
|
||||||
|
readonly property var selectedIds: {
|
||||||
|
const data = service ? service.pluginData : null;
|
||||||
|
const stored = data ? data.haEntities : null;
|
||||||
|
if (Array.isArray(stored))
|
||||||
|
return stored;
|
||||||
|
|
||||||
|
const fallback = settings ? settings.loadValue("haEntities", []) : [];
|
||||||
|
return Array.isArray(fallback) ? fallback : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property var filteredEntities: {
|
||||||
|
const query = filterText.trim().toLowerCase();
|
||||||
|
const list = haSettings.entityList;
|
||||||
|
if (query === "")
|
||||||
|
return list;
|
||||||
|
|
||||||
|
return list.filter(entity => {
|
||||||
|
const name = (entity.attributes && entity.attributes.friendly_name) ? entity.attributes.friendly_name : "";
|
||||||
|
return entity.entity_id.toLowerCase().includes(query) || name.toLowerCase().includes(query);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string statusText: {
|
||||||
|
if (!service)
|
||||||
|
return "Plugin daemon not running";
|
||||||
|
if (!service.configured)
|
||||||
|
return "Enter a URL and token, then refresh";
|
||||||
|
if (service.loading)
|
||||||
|
return "Contacting Home Assistant…";
|
||||||
|
if (service.lastError !== "")
|
||||||
|
return service.lastError;
|
||||||
|
if (service.everLoaded)
|
||||||
|
return entityList.length + " entities · " + selectedIds.length + " picked";
|
||||||
|
return "Not loaded yet";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(entityId) {
|
||||||
|
return haSettings.selectedIds.indexOf(entityId) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSelected(entityId, selected) {
|
||||||
|
const next = haSettings.selectedIds.slice();
|
||||||
|
const index = next.indexOf(entityId);
|
||||||
|
if (selected && index === -1)
|
||||||
|
next.push(entityId);
|
||||||
|
else if (!selected && index !== -1)
|
||||||
|
next.splice(index, 1);
|
||||||
|
|
||||||
|
if (settings)
|
||||||
|
settings.saveValue("haEntities", next);
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitHeight: column.implicitHeight
|
||||||
|
height: implicitHeight
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (service && service.configured && !service.everLoaded)
|
||||||
|
service.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: column
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
text: "Home Assistant"
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: "Pick the entities you want in the Home widget. The access token is stored in plugin_settings.json."
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
width: parent.width
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: Math.round(parent.width * 0.3)
|
||||||
|
text: "Status"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - Math.round(parent.width * 0.3) - refreshButton.width - parent.spacing * 2
|
||||||
|
text: haSettings.statusText
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: (haSettings.service && haSettings.service.lastError !== "") ? Theme.error : Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
DankButton {
|
||||||
|
id: refreshButton
|
||||||
|
|
||||||
|
text: "Refresh"
|
||||||
|
iconName: "refresh"
|
||||||
|
enabled: haSettings.service !== null && haSettings.service.configured
|
||||||
|
onClicked: haSettings.service.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankTextField {
|
||||||
|
width: parent.width
|
||||||
|
placeholderText: "Filter entities (e.g. light, kitchen)"
|
||||||
|
leftIconName: "search"
|
||||||
|
showClearButton: true
|
||||||
|
text: haSettings.filterText
|
||||||
|
onTextChanged: haSettings.filterText = text
|
||||||
|
visible: haSettings.entityList.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 280
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency)
|
||||||
|
border.color: Theme.outlineMedium
|
||||||
|
border.width: Theme.layerOutlineWidth
|
||||||
|
visible: haSettings.entityList.length > 0
|
||||||
|
|
||||||
|
DankListView {
|
||||||
|
id: entityListView
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
clip: true
|
||||||
|
spacing: 2
|
||||||
|
model: haSettings.filteredEntities
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: entityRow
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool picked: haSettings.isSelected(modelData.entity_id)
|
||||||
|
|
||||||
|
width: entityListView.width
|
||||||
|
height: 44
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: picked ? Theme.primarySelected : (rowArea.containsMouse ? Theme.surfaceHover : "transparent")
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: pickToggle.left
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: (entityRow.modelData.attributes && entityRow.modelData.attributes.friendly_name) ? entityRow.modelData.attributes.friendly_name : entityRow.modelData.entity_id
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: entityRow.picked ? Font.Medium : Font.Normal
|
||||||
|
color: Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: entityRow.modelData.entity_id + " · " + entityRow.modelData.state
|
||||||
|
font.pixelSize: Theme.fontSizeSmall - 1
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankToggle {
|
||||||
|
id: pickToggle
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
hideText: true
|
||||||
|
checked: entityRow.picked
|
||||||
|
onToggled: isChecked => haSettings.setSelected(entityRow.modelData.entity_id, isChecked)
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: rowArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.rightMargin: pickToggle.width + Theme.spacingM
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: haSettings.setSelected(entityRow.modelData.entity_id, !entityRow.picked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Quickshell.Services.Pipewire
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
import "../components"
|
||||||
|
|
||||||
DankFlickable {
|
DankFlickable {
|
||||||
id: mixer
|
id: mixer
|
||||||
@@ -109,7 +110,7 @@ DankFlickable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarVolumeRow {
|
VolumeRow {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.bottom: parent.bottom
|
anchors.bottom: parent.bottom
|
||||||
@@ -2,13 +2,14 @@ import QtQuick
|
|||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
import qs.Widgets
|
import qs.Widgets
|
||||||
|
import "../components"
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: widget
|
id: widget
|
||||||
|
|
||||||
readonly property real sectionSpacing: Theme.spacingS
|
readonly property real sectionSpacing: Theme.spacingS
|
||||||
|
|
||||||
GameBarVolumeRow {
|
VolumeRow {
|
||||||
id: outputVolume
|
id: outputVolume
|
||||||
|
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -30,7 +31,7 @@ Item {
|
|||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarDeviceList {
|
DeviceList {
|
||||||
id: outputDevices
|
id: outputDevices
|
||||||
|
|
||||||
anchors.top: outputLabel.bottom
|
anchors.top: outputLabel.bottom
|
||||||
@@ -41,7 +42,7 @@ Item {
|
|||||||
isSink: true
|
isSink: true
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarVolumeRow {
|
VolumeRow {
|
||||||
id: inputVolume
|
id: inputVolume
|
||||||
|
|
||||||
anchors.top: outputDevices.bottom
|
anchors.top: outputDevices.bottom
|
||||||
@@ -64,7 +65,7 @@ Item {
|
|||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarDeviceList {
|
DeviceList {
|
||||||
anchors.top: inputLabel.bottom
|
anchors.top: inputLabel.bottom
|
||||||
anchors.topMargin: Theme.spacingXS
|
anchors.topMargin: Theme.spacingXS
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Bluetooth
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
readonly property var adapter: BluetoothService.adapter
|
||||||
|
readonly property bool adapterEnabled: BluetoothService.enabled
|
||||||
|
readonly property var devices: BluetoothService.pairedDevices || []
|
||||||
|
|
||||||
|
function batteryText(device) {
|
||||||
|
if (device && device.batteryAvailable && device.battery > 0)
|
||||||
|
return Math.round(device.battery * 100) + "%";
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: header
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
height: 34
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: widget.adapterEnabled ? "bluetooth" : "bluetooth_disabled"
|
||||||
|
size: Theme.iconSize - 6
|
||||||
|
color: widget.adapterEnabled ? Theme.primary : Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: parent.width - Theme.iconSize - scanButton.width - toggle.width - parent.spacing * 3
|
||||||
|
text: widget.adapter ? (widget.adapterEnabled ? "On" : "Off") : "No adapter"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
id: scanButton
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconName: "search"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 17
|
||||||
|
iconColor: BluetoothService.discovering ? Theme.primary : Theme.surfaceText
|
||||||
|
enabled: widget.adapterEnabled
|
||||||
|
onClicked: {
|
||||||
|
if (widget.adapter)
|
||||||
|
widget.adapter.discovering = !widget.adapter.discovering;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankToggle {
|
||||||
|
id: toggle
|
||||||
|
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
hideText: true
|
||||||
|
checked: widget.adapterEnabled
|
||||||
|
enabled: widget.adapter !== null
|
||||||
|
onToggled: isChecked => {
|
||||||
|
if (widget.adapter)
|
||||||
|
widget.adapter.enabled = isChecked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankListView {
|
||||||
|
id: deviceList
|
||||||
|
|
||||||
|
anchors.top: header.bottom
|
||||||
|
anchors.topMargin: Theme.spacingXS
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
clip: true
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
visible: widget.adapterEnabled && widget.devices.length > 0
|
||||||
|
model: widget.devices
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: deviceItem
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool isConnected: modelData.connected
|
||||||
|
readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData)
|
||||||
|
|
||||||
|
width: deviceList.width
|
||||||
|
height: 46
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: isConnected ? Theme.primarySelected : (deviceArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency))
|
||||||
|
border.color: isConnected ? Theme.primary : Theme.outlineLight
|
||||||
|
border.width: isConnected ? 1 : Theme.layerOutlineWidth
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
id: deviceIcon
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: BluetoothService.getDeviceIcon(deviceItem.modelData)
|
||||||
|
size: Theme.iconSize - 6
|
||||||
|
color: deviceItem.isConnected ? Theme.primary : Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: deviceIcon.right
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: busyIndicator.left
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: deviceItem.modelData.name || deviceItem.modelData.address
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: deviceItem.isConnected ? Font.Medium : Font.Normal
|
||||||
|
color: Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: {
|
||||||
|
const battery = widget.batteryText(deviceItem.modelData);
|
||||||
|
const state = deviceItem.isBusy ? "Working…" : (deviceItem.isConnected ? "Connected" : "Disconnected");
|
||||||
|
return battery !== "" ? state + " · " + battery : state;
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankSpinner {
|
||||||
|
id: busyIndicator
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
size: 18
|
||||||
|
running: deviceItem.isBusy
|
||||||
|
visible: deviceItem.isBusy
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: deviceArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: !deviceItem.isBusy
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: {
|
||||||
|
if (deviceItem.isConnected)
|
||||||
|
deviceItem.modelData.disconnect();
|
||||||
|
else
|
||||||
|
BluetoothService.connectDeviceWithTrust(deviceItem.modelData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: !deviceList.visible
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "bluetooth_disabled"
|
||||||
|
size: Theme.iconSizeLarge
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: widget.adapterEnabled ? "No paired devices" : "Bluetooth is off"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
property var service: null
|
||||||
|
property var panel: null
|
||||||
|
|
||||||
|
readonly property bool recording: service ? service.recording : false
|
||||||
|
readonly property bool replayArmed: service ? service.replayArmed : false
|
||||||
|
readonly property bool busy: service ? service.busy : false
|
||||||
|
|
||||||
|
function monitorName() {
|
||||||
|
const screen = panel ? panel.targetScreen : null;
|
||||||
|
return (screen && screen.name) ? screen.name : "focused";
|
||||||
|
}
|
||||||
|
|
||||||
|
function elapsedText() {
|
||||||
|
const total = service ? service.elapsedSeconds : 0;
|
||||||
|
const mins = Math.floor(total / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
return (mins < 10 ? "0" : "") + mins + ":" + (secs < 10 ? "0" : "") + secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
component CaptureButton: Rectangle {
|
||||||
|
id: button
|
||||||
|
|
||||||
|
property string icon: ""
|
||||||
|
property string label: ""
|
||||||
|
property bool active: false
|
||||||
|
property bool enabled: true
|
||||||
|
|
||||||
|
signal triggered
|
||||||
|
|
||||||
|
height: 46
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: active ? Theme.primarySelected : (buttonArea.containsMouse && enabled ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency))
|
||||||
|
border.color: active ? Theme.primary : Theme.outlineLight
|
||||||
|
border.width: active ? 1 : Theme.layerOutlineWidth
|
||||||
|
opacity: enabled ? 1 : 0.45
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: button.icon
|
||||||
|
size: Theme.iconSize - 6
|
||||||
|
color: button.active ? Theme.primary : Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: button.label
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: button.active ? Font.Medium : Font.Normal
|
||||||
|
color: button.active ? Theme.primary : Theme.surfaceText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: buttonArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
enabled: button.enabled
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: button.triggered()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 40
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: widget.busy ? Theme.withAlpha(Theme.error, 0.15) : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
|
||||||
|
border.color: widget.busy ? Theme.error : Theme.outlineLight
|
||||||
|
border.width: Theme.layerOutlineWidth
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: statusDot
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 10
|
||||||
|
height: 10
|
||||||
|
radius: 5
|
||||||
|
color: widget.recording ? Theme.error : (widget.replayArmed ? Theme.warning : Theme.surfaceVariantText)
|
||||||
|
|
||||||
|
SequentialAnimation on opacity {
|
||||||
|
running: widget.busy
|
||||||
|
loops: Animation.Infinite
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
to: 0.25
|
||||||
|
duration: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
NumberAnimation {
|
||||||
|
to: 1
|
||||||
|
duration: 700
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.left: statusDot.right
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: elapsed.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: {
|
||||||
|
if (widget.recording)
|
||||||
|
return "Recording " + widget.monitorName();
|
||||||
|
if (widget.replayArmed)
|
||||||
|
return "Replay buffer armed";
|
||||||
|
return "Idle · " + widget.monitorName();
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: elapsed
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: widget.elapsedText()
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: widget.busy ? Theme.error : Theme.surfaceVariantText
|
||||||
|
visible: widget.busy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
width: parent.width
|
||||||
|
columns: 2
|
||||||
|
rowSpacing: Theme.spacingS
|
||||||
|
columnSpacing: Theme.spacingS
|
||||||
|
|
||||||
|
readonly property real cellWidth: (width - columnSpacing) / 2
|
||||||
|
|
||||||
|
CaptureButton {
|
||||||
|
width: parent.cellWidth
|
||||||
|
icon: widget.recording ? "stop_circle" : "videocam"
|
||||||
|
label: widget.recording ? "Stop & save" : "Record"
|
||||||
|
active: widget.recording
|
||||||
|
enabled: !widget.replayArmed
|
||||||
|
onTriggered: widget.service.toggleRecording(widget.monitorName())
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureButton {
|
||||||
|
width: parent.cellWidth
|
||||||
|
icon: widget.replayArmed ? "stop_circle" : "history"
|
||||||
|
label: widget.replayArmed ? "Disarm replay" : "Arm replay"
|
||||||
|
active: widget.replayArmed
|
||||||
|
enabled: !widget.recording
|
||||||
|
onTriggered: widget.service.toggleReplay(widget.monitorName())
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureButton {
|
||||||
|
width: parent.cellWidth
|
||||||
|
icon: "save"
|
||||||
|
label: "Save last " + (widget.service ? widget.service.replaySeconds : 30) + "s"
|
||||||
|
enabled: widget.replayArmed
|
||||||
|
onTriggered: widget.service.saveReplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
CaptureButton {
|
||||||
|
width: parent.cellWidth
|
||||||
|
icon: "screenshot_region"
|
||||||
|
label: "Screenshot"
|
||||||
|
onTriggered: {
|
||||||
|
if (widget.panel)
|
||||||
|
widget.panel.requestClose();
|
||||||
|
widget.service.screenshot("region");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: (widget.service && widget.service.lastFile !== "") ? "Last: " + widget.service.lastFile.split("/").pop() : "Saves to " + (widget.service ? widget.service.saveDirectory : "")
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: row
|
||||||
|
|
||||||
|
property var service: null
|
||||||
|
property string entityId: ""
|
||||||
|
|
||||||
|
readonly property var entity: service ? service.entityState(entityId) : null
|
||||||
|
readonly property string domain: service ? service.domainOf(entityId) : ""
|
||||||
|
readonly property var attributes: (entity && entity.attributes) ? entity.attributes : ({})
|
||||||
|
readonly property string state: entity ? String(entity.state) : "unavailable"
|
||||||
|
// Scenes/scripts/buttons report a timestamp or "unknown" rather than a state,
|
||||||
|
// so they must not be greyed out as unavailable.
|
||||||
|
readonly property bool available: entity !== null && (isButton || (state !== "unavailable" && state !== "unknown"))
|
||||||
|
readonly property bool isOn: state === "on" || state === "playing" || state === "open" || state === "home"
|
||||||
|
|
||||||
|
readonly property bool isToggleable: ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "automation"].indexOf(domain) !== -1
|
||||||
|
readonly property bool isMediaPlayer: domain === "media_player"
|
||||||
|
readonly property bool isCover: domain === "cover"
|
||||||
|
readonly property bool isButton: ["scene", "script", "button", "input_button"].indexOf(domain) !== -1
|
||||||
|
// number / input_number expose min, max and step and are set with set_value.
|
||||||
|
readonly property bool isNumber: domain === "number" || domain === "input_number"
|
||||||
|
readonly property real numberMin: attributes.min !== undefined ? attributes.min : 0
|
||||||
|
readonly property real numberMax: attributes.max !== undefined ? attributes.max : 100
|
||||||
|
readonly property real numberStep: (attributes.step !== undefined && attributes.step > 0) ? attributes.step : 1
|
||||||
|
readonly property real numberValue: {
|
||||||
|
const parsed = parseFloat(state);
|
||||||
|
return isNaN(parsed) ? numberMin : parsed;
|
||||||
|
}
|
||||||
|
// DankSlider works in whole numbers, so only drive it directly when the
|
||||||
|
// entity's own range is integral; otherwise map it onto 0-100.
|
||||||
|
readonly property bool numberDirect: isNumber && Number.isInteger(numberMin) && Number.isInteger(numberMax) && Number.isInteger(numberStep)
|
||||||
|
readonly property string numberUnit: attributes.unit_of_measurement || ""
|
||||||
|
|
||||||
|
function numberFromSlider(sliderValue) {
|
||||||
|
if (row.numberDirect)
|
||||||
|
return sliderValue;
|
||||||
|
|
||||||
|
const span = row.numberMax - row.numberMin;
|
||||||
|
const raw = row.numberMin + (sliderValue / 100) * span;
|
||||||
|
const stepped = row.numberMin + Math.round((raw - row.numberMin) / row.numberStep) * row.numberStep;
|
||||||
|
return Math.min(row.numberMax, Math.max(row.numberMin, Math.round(stepped * 1000) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property bool hasBrightness: domain === "light" && isOn && attributes.brightness !== undefined && attributes.brightness !== null
|
||||||
|
readonly property bool hasVolume: isMediaPlayer && attributes.volume_level !== undefined && attributes.volume_level !== null
|
||||||
|
readonly property bool hasSlider: hasBrightness || hasVolume || (isNumber && available)
|
||||||
|
|
||||||
|
readonly property int sliderPercent: {
|
||||||
|
if (hasBrightness)
|
||||||
|
return Math.round((attributes.brightness / 255) * 100);
|
||||||
|
if (hasVolume)
|
||||||
|
return Math.round(attributes.volume_level * 100);
|
||||||
|
if (isNumber) {
|
||||||
|
if (numberDirect)
|
||||||
|
return Math.round(numberValue);
|
||||||
|
|
||||||
|
const span = numberMax - numberMin;
|
||||||
|
return span > 0 ? Math.round(((numberValue - numberMin) / span) * 100) : 0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string icon: {
|
||||||
|
switch (domain) {
|
||||||
|
case "light":
|
||||||
|
return "lightbulb";
|
||||||
|
case "switch":
|
||||||
|
return "power_settings_new";
|
||||||
|
case "fan":
|
||||||
|
return "mode_fan";
|
||||||
|
case "media_player":
|
||||||
|
return "speaker";
|
||||||
|
case "cover":
|
||||||
|
return "blinds";
|
||||||
|
case "scene":
|
||||||
|
return "auto_awesome";
|
||||||
|
case "script":
|
||||||
|
return "play_arrow";
|
||||||
|
case "button":
|
||||||
|
case "input_button":
|
||||||
|
return "radio_button_checked";
|
||||||
|
case "climate":
|
||||||
|
return "thermostat";
|
||||||
|
case "number":
|
||||||
|
case "input_number":
|
||||||
|
return "tune";
|
||||||
|
case "lock":
|
||||||
|
return "lock";
|
||||||
|
case "binary_sensor":
|
||||||
|
return "sensors";
|
||||||
|
case "sensor":
|
||||||
|
return "monitoring";
|
||||||
|
}
|
||||||
|
return "home";
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly property string detail: {
|
||||||
|
if (!available)
|
||||||
|
return "Unavailable";
|
||||||
|
if (row.isButton) {
|
||||||
|
switch (domain) {
|
||||||
|
case "scene":
|
||||||
|
return "Scene";
|
||||||
|
case "script":
|
||||||
|
return "Script";
|
||||||
|
}
|
||||||
|
return "Button";
|
||||||
|
}
|
||||||
|
if (row.isMediaPlayer) {
|
||||||
|
const media = attributes.media_title || "";
|
||||||
|
return media !== "" ? state + " · " + media : state;
|
||||||
|
}
|
||||||
|
if (domain === "sensor" || domain === "binary_sensor")
|
||||||
|
return state + (attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "");
|
||||||
|
if (domain === "climate" && attributes.current_temperature !== undefined)
|
||||||
|
return state + " · " + attributes.current_temperature + "°";
|
||||||
|
if (row.isNumber)
|
||||||
|
return row.numberValue + (row.numberUnit !== "" ? " " + row.numberUnit : "");
|
||||||
|
if (row.hasBrightness)
|
||||||
|
return row.sliderPercent + "%";
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primaryAction() {
|
||||||
|
if (!service || !available)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (row.isButton)
|
||||||
|
service.press(entityId);
|
||||||
|
else if (row.isToggleable || row.isMediaPlayer)
|
||||||
|
service.toggle(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
height: hasSlider ? 74 : 48
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: row.isOn ? Theme.primarySelected : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
|
||||||
|
border.color: row.isOn ? Theme.primary : Theme.outlineLight
|
||||||
|
border.width: row.isOn ? 1 : Theme.layerOutlineWidth
|
||||||
|
opacity: available ? 1 : 0.55
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
id: entityIcon
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 12
|
||||||
|
name: row.icon
|
||||||
|
size: Theme.iconSize - 6
|
||||||
|
color: row.isOn ? Theme.primary : Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: labels
|
||||||
|
|
||||||
|
anchors.left: entityIcon.right
|
||||||
|
anchors.leftMargin: Theme.spacingS
|
||||||
|
anchors.right: controls.left
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 8
|
||||||
|
spacing: 0
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: row.service ? row.service.friendlyName(row.entityId) : row.entityId
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.weight: row.isOn ? Font.Medium : Font.Normal
|
||||||
|
color: Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: row.detail
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: controls
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 6
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isCover
|
||||||
|
iconName: "keyboard_arrow_up"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
onClicked: row.service.cover(row.entityId, "open_cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isCover
|
||||||
|
iconName: "stop"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 16
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
onClicked: row.service.cover(row.entityId, "stop_cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isCover
|
||||||
|
iconName: "keyboard_arrow_down"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
onClicked: row.service.cover(row.entityId, "close_cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isMediaPlayer
|
||||||
|
iconName: row.attributes.is_volume_muted === true ? "volume_off" : "volume_up"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 17
|
||||||
|
iconColor: row.attributes.is_volume_muted === true ? Theme.error : Theme.surfaceText
|
||||||
|
onClicked: row.service.setMuted(row.entityId, row.attributes.is_volume_muted !== true)
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isMediaPlayer
|
||||||
|
iconName: row.state === "playing" ? "pause" : "play_arrow"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
onClicked: row.service.callService("media_player", "media_play_pause", {
|
||||||
|
"entity_id": row.entityId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
DankToggle {
|
||||||
|
visible: row.isToggleable
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
checked: row.isOn
|
||||||
|
enabled: row.available
|
||||||
|
onToggled: isChecked => row.service.toggle(row.entityId)
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
visible: row.isButton
|
||||||
|
iconName: "play_arrow"
|
||||||
|
buttonSize: 30
|
||||||
|
iconSize: 18
|
||||||
|
iconColor: Theme.primary
|
||||||
|
onClicked: row.service.press(row.entityId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankSlider {
|
||||||
|
id: slider
|
||||||
|
|
||||||
|
visible: row.hasSlider
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.rightMargin: Theme.spacingM
|
||||||
|
anchors.bottomMargin: -4
|
||||||
|
minimum: row.numberDirect ? Math.round(row.numberMin) : 0
|
||||||
|
maximum: row.numberDirect ? Math.round(row.numberMax) : 100
|
||||||
|
step: row.numberDirect ? Math.round(row.numberStep) : 1
|
||||||
|
showValue: false
|
||||||
|
enabled: row.available
|
||||||
|
onSliderValueChanged: newValue => {
|
||||||
|
if (row.hasBrightness)
|
||||||
|
row.service.setBrightness(row.entityId, newValue);
|
||||||
|
else if (row.hasVolume)
|
||||||
|
row.service.setVolume(row.entityId, newValue);
|
||||||
|
else if (row.isNumber)
|
||||||
|
row.service.setNumber(row.entityId, row.numberFromSlider(newValue));
|
||||||
|
}
|
||||||
|
Component.onCompleted: slider.value = row.sliderPercent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the slider in step with Home Assistant without fighting the user's drag.
|
||||||
|
onSliderPercentChanged: {
|
||||||
|
if (!slider.isDragging)
|
||||||
|
slider.value = row.sliderPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: row.hasSlider ? slider.top : parent.bottom
|
||||||
|
anchors.right: controls.left
|
||||||
|
enabled: row.available && (row.isToggleable || row.isMediaPlayer || row.isButton)
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: row.primaryAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
property var service: null
|
||||||
|
|
||||||
|
readonly property var selected: {
|
||||||
|
const stored = (service && service.pluginData) ? service.pluginData.haEntities : null;
|
||||||
|
return Array.isArray(stored) ? stored : [];
|
||||||
|
}
|
||||||
|
readonly property bool configured: service ? service.configured : false
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (service)
|
||||||
|
service.addConsumer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onDestruction: {
|
||||||
|
if (service)
|
||||||
|
service.removeConsumer();
|
||||||
|
}
|
||||||
|
|
||||||
|
DankFlickable {
|
||||||
|
id: list
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: widget.configured && widget.selected.length > 0
|
||||||
|
clip: true
|
||||||
|
contentHeight: entityColumn.height
|
||||||
|
contentWidth: width
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: entityColumn
|
||||||
|
|
||||||
|
width: list.width
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: widget.selected
|
||||||
|
|
||||||
|
delegate: HomeRow {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
width: entityColumn.width
|
||||||
|
service: widget.service
|
||||||
|
entityId: modelData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - Theme.spacingL
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: !list.visible
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: widget.configured ? "playlist_add" : "home"
|
||||||
|
size: Theme.iconSizeLarge
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
text: widget.configured ? "No entities picked yet.\nChoose them in Settings > Plugins > Game Bar." : "Home Assistant is not set up.\nAdd your URL and token in Settings > Plugins > Game Bar."
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
text: widget.service ? widget.service.lastError : ""
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.error
|
||||||
|
elide: Text.ElideRight
|
||||||
|
visible: text !== ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell.Services.Mpris
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
readonly property var activePlayer: MprisController.activePlayer
|
||||||
|
readonly property var players: MprisController.availablePlayers || []
|
||||||
|
readonly property bool hasPlayer: activePlayer !== null && activePlayer !== undefined
|
||||||
|
readonly property bool isPlaying: hasPlayer && activePlayer.playbackState === MprisPlaybackState.Playing
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const total = Math.max(0, Math.floor(seconds || 0));
|
||||||
|
const mins = Math.floor(total / 60);
|
||||||
|
const secs = total % 60;
|
||||||
|
return mins + ":" + (secs < 10 ? "0" : "") + secs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerName(player) {
|
||||||
|
if (!player)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
return player.identity || player.dbusName || "Player";
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: widget.hasPlayer
|
||||||
|
|
||||||
|
DankAlbumArt {
|
||||||
|
id: art
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
width: 84
|
||||||
|
height: 84
|
||||||
|
activePlayer: widget.activePlayer
|
||||||
|
showAnimation: widget.isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: meta
|
||||||
|
|
||||||
|
anchors.left: art.right
|
||||||
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: Theme.spacingXS
|
||||||
|
spacing: 2
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: (widget.activePlayer && widget.activePlayer.trackTitle) ? widget.activePlayer.trackTitle : "Nothing playing"
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.Medium
|
||||||
|
color: Theme.surfaceText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: (widget.activePlayer && widget.activePlayer.trackArtist) ? widget.activePlayer.trackArtist : ""
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
visible: text !== ""
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
width: parent.width
|
||||||
|
text: (widget.activePlayer && widget.activePlayer.trackAlbum) ? widget.activePlayer.trackAlbum : ""
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
visible: text !== ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankSeekbar {
|
||||||
|
id: seekbar
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: art.bottom
|
||||||
|
anchors.topMargin: Theme.spacingS
|
||||||
|
height: 20
|
||||||
|
activePlayer: widget.activePlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: positionLabel
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: seekbar.bottom
|
||||||
|
text: widget.formatTime(widget.activePlayer ? widget.activePlayer.position : 0)
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: seekbar.bottom
|
||||||
|
text: widget.formatTime(MprisController.activePlayerStableLength)
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
visible: MprisController.activePlayerStableLength > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: transport
|
||||||
|
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
anchors.top: positionLabel.bottom
|
||||||
|
anchors.topMargin: Theme.spacingXS
|
||||||
|
spacing: Theme.spacingM
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconName: "skip_previous"
|
||||||
|
buttonSize: 38
|
||||||
|
iconSize: 22
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
enabled: widget.hasPlayer && widget.activePlayer.canGoPrevious
|
||||||
|
onClicked: MprisController.previousOrRewind()
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 46
|
||||||
|
height: 46
|
||||||
|
radius: width / 2
|
||||||
|
color: Theme.primary
|
||||||
|
opacity: (widget.hasPlayer && widget.activePlayer.canTogglePlaying) ? 1 : 0.5
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
name: widget.isPlaying ? "pause" : "play_arrow"
|
||||||
|
size: 26
|
||||||
|
color: Theme.onPrimary
|
||||||
|
weight: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: widget.hasPlayer && widget.activePlayer.canTogglePlaying
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
onClicked: widget.activePlayer.togglePlaying()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DankActionButton {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
iconName: "skip_next"
|
||||||
|
buttonSize: 38
|
||||||
|
iconSize: 22
|
||||||
|
iconColor: Theme.surfaceText
|
||||||
|
enabled: widget.hasPlayer && widget.activePlayer.canGoNext
|
||||||
|
onClicked: MprisController.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source switcher - one chip per MPRIS player that is currently around.
|
||||||
|
DankFlickable {
|
||||||
|
id: sourceRow
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 30
|
||||||
|
clip: true
|
||||||
|
contentHeight: height
|
||||||
|
contentWidth: sourceChips.width
|
||||||
|
flickableDirection: Flickable.HorizontalFlick
|
||||||
|
visible: widget.players.length > 1
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: sourceChips
|
||||||
|
|
||||||
|
height: sourceRow.height
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: widget.players
|
||||||
|
|
||||||
|
delegate: Rectangle {
|
||||||
|
id: chip
|
||||||
|
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property bool current: modelData === widget.activePlayer
|
||||||
|
|
||||||
|
width: chipLabel.implicitWidth + Theme.spacingM * 2
|
||||||
|
height: 26
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
radius: height / 2
|
||||||
|
color: current ? Theme.primarySelected : (chipArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency))
|
||||||
|
border.color: current ? Theme.primary : Theme.outlineLight
|
||||||
|
border.width: current ? 1 : Theme.layerOutlineWidth
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: chipLabel
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: widget.playerName(chip.modelData)
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: chip.current ? Font.Medium : Font.Normal
|
||||||
|
color: chip.current ? Theme.primary : Theme.surfaceText
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: chipArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: MprisController.setActivePlayer(chip.modelData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingS
|
||||||
|
visible: !widget.hasPlayer
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
name: "music_off"
|
||||||
|
size: Theme.iconSizeLarge
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
text: "No media player running"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import QtQuick
|
||||||
|
import QtQuick.Controls
|
||||||
|
import Quickshell
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
readonly property var currentTab: {
|
||||||
|
const tabs = NotepadStorageService.tabs || [];
|
||||||
|
const index = NotepadStorageService.currentTabIndex;
|
||||||
|
return (index >= 0 && index < tabs.length) ? tabs[index] : null;
|
||||||
|
}
|
||||||
|
// DMS only writes back temporary (scratch) tabs automatically; a tab backed
|
||||||
|
// by a real file is shown read-only so edits here cannot clobber it.
|
||||||
|
readonly property bool editable: currentTab !== null && currentTab.isTemporary === true
|
||||||
|
property bool contentLoaded: false
|
||||||
|
property int loadedTabId: -1
|
||||||
|
property bool applyingRemote: false
|
||||||
|
property bool dirty: false
|
||||||
|
|
||||||
|
function loadContent() {
|
||||||
|
if (!widget.currentTab)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const tabId = widget.currentTab.id;
|
||||||
|
widget.contentLoaded = false;
|
||||||
|
NotepadStorageService.loadTabContent(NotepadStorageService.currentTabIndex, content => {
|
||||||
|
const buffer = NotepadStorageService.getSessionBuffer(tabId);
|
||||||
|
widget.applyingRemote = true;
|
||||||
|
editor.text = (buffer !== undefined) ? buffer.content : content;
|
||||||
|
widget.applyingRemote = false;
|
||||||
|
widget.loadedTabId = tabId;
|
||||||
|
widget.contentLoaded = true;
|
||||||
|
widget.dirty = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!widget.contentLoaded || !widget.editable || widget.loadedTabId < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
NotepadStorageService.setSessionBuffer(widget.loadedTabId, editor.text, editor.text);
|
||||||
|
NotepadStorageService.saveTabContent(NotepadStorageService.currentTabIndex, editor.text);
|
||||||
|
widget.dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ref {
|
||||||
|
service: NotepadStorageService
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: widget.loadContent()
|
||||||
|
Component.onDestruction: {
|
||||||
|
if (widget.dirty)
|
||||||
|
widget.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: NotepadStorageService
|
||||||
|
|
||||||
|
function onCurrentTabIndexChanged() {
|
||||||
|
widget.loadContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: saveTimer
|
||||||
|
|
||||||
|
interval: 900
|
||||||
|
repeat: false
|
||||||
|
onTriggered: widget.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: editorFrame
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: footer.top
|
||||||
|
anchors.bottomMargin: Theme.spacingXS
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
|
||||||
|
border.color: editor.activeFocus ? Theme.primary : Theme.outlineLight
|
||||||
|
border.width: editor.activeFocus ? 1 : Theme.layerOutlineWidth
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
DankFlickable {
|
||||||
|
id: editorFlick
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: Theme.spacingS
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: editor.implicitHeight
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
TextArea {
|
||||||
|
id: editor
|
||||||
|
|
||||||
|
width: editorFlick.width
|
||||||
|
readOnly: !widget.editable
|
||||||
|
wrapMode: TextArea.Wrap
|
||||||
|
selectByMouse: true
|
||||||
|
placeholderText: widget.editable ? "Notes, seeds, codes…" : "Open the DMS notepad to edit this tab"
|
||||||
|
color: Theme.surfaceText
|
||||||
|
placeholderTextColor: Theme.surfaceVariantText
|
||||||
|
font.pixelSize: Theme.fontSizeMedium
|
||||||
|
font.family: Theme.monoFontFamily
|
||||||
|
background: null
|
||||||
|
|
||||||
|
onTextChanged: {
|
||||||
|
if (widget.applyingRemote || !widget.contentLoaded || !widget.editable)
|
||||||
|
return;
|
||||||
|
|
||||||
|
widget.dirty = true;
|
||||||
|
saveTimer.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: footer
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 26
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.right: openButton.left
|
||||||
|
anchors.rightMargin: Theme.spacingS
|
||||||
|
text: {
|
||||||
|
if (!widget.currentTab)
|
||||||
|
return "No notepad tab";
|
||||||
|
if (!widget.editable)
|
||||||
|
return widget.currentTab.title + " · read-only here";
|
||||||
|
return widget.currentTab.title + (widget.dirty ? " · saving…" : " · saved");
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: openButton
|
||||||
|
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: openRow.width + Theme.spacingM * 2
|
||||||
|
height: 24
|
||||||
|
radius: Theme.cornerRadius
|
||||||
|
color: openArea.containsMouse ? Theme.surfaceHover : "transparent"
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: openRow
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
spacing: Theme.spacingXS
|
||||||
|
|
||||||
|
DankIcon {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
name: "open_in_new"
|
||||||
|
size: Theme.fontSizeMedium
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Full notepad"
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: openArea
|
||||||
|
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onClicked: {
|
||||||
|
widget.save();
|
||||||
|
Quickshell.execDetached(["dms", "ipc", "call", "notepad", "toggle"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell.Services.Mpris
|
||||||
|
import qs.Common
|
||||||
|
import qs.Services
|
||||||
|
import qs.Widgets
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: widget
|
||||||
|
|
||||||
|
readonly property var activePlayer: MprisController.activePlayer
|
||||||
|
readonly property bool isPlaying: activePlayer !== null && activePlayer !== undefined && activePlayer.playbackState === MprisPlaybackState.Playing
|
||||||
|
// Follow whatever is actually coming out of this machine - games do not
|
||||||
|
// publish MPRIS, and a remote MPRIS player produces no local audio at all.
|
||||||
|
readonly property bool live: visible
|
||||||
|
readonly property bool hasSignal: {
|
||||||
|
const values = CavaService.values;
|
||||||
|
if (!values || values.length < 6)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
if (values[i] > 0)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cava reports six bands; mirroring them reads as a classic visualiser.
|
||||||
|
// CavaService.values is a list<int>, so follow its change signal rather than
|
||||||
|
// binding to it (this is how the shell's own visualiser reads it).
|
||||||
|
property var bands: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
function refreshBands() {
|
||||||
|
const values = CavaService.values;
|
||||||
|
if (!values || values.length < 6) {
|
||||||
|
widget.bands = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = [values[5], values[4], values[3], values[2], values[1], values[0]];
|
||||||
|
widget.bands = left.concat([values[0], values[1], values[2], values[3], values[4], values[5]]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: CavaService
|
||||||
|
enabled: widget.live
|
||||||
|
|
||||||
|
function onValuesChanged() {
|
||||||
|
widget.refreshBands();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLiveChanged: {
|
||||||
|
if (!widget.live)
|
||||||
|
widget.bands = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
||||||
|
else
|
||||||
|
widget.refreshBands();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holding a Ref keeps the cava process alive only while this widget is up.
|
||||||
|
Loader {
|
||||||
|
active: widget.live
|
||||||
|
|
||||||
|
sourceComponent: Component {
|
||||||
|
Ref {
|
||||||
|
service: CavaService
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
id: barRow
|
||||||
|
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
width: parent.width
|
||||||
|
height: parent.height - trackLabel.height - Theme.spacingS
|
||||||
|
spacing: Math.max(2, width * 0.012)
|
||||||
|
|
||||||
|
readonly property real barWidth: (width - spacing * 11) / 12
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: widget.bands
|
||||||
|
|
||||||
|
// A positioner manages x, so the growing bar lives inside a
|
||||||
|
// full-height cell rather than anchoring to the Row itself.
|
||||||
|
delegate: Item {
|
||||||
|
required property var modelData
|
||||||
|
|
||||||
|
readonly property real level: {
|
||||||
|
if (!widget.live)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Square-root curve, as the shell's own visualiser uses -
|
||||||
|
// linear levels look flat for anything but loud passages.
|
||||||
|
const raw = Math.max(0, Math.min(100, modelData || 0));
|
||||||
|
return Math.sqrt(raw * 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
width: barRow.barWidth
|
||||||
|
height: barRow.height
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: parent.width
|
||||||
|
height: Math.max(3, parent.height * parent.level)
|
||||||
|
radius: width / 2
|
||||||
|
color: Theme.primary
|
||||||
|
opacity: 0.55 + parent.level * 0.45
|
||||||
|
|
||||||
|
Behavior on height {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 90
|
||||||
|
easing.type: Easing.OutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: trackLabel
|
||||||
|
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
text: {
|
||||||
|
if (!CavaService.cavaAvailable)
|
||||||
|
return "cava is not installed";
|
||||||
|
if (widget.isPlaying) {
|
||||||
|
const title = widget.activePlayer.trackTitle || "";
|
||||||
|
const artist = widget.activePlayer.trackArtist || "";
|
||||||
|
return artist !== "" ? title + " — " + artist : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return widget.hasSignal ? "Playing" : "No audio playing";
|
||||||
|
}
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
color: Theme.surfaceVariantText
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user