feat: added widgets capture, music visualiser, bluetooth, notes

This commit is contained in:
2026-09-22 01:22:27 +02:00
parent 9b023a0993
commit d15bb4687c
25 changed files with 1675 additions and 55 deletions
+157
View File
@@ -0,0 +1,157 @@
import QtQuick
import Quickshell
import Quickshell.Services.Pipewire
import qs.Common
import qs.Services
import qs.Widgets
import "../components"
DankFlickable {
id: mixer
readonly property var streams: Pipewire.nodes.values.filter(node => node.audio && node.isSink && node.isStream)
readonly property int streamCount: streams.length
function appName(node) {
if (!node)
return "";
const props = node.properties || {};
return props["application.name"] || AudioService.displayName(node) || "Unknown";
}
function mediaName(node) {
const props = (node && node.properties) ? node.properties : {};
return props["media.name"] || "";
}
function appIconValue(node) {
const props = (node && node.properties) ? node.properties : {};
const candidates = [props["application.icon-name"], props["application.process.binary"], props["application.name"]];
for (const candidate of candidates) {
if (!candidate)
continue;
const entry = DesktopEntries.heuristicLookup(String(candidate));
if (entry && entry.icon)
return entry.icon;
}
return "material:graphic_eq";
}
clip: true
contentHeight: streamColumn.height
contentWidth: width
Column {
id: streamColumn
width: mixer.width
spacing: Theme.spacingS
Repeater {
model: ScriptModel {
values: mixer.streams
}
delegate: Rectangle {
id: streamItem
required property var modelData
width: streamColumn.width
height: 86
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
border.color: Theme.outlineLight
border.width: Theme.layerOutlineWidth
AppIconRenderer {
id: streamIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
width: Theme.iconSize
height: Theme.iconSize
iconValue: mixer.appIconValue(streamItem.modelData)
iconSize: Theme.iconSize
fallbackText: mixer.appName(streamItem.modelData).charAt(0).toUpperCase()
}
Column {
anchors.left: streamIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
spacing: 1
StyledText {
width: parent.width
text: mixer.appName(streamItem.modelData)
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
width: parent.width
text: mixer.mediaName(streamItem.modelData)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
visible: text !== ""
}
}
VolumeRow {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: Theme.spacingXS
node: streamItem.modelData
maxPercent: 100
activeIcon: "volume_up"
mutedIcon: "volume_off"
}
PwObjectTracker {
objects: [streamItem.modelData]
}
}
}
Item {
width: streamColumn.width
height: 120
visible: mixer.streamCount === 0
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "music_off"
size: Theme.iconSizeLarge
color: Theme.surfaceVariantText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: "No apps are playing audio"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
}
}
+76
View File
@@ -0,0 +1,76 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import "../components"
Item {
id: widget
readonly property real sectionSpacing: Theme.spacingS
VolumeRow {
id: outputVolume
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
node: AudioService.sink
maxPercent: AudioService.sinkMaxVolume
activeIcon: "volume_up"
mutedIcon: "volume_off"
}
StyledText {
id: outputLabel
anchors.top: outputVolume.bottom
anchors.left: parent.left
text: "Output"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DeviceList {
id: outputDevices
anchors.top: outputLabel.bottom
anchors.topMargin: Theme.spacingXS
anchors.left: parent.left
anchors.right: parent.right
height: Math.max(52, (parent.height - outputVolume.height - inputVolume.height - outputLabel.height - inputLabel.height - widget.sectionSpacing * 3 - Theme.spacingXS * 2) * 0.5)
isSink: true
}
VolumeRow {
id: inputVolume
anchors.top: outputDevices.bottom
anchors.topMargin: widget.sectionSpacing
anchors.left: parent.left
anchors.right: parent.right
node: AudioService.source
maxPercent: 100
activeIcon: "mic"
mutedIcon: "mic_off"
}
StyledText {
id: inputLabel
anchors.top: inputVolume.bottom
anchors.left: parent.left
text: "Input"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
DeviceList {
anchors.top: inputLabel.bottom
anchors.topMargin: Theme.spacingXS
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
isSink: false
}
}
+192
View File
@@ -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
}
}
}
+199
View File
@@ -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
}
}
}
+301
View File
@@ -0,0 +1,301 @@
import QtQuick
import qs.Common
import qs.Widgets
Rectangle {
id: row
property var service: null
property string entityId: ""
readonly property var entity: service ? service.entityState(entityId) : null
readonly property string domain: service ? service.domainOf(entityId) : ""
readonly property var attributes: (entity && entity.attributes) ? entity.attributes : ({})
readonly property string state: entity ? String(entity.state) : "unavailable"
// Scenes/scripts/buttons report a timestamp or "unknown" rather than a state,
// so they must not be greyed out as unavailable.
readonly property bool available: entity !== null && (isButton || (state !== "unavailable" && state !== "unknown"))
readonly property bool isOn: state === "on" || state === "playing" || state === "open" || state === "home"
readonly property bool isToggleable: ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "automation"].indexOf(domain) !== -1
readonly property bool isMediaPlayer: domain === "media_player"
readonly property bool isCover: domain === "cover"
readonly property bool isButton: ["scene", "script", "button", "input_button"].indexOf(domain) !== -1
// number / input_number expose min, max and step and are set with set_value.
readonly property bool isNumber: domain === "number" || domain === "input_number"
readonly property real numberMin: attributes.min !== undefined ? attributes.min : 0
readonly property real numberMax: attributes.max !== undefined ? attributes.max : 100
readonly property real numberStep: (attributes.step !== undefined && attributes.step > 0) ? attributes.step : 1
readonly property real numberValue: {
const parsed = parseFloat(state);
return isNaN(parsed) ? numberMin : parsed;
}
// DankSlider works in whole numbers, so only drive it directly when the
// entity's own range is integral; otherwise map it onto 0-100.
readonly property bool numberDirect: isNumber && Number.isInteger(numberMin) && Number.isInteger(numberMax) && Number.isInteger(numberStep)
readonly property string numberUnit: attributes.unit_of_measurement || ""
function numberFromSlider(sliderValue) {
if (row.numberDirect)
return sliderValue;
const span = row.numberMax - row.numberMin;
const raw = row.numberMin + (sliderValue / 100) * span;
const stepped = row.numberMin + Math.round((raw - row.numberMin) / row.numberStep) * row.numberStep;
return Math.min(row.numberMax, Math.max(row.numberMin, Math.round(stepped * 1000) / 1000));
}
readonly property bool hasBrightness: domain === "light" && isOn && attributes.brightness !== undefined && attributes.brightness !== null
readonly property bool hasVolume: isMediaPlayer && attributes.volume_level !== undefined && attributes.volume_level !== null
readonly property bool hasSlider: hasBrightness || hasVolume || (isNumber && available)
readonly property int sliderPercent: {
if (hasBrightness)
return Math.round((attributes.brightness / 255) * 100);
if (hasVolume)
return Math.round(attributes.volume_level * 100);
if (isNumber) {
if (numberDirect)
return Math.round(numberValue);
const span = numberMax - numberMin;
return span > 0 ? Math.round(((numberValue - numberMin) / span) * 100) : 0;
}
return 0;
}
readonly property string icon: {
switch (domain) {
case "light":
return "lightbulb";
case "switch":
return "power_settings_new";
case "fan":
return "mode_fan";
case "media_player":
return "speaker";
case "cover":
return "blinds";
case "scene":
return "auto_awesome";
case "script":
return "play_arrow";
case "button":
case "input_button":
return "radio_button_checked";
case "climate":
return "thermostat";
case "number":
case "input_number":
return "tune";
case "lock":
return "lock";
case "binary_sensor":
return "sensors";
case "sensor":
return "monitoring";
}
return "home";
}
readonly property string detail: {
if (!available)
return "Unavailable";
if (row.isButton) {
switch (domain) {
case "scene":
return "Scene";
case "script":
return "Script";
}
return "Button";
}
if (row.isMediaPlayer) {
const media = attributes.media_title || "";
return media !== "" ? state + " · " + media : state;
}
if (domain === "sensor" || domain === "binary_sensor")
return state + (attributes.unit_of_measurement ? " " + attributes.unit_of_measurement : "");
if (domain === "climate" && attributes.current_temperature !== undefined)
return state + " · " + attributes.current_temperature + "°";
if (row.isNumber)
return row.numberValue + (row.numberUnit !== "" ? " " + row.numberUnit : "");
if (row.hasBrightness)
return row.sliderPercent + "%";
return state;
}
function primaryAction() {
if (!service || !available)
return;
if (row.isButton)
service.press(entityId);
else if (row.isToggleable || row.isMediaPlayer)
service.toggle(entityId);
}
height: hasSlider ? 74 : 48
radius: Theme.cornerRadius
color: row.isOn ? Theme.primarySelected : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
border.color: row.isOn ? Theme.primary : Theme.outlineLight
border.width: row.isOn ? 1 : Theme.layerOutlineWidth
opacity: available ? 1 : 0.55
DankIcon {
id: entityIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: 12
name: row.icon
size: Theme.iconSize - 6
color: row.isOn ? Theme.primary : Theme.surfaceText
}
Column {
id: labels
anchors.left: entityIcon.right
anchors.leftMargin: Theme.spacingS
anchors.right: controls.left
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.topMargin: 8
spacing: 0
StyledText {
width: parent.width
text: row.service ? row.service.friendlyName(row.entityId) : row.entityId
font.pixelSize: Theme.fontSizeMedium
font.weight: row.isOn ? Font.Medium : Font.Normal
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
width: parent.width
text: row.detail
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
}
}
Row {
id: controls
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.top: parent.top
anchors.topMargin: 6
spacing: Theme.spacingXS
DankActionButton {
visible: row.isCover
iconName: "keyboard_arrow_up"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "open_cover")
}
DankActionButton {
visible: row.isCover
iconName: "stop"
buttonSize: 30
iconSize: 16
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "stop_cover")
}
DankActionButton {
visible: row.isCover
iconName: "keyboard_arrow_down"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.cover(row.entityId, "close_cover")
}
DankActionButton {
visible: row.isMediaPlayer
iconName: row.attributes.is_volume_muted === true ? "volume_off" : "volume_up"
buttonSize: 30
iconSize: 17
iconColor: row.attributes.is_volume_muted === true ? Theme.error : Theme.surfaceText
onClicked: row.service.setMuted(row.entityId, row.attributes.is_volume_muted !== true)
}
DankActionButton {
visible: row.isMediaPlayer
iconName: row.state === "playing" ? "pause" : "play_arrow"
buttonSize: 30
iconSize: 18
iconColor: Theme.surfaceText
onClicked: row.service.callService("media_player", "media_play_pause", {
"entity_id": row.entityId
})
}
DankToggle {
visible: row.isToggleable
anchors.verticalCenter: parent.verticalCenter
checked: row.isOn
enabled: row.available
onToggled: isChecked => row.service.toggle(row.entityId)
}
DankActionButton {
visible: row.isButton
iconName: "play_arrow"
buttonSize: 30
iconSize: 18
iconColor: Theme.primary
onClicked: row.service.press(row.entityId)
}
}
DankSlider {
id: slider
visible: row.hasSlider
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: -4
minimum: row.numberDirect ? Math.round(row.numberMin) : 0
maximum: row.numberDirect ? Math.round(row.numberMax) : 100
step: row.numberDirect ? Math.round(row.numberStep) : 1
showValue: false
enabled: row.available
onSliderValueChanged: newValue => {
if (row.hasBrightness)
row.service.setBrightness(row.entityId, newValue);
else if (row.hasVolume)
row.service.setVolume(row.entityId, newValue);
else if (row.isNumber)
row.service.setNumber(row.entityId, row.numberFromSlider(newValue));
}
Component.onCompleted: slider.value = row.sliderPercent
}
// Keep the slider in step with Home Assistant without fighting the user's drag.
onSliderPercentChanged: {
if (!slider.isDragging)
slider.value = row.sliderPercent;
}
MouseArea {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: row.hasSlider ? slider.top : parent.bottom
anchors.right: controls.left
enabled: row.available && (row.isToggleable || row.isMediaPlayer || row.isButton)
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: row.primaryAction()
}
}
+89
View File
@@ -0,0 +1,89 @@
import QtQuick
import Quickshell
import qs.Common
import qs.Widgets
Item {
id: widget
property var service: null
readonly property var selected: {
const stored = (service && service.pluginData) ? service.pluginData.haEntities : null;
return Array.isArray(stored) ? stored : [];
}
readonly property bool configured: service ? service.configured : false
Component.onCompleted: {
if (service)
service.addConsumer();
}
Component.onDestruction: {
if (service)
service.removeConsumer();
}
DankFlickable {
id: list
anchors.fill: parent
visible: widget.configured && widget.selected.length > 0
clip: true
contentHeight: entityColumn.height
contentWidth: width
Column {
id: entityColumn
width: list.width
spacing: Theme.spacingXS
Repeater {
model: widget.selected
delegate: HomeRow {
required property var modelData
width: entityColumn.width
service: widget.service
entityId: modelData
}
}
}
}
Column {
anchors.centerIn: parent
width: parent.width - Theme.spacingL
spacing: Theme.spacingS
visible: !list.visible
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: widget.configured ? "playlist_add" : "home"
size: Theme.iconSizeLarge
color: Theme.surfaceVariantText
}
StyledText {
width: parent.width
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: widget.configured ? "No entities picked yet.\nChoose them in Settings > Plugins > Game Bar." : "Home Assistant is not set up.\nAdd your URL and token in Settings > Plugins > Game Bar."
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
StyledText {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
text: widget.service ? widget.service.lastError : ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.error
elide: Text.ElideRight
visible: text !== ""
}
}
+248
View File
@@ -0,0 +1,248 @@
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 var players: MprisController.availablePlayers || []
readonly property bool hasPlayer: activePlayer !== null && activePlayer !== undefined
readonly property bool isPlaying: hasPlayer && activePlayer.playbackState === MprisPlaybackState.Playing
function formatTime(seconds) {
const total = Math.max(0, Math.floor(seconds || 0));
const mins = Math.floor(total / 60);
const secs = total % 60;
return mins + ":" + (secs < 10 ? "0" : "") + secs;
}
function playerName(player) {
if (!player)
return "";
return player.identity || player.dbusName || "Player";
}
Item {
id: content
anchors.fill: parent
visible: widget.hasPlayer
DankAlbumArt {
id: art
anchors.left: parent.left
anchors.top: parent.top
width: 84
height: 84
activePlayer: widget.activePlayer
showAnimation: widget.isPlaying
}
Column {
id: meta
anchors.left: art.right
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.top: parent.top
anchors.topMargin: Theme.spacingXS
spacing: 2
StyledText {
width: parent.width
text: (widget.activePlayer && widget.activePlayer.trackTitle) ? widget.activePlayer.trackTitle : "Nothing playing"
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
}
StyledText {
width: parent.width
text: (widget.activePlayer && widget.activePlayer.trackArtist) ? widget.activePlayer.trackArtist : ""
font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText
elide: Text.ElideRight
visible: text !== ""
}
StyledText {
width: parent.width
text: (widget.activePlayer && widget.activePlayer.trackAlbum) ? widget.activePlayer.trackAlbum : ""
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
visible: text !== ""
}
}
DankSeekbar {
id: seekbar
anchors.left: parent.left
anchors.right: parent.right
anchors.top: art.bottom
anchors.topMargin: Theme.spacingS
height: 20
activePlayer: widget.activePlayer
}
StyledText {
id: positionLabel
anchors.left: parent.left
anchors.top: seekbar.bottom
text: widget.formatTime(widget.activePlayer ? widget.activePlayer.position : 0)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
anchors.right: parent.right
anchors.top: seekbar.bottom
text: widget.formatTime(MprisController.activePlayerStableLength)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
visible: MprisController.activePlayerStableLength > 0
}
Row {
id: transport
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: positionLabel.bottom
anchors.topMargin: Theme.spacingXS
spacing: Theme.spacingM
DankActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "skip_previous"
buttonSize: 38
iconSize: 22
iconColor: Theme.surfaceText
enabled: widget.hasPlayer && widget.activePlayer.canGoPrevious
onClicked: MprisController.previousOrRewind()
}
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 46
height: 46
radius: width / 2
color: Theme.primary
opacity: (widget.hasPlayer && widget.activePlayer.canTogglePlaying) ? 1 : 0.5
DankIcon {
anchors.centerIn: parent
name: widget.isPlaying ? "pause" : "play_arrow"
size: 26
color: Theme.onPrimary
weight: 500
}
MouseArea {
anchors.fill: parent
enabled: widget.hasPlayer && widget.activePlayer.canTogglePlaying
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: widget.activePlayer.togglePlaying()
}
}
DankActionButton {
anchors.verticalCenter: parent.verticalCenter
iconName: "skip_next"
buttonSize: 38
iconSize: 22
iconColor: Theme.surfaceText
enabled: widget.hasPlayer && widget.activePlayer.canGoNext
onClicked: MprisController.next()
}
}
// Source switcher - one chip per MPRIS player that is currently around.
DankFlickable {
id: sourceRow
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 30
clip: true
contentHeight: height
contentWidth: sourceChips.width
flickableDirection: Flickable.HorizontalFlick
visible: widget.players.length > 1
Row {
id: sourceChips
height: sourceRow.height
spacing: Theme.spacingXS
Repeater {
model: widget.players
delegate: Rectangle {
id: chip
required property var modelData
readonly property bool current: modelData === widget.activePlayer
width: chipLabel.implicitWidth + Theme.spacingM * 2
height: 26
anchors.verticalCenter: parent.verticalCenter
radius: height / 2
color: current ? Theme.primarySelected : (chipArea.containsMouse ? Theme.surfaceHover : Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency))
border.color: current ? Theme.primary : Theme.outlineLight
border.width: current ? 1 : Theme.layerOutlineWidth
StyledText {
id: chipLabel
anchors.centerIn: parent
text: widget.playerName(chip.modelData)
font.pixelSize: Theme.fontSizeSmall
font.weight: chip.current ? Font.Medium : Font.Normal
color: chip.current ? Theme.primary : Theme.surfaceText
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: MprisController.setActivePlayer(chip.modelData)
}
}
}
}
}
}
Column {
anchors.centerIn: parent
spacing: Theme.spacingS
visible: !widget.hasPlayer
DankIcon {
anchors.horizontalCenter: parent.horizontalCenter
name: "music_off"
size: Theme.iconSizeLarge
color: Theme.surfaceVariantText
}
StyledText {
anchors.horizontalCenter: parent.horizontalCenter
text: "No media player running"
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
}
}
+193
View File
@@ -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"]);
}
}
}
}
}
+197
View File
@@ -0,0 +1,197 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: widget
readonly property var activeModules: ["system", "cpu", "memory", "network", "disk", "diskmounts"]
readonly property var rootMount: {
const mounts = DgopService.diskMounts || [];
return mounts.find(mount => mount.mount === "/") || mounts[0] || null;
}
readonly property real diskPercent: {
if (!rootMount || !rootMount.percent)
return 0;
return parseFloat(String(rootMount.percent).replace("%", "")) || 0;
}
function formatRate(bytesPerSecond) {
const value = bytesPerSecond || 0;
if (value < 1024)
return value.toFixed(0) + " B/s";
if (value < 1024 * 1024)
return (value / 1024).toFixed(0) + " K/s";
if (value < 1024 * 1024 * 1024)
return (value / (1024 * 1024)).toFixed(1) + " M/s";
return (value / (1024 * 1024 * 1024)).toFixed(1) + " G/s";
}
function formatGiB(megabytes) {
return ((megabytes || 0) / 1024).toFixed(1) + " GiB";
}
// Stats only get polled while this widget exists.
Component.onCompleted: DgopService.addRef(widget.activeModules)
Component.onDestruction: DgopService.removeRef(widget.activeModules)
component StatTile: Rectangle {
id: tile
property string icon: ""
property string label: ""
property string value: ""
property string detail: ""
property real fraction: 0
property color accent: Theme.primary
radius: Theme.cornerRadius
color: Theme.withAlpha(Theme.surfaceLight, Theme.popupTransparency)
border.color: Theme.outlineLight
border.width: Theme.layerOutlineWidth
DankIcon {
id: tileIcon
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.top: parent.top
anchors.topMargin: Theme.spacingM
name: tile.icon
size: Theme.iconSize - 6
color: tile.accent
}
StyledText {
anchors.left: tileIcon.right
anchors.leftMargin: Theme.spacingXS
anchors.verticalCenter: tileIcon.verticalCenter
text: tile.label
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
}
StyledText {
id: tileValue
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: tileIcon.verticalCenter
text: tile.value
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
}
StyledText {
id: tileDetail
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.bottom: tileTrack.top
anchors.bottomMargin: Theme.spacingXS
text: tile.detail
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
elide: Text.ElideRight
}
Rectangle {
id: tileTrack
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: Theme.spacingM
anchors.rightMargin: Theme.spacingM
anchors.bottomMargin: Theme.spacingM
height: 5
radius: height / 2
color: Theme.withAlpha(Theme.outline, 0.4)
Rectangle {
width: parent.width * Math.max(0, Math.min(1, tile.fraction))
height: parent.height
radius: parent.radius
color: tile.accent
Behavior on width {
NumberAnimation {
duration: Theme.shortDuration
easing.type: Easing.OutCubic
}
}
}
}
}
Grid {
anchors.fill: parent
columns: 2
rowSpacing: Theme.spacingS
columnSpacing: Theme.spacingS
readonly property real tileWidth: (width - columnSpacing) / 2
readonly property real tileHeight: (height - rowSpacing) / 2
StatTile {
width: parent.tileWidth
height: parent.tileHeight
icon: "memory"
label: "CPU"
value: Math.round(DgopService.cpuUsage || 0) + "%"
detail: DgopService.cpuTemperature > 0 ? DgopService.cpuTemperature.toFixed(0) + "°C" : (DgopService.cpuCores + " cores")
fraction: (DgopService.cpuUsage || 0) / 100
accent: DgopService.cpuUsage > 85 ? Theme.error : (DgopService.cpuUsage > 60 ? Theme.warning : Theme.primary)
}
StatTile {
width: parent.tileWidth
height: parent.tileHeight
icon: "developer_board"
label: "Memory"
value: Math.round(DgopService.memoryUsage || 0) + "%"
detail: widget.formatGiB(DgopService.usedMemoryMB) + " / " + widget.formatGiB(DgopService.totalMemoryMB)
fraction: (DgopService.memoryUsage || 0) / 100
accent: DgopService.memoryUsage > 85 ? Theme.error : (DgopService.memoryUsage > 60 ? Theme.warning : Theme.primary)
}
StatTile {
width: parent.tileWidth
height: parent.tileHeight
icon: "swap_vert"
label: "Network"
value: "↓ " + widget.formatRate(DgopService.networkRxRate)
detail: "↑ " + widget.formatRate(DgopService.networkTxRate)
// Scaled against 100 MiB/s so the bar stays readable on fast links.
fraction: Math.min(1, ((DgopService.networkRxRate || 0) + (DgopService.networkTxRate || 0)) / (100 * 1024 * 1024))
accent: Theme.primary
}
StatTile {
width: parent.tileWidth
height: parent.tileHeight
icon: "storage"
label: widget.rootMount ? widget.rootMount.mount : "Disk"
value: Math.round(widget.diskPercent) + "%"
detail: "R " + widget.formatRate(DgopService.diskReadRate) + " W " + widget.formatRate(DgopService.diskWriteRate)
fraction: widget.diskPercent / 100
accent: widget.diskPercent > 90 ? Theme.error : (widget.diskPercent > 75 ? Theme.warning : Theme.primary)
}
}
StyledText {
anchors.centerIn: parent
width: parent.width - Theme.spacingL * 2
text: "dgop is not available - install it to see system stats."
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
visible: !DgopService.dgopAvailable
}
}
+143
View File
@@ -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<int>, 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
}
}