feat: added widgets capture, music visualiser, bluetooth, notes
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 !== ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user