commit 62e760f126f7d3021dbb0f26264355ffaf00e426 Author: Keule2 Date: Mon Sep 21 16:50:56 2026 +0200 letsss GOOO 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" + ] +}