From 7b61d36718c419503e46146820e0316e63db6ed2 Mon Sep 17 00:00:00 2001 From: pax Date: Tue, 7 Apr 2026 22:43:49 -0500 Subject: [PATCH] Popout polish + Discord audio fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes accumulated since the v0.2.2 viewport compute swap. Bundled because they all touch preview.py and app.py and the staging surface doesn't split cleanly. 1. Suppress dl_progress flash when popout is open (gui/app.py) The QProgressBar at the bottom of the right splitter was unconditionally show()'d on every post click via _on_post_activated and _on_download_progress, including when the popout was open. With the popout open, the right splitter is set to [0, 0, 1000] and the user typically has the main splitter dragged to give the grid full width — the show() call then forces a layout pass on the right splitter that briefly compresses the main grid before the download finishes (often near-instant for cached files) and hide() fires. Visible flash on every grid click, including clicks on the same post that's already loaded, because download_image still runs against the cache and the show/hide cycle still fires. Three callsites now skip the dl_progress widget entirely when the popout is visible. The status bar message ("Loading #X...") still updates so the user has feedback in the main window. With the popout closed, behavior is unchanged. 2. Cache hyprctl_get_window across one fit call (gui/preview.py) _fit_to_content was calling _hyprctl_get_window three times per fit: - At the top, to determine the floating state - Inside _derive_viewport_for_fit, to read at/size for the viewport derivation - Inside _hyprctl_resize_and_move, to look up the window address for the dispatch Each call is a ~3ms subprocess.run that blocks the Qt event loop. ~9ms of UI freeze per navigation, perceptible as "slow/glitchy" especially on rapid clicking. Added optional `win=None` parameter to _derive_viewport_for_fit and _hyprctl_resize_and_move. _fit_to_content now fetches `win` once at the top and threads it down. Per-fit subprocess count drops from 3 to 1 (~6ms saved per navigation). 3. Discord screen-share audio capture works (gui/preview.py) mpv defaults to ao=pipewire on Linux, which is the native PipeWire audio output. Discord's screen-share-with-audio capture on Linux only enumerates clients connected via the libpulse API; native PipeWire clients are invisible to it. The visible symptom: video plays locally fine but audio is silently dropped from any Discord screen share. Firefox works because Firefox uses libpulse to talk to PipeWire's pulseaudio compat layer. Verified by inspection: with ao=pipewire, mpv's sink-input had `module-stream-restore.id = "sink-input-by-application-id:..."` (the native-pipewire form). With ao=pulse, the same client shows `"sink-input-by-application-name:..."` (the pulseaudio protocol form, identical to Firefox's entry). wireplumber literally renames the restore key to indicate the protocol. Fix is one mpv option. Set `ao="pulse,wasapi,"` in the MPV constructor: comma-separated priority list, mpv tries each in order. `pulse` works on Linux via the pipewire pulseaudio compat layer; `wasapi` is the Windows audio API; trailing empty falls through to the compiled-in default. No platform branch needed in the constructor — mpv silently skips audio outputs that aren't available on the current platform. Also added `audio_client_name="booru-viewer"` so the client shows up in pulseaudio/pipewire introspection tools as booru-viewer rather than the default "mpv Media Player". Sets application.name, application.id, application.icon_name, node.name, and device.description to "booru-viewer". Cosmetic on its own but groups mpv's audio under the same identity as the Qt application. References for the Discord audio bug: https://github.com/mpv-player/mpv/issues/11100 https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux https://bbs.archlinux.org/viewtopic.php?id=307698 --- booru_viewer/gui/app.py | 25 ++++++++++++---- booru_viewer/gui/preview.py | 59 +++++++++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 684d59d..f160b38 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -1275,8 +1275,16 @@ class BooruApp(QMainWindow): ) self._preview.update_save_state(self._is_post_saved(post.id)) self._status.showMessage(f"Loading #{post.id}...") - self._dl_progress.show() - self._dl_progress.setRange(0, 0) + # Skip the dl_progress widget when the popout is open. The user + # is looking at the popout, not the embedded preview area, and + # the right splitter is set to [0, 0, 1000] so the show/hide + # pulse on the dl_progress section forces a layout pass that + # briefly compresses the main grid (visible flash on every + # click, even on the same post since download_image still runs + # against the cache and the show/hide cycle still fires). + if not (self._fullscreen_window and self._fullscreen_window.isVisible()): + self._dl_progress.show() + self._dl_progress.setRange(0, 0) def _progress(downloaded, total): self._signals.download_progress.emit(downloaded, total) @@ -1356,14 +1364,19 @@ class BooruApp(QMainWindow): self._run_async(_prefetch_spiral) def _on_download_progress(self, downloaded: int, total: int) -> None: + # Same suppression as _on_post_activated: when the popout is open, + # don't manipulate the dl_progress widget at all. Status bar still + # gets the byte counts so the user has feedback in the main window. + popout_open = bool(self._fullscreen_window and self._fullscreen_window.isVisible()) if total > 0: - self._dl_progress.setRange(0, total) - self._dl_progress.setValue(downloaded) - self._dl_progress.show() + if not popout_open: + self._dl_progress.setRange(0, total) + self._dl_progress.setValue(downloaded) + self._dl_progress.show() mb = downloaded / (1024 * 1024) total_mb = total / (1024 * 1024) self._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB") - else: + elif not popout_open: self._dl_progress.setRange(0, 0) # indeterminate self._dl_progress.show() diff --git a/booru_viewer/gui/preview.py b/booru_viewer/gui/preview.py index 91542cc..e0a9f44 100644 --- a/booru_viewer/gui/preview.py +++ b/booru_viewer/gui/preview.py @@ -435,7 +435,9 @@ class FullscreenPreview(QMainWindow): return (round(x), round(y), round(w), round(h)) - def _derive_viewport_for_fit(self, floating: bool | None) -> Viewport | None: + def _derive_viewport_for_fit( + self, floating: bool | None, win: dict | None = None + ) -> Viewport | None: """Build a viewport from existing state at the start of a fit call. This is the scoped (recompute-from-current-state) approach. The @@ -452,6 +454,13 @@ class FullscreenPreview(QMainWindow): 3. Navigation fit on non-Hyprland: derive from `self.geometry()` for the same reason. + `win` may be passed in by the caller (typically `_fit_to_content`, + which already fetched it for the floating check) to skip the + otherwise-redundant `_hyprctl_get_window()` subprocess call. + Each `hyprctl clients -j` is ~3ms of subprocess.run on the GUI + thread, and reusing the cached dict cuts the per-fit count from + three calls to one. + Returns None only if every source fails (Hyprland reports no window AND non-Hyprland geometry is invalid), in which case the caller should fall back to the existing pixel-space code path. @@ -465,7 +474,8 @@ class FullscreenPreview(QMainWindow): long_side=float(max(pw, ph)), ) if floating is True: - win = self._hyprctl_get_window() + if win is None: + win = self._hyprctl_get_window() if win and win.get("at") and win.get("size"): wx, wy = win["at"] ww, wh = win["size"] @@ -512,6 +522,12 @@ class FullscreenPreview(QMainWindow): return import os on_hypr = bool(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")) + # Cache the hyprctl window query — `_hyprctl_get_window()` is a + # ~3ms subprocess.run call on the GUI thread, and the helpers + # below would each fire it again if we didn't pass it down. + # Threading the dict through cuts the per-fit subprocess count + # from three to one, eliminating ~6ms of UI freeze per navigation. + win = None if on_hypr: win = self._hyprctl_get_window() if win is None: @@ -531,7 +547,7 @@ class FullscreenPreview(QMainWindow): screen = self.screen() if screen is None: return - viewport = self._derive_viewport_for_fit(floating) + viewport = self._derive_viewport_for_fit(floating, win=win) if viewport is None: # No source for a viewport (Hyprland reported no window AND # Qt geometry is invalid). Bail without dispatching — clearing @@ -543,7 +559,7 @@ class FullscreenPreview(QMainWindow): # Hyprland: hyprctl is the sole authority. Calling self.resize() # here would race with the batch below and produce visible flashing # when the window also has to move. - self._hyprctl_resize_and_move(w, h, x, y) + self._hyprctl_resize_and_move(w, h, x, y, win=win) else: # Non-Hyprland fallback: Qt drives geometry directly. Use # setGeometry with the computed top-left rather than resize() @@ -724,12 +740,20 @@ class FullscreenPreview(QMainWindow): except FileNotFoundError: pass - def _hyprctl_resize_and_move(self, w: int, h: int, x: int, y: int) -> None: + def _hyprctl_resize_and_move( + self, w: int, h: int, x: int, y: int, win: dict | None = None + ) -> None: """Atomically resize and move this window via a single hyprctl batch. Gated by BOORU_VIEWER_NO_HYPR_RULES (resize/move/no_anim parts) and BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK (the keep_aspect_ratio parts) — see core/config.py. + + `win` may be passed in by the caller to skip the + `_hyprctl_get_window()` subprocess call. The address is the only + thing we actually need from it; cutting the per-fit subprocess + count from three to one removes ~6ms of GUI-thread blocking + every time `_fit_to_content` runs. """ import os, subprocess from ..core.config import hypr_rules_enabled, popout_aspect_lock_enabled @@ -739,7 +763,8 @@ class FullscreenPreview(QMainWindow): aspect_on = popout_aspect_lock_enabled() if not rules_on and not aspect_on: return - win = self._hyprctl_get_window() + if win is None: + win = self._hyprctl_get_window() if not win or not win.get("floating"): return addr = win.get("address") @@ -1032,10 +1057,32 @@ class _MpvGLWidget(QWidget): self._proc_addr_fn = None self._frame_ready.connect(self._gl.update) # Create mpv eagerly on the main thread. + # + # `ao=pulse` is critical for Linux Discord screen-share audio + # capture. Discord on Linux only enumerates audio clients via + # the libpulse API; it does not see clients that talk to + # PipeWire natively (which is mpv's default `ao=pipewire`). + # Forcing the pulseaudio output here makes mpv go through + # PipeWire's pulseaudio compatibility layer, which Discord + # picks up the same way it picks up Firefox. Without this, + # videos play locally but the audio is silently dropped from + # any Discord screen share. See: + # https://github.com/mpv-player/mpv/issues/11100 + # https://github.com/edisionnano/Screenshare-with-audio-on-Discord-with-Linux + # On Windows mpv ignores `ao=pulse` and falls through to the + # next entry, so listing `wasapi` second keeps Windows playback + # working without a platform branch here. + # + # `audio_client_name` is the name mpv registers with the audio + # backend. Sets `application.name` and friends so capture tools + # group mpv's audio under the booru-viewer app identity instead + # of the default "mpv Media Player". self._mpv = mpvlib.MPV( vo="libmpv", hwdec="auto", keep_open="yes", + ao="pulse,wasapi,", + audio_client_name="booru-viewer", input_default_bindings=False, input_vo_keyboard=False, osc=False,