From d15bb4687cd49bc1d404312ecd85d90c39a9f657 Mon Sep 17 00:00:00 2001 From: Keule2 Date: Tue, 22 Sep 2026 01:22:27 +0200 Subject: [PATCH] feat: added widgets capture, music visualiser, bluetooth, notes --- GameBarBarWidget.qml | 200 ++++++++++++ GameBarDaemon.qml | 300 ++++++++++++++++-- GameBarSettings.qml | 92 +++++- README.md | 12 + components/BrightnessControl.qml | 117 +++++++ .../DeviceList.qml | 0 .../VolumeRow.qml | 0 .../OverlayModal.qml | 2 +- GameBarPanel.qml => overlay/Panel.qml | 63 +++- GameBarTopBar.qml => overlay/TopBar.qml | 15 +- GameBarWindow.qml => overlay/WidgetWindow.qml | 27 +- plugin.json | 11 +- services/CaptureService.qml | 150 +++++++++ .../HomeService.qml | 0 .../HomeSettings.qml | 0 GameBarAppMixer.qml => widgets/AppMixer.qml | 3 +- .../AudioWidget.qml | 9 +- widgets/BluetoothWidget.qml | 192 +++++++++++ widgets/CaptureWidget.qml | 199 ++++++++++++ GameBarHomeRow.qml => widgets/HomeRow.qml | 0 .../HomeWidget.qml | 2 +- .../MediaWidget.qml | 0 widgets/NotepadWidget.qml | 193 +++++++++++ .../SystemWidget.qml | 0 widgets/VisualizerWidget.qml | 143 +++++++++ 25 files changed, 1675 insertions(+), 55 deletions(-) create mode 100644 GameBarBarWidget.qml create mode 100644 components/BrightnessControl.qml rename GameBarDeviceList.qml => components/DeviceList.qml (100%) rename GameBarVolumeRow.qml => components/VolumeRow.qml (100%) rename GameBarOverlay.qml => overlay/OverlayModal.qml (98%) rename GameBarPanel.qml => overlay/Panel.qml (71%) rename GameBarTopBar.qml => overlay/TopBar.qml (86%) rename GameBarWindow.qml => overlay/WidgetWindow.qml (83%) create mode 100644 services/CaptureService.qml rename GameBarHomeService.qml => services/HomeService.qml (100%) rename GameBarHomeSettings.qml => settings/HomeSettings.qml (100%) rename GameBarAppMixer.qml => widgets/AppMixer.qml (99%) rename GameBarAudioWidget.qml => widgets/AudioWidget.qml (95%) create mode 100644 widgets/BluetoothWidget.qml create mode 100644 widgets/CaptureWidget.qml rename GameBarHomeRow.qml => widgets/HomeRow.qml (100%) rename GameBarHomeWidget.qml => widgets/HomeWidget.qml (98%) rename GameBarMediaWidget.qml => widgets/MediaWidget.qml (100%) create mode 100644 widgets/NotepadWidget.qml rename GameBarSystemWidget.qml => widgets/SystemWidget.qml (100%) create mode 100644 widgets/VisualizerWidget.qml diff --git a/GameBarBarWidget.qml b/GameBarBarWidget.qml new file mode 100644 index 0000000..fc2c002 --- /dev/null +++ b/GameBarBarWidget.qml @@ -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 + } + } + } + } +} diff --git a/GameBarDaemon.qml b/GameBarDaemon.qml index a004fbd..7f72dd5 100644 --- a/GameBarDaemon.qml +++ b/GameBarDaemon.qml @@ -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; diff --git a/GameBarSettings.qml b/GameBarSettings.qml index 3e7d042..c364915 100644 --- a/GameBarSettings.qml +++ b/GameBarSettings.qml @@ -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 / resetLayout" + text: "Commands: dms ipc call gameBar toggle / open / close / widget / 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 } diff --git a/README.md b/README.md index 516eca6..7a03b0b 100644 --- a/README.md +++ b/README.md @@ -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 ` | 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. diff --git a/components/BrightnessControl.qml b/components/BrightnessControl.qml new file mode 100644 index 0000000..b12553a --- /dev/null +++ b/components/BrightnessControl.qml @@ -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() + } +} diff --git a/GameBarDeviceList.qml b/components/DeviceList.qml similarity index 100% rename from GameBarDeviceList.qml rename to components/DeviceList.qml diff --git a/GameBarVolumeRow.qml b/components/VolumeRow.qml similarity index 100% rename from GameBarVolumeRow.qml rename to components/VolumeRow.qml diff --git a/GameBarOverlay.qml b/overlay/OverlayModal.qml similarity index 98% rename from GameBarOverlay.qml rename to overlay/OverlayModal.qml index 69d81ab..4dda520 100644 --- a/GameBarOverlay.qml +++ b/overlay/OverlayModal.qml @@ -42,7 +42,7 @@ DankModal { modalHeight: availableHeight content: Component { - GameBarPanel { + Panel { overlayRef: overlay } } diff --git a/GameBarPanel.qml b/overlay/Panel.qml similarity index 71% rename from GameBarPanel.qml rename to overlay/Panel.qml index 1433b53..8086170 100644 --- a/GameBarPanel.qml +++ b/overlay/Panel.qml @@ -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 {} } } diff --git a/GameBarTopBar.qml b/overlay/TopBar.qml similarity index 86% rename from GameBarTopBar.qml rename to overlay/TopBar.qml index e628f4d..57c3ceb 100644 --- a/GameBarTopBar.qml +++ b/overlay/TopBar.qml @@ -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 } } diff --git a/GameBarWindow.qml b/overlay/WidgetWindow.qml similarity index 83% rename from GameBarWindow.qml rename to overlay/WidgetWindow.qml index 2712026..9f8577a 100644 --- a/GameBarWindow.qml +++ b/overlay/WidgetWindow.qml @@ -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 diff --git a/plugin.json b/plugin.json index 51b92bc..76f6fe8 100644 --- a/plugin.json +++ b/plugin.json @@ -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": [ diff --git a/services/CaptureService.qml b/services/CaptureService.qml new file mode 100644 index 0000000..35c3320 --- /dev/null +++ b/services/CaptureService.qml @@ -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 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) + } +} diff --git a/GameBarHomeService.qml b/services/HomeService.qml similarity index 100% rename from GameBarHomeService.qml rename to services/HomeService.qml diff --git a/GameBarHomeSettings.qml b/settings/HomeSettings.qml similarity index 100% rename from GameBarHomeSettings.qml rename to settings/HomeSettings.qml diff --git a/GameBarAppMixer.qml b/widgets/AppMixer.qml similarity index 99% rename from GameBarAppMixer.qml rename to widgets/AppMixer.qml index a1ce8f6..4b8cd27 100644 --- a/GameBarAppMixer.qml +++ b/widgets/AppMixer.qml @@ -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 diff --git a/GameBarAudioWidget.qml b/widgets/AudioWidget.qml similarity index 95% rename from GameBarAudioWidget.qml rename to widgets/AudioWidget.qml index 5e66a37..8c764db 100644 --- a/GameBarAudioWidget.qml +++ b/widgets/AudioWidget.qml @@ -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 diff --git a/widgets/BluetoothWidget.qml b/widgets/BluetoothWidget.qml new file mode 100644 index 0000000..624e8f3 --- /dev/null +++ b/widgets/BluetoothWidget.qml @@ -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 + } + } +} diff --git a/widgets/CaptureWidget.qml b/widgets/CaptureWidget.qml new file mode 100644 index 0000000..18a56d8 --- /dev/null +++ b/widgets/CaptureWidget.qml @@ -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 + } + } +} diff --git a/GameBarHomeRow.qml b/widgets/HomeRow.qml similarity index 100% rename from GameBarHomeRow.qml rename to widgets/HomeRow.qml diff --git a/GameBarHomeWidget.qml b/widgets/HomeWidget.qml similarity index 98% rename from GameBarHomeWidget.qml rename to widgets/HomeWidget.qml index 69af45a..cadfe96 100644 --- a/GameBarHomeWidget.qml +++ b/widgets/HomeWidget.qml @@ -42,7 +42,7 @@ Item { Repeater { model: widget.selected - delegate: GameBarHomeRow { + delegate: HomeRow { required property var modelData width: entityColumn.width diff --git a/GameBarMediaWidget.qml b/widgets/MediaWidget.qml similarity index 100% rename from GameBarMediaWidget.qml rename to widgets/MediaWidget.qml diff --git a/widgets/NotepadWidget.qml b/widgets/NotepadWidget.qml new file mode 100644 index 0000000..ac944fc --- /dev/null +++ b/widgets/NotepadWidget.qml @@ -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"]); + } + } + } + } +} diff --git a/GameBarSystemWidget.qml b/widgets/SystemWidget.qml similarity index 100% rename from GameBarSystemWidget.qml rename to widgets/SystemWidget.qml diff --git a/widgets/VisualizerWidget.qml b/widgets/VisualizerWidget.qml new file mode 100644 index 0000000..c6da1cd --- /dev/null +++ b/widgets/VisualizerWidget.qml @@ -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, 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 + } +}