Popout polish + Discord audio fix
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
This commit is contained in:
parent
5a44593a6a
commit
7b61d36718
@ -1275,6 +1275,14 @@ class BooruApp(QMainWindow):
|
|||||||
)
|
)
|
||||||
self._preview.update_save_state(self._is_post_saved(post.id))
|
self._preview.update_save_state(self._is_post_saved(post.id))
|
||||||
self._status.showMessage(f"Loading #{post.id}...")
|
self._status.showMessage(f"Loading #{post.id}...")
|
||||||
|
# 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.show()
|
||||||
self._dl_progress.setRange(0, 0)
|
self._dl_progress.setRange(0, 0)
|
||||||
|
|
||||||
@ -1356,14 +1364,19 @@ class BooruApp(QMainWindow):
|
|||||||
self._run_async(_prefetch_spiral)
|
self._run_async(_prefetch_spiral)
|
||||||
|
|
||||||
def _on_download_progress(self, downloaded: int, total: int) -> None:
|
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:
|
if total > 0:
|
||||||
|
if not popout_open:
|
||||||
self._dl_progress.setRange(0, total)
|
self._dl_progress.setRange(0, total)
|
||||||
self._dl_progress.setValue(downloaded)
|
self._dl_progress.setValue(downloaded)
|
||||||
self._dl_progress.show()
|
self._dl_progress.show()
|
||||||
mb = downloaded / (1024 * 1024)
|
mb = downloaded / (1024 * 1024)
|
||||||
total_mb = total / (1024 * 1024)
|
total_mb = total / (1024 * 1024)
|
||||||
self._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB")
|
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.setRange(0, 0) # indeterminate
|
||||||
self._dl_progress.show()
|
self._dl_progress.show()
|
||||||
|
|
||||||
|
|||||||
@ -435,7 +435,9 @@ class FullscreenPreview(QMainWindow):
|
|||||||
|
|
||||||
return (round(x), round(y), round(w), round(h))
|
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.
|
"""Build a viewport from existing state at the start of a fit call.
|
||||||
|
|
||||||
This is the scoped (recompute-from-current-state) approach. The
|
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()`
|
3. Navigation fit on non-Hyprland: derive from `self.geometry()`
|
||||||
for the same reason.
|
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
|
Returns None only if every source fails (Hyprland reports no
|
||||||
window AND non-Hyprland geometry is invalid), in which case the
|
window AND non-Hyprland geometry is invalid), in which case the
|
||||||
caller should fall back to the existing pixel-space code path.
|
caller should fall back to the existing pixel-space code path.
|
||||||
@ -465,6 +474,7 @@ class FullscreenPreview(QMainWindow):
|
|||||||
long_side=float(max(pw, ph)),
|
long_side=float(max(pw, ph)),
|
||||||
)
|
)
|
||||||
if floating is True:
|
if floating is True:
|
||||||
|
if win is None:
|
||||||
win = self._hyprctl_get_window()
|
win = self._hyprctl_get_window()
|
||||||
if win and win.get("at") and win.get("size"):
|
if win and win.get("at") and win.get("size"):
|
||||||
wx, wy = win["at"]
|
wx, wy = win["at"]
|
||||||
@ -512,6 +522,12 @@ class FullscreenPreview(QMainWindow):
|
|||||||
return
|
return
|
||||||
import os
|
import os
|
||||||
on_hypr = bool(os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"))
|
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:
|
if on_hypr:
|
||||||
win = self._hyprctl_get_window()
|
win = self._hyprctl_get_window()
|
||||||
if win is None:
|
if win is None:
|
||||||
@ -531,7 +547,7 @@ class FullscreenPreview(QMainWindow):
|
|||||||
screen = self.screen()
|
screen = self.screen()
|
||||||
if screen is None:
|
if screen is None:
|
||||||
return
|
return
|
||||||
viewport = self._derive_viewport_for_fit(floating)
|
viewport = self._derive_viewport_for_fit(floating, win=win)
|
||||||
if viewport is None:
|
if viewport is None:
|
||||||
# No source for a viewport (Hyprland reported no window AND
|
# No source for a viewport (Hyprland reported no window AND
|
||||||
# Qt geometry is invalid). Bail without dispatching — clearing
|
# Qt geometry is invalid). Bail without dispatching — clearing
|
||||||
@ -543,7 +559,7 @@ class FullscreenPreview(QMainWindow):
|
|||||||
# Hyprland: hyprctl is the sole authority. Calling self.resize()
|
# Hyprland: hyprctl is the sole authority. Calling self.resize()
|
||||||
# here would race with the batch below and produce visible flashing
|
# here would race with the batch below and produce visible flashing
|
||||||
# when the window also has to move.
|
# 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:
|
else:
|
||||||
# Non-Hyprland fallback: Qt drives geometry directly. Use
|
# Non-Hyprland fallback: Qt drives geometry directly. Use
|
||||||
# setGeometry with the computed top-left rather than resize()
|
# setGeometry with the computed top-left rather than resize()
|
||||||
@ -724,12 +740,20 @@ class FullscreenPreview(QMainWindow):
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
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.
|
"""Atomically resize and move this window via a single hyprctl batch.
|
||||||
|
|
||||||
Gated by BOORU_VIEWER_NO_HYPR_RULES (resize/move/no_anim parts) and
|
Gated by BOORU_VIEWER_NO_HYPR_RULES (resize/move/no_anim parts) and
|
||||||
BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK (the keep_aspect_ratio parts) —
|
BOORU_VIEWER_NO_POPOUT_ASPECT_LOCK (the keep_aspect_ratio parts) —
|
||||||
see core/config.py.
|
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
|
import os, subprocess
|
||||||
from ..core.config import hypr_rules_enabled, popout_aspect_lock_enabled
|
from ..core.config import hypr_rules_enabled, popout_aspect_lock_enabled
|
||||||
@ -739,6 +763,7 @@ class FullscreenPreview(QMainWindow):
|
|||||||
aspect_on = popout_aspect_lock_enabled()
|
aspect_on = popout_aspect_lock_enabled()
|
||||||
if not rules_on and not aspect_on:
|
if not rules_on and not aspect_on:
|
||||||
return
|
return
|
||||||
|
if win is None:
|
||||||
win = self._hyprctl_get_window()
|
win = self._hyprctl_get_window()
|
||||||
if not win or not win.get("floating"):
|
if not win or not win.get("floating"):
|
||||||
return
|
return
|
||||||
@ -1032,10 +1057,32 @@ class _MpvGLWidget(QWidget):
|
|||||||
self._proc_addr_fn = None
|
self._proc_addr_fn = None
|
||||||
self._frame_ready.connect(self._gl.update)
|
self._frame_ready.connect(self._gl.update)
|
||||||
# Create mpv eagerly on the main thread.
|
# 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(
|
self._mpv = mpvlib.MPV(
|
||||||
vo="libmpv",
|
vo="libmpv",
|
||||||
hwdec="auto",
|
hwdec="auto",
|
||||||
keep_open="yes",
|
keep_open="yes",
|
||||||
|
ao="pulse,wasapi,",
|
||||||
|
audio_client_name="booru-viewer",
|
||||||
input_default_bindings=False,
|
input_default_bindings=False,
|
||||||
input_vo_keyboard=False,
|
input_vo_keyboard=False,
|
||||||
osc=False,
|
osc=False,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user