diff --git a/default/hypr/bindings/media.lua b/default/hypr/bindings/media.lua index 52779baa..a9961466 100644 --- a/default/hypr/bindings/media.lua +++ b/default/hypr/bindings/media.lua @@ -1,7 +1,7 @@ -- Volume, brightness, keyboard backlight, and touchpad controls. -o.bind("XF86AudioRaiseVolume", "Volume up", "omarchy-audio-output-volume raise", { locked = true, repeating = true }) -o.bind("XF86AudioLowerVolume", "Volume down", "omarchy-audio-output-volume lower", { locked = true, repeating = true }) -o.bind("XF86AudioMute", "Mute", "omarchy-audio-output-volume mute-toggle", { locked = true }) +o.bind("XF86AudioRaiseVolume", "Volume up", { audio = "raise" }, { locked = true, repeating = true }) +o.bind("XF86AudioLowerVolume", "Volume down", { audio = "lower" }, { locked = true, repeating = true }) +o.bind("XF86AudioMute", "Mute", { audio = "mute-toggle" }, { locked = true }) o.bind("XF86AudioMicMute", "Mute microphone", "omarchy-audio-input-mute", { locked = true }) o.bind("XF86MonBrightnessUp", "Brightness up", "omarchy-brightness-display +5%", { locked = true, repeating = true }) o.bind("XF86MonBrightnessDown", "Brightness down", "omarchy-brightness-display 5%-", { locked = true, repeating = true }) @@ -21,12 +21,12 @@ o.bind("ALT + XF86MonBrightnessUp", "Brightness up precise", "omarchy-brightness o.bind("ALT + XF86MonBrightnessDown", "Brightness down precise", "omarchy-brightness-display 1%-", { locked = true, repeating = true }) -- Media controls. -o.bind("XF86AudioNext", "Next track", "omarchy-shell media next", { locked = true }) -o.bind("ALT + XF86AudioPlay", "Next track", "omarchy-shell media next", { locked = true }) -o.bind("XF86AudioPause", "Pause", "omarchy-shell media playPause", { locked = true }) -o.bind("XF86AudioPlay", "Play", "omarchy-shell media playPause", { locked = true }) -o.bind("XF86AudioPrev", "Previous track", "omarchy-shell media previous", { locked = true }) -o.bind("ALT + SHIFT + XF86AudioPlay", "Previous track", "omarchy-shell media previous", { locked = true }) +o.bind("XF86AudioNext", "Next track", { ipc = "media.next" }, { locked = true }) +o.bind("ALT + XF86AudioPlay", "Next track", { ipc = "media.next" }, { locked = true }) +o.bind("XF86AudioPause", "Pause", { ipc = "media.playPause" }, { locked = true }) +o.bind("XF86AudioPlay", "Play", { ipc = "media.playPause" }, { locked = true }) +o.bind("XF86AudioPrev", "Previous track", { ipc = "media.previous" }, { locked = true }) +o.bind("ALT + SHIFT + XF86AudioPlay", "Previous track", { ipc = "media.previous" }, { locked = true }) o.bind("XF86Eject", "Eject media", "eject", { locked = true }) o.bind("SHIFT + XF86AudioMute", "Switch audio output", "omarchy-audio-output-switch", { locked = true }) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 23066f29..836fa5e5 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -22,11 +22,11 @@ o.bind("SUPER + CTRL + BACKSPACE", "Toggle single-window square aspect", "omarch o.bind_toggle("SUPER + CTRL + ALT + F", "Toggle full screen desktop", "fullscreen-desktop") -- xkbcommon names the comma keysym "comma"; the upper-case "COMMA" does not match. -o.bind("SUPER + comma", "Dismiss last notification", "omarchy-shell notifications dismissOne") -o.bind("SUPER + SHIFT + comma", "Dismiss all notifications", "omarchy-shell notifications dismissAll") +o.bind("SUPER + comma", "Dismiss last notification", { ipc = "notifications.dismissOne" }) +o.bind("SUPER + SHIFT + comma", "Dismiss all notifications", { ipc = "notifications.dismissAll" }) o.bind_toggle("SUPER + CTRL + comma", "Toggle silencing notifications", "notification-silencing") -o.bind("SUPER + ALT + comma", "Invoke last notification", "omarchy-shell notifications invokeLast") -o.bind("SUPER + SHIFT + ALT + comma", "Open notification history", "omarchy-shell notifications showHistory") +o.bind("SUPER + ALT + comma", "Invoke last notification", { ipc = "notifications.invokeLast" }) +o.bind("SUPER + SHIFT + ALT + comma", "Open notification history", { ipc = "notifications.showHistory" }) o.bind_toggle("SUPER + CTRL + I", "Toggle locking on idle", "idle") o.bind_toggle("SUPER + CTRL + N", "Toggle nightlight", "nightlight") diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index ca557f52..eaccf957 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -98,6 +98,11 @@ local function command_from(value, description) return shell_dispatcher("menu", value.menu, "omarchy-menu toggle " .. shell_quote(value.menu)) elseif value.panel then return shell_dispatcher("panel", value.panel, "omarchy-shell shell toggle " .. shell_quote(value.panel)) + elseif value.audio then + return shell_dispatcher("audio", value.audio, "omarchy-audio-output-volume " .. shell_quote(value.audio)) + elseif value.ipc then + local target, method = value.ipc:match("^([^.]+)%.(.+)$") + return shell_dispatcher("ipc", value.ipc, "omarchy-shell " .. shell_quote(target) .. " " .. shell_quote(method)) elseif value.focus and value.launch then return o.launch_sole(value.focus, value.launch) elseif value.launch then diff --git a/default/omarchy/shortcuts b/default/omarchy/shortcuts index 3975edc3..27cd0448 100644 --- a/default/omarchy/shortcuts +++ b/default/omarchy/shortcuts @@ -1,9 +1,11 @@ # Hyprland global shortcuts the Omarchy shell registers, one per line: a menu -# route to toggle, or a panel plugin to toggle. A binding written as -# o.bind(keys, description, { menu = "theme" }) or { panel = "omarchy.emojis" } -# dispatches the matching shortcut straight to the shell, with no IPC client to -# spawn per keypress. A route or panel missing here still binds, through the -# omarchy-menu or omarchy-shell command instead. +# route to toggle, a panel plugin to toggle, a volume key, or an argument-free +# IPC call as target.method. A binding written as o.bind(keys, description, +# { menu = "theme" }), { panel = "omarchy.emojis" }, { audio = "raise" } or +# { ipc = "media.next" } dispatches the matching shortcut straight to the shell, +# with no process to spawn per keypress. Anything missing here still binds, +# through the omarchy-menu, omarchy-shell or omarchy-audio-output-volume +# command instead. menu root menu apps menu capture @@ -22,3 +24,13 @@ panel omarchy.monitor panel omarchy.clock panel omarchy.network panel omarchy.power +audio raise +audio lower +audio mute-toggle +ipc media.playPause +ipc media.next +ipc media.previous +ipc notifications.dismissOne +ipc notifications.dismissAll +ipc notifications.invokeLast +ipc notifications.showHistory diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 0122310e..39b8d43b 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -854,7 +854,16 @@ Item { // ---------------------------------------------------- IPC + // Keybindings reach these handlers as Hyprland global shortcuts, run here + // exactly as the IPC call would run them, with no client to spawn. + function runShortcut(method) { + if (typeof ipcHandler[method] !== "function") return false + ipcHandler[method]() + return true + } + IpcHandler { + id: ipcHandler target: "notifications" function dndState(): string { diff --git a/shell/plugins/services/media/MediaModel.js b/shell/plugins/services/media/MediaModel.js index 3edd3a08..c40ccd45 100644 --- a/shell/plugins/services/media/MediaModel.js +++ b/shell/plugins/services/media/MediaModel.js @@ -119,6 +119,20 @@ function osdMessage(player, fallback) { return label || fallback } +// What a volume key does to the output, by omarchy-audio-output-volume's rules: +// raise and lower step 5 and clamp to 0..100 (so a boosted sink drops to 100 +// on raise), unmuting as they go; mute-toggle flips mute and keeps the volume. +function volumeKeyStep(action, percent, muted) { + if (action === "raise") return { percent: Math.min(percent + 5, 100), muted: false } + if (action === "lower") return { percent: Math.max(percent - 5, 0), muted: false } + if (action === "mute-toggle") return { percent: percent, muted: !muted } + return null +} + +function volumeOsdIcon(percent, muted) { + return muted || percent === 0 ? "volume-muted" : "volume-high" +} + if (typeof module !== "undefined") { module.exports = { isProxyPlayer: isProxyPlayer, @@ -137,6 +151,8 @@ if (typeof module !== "undefined") { trackSignature: trackSignature, trackChanged: trackChanged, labelFor: labelFor, - osdMessage: osdMessage + osdMessage: osdMessage, + volumeKeyStep: volumeKeyStep, + volumeOsdIcon: volumeOsdIcon } } diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index 1cfeb9c9..6192a0f9 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -455,6 +455,55 @@ Item { PwObjectTracker { objects: root.playbackStreams } + // ------------------------------------------------------------- volume keys + // + // The volume keys arrive as global shortcuts and change the volume here, + // with no process per press, stepping, clamping, unmuting and debouncing the + // way omarchy-audio-output-volume does, so either path lands on the same + // volume and OSD. That script resolves a DSP sink through to the physical + // sink it feeds on every press, from the live routing. An ALSA sink is its + // own physical sink, so only then do the keys act here; any other default + // sink falls back to the script. + readonly property var defaultSink: Pipewire.defaultAudioSink + readonly property var volumeSink: defaultSink && String(defaultSink.name).indexOf("alsa_output.") === 0 ? defaultSink : null + property double lastMuteToggle: 0 + + // Returns false when the default sink is not one to control here, so the + // caller falls back to the script. + function handleVolumeKey(action) { + var audio = volumeSink && volumeSink.audio + if (!audio) return false + + var step = MediaModel.volumeKeyStep(action, Math.round(audio.volume * 100), audio.muted) + if (!step) return false + + if (action === "mute-toggle") { + // Some keyboards bounce the mute key; the script ignores a second + // toggle within 250ms too. + var now = Date.now() + if (now - lastMuteToggle < 250) return true + lastMuteToggle = now + audio.muted = step.muted + } else { + audio.muted = false + audio.volume = step.percent / 100 + } + + // The payload omarchy-osd builds, from the values just set: the node may + // not report them back before the OSD draws. + shell.summon("omarchy.osd", JSON.stringify({ + icon: MediaModel.volumeOsdIcon(step.percent, step.muted), + message: "", + value: String(step.percent), + progressText: step.percent + "%", + max: "100", + duration: "" + })) + return true + } + + PwObjectTracker { objects: root.defaultSink ? [root.defaultSink] : [] } + function statusJson() { var p = activePlayer return JSON.stringify({ @@ -473,7 +522,16 @@ Item { }) } + // Keybindings reach these handlers as Hyprland global shortcuts, run here + // exactly as the IPC call would run them, with no client to spawn. + function runShortcut(method) { + if (typeof ipcHandler[method] !== "function") return false + ipcHandler[method]() + return true + } + IpcHandler { + id: ipcHandler target: "media" function status(): string { diff --git a/shell/shell.qml b/shell/shell.qml index 21f13e0a..8af1b752 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -1516,9 +1516,9 @@ ShellRoot { // ------------------------------------------------------ global shortcuts // - // Bindings that open a menu route or panel dispatch these through Hyprland, - // so a keypress reaches the shell without spawning an omarchy-shell IPC - // client. The list is shared with default/hypr/helpers.lua, which binds a + // Bindings that open a menu route or panel, or step the volume, dispatch + // these through Hyprland, so a keypress reaches the shell without spawning + // an IPC client or script. The list is shared with default/hypr/helpers.lua, which binds a // route or panel missing from it through the command instead. function parseShortcuts(raw) { @@ -1526,17 +1526,33 @@ ShellRoot { var lines = String(raw || "").split("\n") for (var i = 0; i < lines.length; i++) { var match = /^([A-Za-z]+)\s+(\S+)\s*$/.exec(lines[i]) - if (match && (match[1] === "menu" || match[1] === "panel")) + if (match && ["menu", "panel", "audio", "ipc"].indexOf(match[1]) !== -1) entries.push({ kind: match[1], target: match[2], name: match[1] + "." + match[2] }) } return entries } + // The IPC targets an ipc shortcut may name, and the service that owns each. + readonly property var ipcShortcutServices: ({ media: "omarchy.media", notifications: "omarchy.notifications" }) + function runShortcut(entry) { - if (entry.kind === "menu") + if (entry.kind === "menu") { shell.toggle("omarchy.menu", JSON.stringify({ menu: entry.target })) - else + } else if (entry.kind === "ipc") { + // "media.next" runs the media service's own IPC handler for next. + var dot = entry.target.indexOf(".") + var target = entry.target.slice(0, dot) + var method = entry.target.slice(dot + 1) + var service = shell.serviceFor(shell.ipcShortcutServices[target] || "") + if (!service || !service.runShortcut(method)) + Util.execArgv(["omarchy-shell", target, method]) + } else if (entry.kind === "audio") { + var media = shell.serviceFor("omarchy.media") + if (!media || !media.handleVolumeKey(entry.target)) + Util.execArgv(["omarchy-audio-output-volume", entry.target]) + } else { shell.toggle(entry.target, "{}") + } } FileView { @@ -1554,7 +1570,7 @@ ShellRoot { appid: "omarchy" name: modelData.name - description: modelData.kind === "menu" ? "Toggle the " + modelData.target + " menu" : "Toggle the " + modelData.target + " panel" + description: modelData.kind === "audio" ? "Volume " + modelData.target : (modelData.kind === "ipc" ? "Run " + modelData.target : "Toggle the " + modelData.target + " " + modelData.kind) onPressed: shell.runShortcut(modelData) } } diff --git a/test/shell.d/media-test.sh b/test/shell.d/media-test.sh index e5281306..4019f3a8 100644 --- a/test/shell.d/media-test.sh +++ b/test/shell.d/media-test.sh @@ -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 diff --git a/test/shell.d/shell-shortcuts-test.sh b/test/shell.d/shell-shortcuts-test.sh index 17e211e1..d259ece8 100644 --- a/test/shell.d/shell-shortcuts-test.sh +++ b/test/shell.d/shell-shortcuts-test.sh @@ -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.