Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion default/agents/skills/omarchy/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,7 @@ automatically. If a change somehow fails to apply, force a reload with

Set `idle.screensaver` and `idle.lock` in `~/.config/omarchy/shell.json`,
in seconds since user idle began. Example: "lock after ten minutes" means
setting `idle.lock` to `600`.
setting `idle.lock` to `600`. Firefox-family browsers (Firefox, Zen,
LibreWolf) also inhibit idle while their MPRIS player reports Playing, so
the screensaver stays off during windowed or fullscreen video without
turning Stay Awake on.
2 changes: 1 addition & 1 deletion default/hypr/apps/browser.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
o.window("((google-)?[cC]hrom(e|ium)|[bB]rave-browser|[mM]icrosoft-edge|Vivaldi-stable|helium)", { tag = "+chromium-based-browser" })
o.window("([fF]irefox|zen|librewolf)", { tag = "+firefox-based-browser" })
o.window({ tag = "chromium-based-browser" }, { tag = "-default-opacity", tile = true, opacity = "1.0 0.985" })
o.window({ tag = "firefox-based-browser" }, { tag = "-default-opacity", opacity = "1.0 0.985" })
o.window({ tag = "firefox-based-browser" }, { tag = "-default-opacity", opacity = "1.0 0.985", idle_inhibit = "fullscreen" })

-- Video apps: remove chromium browser tag so they don't get opacity applied.
o.window("(^.+-youtube\\.com__.*$|^.+-app\\.zoom\\.us__wc_home.*$)", { tag = "-chromium-based-browser" })
Expand Down
2 changes: 2 additions & 0 deletions manual/13-toggles-idle-screensaver.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ The Omarchy shell owns idle behavior, and the timings are a top-level `idle` blo

Both numbers are seconds counted from the moment you went idle — not from each other. So with the defaults, the screensaver comes up after two and a half minutes and the lock screen takes over at five minutes, whether or not the screensaver ran. Save the file and the shell picks up the new timings right away.

Playing a video in Firefox, Zen, or LibreWolf also holds idle off for the duration of playback — windowed or fullscreen — and lets the screensaver run again once you pause. Chromium-based browsers already do this themselves. You don't need Stay Awake just to watch YouTube.

If you dismiss the screensaver before the lock deadline, that counts as activity and the pending lock is cancelled. You don't get locked out for glancing at your machine.

To stop locking on idle entirely, `Super + Ctrl + I` — or `omarchy toggle idle` — flips stay awake on, and the coffee cup indicator appears in the bar. That's the one to hit before a long presentation or a build you want to watch. Hit it again to go back to normal. `omarchy toggle idle status` prints the current state as JSON if you need it from a script.
Expand Down
46 changes: 46 additions & 0 deletions shell/plugins/services/idle/IdleModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,47 @@ function eventParts(event, count) {
return String(event && event.data ? event.data : "").split(",")
}

function playerHaystack(player) {
return [
player && player.dbusName,
player && player.desktopEntry,
player && player.identity
].join(" ").toLowerCase()
}

function isProxyPlayer(player) {
return playerHaystack(player).indexOf("playerctld") !== -1
}

function isFirefoxFamilyPlayer(player) {
if (!player || isProxyPlayer(player)) return false

var dbus = String(player.dbusName || "").toLowerCase()
var desktop = String(player.desktopEntry || "").toLowerCase()
var identity = String(player.identity || "").toLowerCase()
var hay = dbus + " " + desktop + " " + identity

if (hay.indexOf("firefox") !== -1) return true
if (hay.indexOf("librewolf") !== -1) return true
if (hay.indexOf("zen-browser") !== -1) return true
if (hay.indexOf("zen browser") !== -1) return true
if (/\.zen(\.|$)/.test(dbus)) return true
if (desktop === "zen" || desktop.indexOf("zen.") === 0) return true
return false
}

function firefoxFamilyIsPlaying(players) {
var list = Array.isArray(players) ? players : []
for (var i = 0; i < list.length; i++) {
if (isFirefoxFamilyPlayer(list[i]) && list[i].isPlaying) return true
}
return false
}

