From 8456d0de79216c79c733fe87c450481cba18fa9c Mon Sep 17 00:00:00 2001 From: Michiel van der Meer Date: Sat, 29 Aug 2026 17:37:19 +0200 Subject: [PATCH] Keep the screensaver off while Firefox-family browsers play media The idle service only honors Wayland inhibitors. Firefox, Zen, and LibreWolf request inhibit over D-Bus, which nothing owns since hypridle was replaced, so the screensaver fires over playing video. Watch those browsers' MPRIS Playing state and hold idle off for the duration of playback. Fullscreen windows also get a Hyprland idle_inhibit rule for videos that never publish MPRIS. --- default/agents/skills/omarchy/plugins.md | 5 +- default/hypr/apps/browser.lua | 2 +- manual/13-toggles-idle-screensaver.md | 2 + shell/plugins/services/idle/IdleModel.js | 46 +++++++++++++++ shell/plugins/services/idle/Service.qml | 52 ++++++++++++++++- test/shell.d/idle-test.sh | 71 ++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 4 deletions(-) diff --git a/default/agents/skills/omarchy/plugins.md b/default/agents/skills/omarchy/plugins.md index 6e3ab0695ea..0c86d83476f 100644 --- a/default/agents/skills/omarchy/plugins.md +++ b/default/agents/skills/omarchy/plugins.md @@ -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. diff --git a/default/hypr/apps/browser.lua b/default/hypr/apps/browser.lua index 22df557753f..bc43d4597ab 100644 --- a/default/hypr/apps/browser.lua +++ b/default/hypr/apps/browser.lua @@ -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" }) diff --git a/manual/13-toggles-idle-screensaver.md b/manual/13-toggles-idle-screensaver.md index 2effdac1e69..27d6198c77a 100644 --- a/manual/13-toggles-idle-screensaver.md +++ b/manual/13-toggles-idle-screensaver.md @@ -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. diff --git a/shell/plugins/services/idle/IdleModel.js b/shell/plugins/services/idle/IdleModel.js index 8e2315d0375..dd52d4c7c00 100644 --- a/shell/plugins/services/idle/IdleModel.js +++ b/shell/plugins/services/idle/IdleModel.js @@ -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) { @@ -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 } } diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 488f29611c3..75b2d717ada 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -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 { @@ -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 @@ -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) @@ -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, @@ -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 @@ -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 @@ -332,6 +379,7 @@ Item { Component.onCompleted: { logEvent("service-ready") refreshStayAwakeState() + root.syncMediaInhibit() } IpcHandler { @@ -354,7 +402,7 @@ Item { } function toggle(): string { - return root.setIdleEnabled(!root.idleEnabled) + return root.setIdleEnabled(root.stayAwake) } } } diff --git a/test/shell.d/idle-test.sh b/test/shell.d/idle-test.sh index 62a19c2ce03..35fceba0a5d 100644 --- a/test/shell.d/idle-test.sh +++ b/test/shell.d/idle-test.sh @@ -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) @@ -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"