Privacy screen: resume video on un-hide, popout uses in-place overlay

Two related improvements to the Ctrl+P privacy screen flow.

1. Resume video on un-hide

Pre-fix: Ctrl+P paused any playing video in the embedded preview and
the popout, but the second Ctrl+P only hid the privacy overlay — the
videos stayed paused. The user had to manually click Play to resume.

Fix: in _toggle_privacy's privacy-off branch, mirror the privacy-on
pause logic with resume() calls on the embedded preview's video player
and the popout's video. Unconditional resume — if the user manually
paused before Ctrl+P, the auto-resume on un-hide is a tiny annoyance,
but the common case (privacy hides → user comes back → video should
be playing again) wins.

2. Popout privacy uses an in-place overlay instead of hide()

Pre-fix attempt: privacy-on called self._fullscreen_window.hide() and
privacy-off called .show(). On Wayland (Hyprland) the hide→show round
trip drops the window's position because the compositor unmaps the
window on hide and remaps it at the default tile position on show.
A first attempt at restoring the position via a deferred
hyprctl_resize_and_move dispatch in privacy_show didn't take — by
the time the dispatch landed, the window had already been re-tiled
and the move was gated by `if not win.get("floating"): return`.

Cleaner fix: don't hide the popout window at all. FullscreenPreview
gains its own _privacy_overlay (a black QWidget child of central,
parallel to the existing toolbar / controls bar children) that
privacy_hide raises over the media stack. The popout window stays
mapped, position is preserved automatically because nothing moves,
and the overlay covers the content visually.

privacy_hide / privacy_show methods live in FullscreenPreview, not
in main_window — popout-internal state belongs to the popout module.
_toggle_privacy in main_window just calls them. This also makes
adding more popout-side privacy state later (e.g. fullscreen save)
a one-method change inside the popout class.

Also added a _popout_was_visible flag in BooruApp._toggle_privacy so
privacy-off only restores the popout if it was actually visible at
privacy-on time. Without the gate, privacy-off would inappropriately
re-show a popout the user had closed before triggering privacy.

Verified manually:
  - popout open + drag to non-default pos + Ctrl+P + Ctrl+P → popout
    still at the dragged position, content visible again
  - popout open + video playing + Ctrl+P + Ctrl+P → video resumes
  - popout closed + Ctrl+P + Ctrl+P → popout stays closed
  - embedded preview video + Ctrl+P + Ctrl+P → resumes
  - Ctrl+P with no video on screen → no errors
This commit is contained in:
pax 2026-04-08 16:30:37 -05:00
parent 92c1824720
commit 553e31075d
2 changed files with 61 additions and 7 deletions

View File

@ -2929,6 +2929,11 @@ class BooruApp(QMainWindow):
self._privacy_overlay = QWidget(self) self._privacy_overlay = QWidget(self)
self._privacy_overlay.setStyleSheet("background: black;") self._privacy_overlay.setStyleSheet("background: black;")
self._privacy_overlay.hide() self._privacy_overlay.hide()
# Tracks whether the popout was visible at privacy-on time
# so privacy-off only restores it if it was actually up
# before. Without the gate, privacy-off would re-show a
# popout that the user closed before triggering privacy.
self._popout_was_visible = False
self._privacy_on = not self._privacy_on self._privacy_on = not self._privacy_on
if self._privacy_on: if self._privacy_on:
@ -2939,15 +2944,26 @@ class BooruApp(QMainWindow):
# Pause preview video # Pause preview video
if self._preview._stack.currentIndex() == 1: if self._preview._stack.currentIndex() == 1:
self._preview._video_player.pause() self._preview._video_player.pause()
# Hide and pause popout # Delegate popout hide-and-pause to FullscreenPreview so it
if self._fullscreen_window and self._fullscreen_window.isVisible(): # can capture its own geometry for restore.
if self._fullscreen_window._stack.currentIndex() == 1: self._popout_was_visible = bool(
self._fullscreen_window._video.pause() self._fullscreen_window and self._fullscreen_window.isVisible()
self._fullscreen_window.hide() )
if self._popout_was_visible:
self._fullscreen_window.privacy_hide()
else: else:
self._privacy_overlay.hide() self._privacy_overlay.hide()
if self._fullscreen_window: # Resume embedded preview video — unconditional resume, the
self._fullscreen_window.show() # common case (privacy hides → user comes back → video should
# be playing again) wins over the manually-paused edge case.
if self._preview._stack.currentIndex() == 1:
self._preview._video_player.resume()
# Restore the popout via its own privacy_show method, which
# also re-dispatches the captured geometry to Hyprland (Qt
# show() alone doesn't preserve position on Wayland) and
# resumes its video.
if self._popout_was_visible and self._fullscreen_window:
self._fullscreen_window.privacy_show()
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
super().resizeEvent(event) super().resizeEvent(event)

