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.Modules.Plugins
|
||||
import qs.Services
|
||||
import "./services"
|
||||
import "./overlay"
|
||||
|
||||
PluginComponent {
|
||||
id: root
|
||||
@@ -22,7 +24,7 @@ PluginComponent {
|
||||
"icon": "volume_up",
|
||||
"width": 430,
|
||||
"height": 470,
|
||||
"x": 0.04,
|
||||
"x": 0.03,
|
||||
"y": 0.14,
|
||||
"defaultVisible": true
|
||||
},
|
||||
@@ -32,18 +34,28 @@ PluginComponent {
|
||||
"icon": "tune",
|
||||
"width": 400,
|
||||
"height": 360,
|
||||
"x": 0.30,
|
||||
"x": 0.3,
|
||||
"y": 0.14,
|
||||
"defaultVisible": true
|
||||
},
|
||||
{
|
||||
"id": "capture",
|
||||
"title": "Capture",
|
||||
"icon": "videocam",
|
||||
"width": 380,
|
||||
"height": 230,
|
||||
"x": 0.03,
|
||||
"y": 0.62,
|
||||
"defaultVisible": false
|
||||
},
|
||||
{
|
||||
"id": "media",
|
||||
"title": "Media",
|
||||
"icon": "music_note",
|
||||
"width": 430,
|
||||
"height": 300,
|
||||
"x": 0.04,
|
||||
"y": 0.56,
|
||||
"x": 0.3,
|
||||
"y": 0.52,
|
||||
"defaultVisible": true
|
||||
},
|
||||
{
|
||||
@@ -52,8 +64,38 @@ PluginComponent {
|
||||
"icon": "home",
|
||||
"width": 420,
|
||||
"height": 420,
|
||||
"x": 0.56,
|
||||
"y": 0.50,
|
||||
"x": 0.57,
|
||||
"y": 0.14,
|
||||
"defaultVisible": false
|
||||
},
|
||||
{
|
||||
"id": "visualizer",
|
||||
"title": "Visualiser",
|
||||
"icon": "graphic_eq",
|
||||
"width": 360,
|
||||
"height": 180,
|
||||
"x": 0.3,
|
||||
"y": 0.79,
|
||||
"defaultVisible": false
|
||||
},
|
||||
{
|
||||
"id": "bluetooth",
|
||||
"title": "Bluetooth",
|
||||
"icon": "bluetooth",
|
||||
"width": 380,
|
||||
"height": 300,
|
||||
"x": 0.8,
|
||||
"y": 0.14,
|
||||
"defaultVisible": false
|
||||
},
|
||||
{
|
||||
"id": "notepad",
|
||||
"title": "Notes",
|
||||
"icon": "edit_note",
|
||||
"width": 400,
|
||||
"height": 320,
|
||||
"x": 0.57,
|
||||
"y": 0.58,
|
||||
"defaultVisible": false
|
||||
},
|
||||
{
|
||||
@@ -62,8 +104,8 @@ PluginComponent {
|
||||
"icon": "speed",
|
||||
"width": 380,
|
||||
"height": 330,
|
||||
"x": 0.56,
|
||||
"y": 0.14,
|
||||
"x": 0.8,
|
||||
"y": 0.45,
|
||||
"defaultVisible": false
|
||||
}
|
||||
]
|
||||
@@ -100,39 +142,185 @@ PluginComponent {
|
||||
// ── Positions ────────────────────────────────────────────────────────────
|
||||
// Stored per screen as fractions of the screen size, so the same layout
|
||||
// survives monitors of different resolutions (and is re-clamped on load).
|
||||
// ── Brightness device mapping ────────────────────────────────────────────
|
||||
// DisplayService only knows DDC devices as i2c bus ids, and the shell maps
|
||||
// them to screens through manual pins. "ddcutil detect" reports the DRM
|
||||
// connector for each bus, which is the Wayland output name, so the mapping
|
||||
// can be worked out once and cached.
|
||||
property var brightnessMap: ({})
|
||||
property bool brightnessMapScanning: false
|
||||
|
||||
// Picks the cached map up as soon as plugin data arrives; surfaces are
|
||||
// created before the daemon registers, so they cannot drive this.
|
||||
readonly property var storedBrightnessMap: (pluginData && pluginData.brightnessMap && typeof pluginData.brightnessMap === "object") ? pluginData.brightnessMap : ({})
|
||||
|
||||
onStoredBrightnessMapChanged: {
|
||||
if (Object.keys(root.storedBrightnessMap).length > 0)
|
||||
root.brightnessMap = root.storedBrightnessMap;
|
||||
|
||||
root.ensureBrightnessMap();
|
||||
}
|
||||
|
||||
function brightnessDeviceForScreen(screenName) {
|
||||
if (!screenName)
|
||||
return "";
|
||||
|
||||
// A pin set in the shell's own UI always wins.
|
||||
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
|
||||
const screen = Quickshell.screens.find(s => s.name === screenName);
|
||||
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
|
||||
if (pins[pinKey] && DisplayService.devices.some(d => d.id === pins[pinKey]))
|
||||
return pins[pinKey];
|
||||
|
||||
const mapped = root.brightnessMap[screenName];
|
||||
if (mapped && DisplayService.devices.some(d => d.id === mapped))
|
||||
return mapped;
|
||||
|
||||
const backlight = DisplayService.devices.find(d => d.class === "backlight");
|
||||
if (backlight)
|
||||
return backlight.id;
|
||||
|
||||
const ddc = DisplayService.devices.find(d => d.class === "ddc");
|
||||
return ddc ? ddc.id : "";
|
||||
}
|
||||
|
||||
// The shell's own Control Center brightness slider resolves its device from
|
||||
// per-screen pins, so publishing the detected map there makes the built-in
|
||||
// slider (and its icon menu) follow whichever monitor the panel is on.
|
||||
function syncBrightnessPins() {
|
||||
if (pluginData.autoPinBrightness === false)
|
||||
return;
|
||||
|
||||
if (typeof CacheData === "undefined" || !CacheData)
|
||||
return;
|
||||
|
||||
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
|
||||
let changed = false;
|
||||
|
||||
for (const screen of Quickshell.screens) {
|
||||
const device = root.brightnessMap[screen.name];
|
||||
if (!device)
|
||||
continue;
|
||||
|
||||
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
|
||||
// Never overwrite a pin the user set by hand.
|
||||
if (pins[key])
|
||||
continue;
|
||||
|
||||
pins[key] = device;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
CacheData.set("brightnessDevicePins", pins);
|
||||
}
|
||||
|
||||
function ensureBrightnessMap() {
|
||||
if (root.brightnessMapScanning)
|
||||
return;
|
||||
|
||||
if (Object.keys(root.brightnessMap).length === 0 && Object.keys(root.storedBrightnessMap).length > 0)
|
||||
root.brightnessMap = root.storedBrightnessMap;
|
||||
|
||||
const screens = Quickshell.screens.map(s => s.name);
|
||||
const missing = screens.filter(name => !root.brightnessMap[name]);
|
||||
if (missing.length === 0) {
|
||||
root.syncBrightnessPins();
|
||||
return;
|
||||
}
|
||||
|
||||
root.brightnessMapScanning = true;
|
||||
Proc.runCommand("gameBar.ddcDetect", ["ddcutil", "detect", "--brief"], (stdout, exitCode) => {
|
||||
root.brightnessMapScanning = false;
|
||||
if (exitCode !== 0)
|
||||
return;
|
||||
|
||||
const map = {};
|
||||
let bus = "";
|
||||
for (const line of String(stdout || "").split("\n")) {
|
||||
const busMatch = line.match(/I2C bus:\s*\/dev\/i2c-([0-9]+)/);
|
||||
if (busMatch) {
|
||||
bus = "ddc:i2c-" + busMatch[1];
|
||||
continue;
|
||||
}
|
||||
|
||||
const connectorMatch = line.match(/DRM connector:\s*card[0-9]+-(.+)$/);
|
||||
if (connectorMatch && bus !== "") {
|
||||
map[connectorMatch[1].trim()] = bus;
|
||||
bus = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(map).length === 0)
|
||||
return;
|
||||
|
||||
root.brightnessMap = map;
|
||||
root.savePluginValue("brightnessMap", map);
|
||||
root.syncBrightnessPins();
|
||||
}, 0, 20000);
|
||||
}
|
||||
|
||||
function focusedMonitorName() {
|
||||
const screen = CompositorService.getFocusedScreen();
|
||||
return (screen && screen.name) ? screen.name : "focused";
|
||||
}
|
||||
|
||||
// Positions are stored per monitor *and* per resolution: a layout arranged at
|
||||
// 4K means nothing at 1440p, and switching back must restore the original
|
||||
// arrangement rather than a rescaled approximation of the other one.
|
||||
function screenKey(screen) {
|
||||
if (!screen)
|
||||
return "default";
|
||||
|
||||
return SettingsData.getScreenDisplayName(screen) || screen.name || "default";
|
||||
const base = SettingsData.getScreenDisplayName(screen) || screen.name || "default";
|
||||
const w = Math.round(screen.width);
|
||||
const h = Math.round(screen.height);
|
||||
return (w > 0 && h > 0) ? base + "@" + w + "x" + h : base;
|
||||
}
|
||||
|
||||
function widgetPosition(screen, widgetId) {
|
||||
function legacyScreenKey(screen) {
|
||||
if (!screen)
|
||||
return "";
|
||||
|
||||
return SettingsData.getScreenDisplayName(screen) || screen.name || "";
|
||||
}
|
||||
|
||||
// Returns absolute pixel coordinates for the given panel size.
|
||||
function widgetPosition(screen, widgetId, panelWidth, panelHeight) {
|
||||
const layout = pluginData.layout || {};
|
||||
const perScreen = layout[root.screenKey(screen)];
|
||||
const saved = perScreen ? perScreen[widgetId] : null;
|
||||
if (saved && saved.x !== undefined && saved.y !== undefined)
|
||||
return saved;
|
||||
return {
|
||||
"x": saved.x,
|
||||
"y": saved.y
|
||||
};
|
||||
|
||||
// Entries written before 2.5.0 were fractions keyed by screen name only.
|
||||
const legacyPerScreen = layout[root.legacyScreenKey(screen)];
|
||||
const legacy = legacyPerScreen ? legacyPerScreen[widgetId] : null;
|
||||
if (legacy && legacy.x !== undefined && legacy.y !== undefined && legacy.x <= 1 && legacy.y <= 1)
|
||||
return {
|
||||
"x": legacy.x * panelWidth,
|
||||
"y": legacy.y * panelHeight
|
||||
};
|
||||
|
||||
const def = widgetDef(widgetId);
|
||||
return def ? ({
|
||||
"x": def.x,
|
||||
"y": def.y
|
||||
}) : ({
|
||||
"x": 0.1,
|
||||
"y": 0.15
|
||||
});
|
||||
return {
|
||||
"x": (def ? def.x : 0.1) * panelWidth,
|
||||
"y": (def ? def.y : 0.15) * panelHeight
|
||||
};
|
||||
}
|
||||
|
||||
function saveWidgetPosition(screen, widgetId, fractionX, fractionY) {
|
||||
function saveWidgetPosition(screen, widgetId, x, y) {
|
||||
const layout = JSON.parse(JSON.stringify(pluginData.layout || {}));
|
||||
const key = root.screenKey(screen);
|
||||
if (!layout[key])
|
||||
layout[key] = {};
|
||||
|
||||
layout[key][widgetId] = {
|
||||
"x": fractionX,
|
||||
"y": fractionY
|
||||
"x": Math.round(x),
|
||||
"y": Math.round(y)
|
||||
};
|
||||
root.savePluginValue("layout", layout);
|
||||
}
|
||||
@@ -207,6 +395,52 @@ PluginComponent {
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
function record() : string {
|
||||
// The recorder stops asynchronously, so report the action taken.
|
||||
const wasRecording = capture.recording;
|
||||
capture.toggleRecording(root.focusedMonitorName());
|
||||
return wasRecording ? "STOPPED" : "RECORDING";
|
||||
}
|
||||
|
||||
function replay() : string {
|
||||
const wasArmed = capture.replayArmed;
|
||||
capture.toggleReplay(root.focusedMonitorName());
|
||||
return wasArmed ? "STOPPED" : "ARMED";
|
||||
}
|
||||
|
||||
function saveReplay() : string {
|
||||
if (!capture.replayArmed)
|
||||
return "REPLAY_NOT_ARMED";
|
||||
|
||||
capture.saveReplay();
|
||||
return "SUCCESS";
|
||||
}
|
||||
|
||||
function layoutStatus() : string {
|
||||
const lines = Quickshell.screens.map(screen => {
|
||||
const key = root.screenKey(screen);
|
||||
const parts = root.widgetDefs.map(def => {
|
||||
const pos = root.widgetPosition(screen, def.id, screen.width, screen.height);
|
||||
return def.id + "(" + Math.round(pos.x) + "," + Math.round(pos.y) + ")";
|
||||
});
|
||||
return key + ": " + parts.join(" ");
|
||||
});
|
||||
return lines.join(" | ");
|
||||
}
|
||||
|
||||
function brightnessStatus() : string {
|
||||
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
|
||||
const perScreen = Quickshell.screens.map(screen => {
|
||||
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
|
||||
return screen.name + "[key=" + key + " device=" + root.brightnessDeviceForScreen(screen.name) + " value=" + DisplayService.getDeviceBrightness(root.brightnessDeviceForScreen(screen.name)) + "]";
|
||||
});
|
||||
return "map=" + JSON.stringify(root.brightnessMap) + " pins=" + JSON.stringify(pins) + " screens=" + perScreen.join(" ") + " currentDevice=" + DisplayService.currentDevice;
|
||||
}
|
||||
|
||||
function captureStatus() : string {
|
||||
return "mode=" + (capture.mode || "idle") + " monitor=" + (capture.targetMonitor || "-") + " elapsed=" + capture.elapsedSeconds + "s dir=" + capture.saveDirectory + " lastFile=" + (capture.lastFile || "(none)") + " error=" + (capture.lastError || "(none)");
|
||||
}
|
||||
|
||||
function homeStatus() : string {
|
||||
const picked = Array.isArray(root.pluginData.haEntities) ? root.pluginData.haEntities.length : 0;
|
||||
return "configured=" + haService.configured + " url=" + (haService.baseUrl || "(none)") + " tokenChars=" + haService.token.length + " http=" + haService.lastStatus + " entities=" + haService.entityList.length + " picked=" + picked + " error=" + (haService.lastError || "(none)");
|
||||
@@ -224,20 +458,40 @@ PluginComponent {
|
||||
}
|
||||
|
||||
readonly property alias homeService: haService
|
||||
readonly property alias captureService: capture
|
||||
|
||||
GameBarHomeService {
|
||||
CaptureService {
|
||||
id: capture
|
||||
|
||||
daemon: root
|
||||
onNotice: message => {
|
||||
if (typeof ToastService !== "undefined" && ToastService)
|
||||
ToastService.showInfo(message);
|
||||
}
|
||||
}
|
||||
|
||||
HomeService {
|
||||
id: haService
|
||||
|
||||
daemon: root
|
||||
}
|
||||
|
||||
GameBarOverlay {
|
||||
Connections {
|
||||
target: Quickshell
|
||||
|
||||
function onScreensChanged() {
|
||||
root.ensureBrightnessMap();
|
||||
}
|
||||
}
|
||||
|
||||
OverlayModal {
|
||||
id: overlay
|
||||
|
||||
daemon: root
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.ensureBrightnessMap();
|
||||
if (pluginService && pluginId) {
|
||||
const instances = Object.assign({}, pluginService.pluginInstances);
|
||||
instances[pluginId] = root;
|
||||
|
||||
+90
-2
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import qs.Common
|
||||
import qs.Modules.Plugins
|
||||
import qs.Widgets
|
||||
import "./settings"
|
||||
|
||||
PluginSettings {
|
||||
id: settings
|
||||
@@ -34,7 +35,7 @@ PluginSettings {
|
||||
|
||||
StyledText {
|
||||
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.family: Theme.monoFontFamily
|
||||
color: Theme.surfaceVariantText
|
||||
@@ -55,6 +56,93 @@ PluginSettings {
|
||||
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"
|
||||
@@ -81,7 +169,7 @@ PluginSettings {
|
||||
rightIcon: "schedule"
|
||||
}
|
||||
|
||||
GameBarHomeSettings {
|
||||
HomeSettings {
|
||||
width: parent.width
|
||||
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 |
|
||||
| **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) |
|
||||
| **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 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 |
|
||||
|
||||
## 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):
|
||||
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
content: Component {
|
||||
GameBarPanel {
|
||||
Panel {
|
||||
overlayRef: overlay
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Quickshell.Services.Pipewire
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import "../widgets"
|
||||
|
||||
FocusScope {
|
||||
id: root
|
||||
@@ -14,6 +15,9 @@ FocusScope {
|
||||
readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({})
|
||||
readonly property var targetScreen: overlayRef ? overlayRef.effectiveScreen : null
|
||||
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
|
||||
|
||||
property int windowZCounter: 1
|
||||
@@ -39,17 +43,17 @@ FocusScope {
|
||||
|
||||
function widgetPosition(widgetId) {
|
||||
if (daemon)
|
||||
return daemon.widgetPosition(root.targetScreen, widgetId);
|
||||
return daemon.widgetPosition(root.targetScreen, widgetId, root.width, root.height);
|
||||
|
||||
return {
|
||||
"x": 0.1,
|
||||
"y": 0.15
|
||||
"x": root.width * 0.1,
|
||||
"y": root.height * 0.15
|
||||
};
|
||||
}
|
||||
|
||||
function saveWidgetPosition(widgetId, fractionX, fractionY) {
|
||||
function saveWidgetPosition(widgetId, x, y) {
|
||||
if (daemon)
|
||||
daemon.saveWidgetPosition(root.targetScreen, widgetId, fractionX, fractionY);
|
||||
daemon.saveWidgetPosition(root.targetScreen, widgetId, x, y);
|
||||
}
|
||||
|
||||
anchors.fill: parent
|
||||
@@ -75,7 +79,7 @@ FocusScope {
|
||||
onClicked: root.requestClose()
|
||||
}
|
||||
|
||||
GameBarTopBar {
|
||||
TopBar {
|
||||
id: topBar
|
||||
|
||||
panel: root
|
||||
@@ -87,7 +91,7 @@ FocusScope {
|
||||
Repeater {
|
||||
model: root.daemon ? root.daemon.widgetDefs : []
|
||||
|
||||
delegate: GameBarWindow {
|
||||
delegate: WidgetWindow {
|
||||
required property var modelData
|
||||
|
||||
panel: root
|
||||
@@ -104,8 +108,16 @@ FocusScope {
|
||||
return audioWidgetComponent;
|
||||
case "apps":
|
||||
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":
|
||||
@@ -119,25 +131,52 @@ FocusScope {
|
||||
Component {
|
||||
id: audioWidgetComponent
|
||||
|
||||
GameBarAudioWidget {}
|
||||
AudioWidget {}
|
||||
}
|
||||
|
||||
Component {
|
||||
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
|
||||
|
||||
GameBarMediaWidget {}
|
||||
MediaWidget {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: homeWidgetComponent
|
||||
|
||||
GameBarHomeWidget {
|
||||
HomeWidget {
|
||||
service: root.daemon ? root.daemon.homeService : null
|
||||
}
|
||||
}
|
||||
@@ -145,6 +184,6 @@ FocusScope {
|
||||
Component {
|
||||
id: systemWidgetComponent
|
||||
|
||||
GameBarSystemWidget {}
|
||||
SystemWidget {}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import QtQuick
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import "../components"
|
||||
|
||||
Rectangle {
|
||||
id: bar
|
||||
@@ -9,6 +10,9 @@ Rectangle {
|
||||
property var panel: null
|
||||
|
||||
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
|
||||
height: 56
|
||||
@@ -68,6 +72,14 @@ Rectangle {
|
||||
|
||||
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 {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: Theme.spacingXS
|
||||
@@ -82,7 +94,7 @@ Rectangle {
|
||||
|
||||
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
|
||||
radius: Theme.cornerRadius
|
||||
color: active ? Theme.primarySelected : (toggleArea.containsMouse ? Theme.surfaceHover : "transparent")
|
||||
@@ -108,6 +120,7 @@ Rectangle {
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: toggleButton.active ? Font.Medium : Font.Normal
|
||||
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 panelWidth: panel ? panel.width : 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 maxY: Math.max(minimumY, (panel ? panel.height : height) - height - margin)
|
||||
|
||||
@@ -29,15 +33,17 @@ Item {
|
||||
return Math.max(minimumY, Math.min(maxY, value));
|
||||
}
|
||||
|
||||
// Positions are stored as fractions of the screen, so a layout made on a 4K
|
||||
// display still lands somewhere sensible on a 1440p one (and vice versa).
|
||||
// Each monitor keeps its own absolute arrangement, so moving between a 4K
|
||||
// and a 1440p screen restores what was arranged there rather than a scaled
|
||||
// copy of the other one.
|
||||
function applyStoredPosition() {
|
||||
if (!panel || panel.width <= 0 || panel.height <= 0)
|
||||
return;
|
||||
|
||||
const stored = panel.widgetPosition(win.widgetId);
|
||||
win.x = win.clampX(stored.x * panel.width);
|
||||
win.y = win.clampY(stored.y * panel.height);
|
||||
win.x = win.clampX(stored.x);
|
||||
win.y = win.clampY(stored.y);
|
||||
win.appliedKey = win.screenKey;
|
||||
}
|
||||
|
||||
function raise() {
|
||||
@@ -49,7 +55,12 @@ Item {
|
||||
if (!panel || panel.width <= 0 || panel.height <= 0)
|
||||
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
|
||||
@@ -71,10 +82,12 @@ Item {
|
||||
win.raise();
|
||||
}
|
||||
}
|
||||
// The panel has no size yet while the delegate is being created, so the
|
||||
// stored fraction has to be re-applied once the screen size is known.
|
||||
// The panel has neither size nor a resolved screen while the delegate is
|
||||
// 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()
|
||||
onPanelHeightChanged: win.applyStoredPosition()
|
||||
onScreenKeyChanged: win.applyStoredPosition()
|
||||
|
||||
Rectangle {
|
||||
id: frame
|
||||
+8
-3
@@ -3,16 +3,21 @@
|
||||
"id": "gameBar",
|
||||
"name": "Game Bar",
|
||||
"description": "Game Bar style overlay with movable widgets for quick settings and controls",
|
||||
"version": "2.0.0",
|
||||
"version": "2.5.0",
|
||||
"license": "MIT",
|
||||
"author": "keule2",
|
||||
"icon": "sports_esports",
|
||||
"type": "daemon",
|
||||
"type": "composite",
|
||||
"capabilities": [
|
||||
"daemon",
|
||||
"dankbar-widget",
|
||||
"control-center",
|
||||
"ipc"
|
||||
],
|
||||
"component": "./GameBarDaemon.qml",
|
||||
"components": {
|
||||
"daemon": "./GameBarDaemon.qml",
|
||||
"widget": "./GameBarBarWidget.qml"
|
||||
},
|
||||
"settings": "./GameBarSettings.qml",
|
||||
"requires_dms": ">=1.5.0",
|
||||
"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.Services
|
||||
import qs.Widgets
|
||||
import "../components"
|
||||
|
||||
DankFlickable {
|
||||
id: mixer
|
||||
@@ -109,7 +110,7 @@ DankFlickable {
|
||||
}
|
||||
}
|
||||
|
||||
GameBarVolumeRow {
|
||||
VolumeRow {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
@@ -2,13 +2,14 @@ import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
import "../components"
|
||||
|
||||
Item {
|
||||
id: widget
|
||||
|
||||
readonly property real sectionSpacing: Theme.spacingS
|
||||
|
||||
GameBarVolumeRow {
|
||||
VolumeRow {
|
||||
id: outputVolume
|
||||
|
||||
anchors.top: parent.top
|
||||
@@ -30,7 +31,7 @@ Item {
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
GameBarDeviceList {
|
||||
DeviceList {
|
||||
id: outputDevices
|
||||
|
||||
anchors.top: outputLabel.bottom
|
||||
@@ -41,7 +42,7 @@ Item {
|
||||
isSink: true
|
||||
}
|
||||
|
||||
GameBarVolumeRow {
|
||||
VolumeRow {
|
||||
id: inputVolume
|
||||
|
||||
anchors.top: outputDevices.bottom
|
||||
@@ -64,7 +65,7 @@ Item {
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
|
||||
GameBarDeviceList {
|
||||
DeviceList {
|
||||
anchors.top: inputLabel.bottom
|
||||
anchors.topMargin: Theme.spacingXS
|
||||
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 {
|
||||
model: widget.selected
|
||||
|
||||
delegate: GameBarHomeRow {
|
||||
delegate: HomeRow {
|
||||
required property var modelData
|
||||
|
||||
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