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

This commit is contained in:
2026-09-22 01:22:27 +02:00
parent 9b023a0993
commit d15bb4687c
25 changed files with 1675 additions and 55 deletions
+200
View File
@@ -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
View File
@@ -4,6 +4,8 @@ import Quickshell.Io
import qs.Common
import qs.Modules.Plugins
import qs.Services
import "./services"
import "./overlay"
PluginComponent {
id: root
@@ -22,7 +24,7 @@ PluginComponent {
"icon": "volume_up",
"width": 430,
"height": 470,
"x": 0.04,
"x": 0.03,
"y": 0.14,
"defaultVisible": true
},
@@ -32,18 +34,28 @@ PluginComponent {
"icon": "tune",
"width": 400,
"height": 360,
"x": 0.30,
"x": 0.3,
"y": 0.14,
"defaultVisible": true
},
{
"id": "capture",
"title": "Capture",
"icon": "videocam",
"width": 380,
"height": 230,
"x": 0.03,
"y": 0.62,
"defaultVisible": false
},
{
"id": "media",
"title": "Media",
"icon": "music_note",
"width": 430,
"height": 300,
"x": 0.04,
"y": 0.56,
"x": 0.3,
"y": 0.52,
"defaultVisible": true
},
{
@@ -52,8 +64,38 @@ PluginComponent {
"icon": "home",
"width": 420,
"height": 420,
"x": 0.56,
"y": 0.50,
"x": 0.57,
"y": 0.14,
"defaultVisible": false
},
{
"id": "visualizer",
"title": "Visualiser",
"icon": "graphic_eq",
"width": 360,
"height": 180,
"x": 0.3,
"y": 0.79,
"defaultVisible": false
},
{
"id": "bluetooth",
"title": "Bluetooth",
"icon": "bluetooth",
"width": 380,
"height": 300,
"x": 0.8,
"y": 0.14,
"defaultVisible": false
},
{
"id": "notepad",
"title": "Notes",
"icon": "edit_note",
"width": 400,
"height": 320,
"x": 0.57,
"y": 0.58,
"defaultVisible": false
},
{
@@ -62,8 +104,8 @@ PluginComponent {
"icon": "speed",
"width": 380,
"height": 330,
"x": 0.56,
"y": 0.14,
"x": 0.8,
"y": 0.45,
"defaultVisible": false
}
]
@@ -100,39 +142,185 @@ PluginComponent {
// ── Positions ────────────────────────────────────────────────────────────
// Stored per screen as fractions of the screen size, so the same layout
// survives monitors of different resolutions (and is re-clamped on load).
// ── Brightness device mapping ────────────────────────────────────────────
// DisplayService only knows DDC devices as i2c bus ids, and the shell maps
// them to screens through manual pins. "ddcutil detect" reports the DRM
// connector for each bus, which is the Wayland output name, so the mapping
// can be worked out once and cached.
property var brightnessMap: ({})
property bool brightnessMapScanning: false
// Picks the cached map up as soon as plugin data arrives; surfaces are
// created before the daemon registers, so they cannot drive this.
readonly property var storedBrightnessMap: (pluginData && pluginData.brightnessMap && typeof pluginData.brightnessMap === "object") ? pluginData.brightnessMap : ({})
onStoredBrightnessMapChanged: {
if (Object.keys(root.storedBrightnessMap).length > 0)
root.brightnessMap = root.storedBrightnessMap;
root.ensureBrightnessMap();
}
function brightnessDeviceForScreen(screenName) {
if (!screenName)
return "";
// A pin set in the shell's own UI always wins.
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
const screen = Quickshell.screens.find(s => s.name === screenName);
const pinKey = screen ? SettingsData.getScreenDisplayName(screen) : screenName;
if (pins[pinKey] && DisplayService.devices.some(d => d.id === pins[pinKey]))
return pins[pinKey];
const mapped = root.brightnessMap[screenName];
if (mapped && DisplayService.devices.some(d => d.id === mapped))
return mapped;
const backlight = DisplayService.devices.find(d => d.class === "backlight");
if (backlight)
return backlight.id;
const ddc = DisplayService.devices.find(d => d.class === "ddc");
return ddc ? ddc.id : "";
}
// The shell's own Control Center brightness slider resolves its device from
// per-screen pins, so publishing the detected map there makes the built-in
// slider (and its icon menu) follow whichever monitor the panel is on.
function syncBrightnessPins() {
if (pluginData.autoPinBrightness === false)
return;
if (typeof CacheData === "undefined" || !CacheData)
return;
const pins = JSON.parse(JSON.stringify(CacheData.brightnessDevicePins || {}));
let changed = false;
for (const screen of Quickshell.screens) {
const device = root.brightnessMap[screen.name];
if (!device)
continue;
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
// Never overwrite a pin the user set by hand.
if (pins[key])
continue;
pins[key] = device;
changed = true;
}
if (changed)
CacheData.set("brightnessDevicePins", pins);
}
function ensureBrightnessMap() {
if (root.brightnessMapScanning)
return;
if (Object.keys(root.brightnessMap).length === 0 && Object.keys(root.storedBrightnessMap).length > 0)
root.brightnessMap = root.storedBrightnessMap;
const screens = Quickshell.screens.map(s => s.name);
const missing = screens.filter(name => !root.brightnessMap[name]);
if (missing.length === 0) {
root.syncBrightnessPins();
return;
}
root.brightnessMapScanning = true;
Proc.runCommand("gameBar.ddcDetect", ["ddcutil", "detect", "--brief"], (stdout, exitCode) => {
root.brightnessMapScanning = false;
if (exitCode !== 0)
return;
const map = {};
let bus = "";
for (const line of String(stdout || "").split("\n")) {
const busMatch = line.match(/I2C bus:\s*\/dev\/i2c-([0-9]+)/);
if (busMatch) {
bus = "ddc:i2c-" + busMatch[1];
continue;
}
const connectorMatch = line.match(/DRM connector:\s*card[0-9]+-(.+)$/);
if (connectorMatch && bus !== "") {
map[connectorMatch[1].trim()] = bus;
bus = "";
}
}
if (Object.keys(map).length === 0)
return;
root.brightnessMap = map;
root.savePluginValue("brightnessMap", map);
root.syncBrightnessPins();
}, 0, 20000);
}
function focusedMonitorName() {
const screen = CompositorService.getFocusedScreen();
return (screen && screen.name) ? screen.name : "focused";
}
// Positions are stored per monitor *and* per resolution: a layout arranged at
// 4K means nothing at 1440p, and switching back must restore the original
// arrangement rather than a rescaled approximation of the other one.
function screenKey(screen) {
if (!screen)
return "default";
return SettingsData.getScreenDisplayName(screen) || screen.name || "default";
const base = SettingsData.getScreenDisplayName(screen) || screen.name || "default";
const w = Math.round(screen.width);
const h = Math.round(screen.height);
return (w > 0 && h > 0) ? base + "@" + w + "x" + h : base;
}
function widgetPosition(screen, widgetId) {
function legacyScreenKey(screen) {
if (!screen)
return "";
return SettingsData.getScreenDisplayName(screen) || screen.name || "";
}
// Returns absolute pixel coordinates for the given panel size.
function widgetPosition(screen, widgetId, panelWidth, panelHeight) {
const layout = pluginData.layout || {};
const perScreen = layout[root.screenKey(screen)];
const saved = perScreen ? perScreen[widgetId] : null;
if (saved && saved.x !== undefined && saved.y !== undefined)
return saved;
return {
"x": saved.x,
"y": saved.y
};
// Entries written before 2.5.0 were fractions keyed by screen name only.
const legacyPerScreen = layout[root.legacyScreenKey(screen)];
const legacy = legacyPerScreen ? legacyPerScreen[widgetId] : null;
if (legacy && legacy.x !== undefined && legacy.y !== undefined && legacy.x <= 1 && legacy.y <= 1)
return {
"x": legacy.x * panelWidth,
"y": legacy.y * panelHeight
};
const def = widgetDef(widgetId);
return def ? ({
"x": def.x,
"y": def.y
}) : ({
"x": 0.1,
"y": 0.15
});
return {
"x": (def ? def.x : 0.1) * panelWidth,
"y": (def ? def.y : 0.15) * panelHeight
};
}
function saveWidgetPosition(screen, widgetId, fractionX, fractionY) {
function saveWidgetPosition(screen, widgetId, x, y) {
const layout = JSON.parse(JSON.stringify(pluginData.layout || {}));
const key = root.screenKey(screen);
if (!layout[key])
layout[key] = {};
layout[key][widgetId] = {
"x": fractionX,
"y": fractionY
"x": Math.round(x),
"y": Math.round(y)
};
root.savePluginValue("layout", layout);
}
@@ -207,6 +395,52 @@ PluginComponent {
return "SUCCESS";
}
function record() : string {
// The recorder stops asynchronously, so report the action taken.
const wasRecording = capture.recording;
capture.toggleRecording(root.focusedMonitorName());
return wasRecording ? "STOPPED" : "RECORDING";
}
function replay() : string {
const wasArmed = capture.replayArmed;
capture.toggleReplay(root.focusedMonitorName());
return wasArmed ? "STOPPED" : "ARMED";
}
function saveReplay() : string {
if (!capture.replayArmed)
return "REPLAY_NOT_ARMED";
capture.saveReplay();
return "SUCCESS";
}
function layoutStatus() : string {
const lines = Quickshell.screens.map(screen => {
const key = root.screenKey(screen);
const parts = root.widgetDefs.map(def => {
const pos = root.widgetPosition(screen, def.id, screen.width, screen.height);
return def.id + "(" + Math.round(pos.x) + "," + Math.round(pos.y) + ")";
});
return key + ": " + parts.join(" ");
});
return lines.join(" | ");
}
function brightnessStatus() : string {
const pins = (typeof CacheData !== "undefined" && CacheData.brightnessDevicePins) ? CacheData.brightnessDevicePins : {};
const perScreen = Quickshell.screens.map(screen => {
const key = SettingsData.getScreenDisplayName(screen) || screen.name;
return screen.name + "[key=" + key + " device=" + root.brightnessDeviceForScreen(screen.name) + " value=" + DisplayService.getDeviceBrightness(root.brightnessDeviceForScreen(screen.name)) + "]";
});
return "map=" + JSON.stringify(root.brightnessMap) + " pins=" + JSON.stringify(pins) + " screens=" + perScreen.join(" ") + " currentDevice=" + DisplayService.currentDevice;
}
function captureStatus() : string {
return "mode=" + (capture.mode || "idle") + " monitor=" + (capture.targetMonitor || "-") + " elapsed=" + capture.elapsedSeconds + "s dir=" + capture.saveDirectory + " lastFile=" + (capture.lastFile || "(none)") + " error=" + (capture.lastError || "(none)");
}
function homeStatus() : string {
const picked = Array.isArray(root.pluginData.haEntities) ? root.pluginData.haEntities.length : 0;
return "configured=" + haService.configured + " url=" + (haService.baseUrl || "(none)") + " tokenChars=" + haService.token.length + " http=" + haService.lastStatus + " entities=" + haService.entityList.length + " picked=" + picked + " error=" + (haService.lastError || "(none)");
@@ -224,20 +458,40 @@ PluginComponent {
}
readonly property alias homeService: haService
readonly property alias captureService: capture
GameBarHomeService {
CaptureService {
id: capture
daemon: root
onNotice: message => {
if (typeof ToastService !== "undefined" && ToastService)
ToastService.showInfo(message);
}
}
HomeService {
id: haService
daemon: root
}
GameBarOverlay {
Connections {
target: Quickshell
function onScreensChanged() {
root.ensureBrightnessMap();
}
}
OverlayModal {
id: overlay
daemon: root
}
Component.onCompleted: {
root.ensureBrightnessMap();
if (pluginService && pluginId) {
const instances = Object.assign({}, pluginService.pluginInstances);
instances[pluginId] = root;
+90 -2
View File
@@ -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
}
+12
View File
@@ -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.
+117
View File
@@ -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
}
}
+51 -12
View File
@@ -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 {}
}
}
+14 -1
View File
@@ -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
}
}
+20 -7
View File
@@ -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
View File
@@ -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": [
+150
View File
@@ -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)
}
}
+2 -1
View File
@@ -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
+192
View File
@@ -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
}
}
}
+199
View File
@@ -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
+193
View File
@@ -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"]);
}
}
}
}
}
+143
View File
@@ -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
}
}