feat: added widgets capture, music visualiser, bluetooth, notes
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+277
-23
@@ -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,28 @@ 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",
|
"id": "media",
|
||||||
"title": "Media",
|
"title": "Media",
|
||||||
"icon": "music_note",
|
"icon": "music_note",
|
||||||
"width": 430,
|
"width": 430,
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"x": 0.04,
|
"x": 0.3,
|
||||||
"y": 0.56,
|
"y": 0.52,
|
||||||
"defaultVisible": true
|
"defaultVisible": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -52,8 +64,38 @@ PluginComponent {
|
|||||||
"icon": "home",
|
"icon": "home",
|
||||||
"width": 420,
|
"width": 420,
|
||||||
"height": 420,
|
"height": 420,
|
||||||
"x": 0.56,
|
"x": 0.57,
|
||||||
"y": 0.50,
|
"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
|
"defaultVisible": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,8 +104,8 @@ PluginComponent {
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -100,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);
|
||||||
}
|
}
|
||||||
@@ -207,6 +395,52 @@ PluginComponent {
|
|||||||
return "SUCCESS";
|
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 {
|
function homeStatus() : string {
|
||||||
const picked = Array.isArray(root.pluginData.haEntities) ? root.pluginData.haEntities.length : 0;
|
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)");
|
return "configured=" + haService.configured + " url=" + (haService.baseUrl || "(none)") + " tokenChars=" + haService.token.length + " http=" + haService.lastStatus + " entities=" + haService.entityList.length + " picked=" + picked + " error=" + (haService.lastError || "(none)");
|
||||||
@@ -224,20 +458,40 @@ PluginComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readonly property alias homeService: haService
|
readonly property alias homeService: haService
|
||||||
|
readonly property alias captureService: capture
|
||||||
|
|
||||||
GameBarHomeService {
|
CaptureService {
|
||||||
|
id: capture
|
||||||
|
|
||||||
|
daemon: root
|
||||||
|
onNotice: message => {
|
||||||
|
if (typeof ToastService !== "undefined" && ToastService)
|
||||||
|
ToastService.showInfo(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeService {
|
||||||
id: haService
|
id: haService
|
||||||
|
|
||||||
daemon: root
|
daemon: root
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarOverlay {
|
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;
|
||||||
|
|||||||
+90
-2
@@ -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|media|home|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,93 @@ 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 {
|
StringSetting {
|
||||||
settingKey: "haUrl"
|
settingKey: "haUrl"
|
||||||
label: "Home Assistant URL"
|
label: "Home Assistant URL"
|
||||||
@@ -81,7 +169,7 @@ PluginSettings {
|
|||||||
rightIcon: "schedule"
|
rightIcon: "schedule"
|
||||||
}
|
}
|
||||||
|
|
||||||
GameBarHomeSettings {
|
HomeSettings {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
settings: settings
|
settings: settings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ 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 |
|
| **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** | 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 |
|
||||||
|
|
||||||
@@ -44,6 +48,12 @@ Note that editing `plugin_settings.json` by hand does not reach a running shell.
|
|||||||
| `dms ipc call gameBar homeToggle <entity_id>` | Toggle an entity (activates a scene/script/button) |
|
| `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 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 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
|
||||||
@@ -51,6 +61,8 @@ Note that editing `plugin_settings.json` by hand does not reach a running shell.
|
|||||||
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.
|
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 +108,16 @@ FocusScope {
|
|||||||
return audioWidgetComponent;
|
return audioWidgetComponent;
|
||||||
case "apps":
|
case "apps":
|
||||||
return appsWidgetComponent;
|
return appsWidgetComponent;
|
||||||
|
case "capture":
|
||||||
|
return captureWidgetComponent;
|
||||||
case "media":
|
case "media":
|
||||||
return mediaWidgetComponent;
|
return mediaWidgetComponent;
|
||||||
|
case "visualizer":
|
||||||
|
return visualizerWidgetComponent;
|
||||||
|
case "bluetooth":
|
||||||
|
return bluetoothWidgetComponent;
|
||||||
|
case "notepad":
|
||||||
|
return notepadWidgetComponent;
|
||||||
case "home":
|
case "home":
|
||||||
return homeWidgetComponent;
|
return homeWidgetComponent;
|
||||||
case "system":
|
case "system":
|
||||||
@@ -119,25 +131,52 @@ 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 {
|
Component {
|
||||||
id: mediaWidgetComponent
|
id: mediaWidgetComponent
|
||||||
|
|
||||||
GameBarMediaWidget {}
|
MediaWidget {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: homeWidgetComponent
|
id: homeWidgetComponent
|
||||||
|
|
||||||
GameBarHomeWidget {
|
HomeWidget {
|
||||||
service: root.daemon ? root.daemon.homeService : null
|
service: root.daemon ? root.daemon.homeService : null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -145,6 +184,6 @@ FocusScope {
|
|||||||
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
|
||||||
+8
-3
@@ -3,16 +3,21 @@
|
|||||||
"id": "gameBar",
|
"id": "gameBar",
|
||||||
"name": "Game Bar",
|
"name": "Game Bar",
|
||||||
"description": "Game Bar style overlay with movable widgets for quick settings and controls",
|
"description": "Game Bar style overlay with movable widgets for quick settings and controls",
|
||||||
"version": "2.0.0",
|
"version": "2.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"author": "keule2",
|
"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": [
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ Item {
|
|||||||
Repeater {
|
Repeater {
|
||||||
model: widget.selected
|
model: widget.selected
|
||||||
|
|
||||||
delegate: GameBarHomeRow {
|
delegate: HomeRow {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
||||||
width: entityColumn.width
|
width: entityColumn.width
|
||||||
@@ -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