better structure
This commit is contained in:
146
config/quickshell/Services/AudioService.qml
Normal file
146
config/quickshell/Services/AudioService.qml
Normal file
@@ -0,0 +1,146 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var nodes: Pipewire.nodes.values.reduce((acc, node) => {
|
||||
if (!node.isStream) {
|
||||
if (node.isSink) {
|
||||
acc.sinks.push(node)
|
||||
} else if (node.audio) {
|
||||
acc.sources.push(node)
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
"sources": [],
|
||||
"sinks": []
|
||||
})
|
||||
|
||||
readonly property PwNode sink: Pipewire.defaultAudioSink
|
||||
readonly property PwNode source: Pipewire.defaultAudioSource
|
||||
readonly property list<PwNode> sinks: nodes.sinks
|
||||
readonly property list<PwNode> sources: nodes.sources
|
||||
|
||||
// Volume [0..1] is readonly from outside
|
||||
readonly property alias volume: root._volume
|
||||
property real _volume: sink?.audio?.volume ?? 0
|
||||
|
||||
readonly property alias muted: root._muted
|
||||
property bool _muted: !!sink?.audio?.muted
|
||||
|
||||
// Input volume [0..1] is readonly from outside
|
||||
readonly property alias inputVolume: root._inputVolume
|
||||
property real _inputVolume: source?.audio?.volume ?? 0
|
||||
|
||||
readonly property alias inputMuted: root._inputMuted
|
||||
property bool _inputMuted: !!source?.audio?.muted
|
||||
|
||||
readonly property real stepVolume: 5 / 100.0
|
||||
|
||||
PwObjectTracker {
|
||||
objects: [...root.sinks, ...root.sources]
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: sink?.audio ? sink?.audio : null
|
||||
|
||||
function onVolumeChanged() {
|
||||
var vol = (sink?.audio.volume ?? 0)
|
||||
if (isNaN(vol)) {
|
||||
vol = 0
|
||||
}
|
||||
root._volume = vol
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
root._muted = (sink?.audio.muted ?? true)
|
||||
Logger.log("AudioService", "OnMuteChanged:", root._muted)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: source?.audio ? source?.audio : null
|
||||
|
||||
function onVolumeChanged() {
|
||||
var vol = (source?.audio.volume ?? 0)
|
||||
if (isNaN(vol)) {
|
||||
vol = 0
|
||||
}
|
||||
root._inputVolume = vol
|
||||
}
|
||||
|
||||
function onMutedChanged() {
|
||||
root._inputMuted = (source?.audio.muted ?? true)
|
||||
Logger.log("AudioService", "OnInputMuteChanged:", root._inputMuted)
|
||||
}
|
||||
}
|
||||
|
||||
function increaseVolume() {
|
||||
setVolume(volume + stepVolume)
|
||||
}
|
||||
|
||||
function decreaseVolume() {
|
||||
setVolume(volume - stepVolume)
|
||||
}
|
||||
|
||||
function setVolume(newVolume: real) {
|
||||
if (sink?.ready && sink?.audio) {
|
||||
// Clamp it accordingly
|
||||
sink.audio.muted = false
|
||||
sink.audio.volume = Math.max(0, Math.min(1.0, newVolume))
|
||||
} else {
|
||||
Logger.warn("AudioService", "No sink available")
|
||||
}
|
||||
}
|
||||
|
||||
function setOutputMuted(muted: bool) {
|
||||
if (sink?.ready && sink?.audio) {
|
||||
sink.audio.muted = muted
|
||||
} else {
|
||||
Logger.warn("AudioService", "No sink available")
|
||||
}
|
||||
}
|
||||
|
||||
function setInputVolume(newVolume: real) {
|
||||
if (source?.ready && source?.audio) {
|
||||
// Clamp it accordingly
|
||||
source.audio.muted = false
|
||||
source.audio.volume = Math.max(0, Math.min(1.0, newVolume))
|
||||
} else {
|
||||
Logger.warn("AudioService", "No source available")
|
||||
}
|
||||
}
|
||||
|
||||
function setInputMuted(muted: bool) {
|
||||
if (source?.ready && source?.audio) {
|
||||
source.audio.muted = muted
|
||||
} else {
|
||||
Logger.warn("AudioService", "No source available")
|
||||
}
|
||||
}
|
||||
|
||||
function setAudioSink(newSink: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSink = newSink
|
||||
// Volume is changed by the sink change
|
||||
root._volume = newSink?.audio?.volume ?? 0
|
||||
root._muted = !!newSink?.audio?.muted
|
||||
}
|
||||
|
||||
function setAudioSource(newSource: PwNode): void {
|
||||
Pipewire.preferredDefaultAudioSource = newSource
|
||||
// Volume is changed by the source change
|
||||
root._inputVolume = newSource?.audio?.volume ?? 0
|
||||
root._inputMuted = !!newSource?.audio?.muted
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
setOutputMuted(!muted)
|
||||
}
|
||||
}
|
||||
308
config/quickshell/Services/BrightnessService.qml
Normal file
308
config/quickshell/Services/BrightnessService.qml
Normal file
@@ -0,0 +1,308 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property list<var> ddcMonitors: []
|
||||
readonly property list<Monitor> monitors: variants.instances
|
||||
property bool appleDisplayPresent: false
|
||||
|
||||
function getMonitorForScreen(screen: ShellScreen): var {
|
||||
return monitors.find(m => m.modelData === screen)
|
||||
}
|
||||
|
||||
// Signal emitted when a specific monitor's brightness changes, includes monitor context
|
||||
signal monitorBrightnessChanged(var monitor, real newBrightness)
|
||||
|
||||
function getAvailableMethods(): list<string> {
|
||||
var methods = []
|
||||
if (monitors.some(m => m.isDdc))
|
||||
methods.push("ddcutil")
|
||||
if (monitors.some(m => !m.isDdc))
|
||||
methods.push("internal")
|
||||
if (appleDisplayPresent)
|
||||
methods.push("apple")
|
||||
return methods
|
||||
}
|
||||
|
||||
// Global helpers for IPC and shortcuts
|
||||
function increaseBrightness(): void {
|
||||
monitors.forEach(m => m.increaseBrightness())
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
monitors.forEach(m => m.decreaseBrightness())
|
||||
}
|
||||
|
||||
function getDetectedDisplays(): list<var> {
|
||||
return detectedDisplays
|
||||
}
|
||||
|
||||
reloadableId: "brightness"
|
||||
|
||||
Component.onCompleted: {
|
||||
Logger.log("Brightness", "Service started")
|
||||
}
|
||||
|
||||
onMonitorsChanged: {
|
||||
ddcMonitors = []
|
||||
ddcProc.running = true
|
||||
}
|
||||
|
||||
Variants {
|
||||
id: variants
|
||||
model: Quickshell.screens
|
||||
Monitor {}
|
||||
}
|
||||
|
||||
// Check for Apple Display support
|
||||
Process {
|
||||
running: true
|
||||
command: ["sh", "-c", "which asdbctl >/dev/null 2>&1 && asdbctl get || echo ''"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.appleDisplayPresent = text.trim().length > 0
|
||||
}
|
||||
}
|
||||
|
||||
// Detect DDC monitors
|
||||
Process {
|
||||
id: ddcProc
|
||||
property list<var> ddcMonitors: []
|
||||
command: ["ddcutil", "detect", "--sleep-multiplier=0.5"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var displays = text.trim().split("\n\n")
|
||||
ddcProc.ddcMonitors = displays.map(d => {
|
||||
|
||||
var ddcModelMatch = d.match(/(This monitor does not support DDC\/CI|Invalid display)/)
|
||||
var modelMatch = d.match(/Model:\s*(.*)/)
|
||||
var busMatch = d.match(/I2C bus:[ ]*\/dev\/i2c-([0-9]+)/)
|
||||
var ddcModel = ddcModelMatch ? ddcModelMatch.length > 0 : false
|
||||
var model = modelMatch ? modelMatch[1] : "Unknown"
|
||||
var bus = busMatch ? busMatch[1] : "Unknown"
|
||||
Logger.log("Brigthness", "Detected DDC Monitor:", model, "on bus", bus, "is DDC:", !ddcModel)
|
||||
return {
|
||||
"model": model,
|
||||
"busNum": bus,
|
||||
"isDdc": !ddcModel
|
||||
}
|
||||
})
|
||||
root.ddcMonitors = ddcProc.ddcMonitors.filter(m => m.isDdc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component Monitor: QtObject {
|
||||
id: monitor
|
||||
|
||||
required property ShellScreen modelData
|
||||
readonly property bool isDdc: root.ddcMonitors.some(m => m.model === modelData.model)
|
||||
readonly property string busNum: root.ddcMonitors.find(m => m.model === modelData.model)?.busNum ?? ""
|
||||
readonly property bool isAppleDisplay: root.appleDisplayPresent && modelData.model.startsWith("StudioDisplay")
|
||||
readonly property string method: isAppleDisplay ? "apple" : (isDdc ? "ddcutil" : "internal")
|
||||
|
||||
property real brightness
|
||||
property real lastBrightness: 0
|
||||
property real queuedBrightness: NaN
|
||||
|
||||
// For internal displays - store the backlight device path
|
||||
property string backlightDevice: ""
|
||||
property string brightnessPath: ""
|
||||
property string maxBrightnessPath: ""
|
||||
property int maxBrightness: 100
|
||||
property bool ignoreNextChange: false
|
||||
|
||||
// Signal for brightness changes
|
||||
signal brightnessUpdated(real newBrightness)
|
||||
|
||||
// Execute a system command to get the current brightness value directly
|
||||
readonly property Process refreshProc: Process {
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var dataText = text.trim()
|
||||
if (dataText === "") {
|
||||
return
|
||||
}
|
||||
|
||||
var lines = dataText.split("\n")
|
||||
if (lines.length >= 2) {
|
||||
var current = parseInt(lines[0].trim())
|
||||
var max = parseInt(lines[1].trim())
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
var newBrightness = current / max
|
||||
// Only update if it's actually different (avoid feedback loops)
|
||||
if (Math.abs(newBrightness - monitor.brightness) > 0.01) {
|
||||
// Update internal value to match system state
|
||||
monitor.brightness = newBrightness
|
||||
monitor.brightnessUpdated(monitor.brightness)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to actively refresh the brightness from system
|
||||
function refreshBrightnessFromSystem() {
|
||||
if (!monitor.isDdc && !monitor.isAppleDisplay) {
|
||||
// For internal displays, query the system directly
|
||||
refreshProc.command = ["sh", "-c", "cat " + monitor.brightnessPath + " && " + "cat " + monitor.maxBrightnessPath]
|
||||
refreshProc.running = true
|
||||
} else if (monitor.isDdc) {
|
||||
// For DDC displays, get the current value
|
||||
refreshProc.command = ["ddcutil", "-b", monitor.busNum, "getvcp", "10", "--brief"]
|
||||
refreshProc.running = true
|
||||
} else if (monitor.isAppleDisplay) {
|
||||
// For Apple displays, get the current value
|
||||
refreshProc.command = ["asdbctl", "get"]
|
||||
refreshProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
// FileView to watch for external brightness changes (internal displays only)
|
||||
readonly property FileView brightnessWatcher: FileView {
|
||||
id: brightnessWatcher
|
||||
// Only set path for internal displays with a valid brightness path
|
||||
path: (!monitor.isDdc && !monitor.isAppleDisplay && monitor.brightnessPath !== "") ? monitor.brightnessPath : ""
|
||||
watchChanges: path !== ""
|
||||
onFileChanged: {
|
||||
// When a file change is detected, actively refresh from system
|
||||
// to ensure we get the most up-to-date value
|
||||
Qt.callLater(() => {
|
||||
monitor.refreshBrightnessFromSystem()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize brightness
|
||||
readonly property Process initProc: Process {
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
var dataText = text.trim()
|
||||
if (dataText === "") {
|
||||
return
|
||||
}
|
||||
|
||||
if (monitor.isAppleDisplay) {
|
||||
var val = parseInt(dataText)
|
||||
if (!isNaN(val)) {
|
||||
monitor.brightness = val / 101
|
||||
Logger.log("Brightness", "Apple display brightness:", monitor.brightness)
|
||||
}
|
||||
} else if (monitor.isDdc) {
|
||||
var parts = dataText.split(" ")
|
||||
if (parts.length >= 4) {
|
||||
var current = parseInt(parts[3])
|
||||
var max = parseInt(parts[4])
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
monitor.brightness = current / max
|
||||
Logger.log("Brightness", "DDC brightness:", current + "/" + max + " =", monitor.brightness)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Internal backlight - parse the response which includes device path
|
||||
var lines = dataText.split("\n")
|
||||
if (lines.length >= 3) {
|
||||
monitor.backlightDevice = lines[0]
|
||||
monitor.brightnessPath = monitor.backlightDevice + "/brightness"
|
||||
monitor.maxBrightnessPath = monitor.backlightDevice + "/max_brightness"
|
||||
|
||||
var current = parseInt(lines[1])
|
||||
var max = parseInt(lines[2])
|
||||
if (!isNaN(current) && !isNaN(max) && max > 0) {
|
||||
monitor.maxBrightness = max
|
||||
monitor.brightness = current / max
|
||||
Logger.log("Brightness", "Internal brightness:", current + "/" + max + " =", monitor.brightness)
|
||||
Logger.log("Brightness", "Using backlight device:", monitor.backlightDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update
|
||||
monitor.brightnessUpdated(monitor.brightness)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readonly property real stepSize: 5.0 / 100.0
|
||||
|
||||
// Timer for debouncing rapid changes
|
||||
readonly property Timer timer: Timer {
|
||||
interval: 100
|
||||
onTriggered: {
|
||||
if (!isNaN(monitor.queuedBrightness)) {
|
||||
monitor.setBrightness(monitor.queuedBrightness)
|
||||
monitor.queuedBrightness = NaN
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setBrightnessDebounced(value: real): void {
|
||||
monitor.queuedBrightness = value
|
||||
timer.start()
|
||||
}
|
||||
|
||||
function increaseBrightness(): void {
|
||||
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
|
||||
setBrightnessDebounced(value + stepSize)
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
const value = !isNaN(monitor.queuedBrightness) ? monitor.queuedBrightness : monitor.brightness
|
||||
setBrightnessDebounced(value - stepSize)
|
||||
}
|
||||
|
||||
function setBrightness(value: real): void {
|
||||
value = Math.max(0, Math.min(1, value))
|
||||
var rounded = Math.round(value * 100)
|
||||
|
||||
if (timer.running) {
|
||||
monitor.queuedBrightness = value
|
||||
return
|
||||
}
|
||||
|
||||
// Update internal value and trigger UI feedback
|
||||
monitor.brightness = value
|
||||
monitor.brightnessUpdated(value)
|
||||
root.monitorBrightnessChanged(monitor, monitor.brightness)
|
||||
|
||||
if (isAppleDisplay) {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["asdbctl", "set", rounded])
|
||||
} else if (isDdc) {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["ddcutil", "-b", busNum, "setvcp", "10", rounded])
|
||||
} else {
|
||||
monitor.ignoreNextChange = true
|
||||
Quickshell.execDetached(["set-brightness", rounded + "%"])
|
||||
}
|
||||
|
||||
if (isDdc) {
|
||||
timer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function initBrightness(): void {
|
||||
if (isAppleDisplay) {
|
||||
initProc.command = ["asdbctl", "get"]
|
||||
} else if (isDdc) {
|
||||
initProc.command = ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"]
|
||||
} else {
|
||||
// Internal backlight - find the first available backlight device and get its info
|
||||
// This now returns: device_path, current_brightness, max_brightness (on separate lines)
|
||||
initProc.command = ["sh", "-c", "for dev in /sys/class/backlight/*; do " + " if [ -f \"$dev/brightness\" ] && [ -f \"$dev/max_brightness\" ]; then " + " echo \"$dev\"; " + " cat \"$dev/brightness\"; " + " cat \"$dev/max_brightness\"; " + " break; " + " fi; " + "done"]
|
||||
}
|
||||
initProc.running = true
|
||||
}
|
||||
|
||||
onBusNumChanged: initBrightness()
|
||||
Component.onCompleted: initBrightness()
|
||||
}
|
||||
}
|
||||
32
config/quickshell/Services/CacheService.qml
Normal file
32
config/quickshell/Services/CacheService.qml
Normal file
@@ -0,0 +1,32 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string cacheDir: Quickshell.env("HOME") + "/.cache/quickshell/"
|
||||
property string recordingDir: Quickshell.env("HOME") + "/Videos/recordings/"
|
||||
property var cacheFiles: ["Location.json", "Ip.json", "Notifications.json", "LyricsOffset.txt"]
|
||||
property bool loaded: false
|
||||
property string locationCacheFile: cacheDir + "Location.json"
|
||||
property string ipCacheFile: cacheDir + "Ip.json"
|
||||
property string notificationsCacheFile: cacheDir + "Notifications.json"
|
||||
property string lyricsOffsetCacheFile: cacheDir + "LyricsOffset.txt"
|
||||
|
||||
Process {
|
||||
id: process
|
||||
|
||||
running: true
|
||||
command: ["sh", "-c", `mkdir -p ${cacheDir} && mkdir -p ${recordingDir} && touch ${cacheDir + cacheFiles.join(` && touch ${cacheDir}`)}`]
|
||||
onExited: (code, status) => {
|
||||
if (code === 0)
|
||||
root.loaded = true;
|
||||
else
|
||||
Logger.error("CacheService", `Failed to create cache files: ${command.join(" ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
143
config/quickshell/Services/Caffeine.qml
Normal file
143
config/quickshell/Services/Caffeine.qml
Normal file
@@ -0,0 +1,143 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string reason: "Application request"
|
||||
property bool isInhibited: false
|
||||
property var activeInhibitors: []
|
||||
// Different inhibitor strategies
|
||||
property string strategy: "systemd"
|
||||
|
||||
// Auto-detect the best strategy
|
||||
function detectStrategy() {
|
||||
if (strategy === "auto") {
|
||||
// Check if systemd-inhibit is available
|
||||
try {
|
||||
var systemdResult = Quickshell.execDetached(["which", "systemd-inhibit"]);
|
||||
strategy = "systemd";
|
||||
return ;
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
var waylandResult = Quickshell.execDetached(["which", "wayhibitor"]);
|
||||
strategy = "wayland";
|
||||
return ;
|
||||
} catch (e) {
|
||||
}
|
||||
strategy = "systemd"; // Fallback to systemd even if not detected
|
||||
}
|
||||
}
|
||||
|
||||
// Add an inhibitor
|
||||
function addInhibitor(id, reason = "Application request") {
|
||||
if (activeInhibitors.includes(id))
|
||||
return false;
|
||||
|
||||
activeInhibitors.push(id);
|
||||
updateInhibition(reason);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove an inhibitor
|
||||
function removeInhibitor(id) {
|
||||
const index = activeInhibitors.indexOf(id);
|
||||
if (index === -1) {
|
||||
console.log("Inhibitor not found:", id);
|
||||
return false;
|
||||
}
|
||||
activeInhibitors.splice(index, 1);
|
||||
updateInhibition();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Update the actual system inhibition
|
||||
function updateInhibition(newReason = reason) {
|
||||
const shouldInhibit = activeInhibitors.length > 0;
|
||||
if (shouldInhibit === isInhibited)
|
||||
return ;
|
||||
|
||||
// No change needed
|
||||
if (shouldInhibit)
|
||||
startInhibition(newReason);
|
||||
else
|
||||
stopInhibition();
|
||||
}
|
||||
|
||||
// Start system inhibition
|
||||
function startInhibition(newReason) {
|
||||
reason = newReason;
|
||||
if (strategy === "systemd")
|
||||
startSystemdInhibition();
|
||||
else if (strategy === "wayland")
|
||||
startWaylandInhibition();
|
||||
else
|
||||
return ;
|
||||
isInhibited = true;
|
||||
}
|
||||
|
||||
// Stop system inhibition
|
||||
function stopInhibition() {
|
||||
if (!isInhibited)
|
||||
return ;
|
||||
|
||||
// SIGTERM
|
||||
if (inhibitorProcess.running)
|
||||
inhibitorProcess.signal(15);
|
||||
|
||||
isInhibited = false;
|
||||
}
|
||||
|
||||
// Systemd inhibition using systemd-inhibit
|
||||
function startSystemdInhibition() {
|
||||
inhibitorProcess.command = ["systemd-inhibit", "--what=idle", "--why=" + reason, "--mode=block", "sleep", "infinity"];
|
||||
inhibitorProcess.running = true;
|
||||
}
|
||||
|
||||
// Wayland inhibition using wayhibitor or similar
|
||||
function startWaylandInhibition() {
|
||||
inhibitorProcess.command = ["wayhibitor"];
|
||||
inhibitorProcess.running = true;
|
||||
}
|
||||
|
||||
// Manual toggle for user control
|
||||
function manualToggle() {
|
||||
if (activeInhibitors.includes("manual")) {
|
||||
removeInhibitor("manual");
|
||||
return false;
|
||||
} else {
|
||||
addInhibitor("manual", "Manually activated by user");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
detectStrategy();
|
||||
}
|
||||
// Clean up on shutdown
|
||||
Component.onDestruction: {
|
||||
stopInhibition();
|
||||
}
|
||||
|
||||
// Process for maintaining the inhibition
|
||||
Process {
|
||||
id: inhibitorProcess
|
||||
|
||||
running: false
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (isInhibited)
|
||||
isInhibited = false;
|
||||
|
||||
Logger.log("Caffeine", "Inhibitor process exited with code:", exitCode, "status:", exitStatus);
|
||||
}
|
||||
onStarted: function() {
|
||||
Logger.log("Caffeine", "Inhibitor process started with PID:", inhibitorProcess.processId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
51
config/quickshell/Services/IPCService.qml
Normal file
51
config/quickshell/Services/IPCService.qml
Normal file
@@ -0,0 +1,51 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
|
||||
Item {
|
||||
IpcHandler {
|
||||
function setPrimary(color: color) {
|
||||
SettingsService.primaryColor = color;
|
||||
}
|
||||
|
||||
target: "colors"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleCalendar() {
|
||||
calendarPanel.toggle();
|
||||
}
|
||||
|
||||
function toggleControlCenter() {
|
||||
controlCenterPanel.toggle();
|
||||
}
|
||||
|
||||
target: "panels"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleBarLyrics() {
|
||||
SettingsService.showLyricsBar = !SettingsService.showLyricsBar;
|
||||
}
|
||||
|
||||
target: "lyrics"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function toggleInhibitor() {
|
||||
Caffeine.manualToggle();
|
||||
}
|
||||
|
||||
target: "idleInhibitor"
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
function startOrStopRecording() {
|
||||
RecordService.startOrStop();
|
||||
}
|
||||
|
||||
target: "recording"
|
||||
}
|
||||
|
||||
}
|
||||
165
config/quickshell/Services/IpService.qml
Normal file
165
config/quickshell/Services/IpService.qml
Normal file
@@ -0,0 +1,165 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property alias ip: cacheFileAdapter.ip
|
||||
property string cacheFilePath: CacheService.ipCacheFile
|
||||
property string countryCode: "N/A"
|
||||
property real fetchInterval: 120 // in s
|
||||
property real fetchTimeout: 10 // in s
|
||||
property string ipURL: "https://api.uyanide.com/ip"
|
||||
property string geoURL: "https://api.ipinfo.io/lite/"
|
||||
property string geoURLToken: ""
|
||||
|
||||
function fetchIP() {
|
||||
curl.fetch(ipURL, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response && response.ip) {
|
||||
let newIP = response.ip;
|
||||
Logger.log("IpService", "Fetched IP: " + newIP);
|
||||
if (newIP !== ip) {
|
||||
ip = newIP;
|
||||
countryCode = "N/A";
|
||||
fetchGeoInfo(true); // Fetch geo info only if IP has changed
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "IP response does not contain 'ip' field");
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("IpService", "Failed to parse IP response: " + e);
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "Failed to fetch IP");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function fetchGeoInfo(notify) {
|
||||
if (!ip || ip === "N/A") {
|
||||
countryCode = "N/A";
|
||||
return ;
|
||||
}
|
||||
let url = geoURL + ip;
|
||||
if (geoURLToken)
|
||||
url += "?token=" + geoURLToken;
|
||||
|
||||
cacheFileAdapter.geoInfo = null
|
||||
curl.fetch(url, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
const response = JSON.parse(data);
|
||||
if (response && (response.country_code || response.country)) {
|
||||
let newCountryCode = response.country_code || response.country;
|
||||
Logger.log("IpService", "Fetched country code: " + newCountryCode);
|
||||
countryCode = newCountryCode;
|
||||
} else {
|
||||
Logger.error("IpService", "Geo response does not contain 'country_code' field");
|
||||
}
|
||||
cacheFileAdapter.geoInfo = response;
|
||||
} catch (e) {
|
||||
Logger.error("IpService", "Failed to parse geo response: " + e);
|
||||
}
|
||||
} else {
|
||||
Logger.error("IpService", "Failed to fetch geo info");
|
||||
}
|
||||
SendNotification.show("New IP", `IP: ${ip}\nCountry: ${countryCode}`);
|
||||
cacheFile.writeAdapter();
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
fetchTimer.stop();
|
||||
ip = "N/A";
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
}
|
||||
|
||||
NetworkFetch {
|
||||
id: curl
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ipMonitor
|
||||
|
||||
command: ["ip", "monitor", "address", "route"]
|
||||
running: true
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: {
|
||||
ipMonitorDebounce.restart();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: ipMonitorDebounce
|
||||
|
||||
interval: 1000
|
||||
repeat: false
|
||||
running: false
|
||||
onTriggered: {
|
||||
fetchIP();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fetchTimer
|
||||
|
||||
interval: fetchInterval * 1000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: {
|
||||
fetchTimer.stop();
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: tokenFile
|
||||
|
||||
path: Qt.resolvedUrl("../Assets/Config/GeoInfoToken.txt")
|
||||
onLoaded: {
|
||||
geoURLToken = tokenFile.text();
|
||||
if (!geoURLToken)
|
||||
Logger.warn("IpService", "No token found for geoIP service, assuming none is required");
|
||||
|
||||
fetchIP();
|
||||
fetchTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cacheFile
|
||||
|
||||
path: cacheFilePath
|
||||
watchChanges: false
|
||||
onLoaded: {
|
||||
Logger.log("IpService", "Loaded IP from cache file: " + cacheFileAdapter.ip);
|
||||
if (cacheFileAdapter.geoInfo) {
|
||||
countryCode = cacheFileAdapter.geoInfo.country_code || cacheFileAdapter.country || "N/A";
|
||||
Logger.log("IpService", "Loaded country code from cache file: " + countryCode);
|
||||
}
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: cacheFileAdapter
|
||||
|
||||
property string ip: "N/A"
|
||||
property var geoInfo: null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
318
config/quickshell/Services/LocationService.qml
Normal file
318
config/quickshell/Services/LocationService.qml
Normal file
@@ -0,0 +1,318 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
// Weather logic and caching with stable UI properties
|
||||
Singleton {
|
||||
//console.log(JSON.stringify(weatherData))
|
||||
|
||||
id: root
|
||||
|
||||
property string locationName: SettingsService.location
|
||||
property string locationFile: CacheService.locationCacheFile
|
||||
property int weatherUpdateFrequency: 30 * 60 // 30 minutes expressed in seconds
|
||||
property bool isFetchingWeather: false
|
||||
readonly property alias data: adapter // Used to access via LocationService.data.xxx from outside, best to use "adapter" inside the service.
|
||||
// Stable UI properties - only updated when location is fully resolved
|
||||
property bool coordinatesReady: false
|
||||
property string stableLatitude: ""
|
||||
property string stableLongitude: ""
|
||||
property string stableName: ""
|
||||
// Helper property for UI components (outside JsonAdapter to avoid binding loops)
|
||||
readonly property string displayCoordinates: {
|
||||
if (!root.coordinatesReady || root.stableLatitude === "" || root.stableLongitude === "")
|
||||
return "";
|
||||
|
||||
const lat = parseFloat(root.stableLatitude).toFixed(4);
|
||||
const lon = parseFloat(root.stableLongitude).toFixed(4);
|
||||
return `${lat}, ${lon}`;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function init() {
|
||||
// does nothing but ensure the singleton is created
|
||||
// do not remove
|
||||
Logger.log("Location", "Service started");
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function resetWeather() {
|
||||
Logger.log("Location", "Resetting weather data");
|
||||
// Mark as changing to prevent UI updates
|
||||
root.coordinatesReady = false;
|
||||
// Reset stable properties
|
||||
root.stableLatitude = "";
|
||||
root.stableLongitude = "";
|
||||
root.stableName = "";
|
||||
// Reset core data
|
||||
adapter.latitude = "";
|
||||
adapter.longitude = "";
|
||||
adapter.name = "";
|
||||
adapter.weatherLastFetch = 0;
|
||||
adapter.weather = null;
|
||||
// Try to fetch immediately
|
||||
updateWeather();
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function updateWeather() {
|
||||
if (isFetchingWeather) {
|
||||
Logger.warn("Location", "Weather is still fetching");
|
||||
return ;
|
||||
}
|
||||
if ((adapter.weatherLastFetch === "") || (adapter.weather === null) || (adapter.latitude === "") || (adapter.longitude === "") || (adapter.name !== root.locationName) || (Time.timestamp >= adapter.weatherLastFetch + weatherUpdateFrequency))
|
||||
getFreshWeather();
|
||||
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function getFreshWeather() {
|
||||
isFetchingWeather = true;
|
||||
// Check if location name has changed
|
||||
const locationChanged = data.name !== root.locationName;
|
||||
if (locationChanged) {
|
||||
root.coordinatesReady = false;
|
||||
Logger.log("Location", "Location changed from", adapter.name, "to", root.locationName);
|
||||
}
|
||||
if ((adapter.latitude === "") || (adapter.longitude === "") || locationChanged)
|
||||
_geocodeLocation(root.locationName, function(latitude, longitude, name, country) {
|
||||
Logger.log("Location", "Geocoded", root.locationName, "to:", latitude, "/", longitude);
|
||||
// Save location name
|
||||
adapter.name = root.locationName;
|
||||
// Save GPS coordinates
|
||||
adapter.latitude = latitude.toString();
|
||||
adapter.longitude = longitude.toString();
|
||||
root.stableName = `${name}, ${country}`;
|
||||
_fetchWeather(latitude, longitude, errorCallback);
|
||||
}, errorCallback);
|
||||
else
|
||||
_fetchWeather(adapter.latitude, adapter.longitude, errorCallback);
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function _geocodeLocation(locationName, callback, errorCallback) {
|
||||
Logger.log("Location", "Geocoding location name");
|
||||
var geoUrl = "https://assets.noctalia.dev/geocode.php?city=" + encodeURIComponent(locationName) + "&language=en&format=json";
|
||||
curl.fetch(geoUrl, function(success, data) {
|
||||
if (success) {
|
||||
try {
|
||||
var geoData = JSON.parse(data);
|
||||
if (geoData.lat != null)
|
||||
callback(geoData.lat, geoData.lng, geoData.name, geoData.country);
|
||||
else
|
||||
errorCallback("Location", "could not resolve location name");
|
||||
} catch (e) {
|
||||
errorCallback("Location", "Failed to parse geocoding data: " + e);
|
||||
}
|
||||
} else {
|
||||
errorCallback("Location", "Geocoding error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function _fetchWeather(latitude, longitude, errorCallback) {
|
||||
Logger.log("Location", "Fetching weather from api.open-meteo.com");
|
||||
var url = "https://api.open-meteo.com/v1/forecast?latitude=" + latitude + "&longitude=" + longitude + "¤t_weather=true¤t=relativehumidity_2m,surface_pressure&daily=temperature_2m_max,temperature_2m_min,weathercode&timezone=auto";
|
||||
curl.fetch(url, function(success, fetchedData) {
|
||||
if (success) {
|
||||
try {
|
||||
var weatherData = JSON.parse(fetchedData);
|
||||
// Save core data
|
||||
data.weather = weatherData;
|
||||
data.weatherLastFetch = Time.timestamp;
|
||||
// Update stable display values only when complete and successful
|
||||
root.stableLatitude = data.latitude = weatherData.latitude.toString();
|
||||
root.stableLongitude = data.longitude = weatherData.longitude.toString();
|
||||
root.coordinatesReady = true;
|
||||
isFetchingWeather = false;
|
||||
Logger.log("Location", "Cached weather to disk - stable coordinates updated");
|
||||
} catch (e) {
|
||||
errorCallback("Location", "Failed to parse weather data: " + e);
|
||||
}
|
||||
} else {
|
||||
errorCallback("Location", "Weather fetch error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function errorCallback(module, message) {
|
||||
Logger.error(module, message);
|
||||
isFetchingWeather = false;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function weatherSymbolFromCode(code) {
|
||||
if (code === 0)
|
||||
return "weather-sun";
|
||||
|
||||
if (code === 1 || code === 2)
|
||||
return "weather-cloud-sun";
|
||||
|
||||
if (code === 3)
|
||||
return "weather-cloud";
|
||||
|
||||
if (code >= 45 && code <= 48)
|
||||
return "weather-cloud-haze";
|
||||
|
||||
if (code >= 51 && code <= 67)
|
||||
return "weather-cloud-rain";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 85 && code <= 86)
|
||||
return "weather-cloud-snow";
|
||||
|
||||
if (code >= 95 && code <= 99)
|
||||
return "weather-cloud-lightning";
|
||||
|
||||
return "weather-cloud";
|
||||
}
|
||||
|
||||
function weatherColorFromCode(code) {
|
||||
// Clear sky - bright yellow
|
||||
if (code === 0)
|
||||
return Colors.yellow;
|
||||
|
||||
// Mainly clear/Partly cloudy - soft peach/rosewater tones
|
||||
if (code === 1 || code === 2)
|
||||
return Colors.peach;
|
||||
|
||||
// Overcast - neutral sky blue
|
||||
if (code === 3)
|
||||
return Colors.sky;
|
||||
|
||||
// Fog - soft lavender/muted tone
|
||||
if (code >= 45 && code <= 48)
|
||||
return Colors.lavender;
|
||||
|
||||
// Drizzle - light blue/sapphire
|
||||
if (code >= 51 && code <= 67)
|
||||
return Colors.sapphire;
|
||||
|
||||
// Snow - cool teal
|
||||
if (code >= 71 && code <= 77)
|
||||
return Colors.teal;
|
||||
|
||||
// Rain showers - deeper blue
|
||||
if (code >= 80 && code <= 82)
|
||||
return Colors.blue;
|
||||
|
||||
// Snow showers - teal
|
||||
if (code >= 85 && code <= 86)
|
||||
return Colors.teal;
|
||||
|
||||
// Thunderstorm - dramatic mauve/pink
|
||||
if (code >= 95 && code <= 99)
|
||||
return Colors.mauve;
|
||||
|
||||
// Default - sky blue
|
||||
return Colors.sky;
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function weatherDescriptionFromCode(code) {
|
||||
if (code === 0)
|
||||
return "Clear sky";
|
||||
|
||||
if (code === 1)
|
||||
return "Mainly clear";
|
||||
|
||||
if (code === 2)
|
||||
return "Partly cloudy";
|
||||
|
||||
if (code === 3)
|
||||
return "Overcast";
|
||||
|
||||
if (code === 45 || code === 48)
|
||||
return "Fog";
|
||||
|
||||
if (code >= 51 && code <= 67)
|
||||
return "Drizzle";
|
||||
|
||||
if (code >= 71 && code <= 77)
|
||||
return "Snow";
|
||||
|
||||
if (code >= 80 && code <= 82)
|
||||
return "Rain showers";
|
||||
|
||||
if (code >= 95 && code <= 99)
|
||||
return "Thunderstorm";
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
function celsiusToFahrenheit(celsius) {
|
||||
return 32 + celsius * 1.8;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: locationFileView
|
||||
|
||||
path: locationFile
|
||||
printErrors: false
|
||||
onAdapterUpdated: saveTimer.start()
|
||||
onLoaded: {
|
||||
Logger.log("Location", "Loaded cached data");
|
||||
// Initialize stable properties on load
|
||||
if (adapter.latitude !== "" && adapter.longitude !== "" && adapter.weatherLastFetch > 0) {
|
||||
root.stableLatitude = adapter.latitude;
|
||||
root.stableLongitude = adapter.longitude;
|
||||
root.stableName = adapter.name;
|
||||
root.coordinatesReady = true;
|
||||
Logger.log("Location", "Coordinates ready");
|
||||
}
|
||||
updateWeather();
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
updateWeather();
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
// Core data properties
|
||||
property string latitude: ""
|
||||
property string longitude: ""
|
||||
property string name: ""
|
||||
property int weatherLastFetch: 0
|
||||
property var weather: null
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Every 20s check if we need to fetch new weather
|
||||
Timer {
|
||||
id: updateTimer
|
||||
|
||||
interval: 20 * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
updateWeather();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: saveTimer
|
||||
|
||||
running: false
|
||||
interval: 1000
|
||||
onTriggered: locationFileView.writeAdapter()
|
||||
}
|
||||
|
||||
NetworkFetch {
|
||||
id: curl
|
||||
}
|
||||
|
||||
}
|
||||
141
config/quickshell/Services/LyricsService.qml
Normal file
141
config/quickshell/Services/LyricsService.qml
Normal file
@@ -0,0 +1,141 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property int linesCount: 3
|
||||
property int linesAhead: linesCount / 2
|
||||
property int currentIndex: linesCount - linesAhead - 1
|
||||
property string offsetFile: CacheService.lyricsOffsetCacheFile
|
||||
property int offset: 0 // in ms
|
||||
property int offsetStep: 500 // in ms
|
||||
property int referenceCount: 0
|
||||
// with linesCount=3 and linesAhead=1, lyrics will be like:
|
||||
// line 1
|
||||
// line 2 <- current line
|
||||
// line 3
|
||||
property var lyrics: Array(linesCount).fill(" ")
|
||||
|
||||
function startSyncing() {
|
||||
referenceCount++;
|
||||
Logger.log("LyricsService", "Reference count:", referenceCount);
|
||||
if (referenceCount === 1) {
|
||||
Logger.log("LyricsService", "Starting lyrics syncing");
|
||||
// fill lyrics with empty lines
|
||||
lyrics = Array(linesCount).fill(" ");
|
||||
listenProcess.exec(["sh", "-c", `sl-wrap listen -l ${linesCount} -a ${linesAhead} -f ${offsetFile}`]);
|
||||
}
|
||||
}
|
||||
|
||||
function stopSyncing() {
|
||||
referenceCount--;
|
||||
Logger.log("LyricsService", "Reference count:", referenceCount);
|
||||
if (referenceCount <= 0) {
|
||||
Logger.log("LyricsService", "Stopping lyrics syncing");
|
||||
// Execute again to stop
|
||||
// kinda ugly but works, but meanwhile:
|
||||
// listenProcess.signal(9)
|
||||
// listenProcess.signal(15)
|
||||
// listenProcess.running = false
|
||||
// counts on exec() to terminate previous exec()
|
||||
// all don't work
|
||||
listenProcess.exec(["sh", "-c", `sl-wrap trackid`]);
|
||||
}
|
||||
}
|
||||
|
||||
function writeOffset() {
|
||||
offsetFileView.setText(String(offset));
|
||||
}
|
||||
|
||||
function increaseOffset() {
|
||||
offset += offsetStep;
|
||||
}
|
||||
|
||||
function decreaseOffset() {
|
||||
offset -= offsetStep;
|
||||
}
|
||||
|
||||
function resetOffset() {
|
||||
offset = 0;
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
action.command = ["sh", "-c", "spotify-lyrics clear $(spotify-lyrics trackid)"];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
function showLyricsText() {
|
||||
action.command = ["sh", "-c", "ghostty -e sh -c 'spotify-lyrics fetch | less'"];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
onOffsetChanged: {
|
||||
if (SettingsService.showLyricsBar)
|
||||
SendNotification.show("Lyrics Offset Changed", `Current offset: ${offset} ms`);
|
||||
|
||||
writeOffset();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: listenProcess
|
||||
|
||||
running: false
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: (data) => {
|
||||
lyrics = data.split("\n").slice(0, linesCount);
|
||||
if (lyrics.length < linesCount) {
|
||||
// fill with empty lines if not enough
|
||||
for (let i = lyrics.length; i < linesCount; i++) {
|
||||
lyrics[i] = " ";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: offsetFileView
|
||||
|
||||
path: offsetFile
|
||||
watchChanges: false
|
||||
onLoaded: {
|
||||
try {
|
||||
const fileContents = text();
|
||||
if (fileContents.length > 0) {
|
||||
const val = parseInt(fileContents);
|
||||
if (!isNaN(val)) {
|
||||
offset = val;
|
||||
Logger.log("LyricsService", "Loaded offset:", offset);
|
||||
} else {
|
||||
offset = 0;
|
||||
writeOffset();
|
||||
}
|
||||
} else {
|
||||
offset = 0;
|
||||
writeOffset();
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("LyricsService", "Error reading offset file:", e);
|
||||
}
|
||||
}
|
||||
onLoadFailed: {
|
||||
Logger.error("LyricsService", "Error loading offset file:", errorString);
|
||||
}
|
||||
onSaveFailed: {
|
||||
Logger.error("LyricsService", "Error saving offset file:", errorString);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
180
config/quickshell/Services/MusicManager.qml
Normal file
180
config/quickshell/Services/MusicManager.qml
Normal file
@@ -0,0 +1,180 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.Modules.Misc
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: manager
|
||||
|
||||
// Properties
|
||||
property var currentPlayer: null
|
||||
property real currentPosition: 0
|
||||
property bool isPlaying: currentPlayer ? currentPlayer.isPlaying : false
|
||||
property int selectedPlayerIndex: -1
|
||||
property string trackTitle: currentPlayer ? (currentPlayer.trackTitle || "Unknown Track") : ""
|
||||
property string trackArtist: currentPlayer ? (currentPlayer.trackArtist || "Unknown Artist") : ""
|
||||
property string trackAlbum: currentPlayer ? (currentPlayer.trackAlbum || "Unknown Album") : ""
|
||||
property string trackArtUrl: currentPlayer ? (currentPlayer.trackArtUrl || "") : ""
|
||||
property real trackLength: currentPlayer ? currentPlayer.length : 0
|
||||
property bool canPlay: currentPlayer ? currentPlayer.canPlay : false
|
||||
property bool canPause: currentPlayer ? currentPlayer.canPause : false
|
||||
property bool canGoNext: currentPlayer ? currentPlayer.canGoNext : false
|
||||
property bool canGoPrevious: currentPlayer ? currentPlayer.canGoPrevious : false
|
||||
property bool canSeek: currentPlayer ? currentPlayer.canSeek : false
|
||||
property bool hasPlayer: getAvailablePlayers().length > 0
|
||||
// Expose cava values
|
||||
property alias cavaValues: cava.values
|
||||
|
||||
// Returns available MPRIS players
|
||||
function getAvailablePlayers() {
|
||||
if (!Mpris.players || !Mpris.players.values)
|
||||
return [];
|
||||
|
||||
let allPlayers = Mpris.players.values;
|
||||
let controllablePlayers = [];
|
||||
for (let i = 0; i < allPlayers.length; i++) {
|
||||
let player = allPlayers[i];
|
||||
if (player && player.canControl)
|
||||
controllablePlayers.push(player);
|
||||
|
||||
}
|
||||
return controllablePlayers;
|
||||
}
|
||||
|
||||
// Returns active player or first available
|
||||
function findActivePlayer() {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
if (availablePlayers.length === 0)
|
||||
return null;
|
||||
|
||||
// Get the first playing player
|
||||
for (let i = availablePlayers.length - 1; i >= 0; i--) {
|
||||
if (availablePlayers[i].isPlaying)
|
||||
return availablePlayers[i];
|
||||
|
||||
}
|
||||
// Fallback to last player
|
||||
return availablePlayers[availablePlayers.length - 1];
|
||||
}
|
||||
|
||||
// Updates currentPlayer and currentPosition
|
||||
function updateCurrentPlayer() {
|
||||
// Use selected player if index is valid
|
||||
if (selectedPlayerIndex >= 0) {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
if (selectedPlayerIndex < availablePlayers.length) {
|
||||
currentPlayer = availablePlayers[selectedPlayerIndex];
|
||||
currentPosition = currentPlayer.position;
|
||||
Logger.log("MusicManager", "Current player set by index:", currentPlayer ? currentPlayer.identity : "None");
|
||||
return ;
|
||||
} else {
|
||||
selectedPlayerIndex = -1; // Reset if index is out of range
|
||||
}
|
||||
}
|
||||
// Otherwise, find active player
|
||||
let newPlayer = findActivePlayer();
|
||||
if (newPlayer !== currentPlayer) {
|
||||
currentPlayer = newPlayer;
|
||||
currentPosition = currentPlayer ? currentPlayer.position : 0;
|
||||
}
|
||||
Logger.log("MusicManager", "Current player updated:", currentPlayer ? currentPlayer.identity : "None");
|
||||
}
|
||||
|
||||
// Player control functions
|
||||
function playPause() {
|
||||
if (currentPlayer) {
|
||||
if (currentPlayer.isPlaying)
|
||||
currentPlayer.pause();
|
||||
else
|
||||
currentPlayer.play();
|
||||
}
|
||||
}
|
||||
|
||||
function isAllPaused() {
|
||||
let availablePlayers = getAvailablePlayers();
|
||||
for (let i = 0; i < availablePlayers.length; i++) {
|
||||
if (availablePlayers[i].isPlaying)
|
||||
return false;
|
||||
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (currentPlayer && currentPlayer.canPlay)
|
||||
currentPlayer.play();
|
||||
|
||||
}
|
||||
|
||||
function pause() {
|
||||
if (currentPlayer && currentPlayer.canPause)
|
||||
currentPlayer.pause();
|
||||
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (currentPlayer && currentPlayer.canGoNext)
|
||||
currentPlayer.next();
|
||||
|
||||
}
|
||||
|
||||
function previous() {
|
||||
if (currentPlayer && currentPlayer.canGoPrevious)
|
||||
currentPlayer.previous();
|
||||
|
||||
}
|
||||
|
||||
function seek(position) {
|
||||
if (currentPlayer && currentPlayer.canSeek) {
|
||||
currentPlayer.position = position;
|
||||
currentPosition = position;
|
||||
}
|
||||
}
|
||||
|
||||
function seekByRatio(ratio) {
|
||||
if (currentPlayer && currentPlayer.canSeek && currentPlayer.length > 0) {
|
||||
let seekPosition = ratio * currentPlayer.length;
|
||||
currentPlayer.position = seekPosition;
|
||||
currentPosition = seekPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize
|
||||
Item {
|
||||
Component.onCompleted: {
|
||||
updateCurrentPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
// Updates progress bar every second
|
||||
Timer {
|
||||
id: positionTimer
|
||||
|
||||
interval: 1000
|
||||
running: currentPlayer && currentPlayer.isPlaying && currentPlayer.length > 0
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
if (currentPlayer && currentPlayer.isPlaying)
|
||||
currentPosition = currentPlayer.position;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Reacts to player list changes
|
||||
Connections {
|
||||
function onValuesChanged() {
|
||||
updateCurrentPlayer();
|
||||
}
|
||||
|
||||
target: Mpris.players
|
||||
}
|
||||
|
||||
Cava {
|
||||
id: cava
|
||||
|
||||
count: 44
|
||||
}
|
||||
|
||||
}
|
||||
65
config/quickshell/Services/NetworkFetch.qml
Normal file
65
config/quickshell/Services/NetworkFetch.qml
Normal file
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property real fetchTimeout: 10 // in seconds
|
||||
property string fetchedData: ""
|
||||
property var fetchingCallback: null
|
||||
|
||||
function fetch(url, callback) {
|
||||
if (curlProcess.running) {
|
||||
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
|
||||
return ;
|
||||
}
|
||||
fetchedData = "";
|
||||
fetchingCallback = callback;
|
||||
curlProcess.command = ["curl", "-s", "-L", "-m", fetchTimeout.toString(), url];
|
||||
curlProcess.running = true;
|
||||
}
|
||||
|
||||
function fakeFetch(resp, callback) {
|
||||
if (curlProcess.running) {
|
||||
Logger.warn("NetworkFetch", "A fetch operation is already in progress.");
|
||||
return ;
|
||||
}
|
||||
fetchedData = "";
|
||||
fetchingCallback = callback;
|
||||
curlProcess.command = ["echo", resp];
|
||||
curlProcess.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: curlProcess
|
||||
|
||||
running: false
|
||||
onStarted: {
|
||||
Logger.log("NetworkFetch", "Process started with command: " + curlProcess.command.join(" "));
|
||||
}
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (!fetchingCallback) {
|
||||
Logger.error("NetworkFetch", "No callback defined for fetch operation.");
|
||||
return ;
|
||||
}
|
||||
if (exitCode === 0) {
|
||||
Logger.log("NetworkFetch", "Fetched data: " + fetchedData);
|
||||
fetchingCallback(true, fetchedData);
|
||||
} else {
|
||||
Logger.error("NetworkFetch", "Fetch failed with exit code: " + exitCode);
|
||||
fetchingCallback(false, "");
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: ""
|
||||
onRead: (data) => {
|
||||
fetchedData += data;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
190
config/quickshell/Services/Niri.qml
Normal file
190
config/quickshell/Services/Niri.qml
Normal file
@@ -0,0 +1,190 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var workspaces: []
|
||||
property var windows: {}
|
||||
property int focusedWindowId: -1
|
||||
property bool noFocus: focusedWindowId === -1
|
||||
property bool inOverview: false
|
||||
property string focusedWindowTitle: ""
|
||||
property string focusedWindowAppId: ""
|
||||
|
||||
function updateFocusedWindowTitle() {
|
||||
if (windows && windows[focusedWindowId]) {
|
||||
focusedWindowTitle = windows[focusedWindowId].title || "";
|
||||
focusedWindowAppId = windows[focusedWindowId].appId || "";
|
||||
} else {
|
||||
focusedWindowTitle = "";
|
||||
focusedWindowAppId = "";
|
||||
}
|
||||
}
|
||||
|
||||
function getFocusedWindow() {
|
||||
return (windows && windows[focusedWindowId]) || null;
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
eventStream.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: workspaceProcess
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "--json", "workspaces"]
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: function(line) {
|
||||
try {
|
||||
const workspacesData = JSON.parse(line);
|
||||
const workspacesList = [];
|
||||
for (const ws of workspacesData) {
|
||||
workspacesList.push({
|
||||
"id": ws.id,
|
||||
"idx": ws.idx,
|
||||
"name": ws.name || "",
|
||||
"output": ws.output || "",
|
||||
"isFocused": ws.is_focused === true,
|
||||
"isActive": ws.is_active === true,
|
||||
"isUrgent": ws.is_urgent === true,
|
||||
"activeWindowId": ws.active_window_id
|
||||
});
|
||||
}
|
||||
workspacesList.sort((a, b) => {
|
||||
if (a.output !== b.output)
|
||||
return a.output.localeCompare(b.output);
|
||||
|
||||
return a.id - b.id;
|
||||
});
|
||||
root.workspaces = workspacesList;
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Failed to parse workspaces:", e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Process {
|
||||
id: eventStream
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "--json", "event-stream"]
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: (data) => {
|
||||
try {
|
||||
const event = JSON.parse(data.trim());
|
||||
if (event.WorkspacesChanged) {
|
||||
workspaceProcess.running = true;
|
||||
} else if (event.WindowsChanged) {
|
||||
try {
|
||||
const windowsData = event.WindowsChanged.windows;
|
||||
const windowsMap = {};
|
||||
for (const win of windowsData) {
|
||||
if (win.is_focused === true) {
|
||||
root.focusedWindowId = win.id;
|
||||
}
|
||||
windowsMap[win.id] = {
|
||||
"title": win.title || "",
|
||||
"appId": win.app_id || "",
|
||||
"workspaceId": win.workspace_id || null,
|
||||
"isFocused": win.is_focused === true
|
||||
};
|
||||
}
|
||||
root.windows = windowsMap;
|
||||
root.updateFocusedWindowTitle();
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing windows event:", e);
|
||||
}
|
||||
} else if (event.WorkspaceActivated) {
|
||||
workspaceProcess.running = true;
|
||||
} else if (event.WindowFocusChanged) {
|
||||
try {
|
||||
const focusedId = event.WindowFocusChanged.id;
|
||||
if (focusedId) {
|
||||
if (root.windows[focusedId]) {
|
||||
root.focusedWindowId = focusedId;
|
||||
} else {
|
||||
root.focusedWindowId = -1;
|
||||
}
|
||||
|
||||
} else {
|
||||
root.focusedWindowId = -1;
|
||||
}
|
||||
root.updateFocusedWindowTitle();
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window focus event:", e);
|
||||
}
|
||||
} else if (event.OverviewOpenedOrClosed) {
|
||||
try {
|
||||
root.inOverview = event.OverviewOpenedOrClosed.is_open === true;
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing overview state:", e);
|
||||
}
|
||||
} else if (event.WindowOpenedOrChanged) {
|
||||
try {
|
||||
const targetWin = event.WindowOpenedOrChanged.window;
|
||||
const id = targetWin.id;
|
||||
const isFocused = targetWin.is_focused === true;
|
||||
let needUpdateTitle = false;
|
||||
if (id) {
|
||||
if (root.windows && root.windows[id]) {
|
||||
const win = root.windows[id];
|
||||
// Update existing window
|
||||
needUpdateTitle = win.title !== targetWin.title;
|
||||
win.title = targetWin.title || win.title;
|
||||
win.appId = targetWin.app_id || win.appId;
|
||||
win.workspaceId = targetWin.workspace_id || win.workspaceId;
|
||||
win.isFocused = isFocused;
|
||||
} else {
|
||||
// New window
|
||||
const newWin = {
|
||||
"title": targetWin.title || "",
|
||||
"appId": targetWin.app_id || "",
|
||||
"workspaceId": targetWin.workspace_id || null,
|
||||
"isFocused": isFocused
|
||||
};
|
||||
root.windows[id] = targetWin;
|
||||
}
|
||||
if (isFocused) {
|
||||
if (root.focusedWindowId !== id || needUpdateTitle){
|
||||
root.focusedWindowId = id;
|
||||
root.updateFocusedWindowTitle();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window opened/changed event:", e);
|
||||
}
|
||||
} else if (event.windowClosed) {
|
||||
try {
|
||||
const closedId = event.windowClosed.id;
|
||||
if (closedId && (root.windows && root.windows[closedId])) {
|
||||
delete root.windows[closedId];
|
||||
if (root.focusedWindowId === closedId) {
|
||||
root.focusedWindowId = -1;
|
||||
root.updateFocusedWindowTitle();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing window closed event:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Niri", "Error parsing event stream:", e, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
474
config/quickshell/Services/NotificationService.qml
Normal file
474
config/quickshell/Services/NotificationService.qml
Normal file
@@ -0,0 +1,474 @@
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.Utils
|
||||
import qs.Services
|
||||
import qs.Constants
|
||||
import "../Utils/sha256.js" as Checksum
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Configuration
|
||||
property int maxVisible: 5
|
||||
property int maxHistory: 100
|
||||
property string historyFile: CacheService.notificationsCacheFile
|
||||
property string cacheDirImagesNotifications: Quickshell.env("HOME") + "/.cache/quickshell/notifications/"
|
||||
property real lowUrgencyDuration: 3
|
||||
property real normalUrgencyDuration: 8
|
||||
property real criticalUrgencyDuration: 15
|
||||
|
||||
// Models
|
||||
property ListModel activeList: ListModel {}
|
||||
property ListModel historyList: ListModel {}
|
||||
|
||||
// Internal state
|
||||
property var activeMap: ({})
|
||||
property var imageQueue: []
|
||||
|
||||
// Performance optimization: Track notification metadata separately
|
||||
property var notificationMetadata: ({}) // Stores timestamp and duration for each notification
|
||||
|
||||
PanelWindow {
|
||||
implicitHeight: 1
|
||||
implicitWidth: 1
|
||||
color: Color.transparent
|
||||
mask: Region {}
|
||||
|
||||
Image {
|
||||
id: cacher
|
||||
width: 64
|
||||
height: 64
|
||||
visible: true
|
||||
cache: false
|
||||
asynchronous: true
|
||||
mipmap: true
|
||||
antialiasing: true
|
||||
|
||||
onStatusChanged: {
|
||||
if (imageQueue.length === 0)
|
||||
return
|
||||
const req = imageQueue[0]
|
||||
|
||||
if (status === Image.Ready) {
|
||||
Quickshell.execDetached(["mkdir", "-p", cacheDirImagesNotifications])
|
||||
grabToImage(result => {
|
||||
if (result.saveToFile(req.dest))
|
||||
updateImagePath(req.imageId, req.dest)
|
||||
processNextImage()
|
||||
})
|
||||
} else if (status === Image.Error) {
|
||||
processNextImage()
|
||||
}
|
||||
}
|
||||
|
||||
function processNextImage() {
|
||||
imageQueue.shift()
|
||||
if (imageQueue.length > 0) {
|
||||
source = imageQueue[0].src
|
||||
} else {
|
||||
source = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification server
|
||||
NotificationServer {
|
||||
keepOnReload: false
|
||||
imageSupported: true
|
||||
actionsSupported: true
|
||||
onNotification: notification => handleNotification(notification)
|
||||
}
|
||||
|
||||
// Main handler
|
||||
function handleNotification(notification) {
|
||||
const data = createData(notification)
|
||||
addToHistory(data)
|
||||
|
||||
if (SettingsService.notifications.doNotDisturb)
|
||||
return
|
||||
|
||||
activeMap[data.id] = notification
|
||||
notification.tracked = true
|
||||
notification.closed.connect(() => removeActive(data.id))
|
||||
|
||||
// Store metadata for efficient progress calculation
|
||||
const durations = [lowUrgencyDuration * 1000, normalUrgencyDuration * 1000, criticalUrgencyDuration * 1000]
|
||||
|
||||
let expire = 0
|
||||
if (data.expireTimeout === 0) {
|
||||
expire = -1 // Never expire
|
||||
} else if (data.expireTimeout > 0) {
|
||||
expire = data.expireTimeout
|
||||
} else {
|
||||
expire = durations[data.urgency]
|
||||
}
|
||||
|
||||
notificationMetadata[data.id] = {
|
||||
"timestamp": data.timestamp.getTime(),
|
||||
"duration": expire,
|
||||
"urgency": data.urgency
|
||||
}
|
||||
|
||||
activeList.insert(0, data)
|
||||
while (activeList.count > maxVisible) {
|
||||
const last = activeList.get(activeList.count - 1)
|
||||
activeMap[last.id]?.dismiss()
|
||||
activeList.remove(activeList.count - 1)
|
||||
delete notificationMetadata[last.id]
|
||||
}
|
||||
}
|
||||
|
||||
function createData(n) {
|
||||
const time = new Date()
|
||||
const id = Checksum.sha256(JSON.stringify({
|
||||
"summary": n.summary,
|
||||
"body": n.body,
|
||||
"app": n.appName,
|
||||
"time": time.getTime()
|
||||
}))
|
||||
|
||||
const image = n.image || getIcon(n.appIcon)
|
||||
const imageId = generateImageId(n, image)
|
||||
queueImage(image, imageId)
|
||||
|
||||
return {
|
||||
"id": id,
|
||||
"summary": (n.summary || ""),
|
||||
"body": stripTags(n.body || ""),
|
||||
"appName": getAppName(n.appName),
|
||||
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
|
||||
"expireTimeout": n.expireTimeout,
|
||||
"timestamp": time,
|
||||
"progress": 1.0,
|
||||
"originalImage": image,
|
||||
"cachedImage": imageId ? (cacheDirImagesNotifications + imageId + ".png") : image,
|
||||
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
|
||||
"text": a.text || "Action",
|
||||
"identifier": a.identifier || ""
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
function queueImage(path, imageId) {
|
||||
if (!path || !path.startsWith("image://") || !imageId)
|
||||
return
|
||||
|
||||
const dest = cacheDirImagesNotifications + imageId + ".png"
|
||||
|
||||
for (const req of imageQueue) {
|
||||
if (req.imageId === imageId)
|
||||
return
|
||||
}
|
||||
|
||||
imageQueue.push({
|
||||
"src": path,
|
||||
"dest": dest,
|
||||
"imageId": imageId
|
||||
})
|
||||
|
||||
if (imageQueue.length === 1)
|
||||
cacher.source = path
|
||||
}
|
||||
|
||||
function updateImagePath(id, path) {
|
||||
updateModel(activeList, id, "cachedImage", path)
|
||||
updateModel(historyList, id, "cachedImage", path)
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
function updateModel(model, id, prop, value) {
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
if (model.get(i).id === id) {
|
||||
model.setProperty(i, prop, value)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function removeActive(id) {
|
||||
for (var i = 0; i < activeList.count; i++) {
|
||||
if (activeList.get(i).id === id) {
|
||||
activeList.remove(i)
|
||||
delete activeMap[id]
|
||||
delete notificationMetadata[id]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optimized batch progress update
|
||||
Timer {
|
||||
interval: 50 // Reduced from 10ms to 50ms (20 updates/sec instead of 100)
|
||||
repeat: true
|
||||
running: activeList.count > 0
|
||||
onTriggered: updateAllProgress()
|
||||
}
|
||||
|
||||
function updateAllProgress() {
|
||||
const now = Date.now()
|
||||
const toRemove = []
|
||||
const updates = [] // Batch updates
|
||||
|
||||
// Collect all updates first
|
||||
for (var i = 0; i < activeList.count; i++) {
|
||||
const notif = activeList.get(i)
|
||||
const meta = notificationMetadata[notif.id]
|
||||
|
||||
if (!meta || meta.duration === -1)
|
||||
continue
|
||||
|
||||
// Skip infinite notifications
|
||||
const elapsed = now - meta.timestamp
|
||||
const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0)
|
||||
|
||||
if (progress <= 0) {
|
||||
toRemove.push(notif.id)
|
||||
} else if (Math.abs(notif.progress - progress) > 0.005) {
|
||||
// Only update if change is significant
|
||||
updates.push({
|
||||
"index": i,
|
||||
"progress": progress
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Apply batch updates
|
||||
for (const update of updates) {
|
||||
activeList.setProperty(update.index, "progress", update.progress)
|
||||
}
|
||||
|
||||
// Remove expired notifications (one at a time to allow animation)
|
||||
if (toRemove.length > 0) {
|
||||
animateAndRemove(toRemove[0])
|
||||
}
|
||||
}
|
||||
|
||||
// History management
|
||||
function addToHistory(data) {
|
||||
historyList.insert(0, data)
|
||||
|
||||
while (historyList.count > maxHistory) {
|
||||
const old = historyList.get(historyList.count - 1)
|
||||
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
|
||||
Quickshell.execDetached(["rm", "-f", old.cachedImage])
|
||||
}
|
||||
historyList.remove(historyList.count - 1)
|
||||
}
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// Persistence
|
||||
FileView {
|
||||
id: historyFileView
|
||||
path: historyFile
|
||||
printErrors: false
|
||||
onLoaded: loadHistory()
|
||||
onLoadFailed: error => {
|
||||
if (error === 2)
|
||||
writeAdapter()
|
||||
}
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
property var notifications: []
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: saveTimer
|
||||
interval: 200
|
||||
onTriggered: performSaveHistory()
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
saveTimer.restart()
|
||||
}
|
||||
|
||||
function performSaveHistory() {
|
||||
try {
|
||||
const items = []
|
||||
for (var i = 0; i < historyList.count; i++) {
|
||||
const n = historyList.get(i)
|
||||
const copy = Object.assign({}, n)
|
||||
copy.timestamp = n.timestamp.getTime()
|
||||
items.push(copy)
|
||||
}
|
||||
adapter.notifications = items
|
||||
historyFileView.writeAdapter()
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Save history failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
historyList.clear()
|
||||
for (const item of adapter.notifications || []) {
|
||||
const time = new Date(item.timestamp)
|
||||
|
||||
let cachedImage = item.cachedImage || ""
|
||||
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
|
||||
const imageId = generateImageId(item, item.originalImage)
|
||||
if (imageId) {
|
||||
cachedImage = cacheDirImagesNotifications + imageId + ".png"
|
||||
}
|
||||
}
|
||||
|
||||
historyList.append({
|
||||
"id": item.id || "",
|
||||
"summary": item.summary || "",
|
||||
"body": item.body || "",
|
||||
"appName": item.appName || "",
|
||||
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
|
||||
"timestamp": time,
|
||||
"originalImage": item.originalImage || "",
|
||||
"cachedImage": cachedImage
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Load failed:", e)
|
||||
}
|
||||
}
|
||||
|
||||
function getAppName(name) {
|
||||
if (!name || name.trim() === "")
|
||||
return "Unknown"
|
||||
|
||||
name = name.trim()
|
||||
|
||||
if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
|
||||
const parts = name.split(".")
|
||||
let appPart = parts[parts.length - 1]
|
||||
|
||||
if (!appPart || appPart === "app" || appPart === "desktop") {
|
||||
appPart = parts[parts.length - 2] || parts[0]
|
||||
}
|
||||
|
||||
if (appPart) {
|
||||
name = appPart
|
||||
}
|
||||
}
|
||||
|
||||
if (name.includes(".")) {
|
||||
const parts = name.split(".")
|
||||
let displayName = parts[parts.length - 1]
|
||||
|
||||
if (!displayName || /^\d+$/.test(displayName)) {
|
||||
displayName = parts[parts.length - 2] || parts[0]
|
||||
}
|
||||
|
||||
if (displayName) {
|
||||
displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1)
|
||||
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
displayName = displayName.replace(/app$/i, '').trim()
|
||||
displayName = displayName.replace(/desktop$/i, '').trim()
|
||||
displayName = displayName.replace(/flatpak$/i, '').trim()
|
||||
|
||||
if (!displayName) {
|
||||
displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1)
|
||||
}
|
||||
}
|
||||
|
||||
return displayName || name
|
||||
}
|
||||
|
||||
let displayName = name.charAt(0).toUpperCase() + name.slice(1)
|
||||
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
displayName = displayName.replace(/app$/i, '').trim()
|
||||
displayName = displayName.replace(/desktop$/i, '').trim()
|
||||
|
||||
return displayName || name
|
||||
}
|
||||
|
||||
function getIcon(icon) {
|
||||
if (!icon)
|
||||
return ""
|
||||
if (icon.startsWith("/") || icon.startsWith("file://"))
|
||||
return icon
|
||||
return ThemeIcons.iconFromName(icon)
|
||||
}
|
||||
|
||||
function stripTags(text) {
|
||||
return text.replace(/<[^>]*>?/gm, '')
|
||||
}
|
||||
|
||||
function generateImageId(notification, image) {
|
||||
if (image && image.startsWith("image://")) {
|
||||
if (image.startsWith("image://qsimage/")) {
|
||||
const key = (notification.appName || "") + "|" + (notification.summary || "")
|
||||
return Checksum.sha256(key)
|
||||
}
|
||||
return Checksum.sha256(image)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Public API
|
||||
function dismissActiveNotification(id) {
|
||||
activeMap[id]?.dismiss()
|
||||
removeActive(id)
|
||||
}
|
||||
|
||||
function dismissAllActive() {
|
||||
Object.values(activeMap).forEach(n => n.dismiss())
|
||||
activeList.clear()
|
||||
activeMap = {}
|
||||
notificationMetadata = {}
|
||||
}
|
||||
|
||||
function invokeAction(id, actionId) {
|
||||
const n = activeMap[id]
|
||||
if (!n?.actions)
|
||||
return false
|
||||
|
||||
for (const action of n.actions) {
|
||||
if (action.identifier === actionId && action.invoke) {
|
||||
action.invoke()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function removeFromHistory(notificationId) {
|
||||
for (var i = 0; i < historyList.count; i++) {
|
||||
const notif = historyList.get(i)
|
||||
if (notif.id === notificationId) {
|
||||
if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) {
|
||||
Quickshell.execDetached(["rm", "-f", notif.cachedImage])
|
||||
}
|
||||
historyList.remove(i)
|
||||
saveHistory()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
try {
|
||||
Quickshell.execDetached(["sh", "-c", `rm -rf "${cacheDirImagesNotifications}"*`])
|
||||
} catch (e) {
|
||||
Logger.error("Notifications", "Failed to clear cache directory:", e)
|
||||
}
|
||||
|
||||
historyList.clear()
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
// Signals & connections
|
||||
signal animateAndRemove(string notificationId)
|
||||
|
||||
Connections {
|
||||
target: SettingsService.notifications
|
||||
function onDoNotDisturbChanged() {
|
||||
const enabled = SettingsService.notifications.doNotDisturb
|
||||
}
|
||||
}
|
||||
}
|
||||
23
config/quickshell/Services/NukeKded6.qml
Normal file
23
config/quickshell/Services/NukeKded6.qml
Normal file
@@ -0,0 +1,23 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property bool done: false
|
||||
|
||||
Process {
|
||||
id: process
|
||||
|
||||
running: true
|
||||
command: ["kquitapp6", "kded6"]
|
||||
onExited: (code, status) => {
|
||||
if (code !== 0)
|
||||
Logger.warn("NukeKded6", `Failed to kill kded6: ${code}`);
|
||||
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
67
config/quickshell/Services/PanelService.qml
Normal file
67
config/quickshell/Services/PanelService.qml
Normal file
@@ -0,0 +1,67 @@
|
||||
pragma Singleton
|
||||
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// A ref. to the lockScreen, so it's accessible from anywhere
|
||||
// This is not a panel...
|
||||
property var lockScreen: null
|
||||
|
||||
// Panels
|
||||
property var registeredPanels: ({})
|
||||
property var openedPanel: null
|
||||
signal willOpen
|
||||
|
||||
// Currently opened popups, can have more than one.
|
||||
// ex: when opening an NIconPicker from a widget setting.
|
||||
property var openedPopups: []
|
||||
property bool hasOpenedPopup: false
|
||||
signal popupChanged
|
||||
|
||||
// Register this panel
|
||||
function registerPanel(panel) {
|
||||
registeredPanels[panel.objectName] = panel
|
||||
}
|
||||
|
||||
// Returns a panel
|
||||
function getPanel(name) {
|
||||
return registeredPanels[name] || null
|
||||
}
|
||||
|
||||
// Check if a panel exists
|
||||
function hasPanel(name) {
|
||||
return name in registeredPanels
|
||||
}
|
||||
|
||||
// Helper to keep only one panel open at any time
|
||||
function willOpenPanel(panel) {
|
||||
if (openedPanel && openedPanel !== panel) {
|
||||
openedPanel.close()
|
||||
}
|
||||
openedPanel = panel
|
||||
|
||||
// emit signal
|
||||
willOpen()
|
||||
}
|
||||
|
||||
function closedPanel(panel) {
|
||||
if (openedPanel && openedPanel === panel) {
|
||||
openedPanel = null
|
||||
}
|
||||
}
|
||||
|
||||
// Popups
|
||||
function willOpenPopup(popup) {
|
||||
openedPopups.push(popup)
|
||||
hasOpenedPopup = (openedPopups.length !== 0)
|
||||
popupChanged()
|
||||
}
|
||||
|
||||
function willClosePopup(popup) {
|
||||
openedPopups = openedPopups.filter(p => p !== popup)
|
||||
hasOpenedPopup = (openedPopups.length !== 0)
|
||||
popupChanged()
|
||||
}
|
||||
}
|
||||
88
config/quickshell/Services/PowerProfileService.qml
Normal file
88
config/quickshell/Services/PowerProfileService.qml
Normal file
@@ -0,0 +1,88 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var powerProfiles: PowerProfiles
|
||||
readonly property bool available: powerProfiles && powerProfiles.hasPerformanceProfile
|
||||
property int profile: powerProfiles ? powerProfiles.profile : PowerProfile.Balanced
|
||||
|
||||
function getName(p) {
|
||||
if (!available)
|
||||
return "Unknown";
|
||||
|
||||
const prof = (p !== undefined) ? p : profile;
|
||||
switch (prof) {
|
||||
case PowerProfile.Performance:
|
||||
return "Performance";
|
||||
case PowerProfile.Balanced:
|
||||
return "Balanced";
|
||||
case PowerProfile.PowerSaver:
|
||||
return "Power saver";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function getIcon(p) {
|
||||
if (!available)
|
||||
return "balanced";
|
||||
|
||||
const prof = (p !== undefined) ? p : profile;
|
||||
switch (prof) {
|
||||
case PowerProfile.Performance:
|
||||
return "performance";
|
||||
case PowerProfile.Balanced:
|
||||
return "balanced";
|
||||
case PowerProfile.PowerSaver:
|
||||
return "powersaver";
|
||||
default:
|
||||
return "balanced";
|
||||
}
|
||||
}
|
||||
|
||||
function setProfile(p) {
|
||||
if (!available)
|
||||
return ;
|
||||
|
||||
try {
|
||||
powerProfiles.profile = p;
|
||||
} catch (e) {
|
||||
Logger.error("PowerProfileService", "Failed to set profile:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function cycleProfile() {
|
||||
if (!available)
|
||||
return ;
|
||||
|
||||
const current = powerProfiles.profile;
|
||||
if (current === PowerProfile.Performance)
|
||||
setProfile(PowerProfile.PowerSaver);
|
||||
else if (current === PowerProfile.Balanced)
|
||||
setProfile(PowerProfile.Performance);
|
||||
else if (current === PowerProfile.PowerSaver)
|
||||
setProfile(PowerProfile.Balanced);
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onProfileChanged() {
|
||||
root.profile = powerProfiles.profile;
|
||||
// Only show toast if we have a valid profile name (not "Unknown")
|
||||
const profileName = root.getName();
|
||||
if (profileName !== "Unknown")
|
||||
ToastService.showNotice(I18n.tr("toast.power-profile.changed"), I18n.tr("toast.power-profile.profile-name", {
|
||||
"profile": profileName
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
target: powerProfiles
|
||||
}
|
||||
|
||||
}
|
||||
163
config/quickshell/Services/RecordService.qml
Normal file
163
config/quickshell/Services/RecordService.qml
Normal file
@@ -0,0 +1,163 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property string recordingDir: CacheService.recordingDir
|
||||
property bool isRecording: false
|
||||
property bool isStopping: false
|
||||
property string codec: "av1_nvenc"
|
||||
property string container: "mkv"
|
||||
property string pixelFormat: "p010le"
|
||||
property string recordingDisplay: ""
|
||||
property int framerate: 60
|
||||
property var codecParams: ["preset=p5", "rc=vbr", "cq=18", "b:v=80M", "maxrate=120M", "bufsize=160M", "color_range=tv"]
|
||||
property var filterArgs: []
|
||||
|
||||
function getFilename() {
|
||||
var d = new Date();
|
||||
var year = d.getFullYear();
|
||||
var month = ("0" + (d.getMonth() + 1)).slice(-2);
|
||||
var day = ("0" + d.getDate()).slice(-2);
|
||||
var hours = ("0" + d.getHours()).slice(-2);
|
||||
var minutes = ("0" + d.getMinutes()).slice(-2);
|
||||
var seconds = ("0" + d.getSeconds()).slice(-2);
|
||||
return "recording_" + year + "-" + month + "-" + day + "_" + hours + "." + minutes + "." + seconds + "." + container;
|
||||
}
|
||||
|
||||
function getAudioSink() {
|
||||
return AudioService.sink ? AudioService.sink.name + '.monitor' : null; // this works on my machine :)
|
||||
}
|
||||
|
||||
function getVideoSource(callback) {
|
||||
if (niriFocusedOutputProcess.running) {
|
||||
Logger.warn("RecordService", "Already fetching focused output, returning null.");
|
||||
callback(null);
|
||||
}
|
||||
niriFocusedOutputProcess.onGetName = callback;
|
||||
niriFocusedOutputProcess.running = true;
|
||||
}
|
||||
|
||||
function startOrStop() {
|
||||
if (isRecording)
|
||||
stop();
|
||||
else
|
||||
start();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!isRecording) {
|
||||
Logger.warn("RecordService", "Not currently recording, cannot stop.");
|
||||
return ;
|
||||
}
|
||||
if (isStopping) {
|
||||
Logger.warn("RecordService", "Already stopping, please wait.");
|
||||
return ;
|
||||
}
|
||||
isStopping = true;
|
||||
recordProcess.signal(15);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (isRecording || isStopping) {
|
||||
Logger.warn("RecordService", "Already recording, cannot start.");
|
||||
return ;
|
||||
}
|
||||
isRecording = true;
|
||||
getVideoSource((source) => {
|
||||
if (!source) {
|
||||
SendNotification.show("Recording failed", "Could not determine which display to record from.");
|
||||
return ;
|
||||
}
|
||||
recordingDisplay = source;
|
||||
const audioSink = getAudioSink();
|
||||
if (!audioSink) {
|
||||
SendNotification.show("Recording failed", "No audio sink available to record from.");
|
||||
return ;
|
||||
}
|
||||
recordProcess.filePath = recordingDir + getFilename();
|
||||
recordProcess.command = ["wf-recorder", "--audio=" + audioSink, "-o", source, "--codec", codec, "--pixel-format", pixelFormat, "--framerate", framerate.toString(), "-f", recordProcess.filePath];
|
||||
for (const param of codecParams) {
|
||||
recordProcess.command.push("-p");
|
||||
recordProcess.command.push(param);
|
||||
}
|
||||
for (const filter of filterArgs) {
|
||||
recordProcess.command.push("-F");
|
||||
recordProcess.command.push(filter);
|
||||
}
|
||||
Logger.log("RecordService", "Starting recording with command: " + recordProcess.command.join(" "));
|
||||
recordProcess.onErrorExit = function() {
|
||||
SendNotification.show("Recording failed", "An error occurred while trying to record the screen.");
|
||||
};
|
||||
recordProcess.onNormalExit = function() {
|
||||
SendNotification.show("Recording stopped", recordProcess.filePath);
|
||||
};
|
||||
recordProcess.running = true;
|
||||
SendNotification.show("Recording started", "Recording to " + recordProcess.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
Process {
|
||||
id: recordProcess
|
||||
|
||||
property string filePath: ""
|
||||
property var onNormalExit: null
|
||||
property var onErrorExit: null
|
||||
|
||||
running: false
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (exitCode === 0) {
|
||||
Logger.log("RecordService", "Recording stopped successfully.");
|
||||
if (onNormalExit) {
|
||||
onNormalExit();
|
||||
onNormalExit = null;
|
||||
}
|
||||
} else {
|
||||
Logger.error("RecordService", "Recording process exited with error code: " + exitCode);
|
||||
if (onErrorExit) {
|
||||
onErrorExit();
|
||||
onErrorExit = null;
|
||||
}
|
||||
}
|
||||
isRecording = false;
|
||||
isStopping = false;
|
||||
recordingDisplay = "";
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: niriFocusedOutputProcess
|
||||
|
||||
property var onGetName: null
|
||||
|
||||
running: false
|
||||
command: ["niri", "msg", "focused-output"]
|
||||
onExited: function(exitCode, exitStatus) {
|
||||
if (exitCode !== 0) {
|
||||
Logger.error("RecordService", "Failed to get focused output via niri.");
|
||||
if (niriFocusedOutputProcess.onGetName) {
|
||||
niriFocusedOutputProcess.onGetName(null);
|
||||
niriFocusedOutputProcess.onGetName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: (data) => {
|
||||
if (niriFocusedOutputProcess.onGetName) {
|
||||
const parts = data.split(' ');
|
||||
const name = parts.length > 0 ? parts[parts.length - 1].slice(1)?.slice(0, -1) : null;
|
||||
name ? Logger.log("RecordService", "Focused output is: " + name) : Logger.warn("RecordService", "No focused output found.");
|
||||
niriFocusedOutputProcess.onGetName(name);
|
||||
niriFocusedOutputProcess.onGetName = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
16
config/quickshell/Services/SendNotification.qml
Normal file
16
config/quickshell/Services/SendNotification.qml
Normal file
@@ -0,0 +1,16 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function show(title, message, icon = "", urgency = "normal") {
|
||||
if (icon)
|
||||
Quickshell.execDetached(["notify-send", "-u", urgency, "-i", icon, title, message, "-a", "quickshell"]);
|
||||
else
|
||||
Quickshell.execDetached(["notify-send", "-u", urgency, title, message, "-a", "quickshell"]);
|
||||
}
|
||||
|
||||
}
|
||||
39
config/quickshell/Services/SettingsService.qml
Normal file
39
config/quickshell/Services/SettingsService.qml
Normal file
@@ -0,0 +1,39 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Constants
|
||||
import qs.Services
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
property alias primaryColor: adapter.primaryColor
|
||||
property alias showLyricsBar: adapter.showLyricsBar
|
||||
property alias notifications: adapter.notifications
|
||||
property alias location: adapter.location
|
||||
property string settingsFilePath: Qt.resolvedUrl("../Assets/Config/Settings.json")
|
||||
|
||||
FileView {
|
||||
id: settingsFile
|
||||
|
||||
path: settingsFilePath
|
||||
watchChanges: true
|
||||
onFileChanged: reload()
|
||||
onAdapterUpdated: writeAdapter()
|
||||
|
||||
JsonAdapter {
|
||||
id: adapter
|
||||
|
||||
property string primaryColor: "#89b4fa"
|
||||
property bool showLyricsBar: false
|
||||
property JsonObject notifications
|
||||
property string location: "New York"
|
||||
|
||||
notifications: JsonObject {
|
||||
property bool doNotDisturb: false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
392
config/quickshell/Services/SystemStatService.qml
Normal file
392
config/quickshell/Services/SystemStatService.qml
Normal file
@@ -0,0 +1,392 @@
|
||||
import Qt.labs.folderlistmodel
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Utils
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
// For Intel coretemp, start averaging all available sensors/cores
|
||||
|
||||
id: root
|
||||
|
||||
// Public values
|
||||
property real cpuUsage: 0
|
||||
property real cpuTemp: 0
|
||||
property real memGb: 0
|
||||
property real memPercent: 0
|
||||
property real diskPercent: 0
|
||||
property real rxSpeed: 0
|
||||
property real txSpeed: 0
|
||||
// Configuration
|
||||
property int sleepDuration: 3000
|
||||
property int fasterSleepDuration: 1000
|
||||
// Internal state for CPU calculation
|
||||
property var prevCpuStats: null
|
||||
// Internal state for network speed calculation
|
||||
// Previous Bytes need to be stored as 'real' as they represent the total of bytes transfered
|
||||
// since the computer started, so their value will easily overlfow a 32bit int.
|
||||
property real prevRxBytes: 0
|
||||
property real prevTxBytes: 0
|
||||
property real prevTime: 0
|
||||
// Cpu temperature is the most complex
|
||||
readonly property var supportedTempCpuSensorNames: ["coretemp", "k10temp", "zenpower"]
|
||||
property string cpuTempSensorName: ""
|
||||
property string cpuTempHwmonPath: ""
|
||||
// For Intel coretemp averaging of all cores/sensors
|
||||
property var intelTempValues: []
|
||||
property int intelTempFilesChecked: 0
|
||||
property int intelTempMaxFiles: 20 // Will test up to temp20_input
|
||||
|
||||
// -------------------------------------------------------
|
||||
// -------------------------------------------------------
|
||||
// Parse memory info from /proc/meminfo
|
||||
function parseMemoryInfo(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const lines = text.split('\n');
|
||||
let memTotal = 0;
|
||||
let memAvailable = 0;
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('MemTotal:'))
|
||||
memTotal = parseInt(line.split(/\s+/)[1]) || 0;
|
||||
else if (line.startsWith('MemAvailable:'))
|
||||
memAvailable = parseInt(line.split(/\s+/)[1]) || 0;
|
||||
}
|
||||
if (memTotal > 0) {
|
||||
const usageKb = memTotal - memAvailable;
|
||||
root.memGb = (usageKb / 1e+06).toFixed(1);
|
||||
root.memPercent = Math.round((usageKb / memTotal) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Calculate CPU usage from /proc/stat
|
||||
function calculateCpuUsage(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const lines = text.split('\n');
|
||||
const cpuLine = lines[0];
|
||||
// First line is total CPU
|
||||
if (!cpuLine.startsWith('cpu '))
|
||||
return ;
|
||||
|
||||
const parts = cpuLine.split(/\s+/);
|
||||
const stats = {
|
||||
"user": parseInt(parts[1]) || 0,
|
||||
"nice": parseInt(parts[2]) || 0,
|
||||
"system": parseInt(parts[3]) || 0,
|
||||
"idle": parseInt(parts[4]) || 0,
|
||||
"iowait": parseInt(parts[5]) || 0,
|
||||
"irq": parseInt(parts[6]) || 0,
|
||||
"softirq": parseInt(parts[7]) || 0,
|
||||
"steal": parseInt(parts[8]) || 0,
|
||||
"guest": parseInt(parts[9]) || 0,
|
||||
"guestNice": parseInt(parts[10]) || 0
|
||||
};
|
||||
const totalIdle = stats.idle + stats.iowait;
|
||||
const total = Object.values(stats).reduce((sum, val) => {
|
||||
return sum + val;
|
||||
}, 0);
|
||||
if (root.prevCpuStats) {
|
||||
const prevTotalIdle = root.prevCpuStats.idle + root.prevCpuStats.iowait;
|
||||
const prevTotal = Object.values(root.prevCpuStats).reduce((sum, val) => {
|
||||
return sum + val;
|
||||
}, 0);
|
||||
const diffTotal = total - prevTotal;
|
||||
const diffIdle = totalIdle - prevTotalIdle;
|
||||
if (diffTotal > 0)
|
||||
root.cpuUsage = (((diffTotal - diffIdle) / diffTotal) * 100).toFixed(1);
|
||||
|
||||
}
|
||||
root.prevCpuStats = stats;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Calculate RX and TX speed from /proc/net/dev
|
||||
// Average speed of all interfaces excepted 'lo'
|
||||
function calculateNetworkSpeed(text) {
|
||||
if (!text)
|
||||
return ;
|
||||
|
||||
const currentTime = Date.now() / 1000;
|
||||
const lines = text.split('\n');
|
||||
let totalRx = 0;
|
||||
let totalTx = 0;
|
||||
for (var i = 2; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line)
|
||||
continue;
|
||||
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1)
|
||||
continue;
|
||||
|
||||
const iface = line.substring(0, colonIndex).trim();
|
||||
if (iface === 'lo')
|
||||
continue;
|
||||
|
||||
const statsLine = line.substring(colonIndex + 1).trim();
|
||||
const stats = statsLine.split(/\s+/);
|
||||
const rxBytes = parseInt(stats[0], 10) || 0;
|
||||
const txBytes = parseInt(stats[8], 10) || 0;
|
||||
totalRx += rxBytes;
|
||||
totalTx += txBytes;
|
||||
}
|
||||
// Compute only if we have a previous run to compare to.
|
||||
if (root.prevTime > 0) {
|
||||
const timeDiff = currentTime - root.prevTime;
|
||||
// Avoid division by zero if time hasn't passed.
|
||||
if (timeDiff > 0) {
|
||||
let rxDiff = totalRx - root.prevRxBytes;
|
||||
let txDiff = totalTx - root.prevTxBytes;
|
||||
// Handle counter resets (e.g., WiFi reconnect), which would cause a negative value.
|
||||
if (rxDiff < 0)
|
||||
rxDiff = 0;
|
||||
|
||||
if (txDiff < 0)
|
||||
txDiff = 0;
|
||||
|
||||
root.rxSpeed = Math.round(rxDiff / timeDiff); // Speed in Bytes/s
|
||||
root.txSpeed = Math.round(txDiff / timeDiff);
|
||||
}
|
||||
}
|
||||
root.prevRxBytes = totalRx;
|
||||
root.prevTxBytes = totalTx;
|
||||
root.prevTime = currentTime;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Helper function to format network speeds
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
if (bytesPerSecond < 1024 * 1024) {
|
||||
const kb = bytesPerSecond / 1024;
|
||||
if (kb < 10)
|
||||
return kb.toFixed(1) + "KB";
|
||||
else
|
||||
return Math.round(kb) + "KB";
|
||||
} else if (bytesPerSecond < 1024 * 1024 * 1024) {
|
||||
return (bytesPerSecond / (1024 * 1024)).toFixed(1) + "MB";
|
||||
} else {
|
||||
return (bytesPerSecond / (1024 * 1024 * 1024)).toFixed(1) + "GB";
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Compact speed formatter for vertical bar display
|
||||
function formatCompactSpeed(bytesPerSecond) {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0)
|
||||
return "0";
|
||||
|
||||
const units = ["", "K", "M", "G"];
|
||||
let value = bytesPerSecond;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
// Promote at ~100 of current unit (e.g., 100k -> ~0.1M shown as 0.1M or 0M if rounded)
|
||||
if (unitIndex < units.length - 1 && value >= 100) {
|
||||
value = value / 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
const display = Math.round(value).toString();
|
||||
return display + units[unitIndex];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Function to start fetching and computing the cpu temperature
|
||||
function updateCpuTemperature() {
|
||||
// For AMD sensors (k10temp and zenpower), only use Tctl sensor
|
||||
// temp1_input corresponds to Tctl (Temperature Control) on these sensors
|
||||
if (root.cpuTempSensorName === "k10temp" || root.cpuTempSensorName === "zenpower") {
|
||||
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp1_input`;
|
||||
cpuTempReader.reload();
|
||||
} else if (root.cpuTempSensorName === "coretemp") {
|
||||
root.intelTempValues = [];
|
||||
root.intelTempFilesChecked = 0;
|
||||
checkNextIntelTemp();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// Function to check next Intel temperature sensor
|
||||
function checkNextIntelTemp() {
|
||||
if (root.intelTempFilesChecked >= root.intelTempMaxFiles) {
|
||||
// Calculate average of all found temperatures
|
||||
if (root.intelTempValues.length > 0) {
|
||||
let sum = 0;
|
||||
for (var i = 0; i < root.intelTempValues.length; i++) {
|
||||
sum += root.intelTempValues[i];
|
||||
}
|
||||
root.cpuTemp = Math.round(sum / root.intelTempValues.length);
|
||||
} else {
|
||||
Logger.warn("SystemStatService", "No temperature sensors found for coretemp");
|
||||
root.cpuTemp = 0;
|
||||
}
|
||||
return ;
|
||||
}
|
||||
// Check next temperature file
|
||||
root.intelTempFilesChecked++;
|
||||
cpuTempReader.path = `${root.cpuTempHwmonPath}/temp${root.intelTempFilesChecked}_input`;
|
||||
cpuTempReader.reload();
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
Component.onCompleted: {
|
||||
// Kickoff the cpu name detection for temperature
|
||||
cpuTempNameReader.checkNext();
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// Timer for periodic updates
|
||||
Timer {
|
||||
id: updateTimer
|
||||
|
||||
interval: root.sleepDuration
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
// Trigger all direct system files reads
|
||||
memInfoFile.reload();
|
||||
cpuStatFile.reload();
|
||||
// Run df (disk free) one time
|
||||
dfProcess.running = true;
|
||||
updateCpuTemperature();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fasterUpdateTimer
|
||||
|
||||
interval: root.fasterSleepDuration
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
netDevFile.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// FileView components for reading system files
|
||||
FileView {
|
||||
id: memInfoFile
|
||||
|
||||
path: "/proc/meminfo"
|
||||
onLoaded: parseMemoryInfo(text())
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: cpuStatFile
|
||||
|
||||
path: "/proc/stat"
|
||||
onLoaded: calculateCpuUsage(text())
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: netDevFile
|
||||
|
||||
path: "/proc/net/dev"
|
||||
onLoaded: calculateNetworkSpeed(text())
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// Process to fetch disk usage in percent
|
||||
// Uses 'df' aka 'disk free'
|
||||
Process {
|
||||
id: dfProcess
|
||||
|
||||
command: ["df", "--output=pcent", "/"]
|
||||
running: false
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length >= 2) {
|
||||
const percent = lines[1].replace(/[^0-9]/g, '');
|
||||
root.diskPercent = parseInt(percent) || 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// --------------------------------------------
|
||||
// --------------------------------------------
|
||||
// CPU Temperature
|
||||
// It's more complex.
|
||||
// ----
|
||||
// #1 - Find a common cpu sensor name ie: "coretemp", "k10temp", "zenpower"
|
||||
FileView {
|
||||
id: cpuTempNameReader
|
||||
|
||||
property int currentIndex: 0
|
||||
|
||||
function checkNext() {
|
||||
if (currentIndex >= 16) {
|
||||
// Check up to hwmon10
|
||||
Logger.warn("SystemStatService", "No supported temperature sensor found");
|
||||
return ;
|
||||
}
|
||||
cpuTempNameReader.path = `/sys/class/hwmon/hwmon${currentIndex}/name`;
|
||||
cpuTempNameReader.reload();
|
||||
}
|
||||
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
const name = text().trim();
|
||||
if (root.supportedTempCpuSensorNames.includes(name)) {
|
||||
root.cpuTempSensorName = name;
|
||||
root.cpuTempHwmonPath = `/sys/class/hwmon/hwmon${currentIndex}`;
|
||||
Logger.log("SystemStatService", `Found ${root.cpuTempSensorName} CPU thermal sensor at ${root.cpuTempHwmonPath}`);
|
||||
} else {
|
||||
currentIndex++;
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNext();
|
||||
});
|
||||
}
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
currentIndex++;
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNext();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ----
|
||||
// #2 - Read sensor value
|
||||
FileView {
|
||||
id: cpuTempReader
|
||||
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
const data = text().trim();
|
||||
if (root.cpuTempSensorName === "coretemp") {
|
||||
// For Intel, collect all temperature values
|
||||
const temp = parseInt(data) / 1000;
|
||||
root.intelTempValues.push(temp);
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNextIntelTemp();
|
||||
});
|
||||
} else {
|
||||
// For AMD sensors (k10temp and zenpower), directly set the temperature
|
||||
root.cpuTemp = Math.round(parseInt(data) / 1000);
|
||||
}
|
||||
}
|
||||
onLoadFailed: function(error) {
|
||||
Qt.callLater(() => {
|
||||
// Qt.callLater is mandatory
|
||||
checkNextIntelTemp();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
46
config/quickshell/Services/ThemeIcons.qml
Normal file
46
config/quickshell/Services/ThemeIcons.qml
Normal file
@@ -0,0 +1,46 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
// ignore and fall back
|
||||
|
||||
id: root
|
||||
|
||||
function iconFromName(iconName, fallbackName) {
|
||||
const fallback = fallbackName || "application-x-executable";
|
||||
try {
|
||||
if (iconName && typeof Quickshell !== 'undefined' && Quickshell.iconPath) {
|
||||
const p = Quickshell.iconPath(iconName, fallback);
|
||||
if (p && p !== "")
|
||||
return p;
|
||||
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
try {
|
||||
return Quickshell.iconPath ? (Quickshell.iconPath(fallback, true) || "") : "";
|
||||
} catch (e2) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve icon path for a DesktopEntries appId - safe on missing entries
|
||||
function iconForAppId(appId, fallbackName) {
|
||||
const fallback = fallbackName || "application-x-executable";
|
||||
if (!appId)
|
||||
return iconFromName(fallback, fallback);
|
||||
|
||||
try {
|
||||
if (typeof DesktopEntries === 'undefined' || !DesktopEntries.byId)
|
||||
return iconFromName(fallback, fallback);
|
||||
|
||||
const entry = (DesktopEntries.heuristicLookup) ? DesktopEntries.heuristicLookup(appId) : DesktopEntries.byId(appId);
|
||||
const name = entry && entry.icon ? entry.icon : "";
|
||||
return iconFromName(name || fallback, fallback);
|
||||
} catch (e) {
|
||||
return iconFromName(fallback, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
44
config/quickshell/Services/TimeService.qml
Normal file
44
config/quickshell/Services/TimeService.qml
Normal file
@@ -0,0 +1,44 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var date: new Date()
|
||||
property string time: Qt.formatDateTime(date, "HH:mm")
|
||||
property string dateString: {
|
||||
let now = date;
|
||||
let dayName = now.toLocaleDateString(Qt.locale(), "ddd");
|
||||
dayName = dayName.charAt(0).toUpperCase() + dayName.slice(1);
|
||||
let day = now.getDate();
|
||||
let suffix;
|
||||
if (day > 3 && day < 21)
|
||||
suffix = 'th';
|
||||
else
|
||||
switch (day % 10) {
|
||||
case 1:
|
||||
suffix = "st";
|
||||
break;
|
||||
case 2:
|
||||
suffix = "nd";
|
||||
break;
|
||||
case 3:
|
||||
suffix = "rd";
|
||||
break;
|
||||
default:
|
||||
suffix = "th";
|
||||
};
|
||||
let month = now.toLocaleDateString(Qt.locale(), "MMMM");
|
||||
let year = now.toLocaleDateString(Qt.locale(), "yyyy");
|
||||
return `${dayName}, ` + `${day}${suffix} ${month} ${year}`;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: true
|
||||
onTriggered: root.date = new Date()
|
||||
}
|
||||
|
||||
}
|
||||
56
config/quickshell/Services/WorkspaceManager.qml
Normal file
56
config/quickshell/Services/WorkspaceManager.qml
Normal file
@@ -0,0 +1,56 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Services
|
||||
import qs.Utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property ListModel workspaces
|
||||
|
||||
workspaces: ListModel {
|
||||
}
|
||||
|
||||
function initNiri() {
|
||||
updateNiriWorkspaces();
|
||||
}
|
||||
|
||||
function updateNiriWorkspaces() {
|
||||
const niriWorkspaces = Niri.workspaces || [];
|
||||
workspaces.clear();
|
||||
for (let i = 0; i < niriWorkspaces.length; i++) {
|
||||
const ws = niriWorkspaces[i];
|
||||
workspaces.append({
|
||||
"id": ws.id,
|
||||
"idx": ws.idx || 1,
|
||||
"name": ws.name || "",
|
||||
"output": ws.output || "",
|
||||
"isFocused": ws.isFocused === true,
|
||||
"isActive": ws.isActive === true,
|
||||
"isUrgent": ws.isUrgent === true
|
||||
});
|
||||
}
|
||||
workspacesChanged();
|
||||
}
|
||||
|
||||
function switchToWorkspace(workspaceId) {
|
||||
try {
|
||||
Quickshell.execDetached(["niri", "msg", "action", "focus-workspace", workspaceId.toString()]);
|
||||
} catch (e) {
|
||||
Logger.error("WorkspaceManager", "Error switching Niri workspace:", e);
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onWorkspacesChanged() {
|
||||
updateNiriWorkspaces();
|
||||
}
|
||||
|
||||
target: Niri
|
||||
}
|
||||
|
||||
}
|
||||
20
config/quickshell/Services/WriteClipboard.qml
Normal file
20
config/quickshell/Services/WriteClipboard.qml
Normal file
@@ -0,0 +1,20 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function write(text) {
|
||||
action.command = ["sh", "-c", `echo ${text} | wl-copy -n`];
|
||||
action.startDetached();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: action
|
||||
|
||||
running: false
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user