From 018e3269183d71a1d92d04a16a4e00e08641787c Mon Sep 17 00:00:00 2001 From: Keule2 Date: Fri, 25 Sep 2026 15:12:49 +0200 Subject: [PATCH] feat: timer widget with countdown, stopwatch and alarm --- GameBarDaemon.qml | 41 ++++- GameBarSettings.qml | 21 +++ README.md | 5 + overlay/Panel.qml | 10 ++ services/TimerService.qml | 200 ++++++++++++++++++++++ widgets/TimerWidget.qml | 349 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 services/TimerService.qml create mode 100644 widgets/TimerWidget.qml diff --git a/GameBarDaemon.qml b/GameBarDaemon.qml index 7f72dd5..50290be 100644 --- a/GameBarDaemon.qml +++ b/GameBarDaemon.qml @@ -38,6 +38,16 @@ PluginComponent { "y": 0.14, "defaultVisible": true }, + { + "id": "timer", + "title": "Timer", + "icon": "timer", + "width": 380, + "height": 320, + "x": 0.57, + "y": 0.58, + "defaultVisible": false + }, { "id": "capture", "title": "Capture", @@ -331,9 +341,8 @@ PluginComponent { // ── Overlay ────────────────────────────────────────────────────────────── function openOverlay() { - if (overlay.shouldBeVisible) - return; - + // No early return on shouldBeVisible: the flag can survive a shell + // restart with no window mapped, which used to make open() a no-op. if (typeof PopoutService !== "undefined" && PopoutService) PopoutService.closeControlCenter(); @@ -416,6 +425,25 @@ PluginComponent { return "SUCCESS"; } + function timer(seconds: string, label: string) : string { + const value = parseInt(seconds, 10); + if (isNaN(value) || value <= 0) + return "USAGE: timer [label]"; + + timers.resetTimer(); + timers.startTimer(value * 1000, label || ""); + return "STARTED " + timers.formatDuration(value * 1000); + } + + function timerStop() : string { + timers.resetTimer(); + return "SUCCESS"; + } + + function timerStatus() : string { + return "timer=" + (timers.timerRunning ? "running" : (timers.timerFinished ? "finished" : "idle")) + " remaining=" + timers.formatDuration(timers.remainingMs) + " label=" + (timers.timerLabel || "(none)") + " stopwatch=" + (timers.stopwatchRunning ? "running" : "idle") + " elapsed=" + timers.formatStopwatch(timers.stopwatchElapsedMs); + } + function layoutStatus() : string { const lines = Quickshell.screens.map(screen => { const key = root.screenKey(screen); @@ -459,6 +487,13 @@ PluginComponent { readonly property alias homeService: haService readonly property alias captureService: capture + readonly property alias timerService: timers + + TimerService { + id: timers + + daemon: root + } CaptureService { id: capture diff --git a/GameBarSettings.qml b/GameBarSettings.qml index c364915..34e9cd7 100644 --- a/GameBarSettings.qml +++ b/GameBarSettings.qml @@ -80,6 +80,27 @@ PluginSettings { defaultValue: "focused" } + ToggleSetting { + settingKey: "timerPlaySound" + label: "Play a sound when a timer ends" + defaultValue: true + } + + ToggleSetting { + settingKey: "timerNotify" + label: "Send a notification when a timer ends" + description: "Shows the timer's label, so it is visible even with the overlay closed" + defaultValue: true + } + + StringSetting { + settingKey: "timerSound" + label: "Timer sound" + description: "Any file paplay can play" + defaultValue: "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" + placeholder: "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" + } + StringSetting { settingKey: "captureDirectory" label: "Capture folder" diff --git a/README.md b/README.md index 7a03b0b..3a74e5c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ An overlay for [DankMaterialShell](https://danklinux.com/docs/dankmaterialshell/ | **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 | +| **Timer** | Countdown with presets and an optional label, plus a stopwatch tab with laps; ends with a sound and a notification even when the overlay is closed | | **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 | @@ -54,6 +55,9 @@ Note that editing `plugin_settings.json` by hand does not reach a running shell. | `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 timer [label]` | Start a countdown | +| `dms ipc call gameBar timerStop` | Stop and reset the countdown | +| `dms ipc call gameBar timerStatus` | Print countdown and stopwatch state | | `dms ipc call gameBar resetLayout` | Forget all saved widget positions | ## Settings @@ -63,6 +67,7 @@ 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`). +- **Timer** — sound on/off, notification on/off and which sound file to play. - **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/overlay/Panel.qml b/overlay/Panel.qml index 8086170..afe9330 100644 --- a/overlay/Panel.qml +++ b/overlay/Panel.qml @@ -108,6 +108,8 @@ FocusScope { return audioWidgetComponent; case "apps": return appsWidgetComponent; + case "timer": + return timerWidgetComponent; case "capture": return captureWidgetComponent; case "media": @@ -140,6 +142,14 @@ FocusScope { AppMixer {} } + Component { + id: timerWidgetComponent + + TimerWidget { + service: root.daemon ? root.daemon.timerService : null + } + } + Component { id: captureWidgetComponent diff --git a/services/TimerService.qml b/services/TimerService.qml new file mode 100644 index 0000000..3925a89 --- /dev/null +++ b/services/TimerService.qml @@ -0,0 +1,200 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.Services + +Item { + id: service + + property var daemon: null + + readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({}) + readonly property string alarmSound: String(pluginData.timerSound || "/usr/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga").trim() + readonly property bool notifyOnFinish: pluginData.timerNotify !== false + readonly property bool soundOnFinish: pluginData.timerPlaySound !== false + + // ── Countdown ──────────────────────────────────────────────────────────── + property int durationMs: 5 * 60 * 1000 + property int remainingMs: durationMs + property double deadline: 0 + property bool timerRunning: false + property bool timerFinished: false + property string timerLabel: "" + + readonly property real timerProgress: durationMs > 0 ? Math.max(0, Math.min(1, 1 - remainingMs / durationMs)) : 0 + + // ── Stopwatch ──────────────────────────────────────────────────────────── + property double stopwatchStartedAt: 0 + property int stopwatchBaseMs: 0 + property int stopwatchElapsedMs: 0 + property bool stopwatchRunning: false + property var laps: [] + + signal finished(string label) + + function formatDuration(ms) { + const total = Math.max(0, Math.round(ms / 1000)); + const hours = Math.floor(total / 3600); + const mins = Math.floor((total % 3600) / 60); + const secs = total % 60; + const pad = value => (value < 10 ? "0" : "") + value; + return hours > 0 ? hours + ":" + pad(mins) + ":" + pad(secs) : pad(mins) + ":" + pad(secs); + } + + function formatStopwatch(ms) { + const hundredths = Math.floor((Math.max(0, ms) % 1000) / 10); + return service.formatDuration(ms) + "." + (hundredths < 10 ? "0" : "") + hundredths; + } + + function setDuration(ms) { + service.durationMs = Math.max(1000, Math.round(ms)); + if (!service.timerRunning) { + service.remainingMs = service.durationMs; + service.timerFinished = false; + } + } + + function addTime(ms) { + if (service.timerRunning) { + service.deadline += ms; + service.durationMs = Math.max(1000, service.durationMs + ms); + service._tick(); + return; + } + + service.setDuration(service.durationMs + ms); + } + + function startTimer(ms, label) { + if (ms !== undefined && ms !== null) + service.setDuration(ms); + + if (label !== undefined && label !== null) + service.timerLabel = String(label); + + if (service.remainingMs <= 0) + service.remainingMs = service.durationMs; + + service.timerFinished = false; + service.deadline = Date.now() + service.remainingMs; + service.timerRunning = true; + } + + function pauseTimer() { + if (!service.timerRunning) + return; + + service.remainingMs = Math.max(0, service.deadline - Date.now()); + service.timerRunning = false; + } + + function toggleTimer() { + if (service.timerRunning) + service.pauseTimer(); + else + service.startTimer(); + } + + function resetTimer() { + service.timerRunning = false; + service.timerFinished = false; + service.remainingMs = service.durationMs; + } + + function _tick() { + if (!service.timerRunning) + return; + + const left = service.deadline - Date.now(); + if (left <= 0) { + service.remainingMs = 0; + service.timerRunning = false; + service.timerFinished = true; + service._announce(); + return; + } + + service.remainingMs = left; + } + + // Fires whether or not the overlay is open - the service lives in the daemon. + function _announce() { + const label = service.timerLabel.trim(); + const body = label !== "" ? label : "Timer finished after " + service.formatDuration(service.durationMs); + + if (service.soundOnFinish && service.alarmSound !== "") + Quickshell.execDetached(["sh", "-c", '[ -r "$1" ] && exec paplay "$1"', "gameBar", service.alarmSound]); + + if (service.notifyOnFinish) + Quickshell.execDetached(["notify-send", "-a", "Game Bar", "-u", "critical", "-i", "timer", "Timer finished", body]); + + if (typeof ToastService !== "undefined" && ToastService) + ToastService.showInfo("Timer finished", body); + + service.finished(label); + } + + function dismissFinished() { + service.timerFinished = false; + service.remainingMs = service.durationMs; + } + + // ── Stopwatch control ──────────────────────────────────────────────────── + function startStopwatch() { + if (service.stopwatchRunning) + return; + + service.stopwatchStartedAt = Date.now(); + service.stopwatchRunning = true; + } + + function pauseStopwatch() { + if (!service.stopwatchRunning) + return; + + service.stopwatchBaseMs = service.stopwatchElapsedMs; + service.stopwatchRunning = false; + } + + function toggleStopwatch() { + if (service.stopwatchRunning) + service.pauseStopwatch(); + else + service.startStopwatch(); + } + + function resetStopwatch() { + service.stopwatchRunning = false; + service.stopwatchBaseMs = 0; + service.stopwatchElapsedMs = 0; + service.laps = []; + } + + function lap() { + if (!service.stopwatchRunning && service.stopwatchElapsedMs === 0) + return; + + const previous = service.laps.length > 0 ? service.laps[0].total : 0; + service.laps = [ + { + "index": service.laps.length + 1, + "total": service.stopwatchElapsedMs, + "split": service.stopwatchElapsedMs - previous + } + ].concat(service.laps); + } + + Timer { + running: service.timerRunning + interval: 200 + repeat: true + onTriggered: service._tick() + } + + Timer { + running: service.stopwatchRunning + interval: 50 + repeat: true + onTriggered: service.stopwatchElapsedMs = service.stopwatchBaseMs + (Date.now() - service.stopwatchStartedAt) + } +} diff --git a/widgets/TimerWidget.qml b/widgets/TimerWidget.qml new file mode 100644 index 0000000..527c12e --- /dev/null +++ b/widgets/TimerWidget.qml @@ -0,0 +1,349 @@ +import QtQuick +import qs.Common +import qs.Widgets + +Item { + id: widget + + property var service: null + + property int tabIndex: 0 + + readonly property bool onTimerTab: tabIndex === 0 + readonly property var presets: [1, 3, 5, 10, 15, 25, 45, 60] + + component ActionButton: Rectangle { + id: button + + property string icon: "" + property string label: "" + property bool accent: false + property bool enabled: true + + signal triggered + + width: buttonRow.width + Theme.spacingM * 2 + height: 34 + radius: Theme.cornerRadius + color: accent ? Theme.primary : (buttonArea.containsMouse && enabled ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)) + border.color: accent ? Theme.primary : Theme.outlineLight + border.width: Theme.layerOutlineWidth + opacity: enabled ? 1 : 0.45 + + Row { + id: buttonRow + + anchors.centerIn: parent + spacing: Theme.spacingXS + + DankIcon { + anchors.verticalCenter: parent.verticalCenter + name: button.icon + size: Theme.iconSize - 8 + color: button.accent ? Theme.onPrimary : Theme.surfaceText + visible: button.icon !== "" + } + + StyledText { + anchors.verticalCenter: parent.verticalCenter + text: button.label + font.pixelSize: Theme.fontSizeSmall + font.weight: button.accent ? Font.Medium : Font.Normal + color: button.accent ? Theme.onPrimary : Theme.surfaceText + visible: button.label !== "" + } + } + + MouseArea { + id: buttonArea + + anchors.fill: parent + hoverEnabled: true + enabled: button.enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: button.triggered() + } + } + + DankTabBar { + id: tabs + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + tabHeight: 34 + showIcons: false + currentIndex: widget.tabIndex + model: [ + { + "text": "Timer" + }, + { + "text": "Stopwatch" + } + ] + onTabClicked: index => widget.tabIndex = index + } + + // ── Timer ──────────────────────────────────────────────────────────────── + Item { + anchors.top: tabs.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: widget.onTimerTab + + StyledText { + id: remaining + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + text: widget.service ? widget.service.formatDuration(widget.service.remainingMs) : "00:00" + font.pixelSize: 40 + font.weight: Font.Medium + color: (widget.service && widget.service.timerFinished) ? Theme.error : Theme.surfaceText + + SequentialAnimation on opacity { + running: widget.service !== null && widget.service.timerFinished + loops: Animation.Infinite + + NumberAnimation { + to: 0.35 + duration: 500 + } + + NumberAnimation { + to: 1 + duration: 500 + } + } + } + + Rectangle { + id: progress + + anchors.top: remaining.bottom + anchors.topMargin: Theme.spacingXS + anchors.left: parent.left + anchors.right: parent.right + height: 4 + radius: 2 + color: Theme.withAlpha(Theme.outline, 0.4) + + Rectangle { + width: parent.width * (widget.service ? widget.service.timerProgress : 0) + height: parent.height + radius: parent.radius + color: (widget.service && widget.service.timerFinished) ? Theme.error : Theme.primary + + Behavior on width { + NumberAnimation { + duration: 180 + } + } + } + } + + DankTextField { + id: labelField + + anchors.top: progress.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + height: 34 + placeholderText: "What is it for? (shown when it ends)" + text: widget.service ? widget.service.timerLabel : "" + onTextChanged: { + if (widget.service) + widget.service.timerLabel = text; + } + } + + Flow { + id: presetFlow + + anchors.top: labelField.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + spacing: Theme.spacingXS + + Repeater { + model: widget.presets + + delegate: ActionButton { + required property var modelData + + label: modelData + "m" + onTriggered: { + if (!widget.service) + return; + + widget.service.resetTimer(); + widget.service.startTimer(modelData * 60 * 1000); + } + } + } + } + + Row { + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXS + + ActionButton { + icon: "remove" + label: "1m" + enabled: widget.service !== null + onTriggered: widget.service.addTime(-60 * 1000) + } + + ActionButton { + icon: (widget.service && widget.service.timerRunning) ? "pause" : "play_arrow" + label: (widget.service && widget.service.timerRunning) ? "Pause" : "Start" + accent: true + enabled: widget.service !== null + onTriggered: widget.service.toggleTimer() + } + + ActionButton { + icon: "add" + label: "1m" + enabled: widget.service !== null + onTriggered: widget.service.addTime(60 * 1000) + } + + ActionButton { + icon: "restart_alt" + label: (widget.service && widget.service.timerFinished) ? "Dismiss" : "Reset" + enabled: widget.service !== null + onTriggered: { + if (widget.service.timerFinished) + widget.service.dismissFinished(); + else + widget.service.resetTimer(); + } + } + } + } + + // ── Stopwatch ──────────────────────────────────────────────────────────── + Item { + anchors.top: tabs.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + visible: !widget.onTimerTab + + StyledText { + id: elapsed + + anchors.top: parent.top + anchors.horizontalCenter: parent.horizontalCenter + text: widget.service ? widget.service.formatStopwatch(widget.service.stopwatchElapsedMs) : "00:00.00" + font.pixelSize: 40 + font.weight: Font.Medium + color: Theme.surfaceText + } + + DankFlickable { + id: lapList + + anchors.top: elapsed.bottom + anchors.topMargin: Theme.spacingS + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: stopwatchControls.top + anchors.bottomMargin: Theme.spacingS + clip: true + contentHeight: lapColumn.height + contentWidth: width + + Column { + id: lapColumn + + width: lapList.width + spacing: 2 + + Repeater { + model: widget.service ? widget.service.laps : [] + + delegate: Item { + required property var modelData + + width: lapColumn.width + height: 22 + + StyledText { + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Lap " + modelData.index + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + anchors.right: totalText.left + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + text: "+" + widget.service.formatStopwatch(modelData.split) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + id: totalText + + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: widget.service.formatStopwatch(modelData.total) + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + } + } + } + } + } + + StyledText { + anchors.centerIn: lapList + text: "No laps yet" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + visible: !widget.service || widget.service.laps.length === 0 + } + + Row { + id: stopwatchControls + + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXS + + ActionButton { + icon: (widget.service && widget.service.stopwatchRunning) ? "pause" : "play_arrow" + label: (widget.service && widget.service.stopwatchRunning) ? "Pause" : "Start" + accent: true + enabled: widget.service !== null + onTriggered: widget.service.toggleStopwatch() + } + + ActionButton { + icon: "flag" + label: "Lap" + enabled: widget.service !== null && widget.service.stopwatchElapsedMs > 0 + onTriggered: widget.service.lap() + } + + ActionButton { + icon: "restart_alt" + label: "Reset" + enabled: widget.service !== null && widget.service.stopwatchElapsedMs > 0 + onTriggered: widget.service.resetStopwatch() + } + } + } +}