Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
@@ -0,0 +1,28 @@
function batteryPercentage(device) {
if (!device || !device.isPresent) return -1
return Math.round(Number(device.percentage || 0) * 100)
}
function isDischarging(device, onBattery, dischargingState) {
return !!(device && device.isPresent && onBattery && device.state === dischargingState)
}
function shouldWarnLowBattery(device, onBattery, dischargingState, threshold, alreadyNotified) {
var level = batteryPercentage(device)
if (level < 0) return { level: level, notify: false, notifiedLowBattery: false }
var low = isDischarging(device, onBattery, dischargingState) && level <= threshold
return {
level: level,
notify: low && !alreadyNotified,
notifiedLowBattery: low
}
}
if (typeof module !== "undefined") {
module.exports = {
batteryPercentage: batteryPercentage,
isDischarging: isDischarging,
shouldWarnLowBattery: shouldWarnLowBattery
}
}
@@ -0,0 +1,78 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
import "BatteryModel.js" as BatteryModel
Item {
id: root
property var shell: null
property string blobPath: Quickshell.env("BLOB_PATH")
readonly property int batteryThreshold: 10
property string pendingPowerSource: ""
PersistentProperties {
id: persisted
reloadableId: "blob-battery"
property bool notifiedLowBattery: false
}
function batteryPercentage() {
return BatteryModel.batteryPercentage(UPower.displayDevice)
}
function isDischarging() {
return BatteryModel.isDischarging(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging)
}
function checkBattery() {
var state = BatteryModel.shouldWarnLowBattery(UPower.displayDevice, UPower.onBattery, UPowerDeviceState.Discharging, batteryThreshold, persisted.notifiedLowBattery)
persisted.notifiedLowBattery = state.notifiedLowBattery
if (state.notify) sendLowBatteryWarning(state.level)
}
function sendLowBatteryWarning(level) {
if (warningProcess.running) return
warningProcess.command = [
"blob-battery-low",
String(level)
]
warningProcess.running = true
}
function applyPowerProfile() {
pendingPowerSource = UPower.onBattery ? "battery" : "ac"
if (!powerProfileProcess.running) runPendingPowerProfile()
}
function runPendingPowerProfile() {
powerProfileProcess.command = ["blob-power-set", pendingPowerSource]
pendingPowerSource = ""
powerProfileProcess.running = true
}
Process { id: warningProcess }
Process {
id: powerProfileProcess
onExited: if (root.pendingPowerSource !== "") root.runPendingPowerProfile()
}
Timer {
interval: 30000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.checkBattery()
}
Connections {
target: UPower
function onOnBatteryChanged() {
root.checkBattery()
root.applyPowerProfile()
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.battery",
"name": "Battery",
"version": "1.0.0",
"author": "Blob",
"description": "Low battery warning service",
"kinds": [
"service"
],
"entryPoints": {
"service": "Service.qml"
}
}
+52
View File
@@ -0,0 +1,52 @@
function secondsFromConfig(value, fallback) {
var n = Number(value)
if (!isFinite(n) || n < 0) return fallback
return Math.floor(n)
}
function eventParts(event, count) {
try {
if (event && event.parse) return event.parse(count)
} catch (error) {
}
return String(event && event.data ? event.data : "").split(",")
}
function screensaverWindowsAfter(windows, address, visible) {
var key = String(address || "")
if (!key) {
var current = windows || {}
var existingCount = 0
for (var currentKey in current) {
if (current[currentKey]) existingCount++
}
return { windows: current, count: existingCount }
}
var next = {}
var count = 0
for (var existing in windows || {}) {
if (existing !== key && windows[existing]) {
next[existing] = true
count++
}
}
if (visible) {
next[key] = true
count++
}
return {
windows: next,
count: count
}
}
if (typeof module !== "undefined") {
module.exports = {
secondsFromConfig: secondsFromConfig,
eventParts: eventParts,
screensaverWindowsAfter: screensaverWindowsAfter
}
}
+361
View File
@@ -0,0 +1,361 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import "IdleModel.js" as IdleModel
Item {
id: root
// Injected by blob-shell (the first-party service loader).
property var shell: null
readonly property string home: Quickshell.env("HOME")
readonly property string stayAwakeStateDir: home + "/.local/state/blob/indicators"
readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake"
readonly property int defaultScreensaverSeconds: 150
readonly property int defaultLockSeconds: 300
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds)
readonly property int screensaverDelaySeconds: Math.max(0, screensaverTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property int lockDelaySeconds: Math.max(0, lockTimeoutSeconds - firstIdleTimeoutSeconds)
readonly property bool idleEnabled: stayAwakeStateLoaded && !stayAwake
readonly property string screensaverClass: "org.blob.screensaver"
property bool stayAwake: false
property bool stayAwakeStateLoaded: false
property bool hasPendingStayAwakePersist: false
property bool pendingStayAwakePersist: false
property bool idledThisCycle: false
property bool screensaverStartedThisCycle: false
property string lastEvent: "starting"
property string lastEventAt: ""
property var screensaverWindows: ({})
property int screensaverWindowCount: 0
function secondsFromConfig(value, fallback) {
return IdleModel.secondsFromConfig(value, fallback)
}
function nowIso() {
return new Date().toISOString()
}
function logEvent(event, details) {
var suffix = details === undefined || details === null || details === "" ? "" : ": " + String(details)
root.lastEventAt = nowIso()
root.lastEvent = event + suffix
console.log("blob idle " + root.lastEventAt + " " + root.lastEvent)
}
function runProcess(process, label, command) {
if (process.running) {
logEvent("process-skip", label + " already running")
return false
}
logEvent("process-start", label + " " + command)
process.command = ["bash", "-lc", command]
process.running = true
return true
}
function launchScreensaver() {
root.screensaverStartedThisCycle = true
screensaverLaunchGraceTimer.restart()
runProcess(screensaverProcess, "screensaver", "[[ $(blob-shell lock isLocked 2>/dev/null) == \"true\" ]] || blob-launch-screensaver")
}
function lockSystem(reason) {
logEvent("lock-system", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
runProcess(lockProcess, "lock", "blob-system-lock")
}
function startIdleCycle() {
if (root.idledThisCycle) {
logEvent("idle-cycle-already-running")
return
}
logEvent("idle-cycle-start", "screensaver=" + root.screensaverTimeoutSeconds + " lock=" + root.lockTimeoutSeconds)
root.idledThisCycle = true
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
if (root.screensaverDelaySeconds === 0) launchScreensaver()
else screensaverTimer.restart()
if (root.lockDelaySeconds === 0) lockSystem("lock-timeout-immediate")
else lockTimer.restart()
}
function cancelIdleCycle(reason) {
logEvent("idle-cycle-cancel", reason || "requested")
screensaverTimer.stop()
lockTimer.stop()
screensaverLaunchGraceTimer.stop()
if (root.idledThisCycle) runProcess(wakeProcess, "wake", "blob-system-wake")
root.idledThisCycle = false
root.screensaverStartedThisCycle = false
resetScreensaverWindows()
}
function resetScreensaverWindows() {
root.screensaverWindows = ({})
root.screensaverWindowCount = 0
}
function setScreensaverWindow(address, visible) {
var next = IdleModel.screensaverWindowsAfter(root.screensaverWindows, address, visible)
root.screensaverWindows = next.windows
root.screensaverWindowCount = next.count
}
function handleScreensaverWindowOpened(address) {
setScreensaverWindow(address, true)
screensaverLaunchGraceTimer.stop()
}
function handleScreensaverWindowClosed(address) {
setScreensaverWindow(address, false)
if (!root.idleEnabled || !root.idledThisCycle || !root.screensaverStartedThisCycle) return
if (root.screensaverWindowCount > 0) return
// The user dismissed the screensaver before the lock deadline. Treat that
// as activity and cancel the pending lock; the lock timer is only allowed
// to fire while the screensaver remains up.
root.cancelIdleCycle("screensaver-dismissed")
}
function eventParts(event, count) {
return IdleModel.eventParts(event, count)
}
function handleHyprlandEvent(event) {
var name = String(event && event.name ? event.name : "")
if (name === "openwindow") {
var open = eventParts(event, 4)
if (String(open[2] || "") === root.screensaverClass) root.handleScreensaverWindowOpened(open[0])
} else if (name === "closewindow") {
var close = eventParts(event, 1)
var address = String(close[0] || "")
if (root.screensaverWindows[address]) root.handleScreensaverWindowClosed(address)
}
}
function handleActiveSignal() {
if (!root.idledThisCycle) return
// Starting the screensaver can make the compositor report activity. Keep
// the lock timer running once the screensaver exists (or during its short
// launch grace); Hyprland window events cancel the cycle if it exits before
// the normal lock deadline.
if (root.screensaverStartedThisCycle && (root.screensaverWindowCount > 0 || screensaverLaunchGraceTimer.running)) {
logEvent("idle-monitor-active", "screensaver cycle remains armed")
return
}
cancelIdleCycle("activity")
}
function handleIdleChanged() {
logEvent("idle-monitor", idleMonitor.isIdle ? "idle" : "active")
if (!root.idleEnabled) return
if (idleMonitor.isIdle) startIdleCycle()
else handleActiveSignal()
}
function statusJson() {
return JSON.stringify({
enabled: root.idleEnabled,
stayAwake: root.stayAwake,
stayAwakeStateLoaded: root.stayAwakeStateLoaded,
stayAwakeStatePath: root.stayAwakeStatePath,
idle: idleMonitor.isIdle,
inIdleCycle: root.idledThisCycle,
screensaverStarted: root.screensaverStartedThisCycle,
screensaver: root.screensaverTimeoutSeconds,
lock: root.lockTimeoutSeconds,
screensaverDelay: root.screensaverDelaySeconds,
lockDelay: root.lockDelaySeconds,
screensaverWindows: root.screensaverWindowCount,
timers: {
screensaver: screensaverTimer.running,
lock: lockTimer.running,
screensaverLaunchGrace: screensaverLaunchGraceTimer.running
},
processes: {
screensaver: screensaverProcess.running,
lock: lockProcess.running,
wake: wakeProcess.running
},
lastEvent: root.lastEvent,
lastEventAt: root.lastEventAt
})
}
function persistStayAwake(value) {
var command = value
? "mkdir -p \"$HOME/.local/state/blob/indicators\" && touch \"$HOME/.local/state/blob/indicators/stay-awake\""
: "rm -f \"$HOME/.local/state/blob/indicators/stay-awake\""
if (stayAwakeStateWriter.running) {
root.pendingStayAwakePersist = !!value
root.hasPendingStayAwakePersist = true
return
}
stayAwakeStateWriter.command = ["bash", "-lc", command]
stayAwakeStateWriter.running = true
}
function refreshStayAwakeState() {
if (!stayAwakeStateProbe.running) stayAwakeStateProbe.running = true
}
function applyStayAwake(value, persist, reason) {
var enabled = !!value
var changed = !root.stayAwakeStateLoaded || root.stayAwake !== enabled
if (persist) persistStayAwake(enabled)
root.stayAwake = enabled
root.stayAwakeStateLoaded = true
if (!changed) return enabled ? "disabled" : "enabled"
logEvent("stay-awake", (enabled ? "enabled" : "disabled") + (reason ? " " + reason : ""))
if (enabled) cancelIdleCycle("stay-awake")
else Qt.callLater(root.handleIdleChanged)
return enabled ? "disabled" : "enabled"
}
function setIdleEnabled(value) {
return applyStayAwake(!value, true, "ipc")
}
IdleMonitor {
id: idleMonitor
enabled: root.idleEnabled
timeout: root.firstIdleTimeoutSeconds
respectInhibitors: true
onIsIdleChanged: root.handleIdleChanged()
}
Timer {
id: screensaverTimer
interval: root.screensaverDelaySeconds * 1000
repeat: false
onTriggered: root.launchScreensaver()
}
Timer {
id: lockTimer
interval: root.lockDelaySeconds * 1000
repeat: false
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout")
}
Timer {
id: screensaverLaunchGraceTimer
interval: 3000
repeat: false
onTriggered: {
if (root.idleEnabled && root.idledThisCycle && root.screensaverStartedThisCycle && root.screensaverWindowCount === 0 && !idleMonitor.isIdle) {
root.cancelIdleCycle("screensaver-not-running")
}
}
}
Connections {
target: Hyprland
function onRawEvent(event) { root.handleHyprlandEvent(event) }
}
Process {
id: screensaverProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "screensaver exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: lockProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "lock exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: wakeProcess
onExited: function(exitCode, exitStatus) { root.logEvent("process-exit", "wake exitCode=" + exitCode + " status=" + exitStatus) }
}
Process {
id: stayAwakeStateProbe
command: ["bash", "-c", "mkdir -p \"$HOME/.local/state/blob/indicators\"; if [[ -f $HOME/.local/state/blob/indicators/stay-awake ]]; then echo yes; else echo no; fi"]
stdout: SplitParser {
onRead: function(line) { root.applyStayAwake(String(line).trim() === "yes", false, "state-file") }
}
onExited: function() { stayAwakeStateDirWatcher.reload() }
}
Process {
id: stayAwakeStateWriter
onExited: function() {
if (root.hasPendingStayAwakePersist) {
var pending = root.pendingStayAwakePersist
root.hasPendingStayAwakePersist = false
root.persistStayAwake(pending)
return
}
root.refreshStayAwakeState()
}
}
FileView {
id: stayAwakeStateDirWatcher
path: root.stayAwakeStateDir
watchChanges: true
printErrors: false
onFileChanged: root.refreshStayAwakeState()
}
Component.onCompleted: {
logEvent("service-ready")
refreshStayAwakeState()
}
IpcHandler {
target: "idle"
function status(): string {
return root.statusJson()
}
function debug(): string {
return root.statusJson()
}
function enable(): string {
return root.setIdleEnabled(true)
}
function disable(): string {
return root.setIdleEnabled(false)
}
function toggle(): string {
return root.setIdleEnabled(!root.idleEnabled)
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"id": "blob.idle",
"name": "Idle",
"version": "1.0.0",
"author": "Blob",
"description": "Quickshell-based idle detection for screensaver, lock, and display wake.",
"kinds": [
"service"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
+313
View File
@@ -0,0 +1,313 @@
import QtQuick
import Quickshell
import qs.Ui
import qs.Commons
BarWidget {
id: root
moduleName: "blob.media"
readonly property var mediaService: bar?.shell?.firstPartyServiceFor("blob.media")
readonly property var activePlayer: mediaService ? mediaService.activePlayer : null
readonly property var sourcePlayers: mediaService ? mediaService.sourcePlayers : []
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
readonly property string playIcon: activePlayer && activePlayer.isPlaying ? "󰏤" : "󰐊"
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
property bool popupOpen: false
function close() { popupOpen = false }
property real maxLabelWidth: 180
visible: hasMedia
implicitWidth: hasMedia ? row.implicitWidth + Style.space(14) : 0
implicitHeight: barSize
Row {
id: row
anchors.centerIn: parent
spacing: Style.space(6)
Text {
id: glyph
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.playIcon
color: activePlayer && activePlayer.isPlaying ? root.bar.barForeground : Qt.darker(root.bar.barForeground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
Behavior on color {
enabled: !root.bar || root.bar.foregroundAnimationEnabled
ColorAnimation { duration: 160 }
}
}
Item {
id: scrollClip
width: Math.min(root.maxLabelWidth, labelText.implicitWidth)
height: glyph.height
clip: true
anchors.verticalCenter: parent.verticalCenter
visible: !root.bar.vertical && root.title !== ""
Text {
id: labelText
textFormat: Text.PlainText
text: root.title + (root.artist ? " · " + root.artist : "")
color: root.bar.barForeground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
anchors.verticalCenter: parent.verticalCenter
property bool needsScroll: implicitWidth > scrollClip.width
NumberAnimation on x {
id: scrollAnim
running: labelText.needsScroll && !root.popupOpen && !root.bar.vertical
loops: Animation.Infinite
duration: Math.max(6000, labelText.implicitWidth * 25)
from: scrollClip.width
to: -labelText.implicitWidth
easing.type: Easing.Linear
}
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: root.activePlayer ? Qt.PointingHandCursor : Qt.ArrowCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onClicked: function(mouse) {
if (!root.activePlayer) return
if (mouse.button === Qt.MiddleButton) {
if (root.mediaService) root.mediaService.runAction("next", false)
} else if (mouse.button === Qt.RightButton) {
root.popupOpen = !root.popupOpen
} else {
if (root.mediaService) root.mediaService.runAction("playPause", false)
}
}
onWheel: function(wheel) {
if (!root.activePlayer) return
if (wheel.angleDelta.y > 0 && root.mediaService) root.mediaService.runAction("previous", false)
else if (wheel.angleDelta.y < 0 && root.mediaService) root.mediaService.runAction("next", false)
}
onEntered: if (root.bar) root.bar.showTooltip(root, root.hasMedia ? (root.title + (root.artist ? " — " + root.artist : "")) : "")
onExited: if (root.bar) root.bar.hideTooltip(root)
}
PopupCard {
id: popup
anchorItem: root
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: popup.fittedContentWidth(Style.space(320))
contentHeight: popup.fittedContentHeight(column.implicitHeight)
Column {
id: column
anchors.fill: parent
spacing: Style.space(10)
Row {
spacing: Style.space(10)
width: parent.width
BorderSurface {
width: Style.space(64)
height: Style.space(64)
radius: Style.spacing.labelGap
color: Style.normalFillFor(root.bar.foreground, Color.accent)
borderSpec: Border.controlSpec("normal", root.bar.foreground, Color.accent)
Image {
anchors.fill: parent
anchors.margins: Style.space(2)
fillMode: Image.PreserveAspectCrop
asynchronous: true
source: root.activePlayer && root.activePlayer.trackArtUrl ? root.activePlayer.trackArtUrl : ""
visible: source !== ""
}
Text {
anchors.centerIn: parent
visible: !root.activePlayer || !root.activePlayer.trackArtUrl
text: "󰝚"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.displayLarge
}
}
Column {
spacing: Style.space(4)
width: parent.width - Style.space(74)
Text {
textFormat: Text.PlainText
text: root.title || "Nothing playing"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.subtitle
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
text: root.artist
color: Qt.darker(root.bar.foreground, 1.3)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
width: parent.width
visible: text !== ""
}
Text {
textFormat: Text.PlainText
text: root.activePlayer && root.activePlayer.trackAlbum ? root.activePlayer.trackAlbum : ""
color: Qt.darker(root.bar.foreground, 1.6)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
visible: text !== ""
}
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Button {
iconText: "󰒮"
foreground: root.bar.foreground
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY
enabled: root.activePlayer && root.activePlayer.canGoPrevious
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.mediaService) root.mediaService.runAction("previous", false, root.mediaService.playerKey(root.activePlayer))
}
Button {
iconText: root.activePlayer && root.activePlayer.isPlaying ? "󰏤" : "󰐊"
foreground: root.bar.foreground
horizontalPadding: Style.spacing.panelGap
verticalPadding: Style.spacing.controlPaddingY
iconSize: Style.font.iconLarge
enabled: root.activePlayer && (root.activePlayer.canTogglePlaying || root.activePlayer.canPlay || root.activePlayer.canPause)
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.mediaService) root.mediaService.runAction("playPause", false, root.mediaService.playerKey(root.activePlayer))
}
Button {
iconText: "󰒭"
foreground: root.bar.foreground
horizontalPadding: Style.spacing.controlPaddingX
verticalPadding: Style.spacing.controlPaddingY
enabled: root.activePlayer && root.activePlayer.canGoNext
opacity: enabled ? 1.0 : 0.4
onClicked: if (root.mediaService) root.mediaService.runAction("next", false, root.mediaService.playerKey(root.activePlayer))
}
}
PanelSeparator {
visible: root.sourcePlayers.length > 1
foreground: root.bar.foreground
}
Column {
id: sourceList
visible: root.sourcePlayers.length > 1
width: parent.width
spacing: Style.space(4)
Repeater {
model: root.sourcePlayers
BorderSurface {
id: sourceRow
required property var modelData
readonly property var player: modelData
readonly property bool selected: root.activePlayer && player
&& root.mediaService.playerKey(root.activePlayer) === root.mediaService.playerKey(player)
readonly property string sourceTitle: player ? (player.trackTitle || player.identity || player.desktopEntry || "Media source") : "Media source"
readonly property string sourceDetail: player && player.trackArtist ? player.trackArtist : (player && player.identity ? player.identity : "")
width: sourceList.width
height: sourceInner.implicitHeight + Style.space(10)
radius: Style.spacing.labelGap
color: selected ? Style.selectedFillFor(root.bar.foreground, Color.accent) : "transparent"
borderSpec: selected ? Border.controlSpec("normal", root.bar.foreground, Color.accent) : Border.none()
Row {
id: sourceInner
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: sourceRow.borderLeft + Style.space(8)
anchors.rightMargin: sourceRow.borderRight + Style.space(8)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: sourceRow.player && sourceRow.player.isPlaying ? "󰏤" : "󰐊"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.body
width: Style.space(18)
horizontalAlignment: Text.AlignHCenter
anchors.verticalCenter: parent.verticalCenter
}
Column {
width: parent.width - Style.space(26)
spacing: Style.space(1)
anchors.verticalCenter: parent.verticalCenter
Text {
textFormat: Text.PlainText
text: sourceRow.sourceTitle
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: sourceRow.selected
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
text: sourceRow.sourceDetail
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
visible: text !== ""
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: if (root.mediaService) root.mediaService.selectPlayer(root.mediaService.playerKey(sourceRow.player))
}
}
}
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
function isProxyPlayer(player) {
var dbusName = String(player && player.dbusName || "").toLowerCase()
var desktopEntry = String(player && player.desktopEntry || "").toLowerCase()
return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld"
}
function hasMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.identity || player.desktopEntry))
}
function hasTrackMetadata(player) {
return !!(player && (player.trackTitle || player.trackArtist || player.trackAlbum || player.trackArtUrl))
}
function playerCanControl(player) {
return !!(player && (player.canTogglePlaying || player.canPlay || player.canPause || player.canGoNext || player.canGoPrevious))
}
function canHandleAction(player, action) {
if (!player) return false
if (action === "next") return !!player.canGoNext
if (action === "previous") return !!player.canGoPrevious
if (action === "play") return !!(player.canPlay || player.canTogglePlaying)
if (action === "pause") return !!(player.canPause || player.canTogglePlaying)
if (action === "playPause") return !!(player.canTogglePlaying || player.canPlay || player.canPause)
return false
}
function canCycleSource(player) {
return !!(player && hasMetadata(player) && (player.isPlaying || player.canPlay))
}
function nodeProps(node) {
return node && node.ready && node.properties ? node.properties : {}
}
function isPlaybackStream(node) {
if (!node || !node.isStream) return false
if (node.isSink === true) return true
var mediaClass = String(node.type || "")
return mediaClass.indexOf("Stream/Output/Audio") !== -1
|| mediaClass.indexOf("AudioOutStream") !== -1
|| mediaClass.indexOf("Output") !== -1
}
function streamLabelKey(label) {
var key = String(label || "").toLowerCase()
key = key.replace(/^pipewire alsa \[/, "")
key = key.replace(/\]$/, "")
key = key.replace(/^alsa playback \[/, "")
key = key.replace(/[^a-z0-9]+/g, "")
return key
}
function rawStreamLabel(node) {
if (!node) return ""
var p = nodeProps(node)
return p["application.name"]
|| node.description
|| p["media.name"]
|| p["node.name"]
|| node.name
}
function playerAppLabel(player) {
if (!player) return ""
var dbus = String(player.dbusName || "")
dbus = dbus.replace(/^org\.mpris\.MediaPlayer2\./, "")
dbus = dbus.replace(/\.instance[0-9]+$/, "")
return player.desktopEntry || player.identity || dbus
}
function playerHasPlaybackStream(player, playbackStreams) {
var playerKey = streamLabelKey(playerAppLabel(player))
if (!playerKey) return false
var streams = Array.isArray(playbackStreams) ? playbackStreams : []
for (var i = 0; i < streams.length; i++) {
var streamKey = streamLabelKey(rawStreamLabel(streams[i]))
if (!streamKey) continue
if (streamKey === playerKey
|| streamKey.indexOf(playerKey) !== -1
|| playerKey.indexOf(streamKey) !== -1)
return true
}
return false
}
function playerKey(player) {
if (!player) return ""
return String(player.dbusName || player.desktopEntry || player.identity || "")
}
function trackSignature(player) {
if (!player) return ""
return [
player.trackTitle || "",
player.trackArtist || "",
player.trackAlbum || "",
player.trackArtUrl || ""
].join("\u001f")
}
function trackChanged(previousSignature, player) {
return trackSignature(player) !== String(previousSignature || "")
}
function labelFor(player) {
if (!player) return ""
return player.trackTitle || player.identity || player.desktopEntry || ""
}
function osdMessage(player, fallback) {
if (!player) return fallback
var label = labelFor(player)
if (label && player.trackArtist) return label + " - " + player.trackArtist
return label || fallback
}
if (typeof module !== "undefined") {
module.exports = {
isProxyPlayer: isProxyPlayer,
hasMetadata: hasMetadata,
hasTrackMetadata: hasTrackMetadata,
playerCanControl: playerCanControl,
canHandleAction: canHandleAction,
canCycleSource: canCycleSource,
nodeProps: nodeProps,
isPlaybackStream: isPlaybackStream,
streamLabelKey: streamLabelKey,
rawStreamLabel: rawStreamLabel,
playerAppLabel: playerAppLabel,
playerHasPlaybackStream: playerHasPlaybackStream,
playerKey: playerKey,
trackSignature: trackSignature,
trackChanged: trackChanged,
labelFor: labelFor,
osdMessage: osdMessage
}
}
+523
View File
@@ -0,0 +1,523 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Mpris
import Quickshell.Services.Pipewire
import "MediaModel.js" as MediaModel
Item {
id: root
property var shell: null
property string preferredPlayerKey: ""
property var playerStartedAt: ({})
property var pendingTrackOsd: null
property int playSerial: 0
readonly property var players: Mpris.players ? Mpris.players.values : []
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
readonly property var playbackStreams: {
var list = []
for (var i = 0; i < nodes.length; i++) {
var n = nodes[i]
if (n && n.isStream && isPlaybackStream(n) && n.audio) list.push(n)
}
return list
}
readonly property var sourcePlayers: orderedSourcePlayers()
readonly property var sourceCyclePlayers: orderedCycleSourcePlayers()
readonly property var activePlayer: selectActivePlayer()
readonly property bool hasMedia: activePlayer !== null && (activePlayer.trackTitle || activePlayer.trackArtist)
readonly property string title: activePlayer ? (activePlayer.trackTitle || "") : ""
readonly property string artist: activePlayer ? (activePlayer.trackArtist || "") : ""
readonly property string album: activePlayer && activePlayer.trackAlbum ? activePlayer.trackAlbum : ""
readonly property string artUrl: activePlayer && activePlayer.trackArtUrl ? activePlayer.trackArtUrl : ""
readonly property string identity: activePlayer ? (activePlayer.identity || activePlayer.desktopEntry || "") : ""
function isProxyPlayer(player) {
return MediaModel.isProxyPlayer(player)
}
function hasMetadata(player) {
return MediaModel.hasMetadata(player)
}
function hasTrackMetadata(player) {
return MediaModel.hasTrackMetadata(player)
}
function playerCanControl(player) {
return MediaModel.playerCanControl(player)
}
function canHandleAction(player, action) {
return MediaModel.canHandleAction(player, action)
}
function canCycleSource(player) {
return MediaModel.canCycleSource(player)
}
function nodeProps(node) {
return MediaModel.nodeProps(node)
}
function isPlaybackStream(node) {
return MediaModel.isPlaybackStream(node)
}
function streamLabelKey(label) {
return MediaModel.streamLabelKey(label)
}
function rawStreamLabel(node) {
return MediaModel.rawStreamLabel(node)
}
function playerAppLabel(player) {
return MediaModel.playerAppLabel(player)
}
function playerHasPlaybackStream(player) {
return MediaModel.playerHasPlaybackStream(player, playbackStreams)
}
function playerKey(player) {
return MediaModel.playerKey(player)
}
function playerForKey(key) {
if (!key) return null
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (playerKey(p) === key) return p
}
return null
}
function playerOrder(player, fallback) {
var key = playerKey(player)
var value = key ? playerStartedAt[key] : undefined
return value === undefined ? fallback : value
}
function syncPlayingOrder() {
var next = {}
var alive = {}
var serial = playSerial
for (var i = 0; i < players.length; i++) {
var p = players[i]
var key = playerKey(p)
if (!key) continue
alive[key] = true
if (!p.isPlaying) continue
if (playerStartedAt[key] === undefined) {
serial += 1
next[key] = serial
} else {
next[key] = playerStartedAt[key]
}
}
if (preferredPlayerKey && !alive[preferredPlayerKey]) preferredPlayerKey = ""
playSerial = serial
playerStartedAt = next
}
function orderedSourcePlayers() {
var list = []
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (hasMetadata(p)) list.push(p)
}
list.sort(function(a, b) {
if (!!a.isPlaying !== !!b.isPlaying) return a.isPlaying ? -1 : 1
if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1
if (a.isPlaying && b.isPlaying) {
var orderDelta = playerOrder(a, 1000) - playerOrder(b, 1000)
if (orderDelta !== 0) return orderDelta
}
return labelFor(a).localeCompare(labelFor(b))
})
return list
}
function orderedCycleSourcePlayers() {
var list = []
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (canCycleSource(p)) list.push(p)
}
list.sort(function(a, b) {
if (isProxyPlayer(a) !== isProxyPlayer(b)) return isProxyPlayer(a) ? 1 : -1
return labelFor(a).localeCompare(labelFor(b))
})
return list
}
function oldestPlayingPlayer(requirePlaybackStream) {
var oldest = null
var oldestOrder = 0
var playingProxy = null
var proxyOrder = 0
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (!p) continue
var proxyPlayer = isProxyPlayer(p)
if (p.isPlaying) {
if (requirePlaybackStream && !playerHasPlaybackStream(p)) continue
var order = playerOrder(p, i + 1000)
if (!proxyPlayer && (!oldest || order < oldestOrder)) {
oldest = p
oldestOrder = order
} else if (proxyPlayer && (!playingProxy || order < proxyOrder)) {
playingProxy = p
proxyOrder = order
}
}
}
return oldest || playingProxy || null
}
function selectActivePlayer() {
var preferred = null
var trackPlayer = null
var trackProxy = null
var streamPlayer = null
var streamProxy = null
var controllablePlayer = null
var controllableProxy = null
var identityPlayer = null
var identityProxy = null
for (var i = 0; i < players.length; i++) {
var p = players[i]
if (!p) continue
var proxy = isProxyPlayer(p)
if (preferredPlayerKey && playerKey(p) === preferredPlayerKey && hasMetadata(p)) preferred = p
if (playerHasPlaybackStream(p)) {
if (!proxy && !streamPlayer) streamPlayer = p
else if (proxy && !streamProxy) streamProxy = p
} else if (hasTrackMetadata(p)) {
if (!proxy && !trackPlayer) trackPlayer = p
else if (proxy && !trackProxy) trackProxy = p
} else if (playerCanControl(p)) {
if (!proxy && !controllablePlayer) controllablePlayer = p
else if (proxy && !controllableProxy) controllableProxy = p
} else if (hasMetadata(p)) {
if (!proxy && !identityPlayer) identityPlayer = p
else if (proxy && !identityProxy) identityProxy = p
}
}
if (preferred && preferred.isPlaying) return preferred
var streamCandidate = streamPlayer || streamProxy
var streamPreferred = preferred && playerHasPlaybackStream(preferred) ? preferred : null
return oldestPlayingPlayer(true) || oldestPlayingPlayer(false) || streamPreferred || streamCandidate || preferred || trackPlayer || trackProxy || controllablePlayer || controllableProxy || identityPlayer || identityProxy || null
}
function labelFor(player) {
return MediaModel.labelFor(player)
}
function osdMessage(player, fallback) {
return MediaModel.osdMessage(player, fallback)
}
function trackSignature(player) {
return MediaModel.trackSignature(player)
}
function showOsd(actionLabel, iconName, player) {
if (!shell) return
shell.summon("blob.osd", JSON.stringify({
icon: iconName || "media",
message: osdMessage(player || activePlayer, actionLabel)
}))
}
function scheduleOsd(actionLabel, iconName, player, waitForTrackChange, beforeTrackSignature) {
if (waitForTrackChange) {
pendingTrackOsd = {
actionLabel: actionLabel,
iconName: iconName,
player: player,
playerKey: playerKey(player),
before: beforeTrackSignature,
attempts: 0
}
trackOsdTimer.restart()
} else {
Qt.callLater(function() { root.showOsd(actionLabel, iconName, player) })
}
}
function flushPendingTrackOsd(force) {
var pending = pendingTrackOsd
if (!pending) return
var player = playerForKey(pending.playerKey) || pending.player
if (force || MediaModel.trackChanged(pending.before, player) || pending.attempts >= 10) {
pendingTrackOsd = null
trackOsdTimer.stop()
root.showOsd(pending.actionLabel, pending.iconName, player)
return
}
pending.attempts = pending.attempts + 1
pendingTrackOsd = pending
trackOsdTimer.restart()
}
function selectPlayer(key) {
var player = playerForKey(key)
if (!player || !hasMetadata(player)) return false
preferredPlayerKey = playerKey(player)
return true
}
function playPlayer(player) {
if (!player) return false
if (player.canPlay) {
player.play()
return true
}
return false
}
function pausePlayer(player) {
if (!player) return false
if (player.canPause) {
player.pause()
return true
}
if (player.canTogglePlaying && player.isPlaying) {
player.togglePlaying()
return true
}
return false
}
function switchSource(delta, transferPlayback, showFeedback) {
var list = sourceCyclePlayers
if (!list || list.length === 0) return false
var activeKey = playerKey(activePlayer)
var index = 0
for (var i = 0; i < list.length; i++) {
if (playerKey(list[i]) === activeKey) {
index = i
break
}
}
index = (index + delta + list.length) % list.length
var current = activePlayer
var next = list[index]
var currentWasPlaying = current && current.isPlaying
var currentKey = playerKey(current)
var nextKey = playerKey(next)
preferredPlayerKey = nextKey
if (transferPlayback && currentWasPlaying && next && nextKey !== currentKey) {
var nextWasPlaying = next.isPlaying
var nextStarted = nextWasPlaying || playPlayer(next)
if (nextStarted) pausePlayer(current)
}
if (showFeedback !== false) Qt.callLater(function() {
root.showOsd("Source", "media-source", next)
})
return true
}
function playerForAction(action, targetKey) {
var targeted = playerForKey(targetKey)
if (targeted) return targeted
if (action === "pause" || action === "playPause") {
var oldest = oldestPlayingPlayer(true) || oldestPlayingPlayer(false)
if (oldest) return oldest
}
if (canHandleAction(activePlayer, action)) return activePlayer
var list = sourcePlayers
for (var i = 0; i < list.length; i++) {
if (canHandleAction(list[i], action)) return list[i]
}
return activePlayer
}
function runAction(action, showFeedback, targetKey) {
var player = playerForAction(action, targetKey)
var key = playerKey(player)
var actionLabel = "Play/pause"
var iconName = "media"
var beforeTrackSignature = trackSignature(player)
var handled = false
if (action === "next") {
actionLabel = "Next"
iconName = "media-next"
if (player && player.canGoNext) {
player.next()
handled = true
}
} else if (action === "previous") {
actionLabel = "Previous"
iconName = "media-previous"
if (player && player.canGoPrevious) {
player.previous()
handled = true
}
} else if (action === "play") {
actionLabel = "Play"
iconName = "media-play"
if (player && player.canPlay) {
player.play()
handled = true
} else if (player && player.canTogglePlaying && !player.isPlaying) {
player.togglePlaying()
handled = true
}
} else if (action === "pause") {
actionLabel = "Pause"
iconName = "media-pause"
if (player && player.canPause) {
player.pause()
handled = true
} else if (player && player.canTogglePlaying && player.isPlaying) {
player.togglePlaying()
handled = true
}
} else if (action === "playPause") {
actionLabel = player && player.isPlaying ? "Pause" : "Play"
iconName = player && player.isPlaying ? "media-pause" : "media-play"
if (player && player.isPlaying && player.canPause) {
player.pause()
handled = true
} else if (player && !player.isPlaying && player.canPlay) {
player.play()
handled = true
} else if (player && player.canTogglePlaying) {
player.togglePlaying()
handled = true
}
}
if (handled && key) preferredPlayerKey = key
if (showFeedback !== false)
scheduleOsd(actionLabel, iconName, player, handled && (action === "next" || action === "previous"), beforeTrackSignature)
return handled
}
// Recompute play-order reactively instead of polling every 500ms.
// syncPlayingOrder only depends on the set of players and each player's
// isPlaying state: onPlayersChanged covers players appearing/disappearing,
// and the Instantiator wires isPlayingChanged for each live player.
Component.onCompleted: root.syncPlayingOrder()
onPlayersChanged: root.syncPlayingOrder()
Instantiator {
model: root.players
delegate: Connections {
required property var modelData
target: modelData
function onIsPlayingChanged() { root.syncPlayingOrder() }
}
}
Timer {
id: trackOsdTimer
interval: 120
repeat: false
onTriggered: root.flushPendingTrackOsd(false)
}
PwObjectTracker { objects: root.playbackStreams }
function statusJson() {
var p = activePlayer
return JSON.stringify({
hasPlayer: p !== null,
hasMedia: root.hasMedia,
playing: p ? !!p.isPlaying : false,
identity: p ? (p.identity || "") : "",
desktopEntry: p ? (p.desktopEntry || "") : "",
title: p ? (p.trackTitle || "") : "",
artist: p ? (p.trackArtist || "") : "",
album: p && p.trackAlbum ? p.trackAlbum : "",
artUrl: p && p.trackArtUrl ? p.trackArtUrl : "",
canGoNext: p ? !!p.canGoNext : false,
canGoPrevious: p ? !!p.canGoPrevious : false,
canTogglePlaying: p ? !!p.canTogglePlaying : false
})
}
IpcHandler {
target: "media"
function status(): string {
return root.statusJson()
}
function playPause(): string {
return root.runAction("playPause", true) ? "ok" : "unhandled"
}
function next(): string {
return root.runAction("next", true) ? "ok" : "unhandled"
}
function previous(): string {
return root.runAction("previous", true) ? "ok" : "unhandled"
}
function play(): string {
return root.runAction("play", true) ? "ok" : "unhandled"
}
function pause(): string {
return root.runAction("pause", true) ? "ok" : "unhandled"
}
function sourceNext(): string {
return root.switchSource(1, false, true) ? "ok" : "unhandled"
}
function sourcePrevious(): string {
return root.switchSource(-1, false, true) ? "ok" : "unhandled"
}
function sourceSwitch(): string {
return root.switchSource(1, true, true) ? "ok" : "unhandled"
}
function sourceSwitchPrevious(): string {
return root.switchSource(-1, true, true) ? "ok" : "unhandled"
}
function ping(): string {
return "ok"
}
}
}
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"id": "blob.media",
"name": "Media",
"version": "1.0.0",
"author": "Blob",
"description": "MPRIS media control service",
"kinds": [
"service",
"bar-widget"
],
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml",
"barWidget": "BarWidget.qml"
},
"barWidget": {
"displayName": "Media",
"description": "MPRIS now-playing with playback controls",
"category": "Media",
"allowMultiple": false
}
}
@@ -0,0 +1,20 @@
// Temperatures below the identity point count as night light. Keep in sync
// with bin/blob-toggle-nightlight, which applies the same threshold.
var IDENTITY_TEMPERATURE = 6000
function temperatureFromOutput(output) {
var match = String(output === undefined || output === null ? "" : output).match(/[0-9]+/)
return match ? Number(match[0]) : null
}
function isNightlight(temperature) {
return temperature !== null && temperature !== undefined && temperature < IDENTITY_TEMPERATURE
}
if (typeof module !== "undefined") {
module.exports = {
IDENTITY_TEMPERATURE: IDENTITY_TEMPERATURE,
temperatureFromOutput: temperatureFromOutput,
isNightlight: isNightlight
}
}
@@ -0,0 +1,115 @@
import QtQuick
import Quickshell.Io
import "NightlightModel.js" as NightlightModel
Item {
id: root
// Injected by blob-shell (the first-party service loader).
property var shell: null
// Keep in sync with bin/blob-toggle-nightlight, which sets the same
// temperatures for callers outside the shell (keybindings, menu, ssh).
readonly property int nightTemperature: 4000
readonly property int dayTemperature: 6500
property bool stateLoaded: false
property var temperature: null
readonly property bool enabled: stateLoaded && NightlightModel.isNightlight(temperature)
property bool hasPendingTemperature: false
property int pendingTemperature: 0
function refresh() {
if (!statusProbe.running) statusProbe.running = true
}
function setNightlight(value) {
applyTemperature(value ? nightTemperature : dayTemperature)
}
function toggle() {
setNightlight(!enabled)
}
function applyTemperature(temp) {
root.temperature = temp
root.stateLoaded = true
if (applyProcess.running) {
root.pendingTemperature = temp
root.hasPendingTemperature = true
return
}
runApply(temp)
}
function runApply(temp) {
applyProcess.command = ["bash", "-lc",
"pgrep -x hyprsunset >/dev/null || { setsid uwsm-app -- hyprsunset >/dev/null 2>&1 & sleep 1; }; " +
"hyprctl hyprsunset temperature " + Number(temp)]
applyProcess.running = true
}
Process {
id: statusProbe
command: ["hyprctl", "hyprsunset", "temperature"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
root.temperature = NightlightModel.temperatureFromOutput(text)
root.stateLoaded = true
}
}
onExited: function(exitCode) {
if (exitCode !== 0) {
root.temperature = null
root.stateLoaded = true
}
}
}
Process {
id: applyProcess
onExited: function() {
if (root.hasPendingTemperature) {
root.hasPendingTemperature = false
root.runApply(root.pendingTemperature)
return
}
root.refresh()
}
}
Component.onCompleted: refresh()
IpcHandler {
target: "nightlight"
function status(): string {
return JSON.stringify({ enabled: root.enabled, temperature: root.temperature })
}
function refresh(): void {
root.refresh()
}
function enable(): string {
root.setNightlight(true)
return "enabled"
}
function disable(): string {
root.setNightlight(false)
return "disabled"
}
function toggle(): string {
var enabling = !root.enabled
root.setNightlight(enabling)
return enabling ? "enabled" : "disabled"
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "blob.nightlight",
"name": "Night Light",
"version": "1.0.0",
"author": "Blob",
"description": "Owns the hyprsunset night light temperature for the bar indicator and CLI.",
"kinds": [
"service"
],
"entryPoints": {
"service": "Service.qml"
}
}