View File

@ -172,6 +172,17 @@ class FullscreenPreview(QMainWindow):
self._video._controls_bar.raise_() self._video._controls_bar.raise_()
self._toolbar.raise_() self._toolbar.raise_()
# Privacy overlay — black QWidget child of central, raised over
# the media stack on privacy_hide. Lives inside the popout
# itself instead of forcing main_window to hide() the popout
# window — Wayland's hide→show round-trip drops position because
# the compositor unmaps and remaps, and Hyprland may re-tile the
# remap depending on window rules. Keeping the popout mapped
# with an in-place overlay sidesteps both issues.
self._privacy_overlay = QWidget(central)
self._privacy_overlay.setStyleSheet("background: black;")
self._privacy_overlay.hide()
# Auto-hide timer for overlay UI # Auto-hide timer for overlay UI
self._ui_visible = True self._ui_visible = True
self._hide_timer = QTimer(self) self._hide_timer = QTimer(self)
@ -904,6 +915,30 @@ class FullscreenPreview(QMainWindow):
except FileNotFoundError: except FileNotFoundError:
pass pass
def privacy_hide(self) -> None:
"""Cover the popout's content with a black overlay for privacy.
The popout window itself is NOT hidden Wayland's hide→show
round-trip drops position because the compositor unmaps and
remaps the window, and Hyprland may re-tile the remapped window
depending on its rules. Instead we raise an in-place black
QWidget overlay over the central widget. The window stays
mapped, position is preserved automatically, video is paused.
"""
if self._stack.currentIndex() == 1:
self._video.pause()
central = self.centralWidget()
if central is not None:
self._privacy_overlay.setGeometry(0, 0, central.width(), central.height())
self._privacy_overlay.raise_()
self._privacy_overlay.show()
def privacy_show(self) -> None:
"""Lift the black overlay and resume video. Counterpart to privacy_hide."""
self._privacy_overlay.hide()
if self._stack.currentIndex() == 1:
self._video.resume()
def _enter_fullscreen(self) -> None: def _enter_fullscreen(self) -> None:
"""Enter fullscreen — capture windowed geometry first so F11 back can restore it. """Enter fullscreen — capture windowed geometry first so F11 back can restore it.
@ -996,6 +1031,9 @@ class FullscreenPreview(QMainWindow):
self._toolbar.setGeometry(0, 0, w, tb_h) self._toolbar.setGeometry(0, 0, w, tb_h)
ctrl_h = self._video._controls_bar.sizeHint().height() ctrl_h = self._video._controls_bar.sizeHint().height()
self._video._controls_bar.setGeometry(0, h - ctrl_h, w, ctrl_h) self._video._controls_bar.setGeometry(0, h - ctrl_h, w, ctrl_h)
# Privacy overlay covers the entire central widget when active.
if self._privacy_overlay.isVisible():
self._privacy_overlay.setGeometry(0, 0, w, h)
# Capture corner-resize into the persistent viewport so the # Capture corner-resize into the persistent viewport so the
# long_side the user chose survives subsequent navigations. # long_side the user chose survives subsequent navigations.
# #