From 62e760f126f7d3021dbb0f26264355ffaf00e426 Mon Sep 17 00:00:00 2001 From: Keule2 Date: Mon, 21 Sep 2026 16:50:56 +0200 Subject: [PATCH] letsss GOOO --- GameBarAppMixer.qml | 156 ++++++++++++++++++++++++++++++ GameBarAudioWidget.qml | 75 +++++++++++++++ GameBarDaemon.qml | 205 ++++++++++++++++++++++++++++++++++++++++ GameBarDeviceList.qml | 147 ++++++++++++++++++++++++++++ GameBarOverlay.qml | 49 ++++++++++ GameBarPanel.qml | 132 ++++++++++++++++++++++++++ GameBarSettings.qml | 91 ++++++++++++++++++ GameBarSystemWidget.qml | 197 ++++++++++++++++++++++++++++++++++++++ GameBarTopBar.qml | 143 ++++++++++++++++++++++++++++ GameBarVolumeRow.qml | 99 +++++++++++++++++++ GameBarWindow.qml | 191 +++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++ README.md | 32 +++++++ plugin.json | 22 +++++ 14 files changed, 1560 insertions(+) create mode 100644 GameBarAppMixer.qml create mode 100644 GameBarAudioWidget.qml create mode 100644 GameBarDaemon.qml create mode 100644 GameBarDeviceList.qml create mode 100644 GameBarOverlay.qml create mode 100644 GameBarPanel.qml create mode 100644 GameBarSettings.qml create mode 100644 GameBarSystemWidget.qml create mode 100644 GameBarTopBar.qml create mode 100644 GameBarVolumeRow.qml create mode 100644 GameBarWindow.qml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 plugin.json diff --git a/GameBarAppMixer.qml b/GameBarAppMixer.qml new file mode 100644 index 0000000..a1ce8f6 --- /dev/null +++ b/GameBarAppMixer.qml @@ -0,0 +1,156 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.Common +import qs.Services +import qs.Widgets + +DankFlickable { + id: mixer + + readonly property var streams: Pipewire.nodes.values.filter(node => node.audio && node.isSink && node.isStream) + readonly property int streamCount: streams.length + + function appName(node) { + if (!node) + return ""; + + const props = node.properties || {}; + return props["application.name"] || AudioService.displayName(node) || "Unknown"; + } + + function mediaName(node) { + const props = (node && node.properties) ? node.properties : {}; + return props["media.name"] || ""; + } + + function appIconValue(node) { + const props = (node && node.properties) ? node.properties : {}; + const candidates = [props["application.icon-name"], props["application.process.binary"], props["application.name"]]; + + for (const candidate of candidates) { + if (!candidate) + continue; + + const entry = DesktopEntries.heuristicLookup(String(candidate)); + if (entry && entry.icon) + return entry.icon; + } + + return "material:graphic_eq"; + } + + clip: true + contentHeight: streamColumn.height + contentWidth: width + + Column { + id: streamColumn + + width: mixer.width + spacing: Theme.spacingS + + Repeater { + model: ScriptModel { + values: mixer.streams + } + + delegate: Rectangle { + id: streamItem + + required property var modelData + + width: streamColumn.width + height: 86 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency) + border.color: Theme.outlineLight + border.width: Theme.layerOutlineWidth + + AppIconRenderer { + id: streamIcon + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingM + width: Theme.iconSize + height: Theme.iconSize + iconValue: mixer.appIconValue(streamItem.modelData) + iconSize: Theme.iconSize + fallbackText: mixer.appName(streamItem.modelData).charAt(0).toUpperCase() + } + + Column { + anchors.left: streamIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingM + spacing: 1 + + StyledText { + width: parent.width + text: mixer.appName(streamItem.modelData) + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: mixer.mediaName(streamItem.modelData) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + visible: text !== "" + } + } + + GameBarVolumeRow { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.spacingS + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: Theme.spacingXS + node: streamItem.modelData + maxPercent: 100 + activeIcon: "volume_up" + mutedIcon: "volume_off" + } + + PwObjectTracker { + objects: [streamItem.modelData] + } + } + } + + Item { + width: streamColumn.width + height: 120 + visible: mixer.streamCount === 0 + + Column { + anchors.centerIn: parent + spacing: Theme.spacingS + + DankIcon { + anchors.horizontalCenter: parent.horizontalCenter + name: "music_off" + size: Theme.iconSizeLarge + color: Theme.surfaceVariantText + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: "No apps are playing audio" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + } +} diff --git a/GameBarAudioWidget.qml b/GameBarAudioWidget.qml new file mode 100644 index 0000000..5e66a37 --- /dev/null +++ b/GameBarAudioWidget.qml @@ -0,0 +1,75 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: widget + + readonly property real sectionSpacing: Theme.spacingS + + GameBarVolumeRow { + id: outputVolume + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + node: AudioService.sink + maxPercent: AudioService.sinkMaxVolume + activeIcon: "volume_up" + mutedIcon: "volume_off" + } + + StyledText { + id: outputLabel + + anchors.top: outputVolume.bottom + anchors.left: parent.left + text: "Output" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + GameBarDeviceList { + id: outputDevices + + anchors.top: outputLabel.bottom + anchors.topMargin: Theme.spacingXS + anchors.left: parent.left + anchors.right: parent.right + height: Math.max(52, (parent.height - outputVolume.height - inputVolume.height - outputLabel.height - inputLabel.height - widget.sectionSpacing * 3 - Theme.spacingXS * 2) * 0.5) + isSink: true + } + + GameBarVolumeRow { + id: inputVolume + + anchors.top: outputDevices.bottom + anchors.topMargin: widget.sectionSpacing + anchors.left: parent.left + anchors.right: parent.right + node: AudioService.source + maxPercent: 100 + activeIcon: "mic" + mutedIcon: "mic_off" + } + + StyledText { + id: inputLabel + + anchors.top: inputVolume.bottom + anchors.left: parent.left + text: "Input" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + GameBarDeviceList { + anchors.top: inputLabel.bottom + anchors.topMargin: Theme.spacingXS + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + isSink: false + } +} diff --git a/GameBarDaemon.qml b/GameBarDaemon.qml new file mode 100644 index 0000000..4eb1f15 --- /dev/null +++ b/GameBarDaemon.qml @@ -0,0 +1,205 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import qs.Common +import qs.Modules.Plugins +import qs.Services + +PluginComponent { + id: root + + pluginId: "gameBar" + pluginService: PluginService + + readonly property bool overlayOpen: overlay.shouldBeVisible + + // Widget catalogue. `id` is the persistence key, the rest drives the top bar + // toggles and the default placement (fractions of the screen size). + readonly property var widgetDefs: [ + { + "id": "audio", + "title": "Audio", + "icon": "volume_up", + "width": 430, + "height": 470, + "x": 0.04, + "y": 0.14, + "defaultVisible": true + }, + { + "id": "apps", + "title": "Apps", + "icon": "tune", + "width": 400, + "height": 360, + "x": 0.30, + "y": 0.14, + "defaultVisible": true + }, + { + "id": "system", + "title": "Performance", + "icon": "speed", + "width": 380, + "height": 330, + "x": 0.56, + "y": 0.14, + "defaultVisible": false + } + ] + + function widgetDef(widgetId) { + return widgetDefs.find(def => def.id === widgetId) || null; + } + + // ── Visibility ─────────────────────────────────────────────────────────── + function savePluginValue(key, value) { + if (pluginService) + pluginService.savePluginData(pluginId, key, value); + } + + function isWidgetVisible(widgetId) { + const stored = pluginData.visibleWidgets; + if (stored && stored[widgetId] !== undefined) + return stored[widgetId] === true; + + const def = widgetDef(widgetId); + return def ? def.defaultVisible === true : false; + } + + function setWidgetVisible(widgetId, visible) { + const stored = Object.assign({}, pluginData.visibleWidgets || {}); + stored[widgetId] = visible === true; + root.savePluginValue("visibleWidgets", stored); + } + + function toggleWidget(widgetId) { + root.setWidgetVisible(widgetId, !root.isWidgetVisible(widgetId)); + } + + // ── 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). + function screenKey(screen) { + if (!screen) + return "default"; + + return SettingsData.getScreenDisplayName(screen) || screen.name || "default"; + } + + function widgetPosition(screen, widgetId) { + 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; + + const def = widgetDef(widgetId); + return def ? ({ + "x": def.x, + "y": def.y + }) : ({ + "x": 0.1, + "y": 0.15 + }); + } + + function saveWidgetPosition(screen, widgetId, fractionX, fractionY) { + 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 + }; + root.savePluginValue("layout", layout); + } + + function resetLayout() { + root.savePluginValue("layout", {}); + } + + // ── Overlay ────────────────────────────────────────────────────────────── + function openOverlay() { + if (overlay.shouldBeVisible) + return; + + if (typeof PopoutService !== "undefined" && PopoutService) + PopoutService.closeControlCenter(); + + overlay.shouldBeVisible = true; + overlay.open(); + } + + function closeOverlay() { + if (!overlay.shouldBeVisible) + return; + + overlay.shouldBeVisible = false; + overlay.close(); + } + + function toggleOverlay() { + if (overlay.shouldBeVisible) + root.closeOverlay(); + else + root.openOverlay(); + } + + IpcHandler { + target: "gameBar" + enabled: true + + function toggle() : string { + root.toggleOverlay(); + return "SUCCESS"; + } + + function open() : string { + root.openOverlay(); + return "SUCCESS"; + } + + function close() : string { + root.closeOverlay(); + return "SUCCESS"; + } + + function widget(name: string) : string { + if (!root.widgetDef(name)) + return "UNKNOWN_WIDGET: " + name; + + root.toggleWidget(name); + return "SUCCESS"; + } + + function resetLayout() : string { + root.resetLayout(); + return "SUCCESS"; + } + } + + GameBarOverlay { + id: overlay + + daemon: root + } + + Component.onCompleted: { + if (pluginService && pluginId) { + const instances = Object.assign({}, pluginService.pluginInstances); + instances[pluginId] = root; + pluginService.pluginInstances = instances; + } + } + + Component.onDestruction: { + if (pluginService && pluginService.pluginInstances[pluginId] === root) { + const instances = Object.assign({}, pluginService.pluginInstances); + delete instances[pluginId]; + pluginService.pluginInstances = instances; + } + } +} diff --git a/GameBarDeviceList.qml b/GameBarDeviceList.qml new file mode 100644 index 0000000..331edfe --- /dev/null +++ b/GameBarDeviceList.qml @@ -0,0 +1,147 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.Common +import qs.Services +import qs.Widgets + +DankFlickable { + id: deviceList + + property bool isSink: true + + readonly property var activeNode: isSink ? AudioService.sink : AudioService.source + readonly property var deviceNodes: { + const hidden = isSink ? (SessionData.hiddenOutputDeviceNames ?? []) : (SessionData.hiddenInputDeviceNames ?? []); + const nodes = Pipewire.nodes.values.filter(node => { + if (!node.audio || node.isStream) + return false; + if (node.isSink !== isSink) + return false; + return !hidden.includes(node.name); + }); + + const active = activeNode; + return [...nodes].sort((a, b) => { + if (a === active) + return -1; + if (b === active) + return 1; + return AudioService.displayName(a).localeCompare(AudioService.displayName(b)); + }); + } + + function activate(node) { + if (!node || !node.name) + return; + + if (deviceList.isSink) + AudioService.setDefaultSinkByName(node.name); + else + AudioService.setDefaultSourceByName(node.name); + } + + clip: true + contentHeight: deviceColumn.height + contentWidth: width + + Column { + id: deviceColumn + + width: deviceList.width + spacing: Theme.spacingXS + + Repeater { + model: ScriptModel { + values: deviceList.deviceNodes + } + + delegate: Rectangle { + id: deviceItem + + required property var modelData + + readonly property bool isActive: modelData === deviceList.activeNode + + width: deviceColumn.width + height: 46 + radius: Theme.cornerRadius + color: isActive ? Theme.primarySelected : (deviceArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)) + border.color: isActive ? Theme.primary : Theme.outlineLight + border.width: isActive ? 2 : Theme.layerOutlineWidth + + DankIcon { + id: deviceIcon + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: deviceList.isSink ? AudioService.sinkIcon(deviceItem.modelData) : "mic" + size: Theme.iconSize - 4 + color: deviceItem.isActive ? Theme.primary : Theme.surfaceText + } + + DankIcon { + id: activeCheck + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: "check_circle" + size: Theme.iconSize - 6 + color: Theme.primary + visible: deviceItem.isActive + } + + Column { + anchors.left: deviceIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: activeCheck.visible ? activeCheck.left : parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + StyledText { + width: parent.width + text: AudioService.displayName(deviceItem.modelData) + font.pixelSize: Theme.fontSizeMedium + font.weight: deviceItem.isActive ? Font.Medium : Font.Normal + color: Theme.surfaceText + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: AudioService.subtitle(deviceItem.modelData.name) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + visible: text !== "" + } + } + + MouseArea { + id: deviceArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: deviceList.activate(deviceItem.modelData) + } + + PwObjectTracker { + objects: [deviceItem.modelData] + } + } + } + + StyledText { + width: deviceColumn.width + text: deviceList.isSink ? "No output devices found" : "No input devices found" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + visible: deviceList.deviceNodes.length === 0 + } + } +} diff --git a/GameBarOverlay.qml b/GameBarOverlay.qml new file mode 100644 index 0000000..69d81ab --- /dev/null +++ b/GameBarOverlay.qml @@ -0,0 +1,49 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Modals.Common + +DankModal { + id: overlay + + property var daemon: null + + readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({}) + readonly property real availableWidth: screenWidth > 0 ? screenWidth : 1920 + readonly property real availableHeight: screenHeight > 0 ? screenHeight : 1080 + + function requestClose() { + if (overlay.daemon) + overlay.daemon.closeOverlay(); + else + overlay.close(); + } + + layerNamespace: "dms:plugins:gameBar" + // Overlay layer puts the widgets above fullscreen windows, like the Xbox Game Bar. + useOverlayLayer: true + positioning: "center" + shouldBeVisible: false + closeOnEscapeKey: true + closeOnBackgroundClick: true + showBackground: true + backgroundOpacity: Math.max(0, Math.min(0.95, (pluginData.backdropOpacity ?? 70) / 100)) + onBackgroundClicked: overlay.requestClose() + + // The modal itself is the full screen; the widgets inside are the visible chrome. + backgroundColor: "transparent" + borderWidth: 0 + cornerRadius: 0 + enableShadow: false + // A full-screen surface should fade, not scale/slide in. + animationScaleCollapsed: 1 + animationOffset: 0 + modalWidth: availableWidth + modalHeight: availableHeight + + content: Component { + GameBarPanel { + overlayRef: overlay + } + } +} diff --git a/GameBarPanel.qml b/GameBarPanel.qml new file mode 100644 index 0000000..1fdaee1 --- /dev/null +++ b/GameBarPanel.qml @@ -0,0 +1,132 @@ +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import qs.Common +import qs.Services +import qs.Widgets + +FocusScope { + id: root + + property var overlayRef: null + + readonly property var daemon: overlayRef ? overlayRef.daemon : null + readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({}) + readonly property var targetScreen: overlayRef ? overlayRef.effectiveScreen : null + readonly property real edgeMargin: Theme.spacingM + readonly property real topLimit: topBar.y + topBar.height + Theme.spacingM + + property int windowZCounter: 1 + + function nextWindowZ() { + root.windowZCounter = root.windowZCounter + 1; + return root.windowZCounter; + } + + function requestClose() { + if (overlayRef) + overlayRef.requestClose(); + } + + function isWidgetVisible(widgetId) { + return daemon ? daemon.isWidgetVisible(widgetId) : false; + } + + function toggleWidget(widgetId) { + if (daemon) + daemon.toggleWidget(widgetId); + } + + function widgetPosition(widgetId) { + if (daemon) + return daemon.widgetPosition(root.targetScreen, widgetId); + + return { + "x": 0.1, + "y": 0.15 + }; + } + + function saveWidgetPosition(widgetId, fractionX, fractionY) { + if (daemon) + daemon.saveWidgetPosition(root.targetScreen, widgetId, fractionX, fractionY); + } + + anchors.fill: parent + focus: true + + // The modal's own Escape handler sits on a sibling focus scope, so the key + // never reaches it once this content holds focus - handle it here instead. + Keys.onEscapePressed: event => { + root.requestClose(); + event.accepted = true; + } + + Component.onCompleted: root.forceActiveFocus() + + // Keeps the default sink/source properties live while the overlay is open. + PwObjectTracker { + objects: [AudioService.sink, AudioService.source].filter(node => !!node) + } + + // Clicking empty space closes, like clicking outside a dialog. + MouseArea { + anchors.fill: parent + onClicked: root.requestClose() + } + + GameBarTopBar { + id: topBar + + panel: root + anchors.top: parent.top + anchors.topMargin: Theme.spacingL + anchors.horizontalCenter: parent.horizontalCenter + } + + Repeater { + model: root.daemon ? root.daemon.widgetDefs : [] + + delegate: GameBarWindow { + required property var modelData + + panel: root + widgetId: modelData.id + title: modelData.title + icon: modelData.icon + widgetWidth: modelData.width + widgetHeight: modelData.height + minimumY: root.topLimit + shown: root.isWidgetVisible(modelData.id) + contentComponent: { + switch (modelData.id) { + case "audio": + return audioWidgetComponent; + case "apps": + return appsWidgetComponent; + case "system": + return systemWidgetComponent; + } + return null; + } + } + } + + Component { + id: audioWidgetComponent + + GameBarAudioWidget {} + } + + Component { + id: appsWidgetComponent + + GameBarAppMixer {} + } + + Component { + id: systemWidgetComponent + + GameBarSystemWidget {} + } +} diff --git a/GameBarSettings.qml b/GameBarSettings.qml new file mode 100644 index 0000000..7552892 --- /dev/null +++ b/GameBarSettings.qml @@ -0,0 +1,91 @@ +import QtQuick +import qs.Common +import qs.Modules.Plugins +import qs.Widgets + +PluginSettings { + id: settings + + pluginId: "gameBar" + + Rectangle { + width: parent.width + height: hintColumn.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + + Column { + id: hintColumn + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Theme.spacingM + spacing: Theme.spacingXS + + StyledText { + text: "Keybind setup" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + width: parent.width + text: "Commands: dms ipc call gameBar toggle / open / close / widget / resetLayout" + font.pixelSize: Theme.fontSizeSmall + font.family: Theme.monoFontFamily + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + } + } + + SliderSetting { + settingKey: "backdropOpacity" + label: "Background dimming" + description: "How much the desktop behind the overlay is darkened" + defaultValue: 70 + minimum: 0 + maximum: 95 + unit: "%" + leftIcon: "light_mode" + rightIcon: "dark_mode" + } + + Item { + width: parent.width + height: resetColumn.implicitHeight + + Column { + id: resetColumn + + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.spacingXS + + StyledText { + text: "Widget layout" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + width: parent.width + text: "Widget positions are saved per monitor. Reset them to the default arrangement." + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + DankButton { + text: "Reset widget positions" + iconName: "restart_alt" + onClicked: settings.saveValue("layout", {}) + } + } + } +} diff --git a/GameBarSystemWidget.qml b/GameBarSystemWidget.qml new file mode 100644 index 0000000..dc2e391 --- /dev/null +++ b/GameBarSystemWidget.qml @@ -0,0 +1,197 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: widget + + readonly property var activeModules: ["system", "cpu", "memory", "network", "disk", "diskmounts"] + readonly property var rootMount: { + const mounts = DgopService.diskMounts || []; + return mounts.find(mount => mount.mount === "/") || mounts[0] || null; + } + readonly property real diskPercent: { + if (!rootMount || !rootMount.percent) + return 0; + + return parseFloat(String(rootMount.percent).replace("%", "")) || 0; + } + + function formatRate(bytesPerSecond) { + const value = bytesPerSecond || 0; + if (value < 1024) + return value.toFixed(0) + " B/s"; + if (value < 1024 * 1024) + return (value / 1024).toFixed(0) + " K/s"; + if (value < 1024 * 1024 * 1024) + return (value / (1024 * 1024)).toFixed(1) + " M/s"; + return (value / (1024 * 1024 * 1024)).toFixed(1) + " G/s"; + } + + function formatGiB(megabytes) { + return ((megabytes || 0) / 1024).toFixed(1) + " GiB"; + } + + // Stats only get polled while this widget exists. + Component.onCompleted: DgopService.addRef(widget.activeModules) + Component.onDestruction: DgopService.removeRef(widget.activeModules) + + component StatTile: Rectangle { + id: tile + + property string icon: "" + property string label: "" + property string value: "" + property string detail: "" + property real fraction: 0 + property color accent: Theme.primary + + radius: Theme.cornerRadius + color: Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency) + border.color: Theme.outlineLight + border.width: Theme.layerOutlineWidth + + DankIcon { + id: tileIcon + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.top: parent.top + anchors.topMargin: Theme.spacingM + name: tile.icon + size: Theme.iconSize - 6 + color: tile.accent + } + + StyledText { + anchors.left: tileIcon.right + anchors.leftMargin: Theme.spacingXS + anchors.verticalCenter: tileIcon.verticalCenter + text: tile.label + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + id: tileValue + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: tileIcon.verticalCenter + text: tile.value + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + id: tileDetail + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.bottom: tileTrack.top + anchors.bottomMargin: Theme.spacingXS + text: tile.detail + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + } + + Rectangle { + id: tileTrack + + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: Theme.spacingM + height: 5 + radius: height / 2 + color: Theme.withAlpha(Theme.outline, 0.4) + + Rectangle { + width: parent.width * Math.max(0, Math.min(1, tile.fraction)) + height: parent.height + radius: parent.radius + color: tile.accent + + Behavior on width { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.OutCubic + } + } + } + } + } + + Grid { + anchors.fill: parent + columns: 2 + rowSpacing: Theme.spacingS + columnSpacing: Theme.spacingS + + readonly property real tileWidth: (width - columnSpacing) / 2 + readonly property real tileHeight: (height - rowSpacing) / 2 + + StatTile { + width: parent.tileWidth + height: parent.tileHeight + icon: "memory" + label: "CPU" + value: Math.round(DgopService.cpuUsage || 0) + "%" + detail: DgopService.cpuTemperature > 0 ? DgopService.cpuTemperature.toFixed(0) + "°C" : (DgopService.cpuCores + " cores") + fraction: (DgopService.cpuUsage || 0) / 100 + accent: DgopService.cpuUsage > 85 ? Theme.error : (DgopService.cpuUsage > 60 ? Theme.warning : Theme.primary) + } + + StatTile { + width: parent.tileWidth + height: parent.tileHeight + icon: "developer_board" + label: "Memory" + value: Math.round(DgopService.memoryUsage || 0) + "%" + detail: widget.formatGiB(DgopService.usedMemoryMB) + " / " + widget.formatGiB(DgopService.totalMemoryMB) + fraction: (DgopService.memoryUsage || 0) / 100 + accent: DgopService.memoryUsage > 85 ? Theme.error : (DgopService.memoryUsage > 60 ? Theme.warning : Theme.primary) + } + + StatTile { + width: parent.tileWidth + height: parent.tileHeight + icon: "swap_vert" + label: "Network" + value: "↓ " + widget.formatRate(DgopService.networkRxRate) + detail: "↑ " + widget.formatRate(DgopService.networkTxRate) + // Scaled against 100 MiB/s so the bar stays readable on fast links. + fraction: Math.min(1, ((DgopService.networkRxRate || 0) + (DgopService.networkTxRate || 0)) / (100 * 1024 * 1024)) + accent: Theme.primary + } + + StatTile { + width: parent.tileWidth + height: parent.tileHeight + icon: "storage" + label: widget.rootMount ? widget.rootMount.mount : "Disk" + value: Math.round(widget.diskPercent) + "%" + detail: "R " + widget.formatRate(DgopService.diskReadRate) + " W " + widget.formatRate(DgopService.diskWriteRate) + fraction: widget.diskPercent / 100 + accent: widget.diskPercent > 90 ? Theme.error : (widget.diskPercent > 75 ? Theme.warning : Theme.primary) + } + } + + StyledText { + anchors.centerIn: parent + width: parent.width - Theme.spacingL * 2 + text: "dgop is not available - install it to see system stats." + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + visible: !DgopService.dgopAvailable + } +} diff --git a/GameBarTopBar.qml b/GameBarTopBar.qml new file mode 100644 index 0000000..e628f4d --- /dev/null +++ b/GameBarTopBar.qml @@ -0,0 +1,143 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Widgets + +Rectangle { + id: bar + + property var panel: null + + readonly property var widgetDefs: (panel && panel.daemon) ? panel.daemon.widgetDefs : [] + + width: barRow.width + Theme.spacingL * 2 + height: 56 + radius: height / 2 + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + border.color: Theme.outlineMedium + border.width: Theme.layerOutlineWidth + + SystemClock { + id: clock + + precision: SystemClock.Minutes + } + + component BarSeparator: Rectangle { + anchors.verticalCenter: parent.verticalCenter + width: 1 + height: 26 + color: Theme.outlineMedium + } + + Row { + id: barRow + + anchors.centerIn: parent + spacing: Theme.spacingM + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: "sports_esports" + size: Theme.iconSize + color: Theme.primary + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 0 + + StyledText { + text: Qt.formatDateTime(clock.date, "HH:mm") + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.Medium + color: Theme.surfaceText + } + + StyledText { + text: Qt.formatDateTime(clock.date, "ddd d MMM") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + } + + BarSeparator {} + + Row { + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Repeater { + model: bar.widgetDefs + + delegate: Rectangle { + id: toggleButton + + required property var modelData + + readonly property bool active: bar.panel ? bar.panel.isWidgetVisible(modelData.id) : false + + width: toggleRow.width + Theme.spacingM * 2 + height: 38 + radius: Theme.cornerRadius + color: active ? Theme.primarySelected : (toggleArea.containsMouse ? Theme.surfaceHover : "transparent") + border.color: active ? Theme.primary : "transparent" + border.width: active ? 1 : 0 + + Row { + id: toggleRow + + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: toggleButton.modelData.icon + size: Theme.iconSize - 6 + color: toggleButton.active ? Theme.primary : Theme.surfaceText + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: toggleButton.modelData.title + font.pixelSize: Theme.fontSizeSmall + font.weight: toggleButton.active ? Font.Medium : Font.Normal + color: toggleButton.active ? Theme.primary : Theme.surfaceText + } + } + + MouseArea { + id: toggleArea + + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (bar.panel) + bar.panel.toggleWidget(toggleButton.modelData.id); + } + } + } + } + } + + BarSeparator {} + + DankActionButton { + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + buttonSize: 34 + iconSize: 18 + iconColor: Theme.surfaceText + onClicked: { + if (bar.panel) + bar.panel.requestClose(); + } + } + } +} diff --git a/GameBarVolumeRow.qml b/GameBarVolumeRow.qml new file mode 100644 index 0000000..7860c2b --- /dev/null +++ b/GameBarVolumeRow.qml @@ -0,0 +1,99 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets + +Item { + id: row + + property var node: null + property int maxPercent: 100 + property string activeIcon: "volume_up" + property string mutedIcon: "volume_off" + + readonly property var audio: (node && node.audio) ? node.audio : null + readonly property bool muted: audio ? audio.muted : false + readonly property int percent: audio ? Math.round(audio.volume * 100) : 0 + + readonly property int sliderMaximum: Math.max(100, maxPercent) + + // DankSlider assigns its own `value` while dragging, which breaks a declarative + // binding, so the slider is synced imperatively whenever the node volume changes. + function syncSlider() { + slider.value = Math.min(row.sliderMaximum, row.percent); + } + + function applyVolume(newValue) { + if (!row.audio) + return; + + row.audio.volume = newValue / 100; + if (newValue > 0 && row.audio.muted) + row.audio.muted = false; + } + + height: 48 + onPercentChanged: row.syncSlider() + onSliderMaximumChanged: row.syncSlider() + onNodeChanged: row.syncSlider() + + Rectangle { + id: muteButton + + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 40 + radius: width / 2 + color: muteArea.containsMouse ? Theme.primaryHover : "transparent" + + DankIcon { + anchors.centerIn: parent + name: (row.muted || row.percent === 0) ? row.mutedIcon : row.activeIcon + size: Theme.iconSize + color: row.muted ? Theme.error : Theme.primary + } + + MouseArea { + id: muteArea + + anchors.fill: parent + hoverEnabled: true + enabled: row.audio !== null + cursorShape: Qt.PointingHandCursor + onClicked: { + if (row.audio) + row.audio.muted = !row.audio.muted; + } + } + } + + StyledText { + id: valueLabel + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: 48 + horizontalAlignment: Text.AlignRight + text: row.percent + "%" + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: row.muted ? Theme.surfaceVariantText : Theme.surfaceText + } + + DankSlider { + id: slider + + anchors.left: muteButton.right + anchors.leftMargin: Theme.spacingS + anchors.right: valueLabel.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + enabled: row.audio !== null + minimum: 0 + maximum: row.sliderMaximum + showValue: false + onSliderValueChanged: newValue => row.applyVolume(newValue) + Component.onCompleted: row.syncSlider() + } +} diff --git a/GameBarWindow.qml b/GameBarWindow.qml new file mode 100644 index 0000000..2712026 --- /dev/null +++ b/GameBarWindow.qml @@ -0,0 +1,191 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: win + + property var panel: null + property string widgetId: "" + property string title: "" + property string icon: "" + property real widgetWidth: 360 + property real widgetHeight: 320 + property real minimumY: 0 + property bool shown: false + property Component contentComponent: null + + 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 real maxX: Math.max(margin, (panel ? panel.width : width) - width - margin) + readonly property real maxY: Math.max(minimumY, (panel ? panel.height : height) - height - margin) + + function clampX(value) { + return Math.max(margin, Math.min(maxX, value)); + } + + function clampY(value) { + 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). + 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); + } + + function raise() { + if (panel) + win.z = panel.nextWindowZ(); + } + + function storePosition() { + if (!panel || panel.width <= 0 || panel.height <= 0) + return; + + panel.saveWidgetPosition(win.widgetId, win.x / panel.width, win.y / panel.height); + } + + width: widgetWidth + height: widgetHeight + visible: shown && opacity > 0 + opacity: shown ? 1 : 0 + + Behavior on opacity { + NumberAnimation { + duration: Theme.shortDuration + easing.type: Easing.OutCubic + } + } + + Component.onCompleted: win.applyStoredPosition() + onShownChanged: { + if (shown) { + win.applyStoredPosition(); + 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. + onPanelWidthChanged: win.applyStoredPosition() + onPanelHeightChanged: win.applyStoredPosition() + + Rectangle { + id: frame + + anchors.fill: parent + radius: Theme.cornerRadius * 1.25 + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + border.color: dragArea.pressed ? Theme.primary : Theme.outlineMedium + border.width: dragArea.pressed ? 2 : Theme.layerOutlineWidth + + Rectangle { + id: titleBar + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + height: 38 + radius: frame.radius + color: dragArea.containsMouse ? Theme.surfaceContainerHigh : "transparent" + + // Square off the bottom corners so the title bar blends into the body. + Rectangle { + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: parent.radius + color: parent.color + } + + DankIcon { + id: titleIcon + + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + name: win.icon + size: Theme.iconSize - 6 + color: Theme.primary + } + + StyledText { + anchors.left: titleIcon.right + anchors.leftMargin: Theme.spacingS + anchors.right: closeButton.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + text: win.title + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + } + + DankActionButton { + id: closeButton + + anchors.right: parent.right + anchors.rightMargin: Theme.spacingXS + anchors.verticalCenter: parent.verticalCenter + iconName: "close" + buttonSize: 26 + iconSize: 15 + iconColor: Theme.surfaceVariantText + onClicked: { + if (win.panel) + win.panel.toggleWidget(win.widgetId); + } + } + + MouseArea { + id: dragArea + + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.right: closeButton.left + hoverEnabled: true + cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor + drag.target: win + drag.axis: Drag.XAndYAxis + drag.minimumX: win.margin + drag.maximumX: win.maxX + drag.minimumY: win.minimumY + drag.maximumY: win.maxY + drag.threshold: 0 + onPressed: win.raise() + onReleased: win.storePosition() + } + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + propagateComposedEvents: true + z: -1 + onPressed: mouse => { + win.raise(); + mouse.accepted = false; + } + } + + Loader { + anchors.top: titleBar.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: Theme.spacingM + anchors.rightMargin: Theme.spacingM + anchors.bottomMargin: Theme.spacingM + active: win.shown + sourceComponent: win.contentComponent + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a9ccf41 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 keule2 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d4dafad --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Game Bar + +An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/plugins-overview). + +## Widgets + +| Widget | Contents | +|---|---| +| **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 | +| **Performance** | CPU (usage, temperature), memory (used / total), network (rx/tx), disk (root usage, read/write) | + +### IPC commands + +| Command | Effect | +|---|---| +| `dms ipc call gameBar toggle` | Toggle the overlay | +| `dms ipc call gameBar open` | Open the overlay | +| `dms ipc call gameBar close` | Close the overlay | +| `dms ipc call gameBar widget audio\|apps\|system` | Show/hide one widget | +| `dms ipc call gameBar resetLayout` | Forget all saved widget positions | + +## Settings + +Settings → Plugins → Game Bar (gear icon): + +- **Background dimming** — how dark the desktop behind the overlay gets (0–95%). +- **Reset widget positions** — back to the default arrangement on every monitor. + +## License + +MIT diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..5468e9b --- /dev/null +++ b/plugin.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://github.com/AvengeMedia/DankMaterialShell/raw/refs/heads/master/PLUGINS/plugin-schema.json", + "id": "gameBar", + "name": "Game Bar", + "description": "Game Bar style overlay with quick actions and system monitoring", + "version": "1.0.0", + "license": "MIT", + "author": "keule", + "icon": "sports_esports", + "type": "daemon", + "capabilities": [ + "daemon", + "ipc" + ], + "component": "./GameBarDaemon.qml", + "settings": "./GameBarSettings.qml", + "requires_dms": ">=1.5.0", + "permissions": [ + "settings_read", + "settings_write" + ] +}