Skip to content

feat(volume): Focused window volume in Hyprland - #163

Merged
nvdweem merged 5 commits into
nvdweem:mainfrom
madebynoxc:feat/linux-hyprland-focus
Sep 20, 2026
Merged

nvdweem merged 5 commits into
nvdweem:mainfrom
madebynoxc:feat/linux-hyprland-focus

Conversation

@madebynoxc

Copy link
Copy Markdown
Contributor

Add support for focused window volume using Hyprland's hyprctl. While not a full Wayland support, some users who also use this compositor might benefit.

Hyprland exposes the focused window over its own IPC socket, so hyprctl -j activewindow gives the pid, class and title in one call which are the same three identifiers the chained xdotool subcommands produce. Adds it as a third Tool in LinuxProcessHelper, tried first and gated on the session so it costs nothing on KDE/X11/GNOME. Hyprctl ships with the compositor.

Additional bugs that I encoundered and Claude investigated/fixed (have to be fixed for this feature to work properly):

  • fix(linux): don't fire session events while holding the sessions lock. initSessions fired CDI events inside synchronized (sessions). MuteColorService takes its own monitor then calls getAllSessions, so the two lock in opposite orders and deadlock; the command handler queues behind the same lock, freezing every control. Collect under the lock, fire after.
  • fix(linux): don't let force-volume fight a dial mid-sweep. setVolumeNoTrigger only suppresses self-triggering while each write event arrives before the next write goes out. A dial sweep breaks that, so force-volume re-asserts another dial's position mid-turn. Track the volumes we wrote per session for 750ms and treat those CHANGED events as ours.

The feature and fixes were planned out and implemented by Claude Code. All tests ran and passed. I did the manual code review and end user testing on multiple apps.

madebynoxc and others added 5 commits September 18, 2026 21:13
Focus volume and every other focused-app feature needed KDE Plasma
(kdotool) or X11 (xdotool). Hyprland serves neither, so on it the features
were dead and the app warned once per launch that the focused window could
not be resolved.

Hyprland does expose the focused window, over its own IPC socket rather
than a Wayland protocol: `hyprctl -j activewindow` returns the pid, class
and title in one call -- the same three identifiers the chained xdotool
subcommands produce, including the class/title pair that Steam and Proton
streams are matched on. Add it as a third tool, tried first and gated on
the session so it costs nothing elsewhere, and reach it through the same
flatpak-spawn shim as pactl.

This says nothing about wlroots compositors in general; the API is
Hyprland's own. GNOME still has none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5365SdDpEexv8TQyTYc3w
initSessions fired its ADDED/REMOVED/CHANGED events from inside
`synchronized (sessions)`. An observer is foreign code, and
MuteColorService takes its own monitor and then calls back into
getAllSessions, which wants `sessions` -- so the two acquire the same
pair in opposite orders and deadlock. The JVM names it outright:

  "PulseAudio change listener": holds the sessions HashSet,
      waits for the MuteColorService monitor (recomputeAll)
  "executor-thread-6":          holds the MuteColorService monitor,
      waits for the sessions HashSet (getAllSessions)

Every dial then stops responding, not just the focused-app ones: the
command handler blocks on `sessions` behind the same cycle.

The MuteColorService side is reached from LinuxWindowFocusPoller, which
fires only when the focused application *changes*. Where the focused
window never resolves, getFocusApplication() is always null, the poller
never fires, and the cycle is unreachable -- which is why this stayed
hidden on any desktop without focus support.

Collect the events under the lock and fire them after releasing it,
which is what the initial-state notification a few lines above already
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5365SdDpEexv8TQyTYc3w
setProcessVolume/setFocusVolume call setVolumeNoTrigger before writing, so
the cached session matches what pactl is about to report and the CHANGED
diff sees nothing. That holds only while each write's event arrives before
the next write goes out. Turning a dial breaks it: writes leave every few
milliseconds and their events land tens of milliseconds later, so the diff
compares an already-superseded cached value against a newer one, calls it a
change, and force-volume re-asserts the position of whichever App-volume
dial also targets that process. With one app on both an App-volume dial and
the focus dial the two then alternate for the length of the sweep, which is
plainly audible.

Track the volumes we wrote per session index for a short window and treat a
CHANGED whose value is one of them as ours. A window rather than a single
expected value because a sweep has many writes in flight and their events
can arrive out of order; keyed on the value so a genuine external change to
some other volume is still reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5365SdDpEexv8TQyTYc3w
Conflicts, all small:
- LinuxProcessHelper: upstream's Tool.available now takes the ProcessHelper
  (a9f6d70); kept that signature with the Hyprland session gate in front.
- LinuxProcessHelperTest: both sides appended a test; kept both.
- CHANGELOG: both sides added to the unreleased section; kept both, and
  added the two entries the bug fixes were missing.

initSessions needed care rather than a textual resolution: upstream moved
it onto an Optional from getSessionsFromCmd with an early return, inside
the same synchronized block the deadlock fix empties. The merged method
keeps both -- the early return, and collecting the events under the lock
to fire them after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5365SdDpEexv8TQyTYc3w
hyprctl answers `{}` whenever focus is on no window at all - an empty
workspace, or a layer surface such as a launcher, a notification or the
lock screen. That empty result went down the same path as a tool that
could not answer, so it raised warnFocusUnavailable() and, with it, the
one-shot "focused-app control unavailable" desktop notification. One
knob tick at such a moment left a Hyprland user permanently told that
focus volume cannot work here, while it works fine.

Read hyprctl's reply as the three-valued answer it is (a window /
nothing focused / unreadable) and warn only on the last. The Settings
"detect focused app" toast says so plainly too, instead of quoting
hyprctl as if it had failed. Only hyprctl gets this: a silent
kdotool/xdotool may equally mean the desktop refused.

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

nvdweem commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Merged, thanks.

Tested on WSL (Linux on Windows), seemed to work. Moved the changelog bullets — they were under an
already-released heading. Fixed the flatpak install, the test indicated it wouldn't have worked
there: flatpak-spawn --host doesn't pass HYPRLAND_INSTANCE_SIGNATURE through.

I'll trigger a snapshot build for the 2.1 once the changes land.

@nvdweem
nvdweem merged commit 4698948 into nvdweem:main Sep 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants