Handle volume, media and notification keys in the shell (#13424)

* Step the volume in the shell instead of a script per keypress

Each volume key ran omarchy-audio-output-volume: several pactl calls in
bash, then omarchy-osd delivering the OSD over a qs IPC client, ~165ms
from keypress to OSD, on keys that repeat while held.

The volume up, down and mute keys now dispatch global shortcuts that the
media service handles over PipeWire, stepping, clamping, unmuting and
debouncing by the script's rules and showing the same OSD. It acts only
when the default sink is an ALSA sink, which is its own physical sink.
Any other default, a DSP chain or EasyEffects above all, falls back to
the script, which resolves the physical sink from the live routing on
every press. The precise +1/-1 keys still run the script.

Keypress to OSD drops from ~168ms to ~20ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run media and notification keys in the shell without an IPC client

The media keys and the notification dismiss, invoke and history keys ran
omarchy-shell, starting a qs client for one argument-free IPC call.

A new ipc shortcut kind names such a call as target.method. The shell
hands it to the service that owns the target, which runs its own
IpcHandler function, so the key behaves exactly as the omarchy-shell
call did. A call missing from the list binds through omarchy-shell.

Dismissing a notification with SUPER+comma clears the popup in ~11ms
instead of ~40ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 19:02:59 +02:00
1 parent bf44355a98
commit 804749970a
10 files changed
+200 -28

No files matched your search

+30
View File
@@ -40,4 +40,34 @@ assert(media.trackChanged(trackSignature, { ...track, trackTitle: 'Next song' })
assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first')
assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages')
assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages')
// The in-shell volume keys follow omarchy-audio-output-volume's rules.
assertDeepEqual(media.volumeKeyStep('raise', 43, true), { percent: 48, muted: false }, 'volume raise steps 5 and unmutes')
assertDeepEqual(media.volumeKeyStep('lower', 48, true), { percent: 43, muted: false }, 'volume lower steps 5 and unmutes')
assertDeepEqual(media.volumeKeyStep('raise', 98, false), { percent: 100, muted: false }, 'volume raise clamps at 100')
assertDeepEqual(media.volumeKeyStep('raise', 120, false), { percent: 100, muted: false }, 'volume raise brings a boosted sink back to 100')
assertDeepEqual(media.volumeKeyStep('lower', 120, false), { percent: 115, muted: false }, 'volume lower steps down from a boosted sink')
assertDeepEqual(media.volumeKeyStep('lower', 3, false), { percent: 0, muted: false }, 'volume lower clamps at 0')
assertDeepEqual(media.volumeKeyStep('mute-toggle', 48, false), { percent: 48, muted: true }, 'mute toggle keeps the volume')
assertEqual(media.volumeKeyStep('+1', 48, false), null, 'volume keys leave other steps to the script')
assertEqual(media.volumeOsdIcon(48, false), 'volume-high', 'volume OSD shows the speaker when audible')
assertEqual(media.volumeOsdIcon(48, true), 'volume-muted', 'volume OSD shows muted when muted')
assertEqual(media.volumeOsdIcon(0, false), 'volume-muted', 'volume OSD shows muted at zero')
// Only an ALSA sink is its own physical sink. Any other default sink, a DSP
// chain or EasyEffects above all, needs omarchy-audio-output-sink's live
// resolution on every press, so its keys fall back to the script.
const fs = require('fs')
const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/services/media/Service.qml'), 'utf8')
assert(
serviceQml.includes('readonly property var volumeSink: defaultSink && String(defaultSink.name).indexOf("alsa_output.") === 0 ? defaultSink : null') &&
/function handleVolumeKey\(action\) \{\s*var audio = volumeSink && volumeSink\.audio\s*if \(!audio\) return false/.test(serviceQml) &&
!serviceQml.includes('volumeSinkName'),
'volume keys act in the shell only on an ALSA sink and otherwise defer to the script'
)
const shellQml = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8')
assert(
/if \(!media \|\| !media\.handleVolumeKey\(entry\.target\)\)\s*Util\.execArgv\(\["omarchy-audio-output-volume", entry\.target\]\)/.test(shellQml),
'a volume key the shell declines runs omarchy-audio-output-volume'
)
JS
+28 -2
View File
@@ -55,8 +55,12 @@ o.bind("A", "listed menu", { menu = "theme" })
o.bind("B", "unlisted menu", { menu = "setup.power" })
o.bind("C", "listed panel", { panel = "omarchy.emojis" })
o.bind("D", "unlisted panel", { panel = "omarchy.wifiqr" })
o.bind("E", "listed audio", { audio = "raise" })
o.bind("F", "unlisted audio", { audio = "+1" })
o.bind("G", "listed ipc", { ipc = "media.next" })
o.bind("H", "unlisted ipc", { ipc = "media.sourceNext" })
for _, file in ipairs({ "utilities", "clipboard" }) do
for _, file in ipairs({ "utilities", "clipboard", "media" }) do
dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bindings/" .. file .. ".lua")
end
LUA
@@ -74,6 +78,10 @@ expect_binding $'global\tomarchy:menu.theme\tlisted menu' "a listed menu route b
expect_binding $'exec\tomarchy-menu toggle \'setup.power\'\tunlisted menu' "an unlisted menu route falls back to the command"
expect_binding $'global\tomarchy:panel.omarchy.emojis\tlisted panel' "a listed panel binds its global shortcut"
expect_binding $'exec\tomarchy-shell shell toggle \'omarchy.wifiqr\'\tunlisted panel' "an unlisted panel falls back to the command"
expect_binding $'global\tomarchy:audio.raise\tlisted audio' "a listed volume key binds its global shortcut"
expect_binding $'exec\tomarchy-audio-output-volume \'+1\'\tunlisted audio' "an unlisted volume step falls back to the script"
expect_binding $'global\tomarchy:ipc.media.next\tlisted ipc' "a listed IPC call binds its global shortcut"
expect_binding $'exec\tomarchy-shell \'media\' \'sourceNext\'\tunlisted ipc' "an unlisted IPC call falls back to omarchy-shell"
pass "shell bindings use global shortcuts only for what the shell registers"
# Every default binding that toggles a listed route or panel goes through its
@@ -83,17 +91,35 @@ pass "shell bindings use global shortcuts only for what the shell registers"
expect_binding $'global\tomarchy:menu.root\tOmarchy menu' "SUPER+SPACE opens the menu through its shortcut"
expect_binding $'global\tomarchy:menu.theme\tTheme menu' "the theme menu binding uses its shortcut"
expect_binding $'global\tomarchy:panel.omarchy.clipboard\tClipboard manager' "the clipboard binding uses its shortcut"
expect_binding $'global\tomarchy:audio.raise\tVolume up' "the volume up key steps the volume in the shell"
expect_binding $'global\tomarchy:audio.lower\tVolume down' "the volume down key steps the volume in the shell"
expect_binding $'global\tomarchy:audio.mute-toggle\tMute' "the mute key toggles mute in the shell"
expect_binding $'global\tomarchy:ipc.media.playPause\tPlay' "the play key reaches the media service directly"
expect_binding $'global\tomarchy:ipc.notifications.dismissOne\tDismiss last notification' "dismissing a notification reaches the service directly"
! grep -E $'^exec\tomarchy-shell (media|notifications) ' <<<"$bindings" ||
fail "default media and notification keys reach their services through global shortcuts"
pass "default bindings toggle menus and panels through global shortcuts"
# The shell and the helpers read the same list, in the same format.
while IFS= read -r line; do
[[ -z $line || $line == \#* ]] && continue
[[ $line =~ ^(menu|panel)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line"
[[ $line =~ ^(menu|panel|audio|ipc)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line"
if [[ $line == ipc\ * ]]; then
[[ $line =~ ^ipc\ (media|notifications)\.[A-Za-z]+$ ]] || fail "ipc shortcuts name a mapped target and method: $line"
fi
done <"$shortcuts"
grep -q 'path: shell.omarchyPath + "/default/omarchy/shortcuts"' "$ROOT/shell/shell.qml" ||
fail "the shell registers the shortcuts the helpers bind"
grep -q 'paths.omarchy_path .. "/default/omarchy/shortcuts"' "$ROOT/default/hypr/helpers.lua" ||
fail "the helpers bind the shortcuts the shell registers"
# An ipc shortcut runs the service's own IPC handler, so it behaves exactly as
# the omarchy-shell call it replaces.
for service in services/media notifications; do
grep -Pzq 'function runShortcut\(method\) \{\n if \(typeof ipcHandler\[method\] !== "function"\) return false\n ipcHandler\[method\]\(\)' "$ROOT/shell/plugins/$service/Service.qml" ||
fail "$service runs ipc shortcuts through its IPC handler"
grep -Pzq 'IpcHandler \{\n id: ipcHandler' "$ROOT/shell/plugins/$service/Service.qml" ||
fail "$service names its IPC handler for shortcuts"
done
pass "the shell and the helpers share one shortcut list"
# Every default menu binding target is a real menu route.