function idleEnabledAfter(stayAwakeStateLoaded, stayAwake, mediaInhibiting) {
return !!stayAwakeStateLoaded && !stayAwake && !mediaInhibiting
}

function screensaverWindowsAfter(windows, address, visible) {
var key = String(address || "")
if (!key) {
Expand Down Expand Up @@ -47,6 +88,11 @@ if (typeof module !== "undefined") {
module.exports = {
secondsFromConfig: secondsFromConfig,
eventParts: eventParts,
playerHaystack: playerHaystack,
isProxyPlayer: isProxyPlayer,
isFirefoxFamilyPlayer: isFirefoxFamilyPlayer,
firefoxFamilyIsPlaying: firefoxFamilyIsPlaying,
idleEnabledAfter: idleEnabledAfter,
screensaverWindowsAfter: screensaverWindowsAfter
}
}
52 changes: 50 additions & 2 deletions shell/plugins/services/idle/Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Mpris
import "IdleModel.js" as IdleModel

Item {
Expand All @@ -22,7 +23,9 @@ Item {
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 var players: Mpris.players ? Mpris.players.values : []
readonly property bool firefoxFamilyPlaying: IdleModel.firefoxFamilyIsPlaying(players)
readonly property bool idleEnabled: IdleModel.idleEnabledAfter(stayAwakeStateLoaded, stayAwake, mediaInhibiting)
readonly property string screensaverClass: "org.omarchy.screensaver"

property bool stayAwake: false
Expand All @@ -35,6 +38,7 @@ Item {
property string lastEventAt: ""
property var screensaverWindows: ({})
property int screensaverWindowCount: 0
property bool mediaInhibiting: false

function secondsFromConfig(value, fallback) {
return IdleModel.secondsFromConfig(value, fallback)
Expand Down Expand Up @@ -183,6 +187,8 @@ Item {
stayAwake: root.stayAwake,
stayAwakeStateLoaded: root.stayAwakeStateLoaded,
stayAwakeStatePath: root.stayAwakeStatePath,
firefoxFamilyPlaying: root.firefoxFamilyPlayingNow(),
mediaInhibiting: root.mediaInhibiting,
idle: idleMonitor.isIdle,
inIdleCycle: root.idledThisCycle,
screensaverStarted: root.screensaverStartedThisCycle,
Expand Down Expand Up @@ -247,6 +253,23 @@ Item {
return applyStayAwake(!value, true, "ipc")
}

function firefoxFamilyPlayingNow() {
return IdleModel.firefoxFamilyIsPlaying(players)
}

function syncMediaInhibit() {
if (firefoxFamilyPlayingNow()) {
mediaReleaseTimer.stop()
if (root.mediaInhibiting) return
root.mediaInhibiting = true
logEvent("media-inhibit", "firefox-family playing")
cancelIdleCycle("media-playing")
return
}

if (root.mediaInhibiting) mediaReleaseTimer.restart()
}

IdleMonitor {
id: idleMonitor
enabled: root.idleEnabled
Expand All @@ -269,6 +292,30 @@ Item {
onTriggered: if (root.idleEnabled && root.idledThisCycle) root.lockSystem("lock-timeout")
}

Timer {
id: mediaReleaseTimer
interval: 2000
repeat: false
onTriggered: {
if (root.firefoxFamilyPlayingNow()) return
root.mediaInhibiting = false
logEvent("media-inhibit", "released")
Qt.callLater(root.handleIdleChanged)
}
}

Instantiator {
model: root.players
delegate: Connections {
required property var modelData
target: modelData
function onIsPlayingChanged() { root.syncMediaInhibit() }
}
}

onFirefoxFamilyPlayingChanged: root.syncMediaInhibit()
onPlayersChanged: root.syncMediaInhibit()

Timer {
id: screensaverLaunchGraceTimer
interval: 3000
Expand Down Expand Up @@ -332,6 +379,7 @@ Item {
Component.onCompleted: {
logEvent("service-ready")
refreshStayAwakeState()
root.syncMediaInhibit()
}

IpcHandler {
Expand All @@ -354,7 +402,7 @@ Item {
}

function toggle(): string {
return root.setIdleEnabled(!root.idleEnabled)
return root.setIdleEnabled(root.stayAwake)
}
}
}
71 changes: 71 additions & 0 deletions test/shell.d/idle-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,56 @@ assertDeepEqual(
{ windows: { a: true }, count: 1 },
'idle leaves screensaver windows unchanged without an address'
)

assertEqual(
idle.isFirefoxFamilyPlayer({ dbusName: 'org.mpris.MediaPlayer2.firefox.instance_1_72', isPlaying: true }),
true,
'idle treats Firefox MPRIS as a firefox-family player'
)
assertEqual(
idle.isFirefoxFamilyPlayer({ dbusName: 'org.mpris.MediaPlayer2.zen', desktopEntry: 'zen', isPlaying: false }),
true,
'idle treats Zen MPRIS as a firefox-family player'
)
assertEqual(
idle.isFirefoxFamilyPlayer({ dbusName: 'org.mpris.MediaPlayer2.spotify', identity: 'Spotify', isPlaying: true }),
false,
'idle does not treat Spotify as a firefox-family player'
)
assertEqual(
idle.isFirefoxFamilyPlayer({ dbusName: 'org.mpris.MediaPlayer2.chromium.instance123', isPlaying: true }),
false,
'idle does not treat Chromium as a firefox-family player'
)
assertEqual(
idle.isFirefoxFamilyPlayer({ dbusName: 'org.mpris.MediaPlayer2.playerctld', desktopEntry: 'playerctld', isPlaying: true }),
false,
'idle ignores playerctld proxies'
)
assertEqual(
idle.isFirefoxFamilyPlayer({ identity: 'Citizen Sleeper', isPlaying: true }),
false,
'idle does not match zen inside unrelated identity strings'
)
assertEqual(
idle.firefoxFamilyIsPlaying([
{ dbusName: 'org.mpris.MediaPlayer2.spotify', isPlaying: true },
{ dbusName: 'org.mpris.MediaPlayer2.firefox.instance1', isPlaying: false }
]),
false,
'idle stays off when Firefox is paused even if other media is playing'
)
assertEqual(
idle.firefoxFamilyIsPlaying([
{ dbusName: 'org.mpris.MediaPlayer2.firefox.instance1', isPlaying: true }
]),
true,
'idle reports firefox-family playback while Firefox is playing'
)
assertEqual(idle.idleEnabledAfter(true, false, false), true, 'idle runs when stay-awake is off and no media inhibit')
assertEqual(idle.idleEnabledAfter(true, true, false), false, 'idle stays off while stay-awake is on')
assertEqual(idle.idleEnabledAfter(true, false, true), false, 'idle stays off while firefox-family media is playing')
assertEqual(idle.idleEnabledAfter(false, false, false), false, 'idle stays off until stay-awake state is loaded')
JS

test_tmp=$(mktemp -d)
Expand All @@ -52,3 +102,24 @@ if rg -q 'omarchy-shell' "$ROOT/bin/omarchy-toggle-idle"; then
fi

pass "Stay Awake toggle persists state without reentrant shell IPC"

rg -F 'IdleModel.idleEnabledAfter(stayAwakeStateLoaded, stayAwake, mediaInhibiting)' \
"$ROOT/shell/plugins/services/idle/Service.qml" >/dev/null ||
fail "idle service gates IdleMonitor on stay-awake and firefox-family media inhibit"

rg -F 'return root.setIdleEnabled(root.stayAwake)' \
"$ROOT/shell/plugins/services/idle/Service.qml" >/dev/null ||
fail "idle toggle flips stayAwake rather than the derived idleEnabled flag"

rg -F 'cancelIdleCycle("media-playing")' \
"$ROOT/shell/plugins/services/idle/Service.qml" >/dev/null ||
fail "idle service cancels an in-flight cycle when firefox-family media starts"

rg -F 'import Quickshell.Services.Mpris' \
"$ROOT/shell/plugins/services/idle/Service.qml" >/dev/null ||
fail "idle service watches MPRIS for firefox-family playback"

rg -F 'idle_inhibit = "fullscreen"' "$ROOT/default/hypr/apps/browser.lua" >/dev/null ||
fail "firefox-based browsers inhibit idle while fullscreen"

pass "idle service wiring holds firefox-family playback off the screensaver"