Compare commits

..
1 Commits
Author SHA1 Message Date
Keule2 018e326918 feat: timer widget with countdown, stopwatch and alarm 2026-09-25 15:12:49 +02:00
6 changed files with 623 additions and 3 deletions
+38 -3
View File
@@ -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 <seconds> [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
+21
View File
@@ -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"
+5
View File
@@ -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 <seconds> [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.
+10
View File
@@ -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
+200
View File
@@ -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)
}
}
+349
View File
@@ -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()
}
}
}
}