Fixed 40px hover zone was too small on high-DPI monitors. Now scales to ~10% of window height with a 60px floor.
1749 lines
79 KiB
Python
1749 lines
79 KiB
Python
"""Popout fullscreen media viewer window."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import Qt, QEventLoop, QRect, QTimer, Signal
|
|
from PySide6.QtGui import QPixmap
|
|
from PySide6.QtWidgets import (
|
|
QHBoxLayout, QInputDialog, QLabel, QMainWindow, QMenu, QPushButton,
|
|
QStackedWidget, QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ..media.constants import _is_video
|
|
from ..media.image_viewer import ImageViewer
|
|
from ..media.video_player import VideoPlayer
|
|
from . import hyprland
|
|
from .effects import (
|
|
ApplyLoopMode,
|
|
ApplyMute,
|
|
ApplyVolume,
|
|
EmitClosed,
|
|
EmitNavigate,
|
|
EmitPlayNextRequested,
|
|
EnterFullscreen,
|
|
ExitFullscreen,
|
|
FitWindowToContent,
|
|
LoadImage,
|
|
LoadVideo,
|
|
SeekVideoTo,
|
|
StopMedia,
|
|
TogglePlay,
|
|
)
|
|
from .state import (
|
|
CloseRequested,
|
|
ContentArrived,
|
|
FullscreenToggled,
|
|
LoopMode,
|
|
LoopModeSet,
|
|
MediaKind,
|
|
MuteToggleRequested,
|
|
NavigateRequested,
|
|
Open,
|
|
SeekCompleted,
|
|
SeekRequested,
|
|
State,
|
|
StateMachine,
|
|
TogglePlayRequested,
|
|
VideoEofReached,
|
|
VideoSizeKnown,
|
|
VideoStarted,
|
|
VolumeSet,
|
|
WindowMoved,
|
|
WindowResized,
|
|
)
|
|
from .viewport import Viewport, _DRIFT_TOLERANCE, anchor_point
|
|
|
|
|
|
# Adapter logger — separate from the popout's main `booru` logger so
|
|
# the dispatch trace can be filtered independently. Format: every
|
|
# dispatch call logs at DEBUG with the event name, state transition,
|
|
# and effect list. The user filters by `POPOUT_FSM` substring to see
|
|
# only the state machine activity during the manual sweep.
|
|
#
|
|
# The booru logger (parent) only has the in-app QTextEdit LogHandler
|
|
# attached (see main_window.py:436-440), so propagating to it routes
|
|
# the dispatch trace to the Ctrl+L log panel — useful but invisible
|
|
# from the shell. We additionally attach a stderr StreamHandler to
|
|
# the adapter logger so `python -m booru_viewer.main_gui 2>&1 |
|
|
# grep POPOUT_FSM` works during the commit-14a verification gate.
|
|
# The handler is tagged with a sentinel attribute so re-imports
|
|
# don't stack duplicates.
|
|
import sys as _sys
|
|
_fsm_log = logging.getLogger("booru.popout.adapter")
|
|
_fsm_log.setLevel(logging.DEBUG)
|
|
if not any(
|
|
getattr(h, "_is_popout_fsm_stderr", False) for h in _fsm_log.handlers
|
|
):
|
|
_stderr_handler = logging.StreamHandler(_sys.stderr)
|
|
_stderr_handler.setLevel(logging.DEBUG)
|
|
_stderr_handler.setFormatter(
|
|
logging.Formatter(
|
|
"[%(asctime)s.%(msecs)03d] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
)
|
|
_stderr_handler._is_popout_fsm_stderr = True
|
|
_fsm_log.addHandler(_stderr_handler)
|
|
|
|
|
|
## Overlay styling for the popout's translucent toolbar / controls bar
|
|
## now lives in the bundled themes (themes/*.qss). The widgets get their
|
|
## object names set in code (FullscreenPreview / VideoPlayer) so theme QSS
|
|
## rules can target them via #_slideshow_toolbar / #_slideshow_controls /
|
|
## #_preview_controls. Users can override the look by editing the
|
|
## overlay_bg slot in their @palette block, or by adding more specific
|
|
## QSS rules in their custom.qss.
|
|
|
|
|
|
class FullscreenPreview(QMainWindow):
|
|
"""Fullscreen media viewer with navigation — images, GIFs, and video."""
|
|
|
|
navigate = Signal(int) # direction: -1/+1 for left/right, -cols/+cols for up/down
|
|
play_next_requested = Signal() # video ended in "Next" mode (wrap-aware)
|
|
bookmark_requested = Signal()
|
|
# Bookmark-as: emitted when the popout's Bookmark button submenu picks
|
|
# a bookmark folder. Empty string = Unfiled. Mirrors ImagePreview's
|
|
# signal so app.py routes both through _bookmark_to_folder_from_preview.
|
|
bookmark_to_folder = Signal(str)
|
|
# Save-to-library: same signal pair as ImagePreview so app.py reuses
|
|
# _save_from_preview / _unsave_from_preview for both. Empty string =
|
|
# Unfiled (root of saved_dir).
|
|
save_to_folder = Signal(str)
|
|
unsave_requested = Signal()
|
|
toggle_save_requested = Signal()
|
|
blacklist_tag_requested = Signal(str) # tag name
|
|
blacklist_post_requested = Signal()
|
|
open_in_default = Signal()
|
|
open_in_browser = Signal()
|
|
privacy_requested = Signal()
|
|
closed = Signal()
|
|
|
|
def __init__(self, grid_cols: int = 3, show_actions: bool = True, monitor: str = "", anchor: str = "center", parent=None) -> None:
|
|
super().__init__(parent, Qt.WindowType.Window)
|
|
self.setWindowTitle("booru-viewer — Popout")
|
|
self._grid_cols = grid_cols
|
|
self._anchor = anchor
|
|
|
|
# Central widget — media fills the entire window
|
|
central = QWidget()
|
|
central.setLayout(QVBoxLayout())
|
|
central.layout().setContentsMargins(0, 0, 0, 0)
|
|
central.layout().setSpacing(0)
|
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.customContextMenuRequested.connect(self._on_context_menu)
|
|
|
|
# Media stack (fills entire window)
|
|
self._stack = QStackedWidget()
|
|
central.layout().addWidget(self._stack)
|
|
|
|
self._vol_scroll_accum = 0
|
|
|
|
self._viewer = ImageViewer()
|
|
self._viewer.close_requested.connect(self.close)
|
|
self._stack.addWidget(self._viewer)
|
|
|
|
self._video = VideoPlayer()
|
|
# Note: two legacy VideoPlayer signal connections removed in
|
|
# commits 14b and 16:
|
|
#
|
|
# - `self._video.play_next.connect(self.play_next_requested)`
|
|
# (removed in 14b): the EmitPlayNextRequested effect now
|
|
# emits play_next_requested via the state machine dispatch
|
|
# path. Keeping the forwarding would double-emit the signal
|
|
# and cause main_window to navigate twice on every video
|
|
# EOF in Loop=Next mode.
|
|
#
|
|
# - `self._video.video_size.connect(self._on_video_size)`
|
|
# (removed in 16): the dispatch path's VideoSizeKnown
|
|
# handler emits FitWindowToContent which the apply path
|
|
# delegates to _fit_to_content. The legacy direct call to
|
|
# _on_video_size → _fit_to_content was a parallel duplicate
|
|
# that the same-rect skip in _fit_to_content made harmless,
|
|
# but it muddied the trace. The dispatch lambda below is
|
|
# wired in the same __init__ block (post state machine
|
|
# construction) and is now the sole path.
|
|
self._stack.addWidget(self._video)
|
|
|
|
self.setCentralWidget(central)
|
|
|
|
# Floating toolbar — overlays on top of media, translucent.
|
|
# Set the object name BEFORE the widget is polished by Qt so that
|
|
# the bundled-theme `QWidget#_slideshow_toolbar` selector matches
|
|
# on the very first style computation. Setting it later requires
|
|
# an explicit unpolish/polish cycle, which we want to avoid.
|
|
self._toolbar = QWidget(central)
|
|
self._toolbar.setObjectName("_slideshow_toolbar")
|
|
# Plain QWidget ignores QSS `background:` declarations unless this
|
|
# attribute is set — without it the toolbar paints transparently
|
|
# and the popout buttons sit on bare letterbox color.
|
|
self._toolbar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
toolbar = QHBoxLayout(self._toolbar)
|
|
toolbar.setContentsMargins(8, 4, 8, 4)
|
|
|
|
# Same compact-padding override as the embedded preview toolbar —
|
|
# Bookmark folders for the popout's Bookmark-as submenu — wired
|
|
# by app.py via set_bookmark_folders_callback after construction.
|
|
self._bookmark_folders_callback = None
|
|
self._is_bookmarked = False
|
|
# Library folders for the popout's Save-to-Library submenu —
|
|
# wired by app.py via set_folders_callback. Same shape as the
|
|
# bookmark folders callback above; library and bookmark folders
|
|
# are independent name spaces and need separate callbacks.
|
|
self._folders_callback = None
|
|
|
|
_tb_sz = 24
|
|
|
|
def _icon_btn(text: str, name: str, tip: str) -> QPushButton:
|
|
btn = QPushButton(text)
|
|
btn.setObjectName(name)
|
|
btn.setFixedSize(_tb_sz, _tb_sz)
|
|
btn.setToolTip(tip)
|
|
return btn
|
|
|
|
self._bookmark_btn = _icon_btn("\u2606", "_tb_bookmark", "Bookmark (B)")
|
|
self._bookmark_btn.clicked.connect(self._on_bookmark_clicked)
|
|
toolbar.addWidget(self._bookmark_btn)
|
|
|
|
self._save_btn = _icon_btn("\u2193", "_tb_save", "Save to library (S)")
|
|
self._save_btn.clicked.connect(self._on_save_clicked)
|
|
toolbar.addWidget(self._save_btn)
|
|
self._is_saved = False
|
|
|
|
self._bl_tag_btn = _icon_btn("\u2298", "_tb_bl_tag", "Blacklist a tag")
|
|
self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu)
|
|
toolbar.addWidget(self._bl_tag_btn)
|
|
|
|
self._bl_post_btn = _icon_btn("\u2297", "_tb_bl_post", "Blacklist this post")
|
|
self._bl_post_btn.clicked.connect(self.blacklist_post_requested)
|
|
toolbar.addWidget(self._bl_post_btn)
|
|
|
|
if not show_actions:
|
|
# Library mode: only the Save button stays — it acts as
|
|
# Unsave for the file currently being viewed. Bookmark and
|
|
# blacklist actions are meaningless on already-saved local
|
|
# files (no site/post id to bookmark, no search to filter).
|
|
self._bookmark_btn.hide()
|
|
self._bl_tag_btn.hide()
|
|
self._bl_post_btn.hide()
|
|
|
|
toolbar.addStretch()
|
|
|
|
self._info_label = QLabel() # kept for API compat but hidden in slideshow
|
|
self._info_label.hide()
|
|
|
|
self._toolbar.raise_()
|
|
|
|
# Reparent video controls bar to central widget so it overlays properly.
|
|
# The translucent overlay styling (background, transparent buttons,
|
|
# white-on-dark text) lives in the bundled themes — see the
|
|
# `Popout overlay bars` section of any themes/*.qss. The object names
|
|
# are what those rules target.
|
|
#
|
|
# The toolbar's object name is set above, in its constructor block,
|
|
# so the first style poll picks it up. The controls bar was already
|
|
# polished as a child of VideoPlayer before being reparented here,
|
|
# so we have to force an unpolish/polish round-trip after setting
|
|
# its object name to make Qt re-evaluate the style with the new
|
|
# `#_slideshow_controls` selector.
|
|
self._video._controls_bar.setParent(central)
|
|
self._video._controls_bar.setObjectName("_slideshow_controls")
|
|
# Same fix as the toolbar above — plain QWidget needs this attribute
|
|
# for the QSS `background: ${overlay_bg}` rule to render.
|
|
self._video._controls_bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
cb_style = self._video._controls_bar.style()
|
|
cb_style.unpolish(self._video._controls_bar)
|
|
cb_style.polish(self._video._controls_bar)
|
|
# Same trick on the toolbar — it might have been polished by the
|
|
# central widget's parent before our object name took effect.
|
|
tb_style = self._toolbar.style()
|
|
tb_style.unpolish(self._toolbar)
|
|
tb_style.polish(self._toolbar)
|
|
self._video._controls_bar.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
|
|
self._ui_visible = True
|
|
self._hide_timer = QTimer(self)
|
|
self._hide_timer.setSingleShot(True)
|
|
self._hide_timer.setInterval(2000)
|
|
self._hide_timer.timeout.connect(self._hide_overlay)
|
|
self._hide_timer.start()
|
|
self.setMouseTracking(True)
|
|
central.setMouseTracking(True)
|
|
self._stack.setMouseTracking(True)
|
|
|
|
from PySide6.QtWidgets import QApplication
|
|
QApplication.instance().installEventFilter(self)
|
|
# Pick target monitor
|
|
target_screen = None
|
|
if monitor and monitor != "Same as app":
|
|
for screen in QApplication.screens():
|
|
label = f"{screen.name()} ({screen.size().width()}x{screen.size().height()})"
|
|
if label == monitor:
|
|
target_screen = screen
|
|
break
|
|
if not target_screen and parent and parent.screen():
|
|
target_screen = parent.screen()
|
|
if target_screen:
|
|
self.setScreen(target_screen)
|
|
self.setGeometry(target_screen.geometry())
|
|
self._adjusting = False
|
|
# Position-restore handshake: setGeometry below seeds Qt with the saved
|
|
# size, but Hyprland ignores the position for child windows. The first
|
|
# _fit_to_content call after show() picks up _pending_position_restore
|
|
# and corrects the position via a hyprctl batch (no race with the
|
|
# resize). After that first fit, navigation center-pins from whatever
|
|
# position the user has dragged the window to.
|
|
self._first_fit_pending = True
|
|
self._pending_position_restore: tuple[int, int] | None = None
|
|
self._pending_size: tuple[int, int] | None = None
|
|
# Persistent viewport — the user's intent for popout center + size.
|
|
# Seeded from `_pending_size` + `_pending_position_restore` on the
|
|
# first fit after open or F11 exit. Updated only by user action
|
|
# (external drag/resize detected via cur-vs-last-dispatched
|
|
# comparison on Hyprland, or via moveEvent/resizeEvent on
|
|
# non-Hyprland). Navigation between posts NEVER writes to it —
|
|
# `_derive_viewport_for_fit` returns it unchanged unless drift
|
|
# has exceeded `_DRIFT_TOLERANCE`. This is what stops the
|
|
# sub-pixel accumulation that the recompute-from-current-state
|
|
# shortcut couldn't avoid.
|
|
self._viewport: Viewport | None = None
|
|
# Last (x, y, w, h) we dispatched to Hyprland (or to setGeometry
|
|
# on non-Hyprland). Used to detect external moves: if the next
|
|
# nav reads a current rect that differs by more than
|
|
# _DRIFT_TOLERANCE, the user moved or resized the window
|
|
# externally and we adopt the new state as the viewport's intent.
|
|
self._last_dispatched_rect: tuple[int, int, int, int] | None = None
|
|
# Reentrancy guard — set to True around every dispatch so the
|
|
# moveEvent/resizeEvent handlers (which fire on the non-Hyprland
|
|
# Qt fallback path) skip viewport updates triggered by our own
|
|
# programmatic geometry changes.
|
|
self._applying_dispatch: bool = False
|
|
# Last known windowed geometry — captured on entering fullscreen so
|
|
# F11 → windowed can land back on the same spot. Seeded from saved
|
|
# geometry when the popout opens windowed, so even an immediate
|
|
# F11 → fullscreen → F11 has a sensible target.
|
|
self._windowed_geometry = None
|
|
# Restore saved state or start fullscreen
|
|
if FullscreenPreview._saved_geometry and not FullscreenPreview._saved_fullscreen:
|
|
self.setGeometry(FullscreenPreview._saved_geometry)
|
|
self._pending_position_restore = (
|
|
FullscreenPreview._saved_geometry.x(),
|
|
FullscreenPreview._saved_geometry.y(),
|
|
)
|
|
self._pending_size = (
|
|
FullscreenPreview._saved_geometry.width(),
|
|
FullscreenPreview._saved_geometry.height(),
|
|
)
|
|
self._windowed_geometry = FullscreenPreview._saved_geometry
|
|
self.show()
|
|
else:
|
|
self.showFullScreen()
|
|
|
|
# ---- State machine adapter wiring (commit 14a) ----
|
|
# Construct the pure-Python state machine and dispatch the
|
|
# initial Open event with the cross-popout-session class state
|
|
# the legacy code stashed above. The state machine runs in
|
|
# PARALLEL with the legacy imperative code: every Qt event
|
|
# handler / mpv signal / button click below dispatches a state
|
|
# machine event AND continues to run the existing imperative
|
|
# action. The state machine's returned effects are LOGGED at
|
|
# DEBUG, not applied to widgets. The legacy path stays
|
|
# authoritative through commit 14a; commit 14b switches the
|
|
# authority to the dispatch path.
|
|
#
|
|
# The grid_cols field is used by the keyboard nav handlers
|
|
# for the Up/Down ±cols stride.
|
|
self._state_machine = StateMachine()
|
|
self._state_machine.grid_cols = grid_cols
|
|
saved_geo_tuple = None
|
|
if FullscreenPreview._saved_geometry:
|
|
sg = FullscreenPreview._saved_geometry
|
|
saved_geo_tuple = (sg.x(), sg.y(), sg.width(), sg.height())
|
|
# Open's handler returns [] (just stashes saved_geo on the
|
|
# state machine) but using _dispatch_and_apply for consistency
|
|
# — every dispatch site goes through one helper.
|
|
self._dispatch_and_apply(Open(
|
|
saved_geo=saved_geo_tuple,
|
|
saved_fullscreen=bool(FullscreenPreview._saved_fullscreen),
|
|
monitor=monitor,
|
|
))
|
|
|
|
# Wire VideoPlayer's playback_restart Signal (added in commit 1)
|
|
# to the adapter's dispatch routing. mpv emits playback-restart
|
|
# once after each loadfile and once after each completed seek;
|
|
# the adapter distinguishes by checking the state machine's
|
|
# current state at dispatch time.
|
|
self._video.playback_restart.connect(self._on_video_playback_restart)
|
|
# Wire VideoPlayer signals to dispatch+apply via the
|
|
# _dispatch_and_apply helper. NOTE: every lambda below MUST
|
|
# call _dispatch_and_apply, not _fsm_dispatch directly. Calling
|
|
# _fsm_dispatch alone produces effects that never reach
|
|
# widgets — the bug that landed in commit 14b and broke
|
|
# video auto-fit (FitWindowToContent never applied) and
|
|
# Loop=Next play_next (EmitPlayNextRequested never applied)
|
|
# until the lambdas were fixed in this commit.
|
|
self._video.play_next.connect(
|
|
lambda: self._dispatch_and_apply(VideoEofReached())
|
|
)
|
|
self._video.video_size.connect(
|
|
lambda w, h: self._dispatch_and_apply(VideoSizeKnown(width=w, height=h))
|
|
)
|
|
self._video._seek_slider.clicked_position.connect(
|
|
lambda v: self._dispatch_and_apply(SeekRequested(target_ms=v))
|
|
)
|
|
self._video._mute_btn.clicked.connect(
|
|
lambda: self._dispatch_and_apply(MuteToggleRequested())
|
|
)
|
|
self._video._vol_slider.valueChanged.connect(
|
|
lambda v: self._dispatch_and_apply(VolumeSet(value=v))
|
|
)
|
|
self._video._loop_btn.clicked.connect(
|
|
lambda: self._dispatch_and_apply(
|
|
LoopModeSet(mode=LoopMode(self._video.loop_state))
|
|
)
|
|
)
|
|
|
|
def _dispatch_and_apply(self, event) -> None:
|
|
"""Dispatch an event and apply the returned effects.
|
|
|
|
Centralizes the dispatch+apply pattern that every event
|
|
wire-point in the adapter follows. Replaces the older
|
|
`effects = self._fsm_dispatch(...); self._apply_effects(effects)`
|
|
two-line pattern in eventFilter and the bare `_fsm_dispatch`
|
|
calls in the signal connection lambdas.
|
|
|
|
Note the historical bug this method exists to prevent: if a
|
|
signal lambda only calls `_fsm_dispatch` (which dispatches
|
|
and logs but does NOT apply effects), the state machine's
|
|
returned effects never reach widgets. That's how commit 14b
|
|
broke video auto-fit and Loop=Next play_next without anyone
|
|
noticing — the lambdas in __init__ were dispatching cleanly
|
|
but the FitWindowToContent and EmitPlayNextRequested effects
|
|
they produced were silently dropped. Going through this
|
|
helper makes the apply step impossible to forget.
|
|
"""
|
|
effects = self._fsm_dispatch(event)
|
|
self._apply_effects(effects)
|
|
|
|
def _fsm_dispatch(self, event) -> list:
|
|
"""Dispatch an event to the state machine and log the result.
|
|
|
|
Adapter-internal helper. Centralizes the dispatch + log path
|
|
so every wire-point is one line. Returns the effect list for
|
|
callers that want to inspect it (commit 14a doesn't use the
|
|
return value; commit 14b will pattern-match and apply).
|
|
|
|
The hasattr guard handles edge cases where Qt events might
|
|
fire during __init__ (e.g. resizeEvent on the first show())
|
|
before the state machine has been constructed. After
|
|
construction the guard is always True.
|
|
"""
|
|
if not hasattr(self, "_state_machine"):
|
|
return []
|
|
old_state = self._state_machine.state
|
|
effects = self._state_machine.dispatch(event)
|
|
new_state = self._state_machine.state
|
|
_fsm_log.debug(
|
|
"POPOUT_FSM %s | %s -> %s | effects=%s",
|
|
type(event).__name__,
|
|
old_state.name,
|
|
new_state.name,
|
|
[type(e).__name__ for e in effects],
|
|
)
|
|
return effects
|
|
|
|
def _on_video_playback_restart(self) -> None:
|
|
"""mpv `playback-restart` event arrived (via VideoPlayer's
|
|
playback_restart Signal added in commit 1). Distinguish
|
|
VideoStarted (after load) from SeekCompleted (after seek) by
|
|
the state machine's current state.
|
|
|
|
This is the ONE place the adapter peeks at state to choose an
|
|
event type — it's a read, not a write, and it's the price of
|
|
having a single mpv event mean two different things.
|
|
"""
|
|
if not hasattr(self, "_state_machine"):
|
|
return
|
|
if self._state_machine.state == State.LOADING_VIDEO:
|
|
self._dispatch_and_apply(VideoStarted())
|
|
elif self._state_machine.state == State.SEEKING_VIDEO:
|
|
self._dispatch_and_apply(SeekCompleted())
|
|
# Other states: drop. The state machine's release-mode
|
|
# legality check would also drop it; this saves the dispatch
|
|
# round trip.
|
|
|
|
# ------------------------------------------------------------------
|
|
# Commit 14b — effect application
|
|
# ------------------------------------------------------------------
|
|
#
|
|
# The state machine's dispatch returns a list of Effect descriptors
|
|
# describing what the adapter should do. `_apply_effects` is the
|
|
# single dispatch point: every wire-point that calls `_fsm_dispatch`
|
|
# follows it with `_apply_effects(effects)`. The pattern-match by
|
|
# type is the architectural choke point — if a new effect type is
|
|
# added in state.py, the type-check below catches the missing
|
|
# handler at runtime instead of silently dropping.
|
|
#
|
|
# Several apply handlers are deliberate no-ops in commit 14b:
|
|
#
|
|
# - ApplyMute / ApplyVolume / ApplyLoopMode: the legacy slot
|
|
# connections on the popout's VideoPlayer are still active and
|
|
# handle the user-facing toggles directly. The state machine
|
|
# tracks these values for the upcoming SyncFromEmbedded path
|
|
# (future commit) but doesn't push them to widgets — pushing
|
|
# would create a sync hazard with the embedded preview's mute
|
|
# state, which main_window pushes via direct attribute writes.
|
|
#
|
|
# - SeekVideoTo: the legacy `_ClickSeekSlider.clicked_position →
|
|
# VideoPlayer._seek` connection still handles both the mpv.seek
|
|
# call and the legacy 500ms `_seek_pending_until` pin window.
|
|
# The state machine's SeekingVideo state tracks the seek for
|
|
# future authority, but the slider rendering and the seek call
|
|
# itself stay legacy. Replacing this requires either modifying
|
|
# VideoPlayer's _poll loop (forbidden by the no-touch rule) or
|
|
# building a custom poll loop in the adapter.
|
|
#
|
|
# The other effect types (LoadImage, LoadVideo, StopMedia,
|
|
# FitWindowToContent, EnterFullscreen, ExitFullscreen,
|
|
# EmitNavigate, EmitPlayNextRequested, EmitClosed, TogglePlay)
|
|
# delegate to existing private helpers in this file. The state
|
|
# machine becomes the official entry point for these operations;
|
|
# the helpers stay in place as the implementation.
|
|
|
|
def _apply_effects(self, effects: list) -> None:
|
|
"""Apply a list of Effect descriptors returned by dispatch.
|
|
|
|
Pattern-matches each effect to its apply handler. Unknown
|
|
effect types raise TypeError so the next added effect type
|
|
in state.py is caught at runtime instead of silently dropped.
|
|
"""
|
|
for e in effects:
|
|
if isinstance(e, LoadImage):
|
|
self._apply_load_image(e)
|
|
elif isinstance(e, LoadVideo):
|
|
self._apply_load_video(e)
|
|
elif isinstance(e, StopMedia):
|
|
self._apply_stop_media()
|
|
elif isinstance(e, ApplyMute):
|
|
# No-op in 14b — legacy slot handles widget update.
|
|
# State machine tracks state.mute for future authority.
|
|
pass
|
|
elif isinstance(e, ApplyVolume):
|
|
pass # same — no-op in 14b
|
|
elif isinstance(e, ApplyLoopMode):
|
|
pass # same — no-op in 14b
|
|
elif isinstance(e, SeekVideoTo):
|
|
# No-op in 14b — legacy `_seek` slot handles both
|
|
# mpv.seek (now exact) and the pin window. Replacing
|
|
# this requires touching VideoPlayer._poll which is
|
|
# out of scope.
|
|
pass
|
|
elif isinstance(e, TogglePlay):
|
|
self._video._toggle_play()
|
|
elif isinstance(e, FitWindowToContent):
|
|
self._fit_to_content(e.content_w, e.content_h)
|
|
elif isinstance(e, EnterFullscreen):
|
|
self._enter_fullscreen()
|
|
elif isinstance(e, ExitFullscreen):
|
|
self._exit_fullscreen()
|
|
elif isinstance(e, EmitNavigate):
|
|
self.navigate.emit(e.direction)
|
|
elif isinstance(e, EmitPlayNextRequested):
|
|
self.play_next_requested.emit()
|
|
elif isinstance(e, EmitClosed):
|
|
self.closed.emit()
|
|
else:
|
|
raise TypeError(
|
|
f"Unknown effect type: {type(e).__name__}. "
|
|
f"Add a handler in _apply_effects."
|
|
)
|
|
|
|
def _apply_load_image(self, e: LoadImage) -> None:
|
|
"""Apply LoadImage effect — display a static image or GIF.
|
|
|
|
Mirrors the legacy `set_media` body's image branch. Stops
|
|
any active video and clears its controls bar before swapping
|
|
in the image. The fit comes from a separate FitWindowToContent
|
|
effect that the state machine emits alongside LoadImage.
|
|
"""
|
|
info = self._state_machine.current_info
|
|
self._video.stop()
|
|
self._video._controls_bar.hide()
|
|
if e.is_gif:
|
|
self._viewer.set_gif(e.path, info)
|
|
else:
|
|
pix = QPixmap(e.path)
|
|
if not pix.isNull():
|
|
self._viewer.set_image(pix, info)
|
|
self._stack.setCurrentIndex(0)
|
|
|
|
def _apply_load_video(self, e: LoadVideo) -> None:
|
|
"""Apply LoadVideo effect — hand the path or URL to mpv.
|
|
|
|
Stack switch happens FIRST so the video surface is visible
|
|
the moment mpv produces its first frame. clear() releases the
|
|
previous image (now hidden behind the video surface). No
|
|
explicit stop() — loadfile("replace") atomically replaces the
|
|
current file without the eof side-effect of command('stop').
|
|
"""
|
|
self._stack.setCurrentIndex(1)
|
|
self._viewer.clear()
|
|
self._video.play_file(e.path, e.info)
|
|
|
|
def _apply_stop_media(self) -> None:
|
|
"""Apply StopMedia effect — clear both surfaces.
|
|
|
|
Idempotent. Called on navigation away from current media,
|
|
on close, and as part of the StopMedia effect from various
|
|
transitions.
|
|
"""
|
|
self._video.stop()
|
|
self._viewer.clear()
|
|
|
|
_saved_geometry = None # remembers window size/position across opens
|
|
_saved_fullscreen = False
|
|
_current_tags: dict[str, list[str]] = {}
|
|
_current_tag_list: list[str] = []
|
|
|
|
def set_post_tags(self, tag_categories: dict[str, list[str]], tag_list: list[str]) -> None:
|
|
self._current_tags = tag_categories
|
|
self._current_tag_list = tag_list
|
|
|
|
def _exec_menu_at_button(self, menu: QMenu, btn: QPushButton):
|
|
"""Open a menu anchored below a button, blocking until dismissed.
|
|
|
|
Uses popup() + QEventLoop instead of exec(pos) because on
|
|
Hyprland/Wayland the popout window gets moved via hyprctl after
|
|
Qt maps it, and Qt's window-position tracking stays stale. Using
|
|
exec(btn.mapToGlobal(...)) resolves to a global point on the
|
|
wrong monitor, causing the menu to flash there before the
|
|
compositor corrects it. popup() routes through the same path
|
|
but with triggered/aboutToHide signals we can block manually.
|
|
"""
|
|
result = [None]
|
|
menu.triggered.connect(lambda a: result.__setitem__(0, a))
|
|
loop = QEventLoop()
|
|
menu.aboutToHide.connect(loop.quit)
|
|
menu.popup(btn.mapToGlobal(btn.rect().bottomLeft()))
|
|
loop.exec()
|
|
return result[0]
|
|
|
|
def _show_bl_tag_menu(self) -> None:
|
|
menu = QMenu(self)
|
|
if self._current_tags:
|
|
for category, tags in self._current_tags.items():
|
|
cat_menu = menu.addMenu(category)
|
|
for tag in tags[:30]:
|
|
cat_menu.addAction(tag)
|
|
else:
|
|
for tag in self._current_tag_list[:30]:
|
|
menu.addAction(tag)
|
|
action = self._exec_menu_at_button(menu, self._bl_tag_btn)
|
|
if action:
|
|
self.blacklist_tag_requested.emit(action.text())
|
|
|
|
def update_state(self, bookmarked: bool, saved: bool) -> None:
|
|
self._is_bookmarked = bookmarked
|
|
self._bookmark_btn.setText("\u2605" if bookmarked else "\u2606") # ★ / ☆
|
|
self._bookmark_btn.setToolTip("Unbookmark (B)" if bookmarked else "Bookmark (B)")
|
|
self._is_saved = saved
|
|
self._save_btn.setText("\u2715" if saved else "\u2193") # ✕ / ⤓
|
|
self._save_btn.setToolTip("Unsave from library" if saved else "Save to library (S)")
|
|
|
|
# ------------------------------------------------------------------
|
|
# Public method interface (commit 15)
|
|
# ------------------------------------------------------------------
|
|
#
|
|
# The methods below replace direct underscore access from
|
|
# main_window.py. They wrap the existing private fields so
|
|
# main_window doesn't have to know about VideoPlayer / ImageViewer
|
|
# / QStackedWidget internals. The legacy private fields stay in
|
|
# place — these are clean public wrappers, not a re-architecture.
|
|
|
|
def is_video_active(self) -> bool:
|
|
"""True if the popout is currently showing a video (vs image).
|
|
|
|
Replaces direct `popout._stack.currentIndex() == 1` checks
|
|
from main_window. Used to gate per-tab video-only operations
|
|
(volume scroll, seek, pause).
|
|
"""
|
|
return self._stack.currentIndex() == 1
|
|
|
|
def set_toolbar_visibility(
|
|
self,
|
|
*,
|
|
bookmark: bool,
|
|
save: bool,
|
|
bl_tag: bool,
|
|
bl_post: bool,
|
|
) -> None:
|
|
"""Per-tab toolbar gating.
|
|
|
|
Replaces direct `popout._bookmark_btn.setVisible(...)` etc
|
|
from main_window's `_update_fullscreen` method. Library tab
|
|
hides Bookmark / BL Tag / BL Post (no site/post id to act
|
|
on) but keeps Save (acts as Unsave for the file currently
|
|
being viewed).
|
|
"""
|
|
self._bookmark_btn.setVisible(bookmark)
|
|
self._save_btn.setVisible(save)
|
|
self._bl_tag_btn.setVisible(bl_tag)
|
|
self._bl_post_btn.setVisible(bl_post)
|
|
|
|
def sync_video_state(
|
|
self,
|
|
*,
|
|
volume: int,
|
|
mute: bool,
|
|
autoplay: bool,
|
|
loop_state: int,
|
|
) -> None:
|
|
"""Push state from the embedded preview into the popout's
|
|
video player.
|
|
|
|
Called by main_window's `_open_fullscreen_preview` after the
|
|
popout is constructed. Replaces direct `popout._video.volume
|
|
= ...` etc writes. Uses VideoPlayer's existing setters which
|
|
handle the lazy-mpv pending-state pattern (mute survives
|
|
first-load via _pending_mute, volume survives via the slider
|
|
widget acting as persistent storage).
|
|
"""
|
|
self._video.volume = volume
|
|
self._video.is_muted = mute
|
|
self._video.autoplay = autoplay
|
|
self._video.loop_state = loop_state
|
|
|
|
def get_video_state(self) -> dict:
|
|
"""Read video player state for the reverse sync at popout close.
|
|
|
|
Returns a dict with `volume`, `mute`, `autoplay`, `loop_state`,
|
|
and `position_ms` (current playback position in milliseconds,
|
|
0 if the popout isn't currently on the video stack). Called
|
|
by main_window's `_on_fullscreen_closed` to push the state
|
|
back into the embedded preview's video player.
|
|
"""
|
|
return {
|
|
"volume": self._video.volume,
|
|
"mute": self._video.is_muted,
|
|
"autoplay": self._video.autoplay,
|
|
"loop_state": self._video.loop_state,
|
|
"position_ms": getattr(self, "_close_position_ms", None) or (
|
|
self._video.get_position_ms()
|
|
if self.is_video_active()
|
|
else 0
|
|
),
|
|
}
|
|
|
|
def seek_video_to(self, ms: int) -> None:
|
|
"""Seek the video to a specific position in milliseconds.
|
|
|
|
Used by main_window's seek-after-load pattern when restoring
|
|
video position across popout open/close cycles. Wraps
|
|
`VideoPlayer.seek_to_ms` (which uses `'absolute+exact'` for
|
|
frame-accurate landing — same as the slider's `_seek` after
|
|
the 609066c drag-back fix).
|
|
"""
|
|
self._video.seek_to_ms(ms)
|
|
|
|
def connect_media_ready_once(self, callback) -> None:
|
|
"""Wire a one-shot callback to the video player's media_ready
|
|
signal. The callback fires once when the next loaded video
|
|
becomes ready, then disconnects itself.
|
|
|
|
Replaces main_window's manual lambda + try/except disconnect
|
|
dance for the seek-when-ready pattern (open popout → wait for
|
|
the new mpv instance to load → restore the embedded preview's
|
|
playback position).
|
|
"""
|
|
def _wrapper():
|
|
try:
|
|
callback()
|
|
finally:
|
|
try:
|
|
self._video.media_ready.disconnect(_wrapper)
|
|
except (TypeError, RuntimeError):
|
|
pass
|
|
self._video.media_ready.connect(_wrapper)
|
|
|
|
def pause_media(self) -> None:
|
|
"""Pause the active video player. No-op if no video is loaded.
|
|
|
|
Replaces direct `popout._video.pause()` calls from main_window
|
|
in privacy-screen / blacklist / video-end paths. Goes through
|
|
VideoPlayer.pause() which handles the play-button text update
|
|
and respects the lazy-mpv state.
|
|
"""
|
|
self._video.pause()
|
|
|
|
def force_mpv_pause(self) -> None:
|
|
"""Set mpv.pause = True directly without going through Qt
|
|
property setters or button text updates.
|
|
|
|
Used by main_window's `_on_post_activated` to prevent the OLD
|
|
video from reaching natural EOF during the new post's async
|
|
download (which would auto-advance past the post the user
|
|
clicked). Different from `pause_media` because this writes
|
|
the mpv property directly — no eof-reached side effect, no
|
|
button text flicker mid-load.
|
|
|
|
Replaces the legacy `popout._video._mpv.pause = True` deep
|
|
attribute access from main_window line ~1125. The
|
|
`_mpv is None` guard handles the pre-first-load case where
|
|
`_ensure_mpv` hasn't run yet.
|
|
"""
|
|
if self._video._mpv is not None:
|
|
try:
|
|
self._video._mpv.pause = True
|
|
except Exception:
|
|
pass
|
|
|
|
def stop_media(self) -> None:
|
|
"""Stop the video and clear the image viewer.
|
|
|
|
Used by main_window's blacklist-removal flow when the post
|
|
being viewed gets blacklisted. Replaces the
|
|
`popout._viewer.clear() + popout._video.stop()` sequence from
|
|
main_window with a single call.
|
|
"""
|
|
self._video.stop()
|
|
self._viewer.clear()
|
|
|
|
def set_bookmark_folders_callback(self, callback) -> None:
|
|
"""Wire the bookmark folder list source. Called once from app.py
|
|
right after the popout is constructed; matches the embedded
|
|
ImagePreview's set_bookmark_folders_callback shape.
|
|
"""
|
|
self._bookmark_folders_callback = callback
|
|
|
|
def set_folders_callback(self, callback) -> None:
|
|
"""Wire the library folder list source. Called once from app.py
|
|
right after the popout is constructed; matches the embedded
|
|
ImagePreview's set_folders_callback shape.
|
|
"""
|
|
self._folders_callback = callback
|
|
|
|
def _on_save_clicked(self) -> None:
|
|
"""Popout Save button — same shape as the embedded preview's
|
|
version. When already saved, emit unsave_requested for the existing
|
|
unsave path. When not saved, pop a menu under the button with
|
|
Unfiled / library folders / + New Folder, then emit the chosen
|
|
name through save_to_folder. app.py reuses _save_from_preview /
|
|
_unsave_from_preview to handle both signals.
|
|
"""
|
|
if self._is_saved:
|
|
self.unsave_requested.emit()
|
|
return
|
|
menu = QMenu(self)
|
|
unfiled = menu.addAction("Unfiled")
|
|
menu.addSeparator()
|
|
folder_actions: dict[int, str] = {}
|
|
if self._folders_callback:
|
|
for folder in self._folders_callback():
|
|
a = menu.addAction(folder)
|
|
folder_actions[id(a)] = folder
|
|
menu.addSeparator()
|
|
new_action = menu.addAction("+ New Folder...")
|
|
action = self._exec_menu_at_button(menu, self._save_btn)
|
|
if not action:
|
|
return
|
|
if action == unfiled:
|
|
self.save_to_folder.emit("")
|
|
elif action == new_action:
|
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
|
if ok and name.strip():
|
|
self.save_to_folder.emit(name.strip())
|
|
elif id(action) in folder_actions:
|
|
self.save_to_folder.emit(folder_actions[id(action)])
|
|
|
|
def _on_bookmark_clicked(self) -> None:
|
|
"""Popout Bookmark button — same shape as the embedded preview's
|
|
version. When already bookmarked, emits bookmark_requested for the
|
|
existing toggle/remove path. When not bookmarked, pops a menu under
|
|
the button with Unfiled / bookmark folders / + New Folder, then
|
|
emits the chosen name through bookmark_to_folder.
|
|
"""
|
|
if self._is_bookmarked:
|
|
self.bookmark_requested.emit()
|
|
return
|
|
menu = QMenu(self)
|
|
unfiled = menu.addAction("Unfiled")
|
|
menu.addSeparator()
|
|
folder_actions: dict[int, str] = {}
|
|
if self._bookmark_folders_callback:
|
|
for folder in self._bookmark_folders_callback():
|
|
a = menu.addAction(folder)
|
|
folder_actions[id(a)] = folder
|
|
menu.addSeparator()
|
|
new_action = menu.addAction("+ New Folder...")
|
|
action = self._exec_menu_at_button(menu, self._bookmark_btn)
|
|
if not action:
|
|
return
|
|
if action == unfiled:
|
|
self.bookmark_to_folder.emit("")
|
|
elif action == new_action:
|
|
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
|
|
if ok and name.strip():
|
|
self.bookmark_to_folder.emit(name.strip())
|
|
elif id(action) in folder_actions:
|
|
self.bookmark_to_folder.emit(folder_actions[id(action)])
|
|
|
|
def _on_context_menu(self, pos) -> None:
|
|
menu = QMenu(self)
|
|
|
|
# Bookmark: unbookmark if already bookmarked, folder submenu if not
|
|
fav_action = None
|
|
bm_folder_actions = {}
|
|
bm_new_action = None
|
|
bm_unfiled = None
|
|
if self._is_bookmarked:
|
|
fav_action = menu.addAction("Unbookmark")
|
|
else:
|
|
bm_menu = menu.addMenu("Bookmark as")
|
|
bm_unfiled = bm_menu.addAction("Unfiled")
|
|
bm_menu.addSeparator()
|
|
if self._bookmark_folders_callback:
|
|
for folder in self._bookmark_folders_callback():
|
|
a = bm_menu.addAction(folder)
|
|
bm_folder_actions[id(a)] = folder
|
|
bm_menu.addSeparator()
|
|
bm_new_action = bm_menu.addAction("+ New Folder...")
|
|
|
|
save_menu = None
|
|
save_unsorted = None
|
|
save_new = None
|
|
save_folder_actions = {}
|
|
unsave_action = None
|
|
if self._is_saved:
|
|
unsave_action = menu.addAction("Unsave from Library")
|
|
else:
|
|
save_menu = menu.addMenu("Save to Library")
|
|
save_unsorted = save_menu.addAction("Unfiled")
|
|
save_menu.addSeparator()
|
|
if self._folders_callback:
|
|
for folder in self._folders_callback():
|
|
a = save_menu.addAction(folder)
|
|
save_folder_actions[id(a)] = folder
|
|
save_menu.addSeparator()
|
|
save_new = save_menu.addAction("+ New Folder...")
|
|
|
|
menu.addSeparator()
|
|
copy_action = menu.addAction("Copy File to Clipboard")
|
|
copy_url_action = menu.addAction("Copy Image URL")
|
|
open_action = menu.addAction("Open in Default App")
|
|
browser_action = menu.addAction("Open in Browser")
|
|
|
|
reset_action = None
|
|
if self._stack.currentIndex() == 0:
|
|
reset_action = menu.addAction("Reset View")
|
|
|
|
menu.addSeparator()
|
|
close_action = menu.addAction("Close Popout")
|
|
|
|
action = menu.exec(self.mapToGlobal(pos))
|
|
if not action:
|
|
return
|
|
if action == fav_action:
|
|
self.bookmark_requested.emit()
|
|
elif action == bm_unfiled:
|
|
self.bookmark_to_folder.emit("")
|
|
elif action == bm_new_action:
|
|
name, ok = QInputDialog.getText(self, "New Bookmark Folder", "Folder name:")
|
|
if ok and name.strip():
|
|
self.bookmark_to_folder.emit(name.strip())
|
|
elif id(action) in bm_folder_actions:
|
|
self.bookmark_to_folder.emit(bm_folder_actions[id(action)])
|
|
elif action == save_unsorted:
|
|
self.save_to_folder.emit("")
|
|
elif action == save_new:
|
|
name, ok = QInputDialog.getText(self, "New Folder", "Folder name:")
|
|
if ok and name.strip():
|
|
self.save_to_folder.emit(name.strip())
|
|
elif id(action) in save_folder_actions:
|
|
self.save_to_folder.emit(save_folder_actions[id(action)])
|
|
elif action == unsave_action:
|
|
self.unsave_requested.emit()
|
|
elif action == copy_action:
|
|
from pathlib import Path as _Path
|
|
from PySide6.QtCore import QMimeData, QUrl
|
|
from PySide6.QtWidgets import QApplication
|
|
from PySide6.QtGui import QPixmap as _QP
|
|
cp = self._state_machine.current_path
|
|
if cp and cp.startswith(("http://", "https://")):
|
|
from ...core.cache import cached_path_for
|
|
cached = cached_path_for(cp)
|
|
cp = str(cached) if cached.exists() else None
|
|
if cp and _Path(cp).exists():
|
|
mime = QMimeData()
|
|
mime.setUrls([QUrl.fromLocalFile(str(_Path(cp).resolve()))])
|
|
pix = _QP(cp)
|
|
if not pix.isNull():
|
|
mime.setImageData(pix.toImage())
|
|
QApplication.clipboard().setMimeData(mime)
|
|
elif action == copy_url_action:
|
|
from PySide6.QtWidgets import QApplication
|
|
url = self._state_machine.current_path or ""
|
|
if url:
|
|
QApplication.clipboard().setText(url)
|
|
elif action == open_action:
|
|
self.open_in_default.emit()
|
|
elif action == browser_action:
|
|
self.open_in_browser.emit()
|
|
elif action == reset_action:
|
|
self._viewer._fit_to_view()
|
|
self._viewer.update()
|
|
elif action == close_action:
|
|
self.close()
|
|
|
|
def set_media(self, path: str, info: str = "", width: int = 0, height: int = 0) -> None:
|
|
"""Display `path` in the popout, info string above it.
|
|
|
|
`width` and `height` are the *known* media dimensions from the
|
|
post metadata (booru API), passed in by the caller when
|
|
available. They're used to pre-fit the popout window for video
|
|
files BEFORE mpv has loaded the file, so cached videos don't
|
|
flash a wrong-shaped black surface while mpv decodes the first
|
|
frame. mpv still fires `video_size` after demuxing and the
|
|
second `_fit_to_content` call corrects the aspect if the
|
|
encoded video-params differ from the API metadata (rare —
|
|
anamorphic / weirdly cropped sources). Both fits use the
|
|
persistent viewport's same `long_side` and the same center,
|
|
so the second fit is a no-op in the common case and only
|
|
produces a shape correction (no positional move) in the
|
|
mismatch case.
|
|
"""
|
|
self._info_label.setText(info)
|
|
ext = Path(path).suffix.lower()
|
|
|
|
# Detect kind for the state machine.
|
|
if _is_video(path):
|
|
kind = MediaKind.VIDEO
|
|
elif ext == ".gif":
|
|
kind = MediaKind.GIF
|
|
else:
|
|
kind = MediaKind.IMAGE
|
|
# Build the per-file referrer for streaming URLs. play_file
|
|
# also computes this internally during the legacy load path,
|
|
# but the dispatch path passes it through ContentArrived for
|
|
# the future when the state machine fully drives the load.
|
|
referer = None
|
|
if path.startswith(("http://", "https://")):
|
|
try:
|
|
from urllib.parse import urlparse
|
|
from ...core.cache import _referer_for
|
|
referer = _referer_for(urlparse(path))
|
|
except Exception:
|
|
pass
|
|
|
|
# Dispatch + apply. The state machine produces:
|
|
# - LoadVideo or LoadImage (loads the media)
|
|
# - FitWindowToContent (delegates to _fit_to_content)
|
|
# Both are applied via _apply_effects which delegates to the
|
|
# private helpers. The state machine becomes the single entry
|
|
# point for media load; set_media's body shrinks to dispatch
|
|
# + apply.
|
|
self._dispatch_and_apply(ContentArrived(
|
|
path=path,
|
|
info=info,
|
|
kind=kind,
|
|
width=width,
|
|
height=height,
|
|
referer=referer,
|
|
))
|
|
# Note: pre-fit to API dimensions was tried (option A from
|
|
# the perf round) but caused a perceptible slowdown in popout
|
|
# video clicks — the redundant second hyprctl dispatch when
|
|
# mpv's video_size callback fired produced a visible
|
|
# re-settle. The width/height params remain on the signature
|
|
# so the streaming and update-fullscreen call sites can keep
|
|
# passing them, but they're currently ignored. Re-enable
|
|
# cautiously if you can prove the second fit becomes a true
|
|
# no-op.
|
|
_ = (width, height)
|
|
# Note: do NOT auto-show the overlay on every set_media. The
|
|
# overlay should appear in response to user hover (handled in
|
|
# eventFilter on mouse-move into the top/bottom edge zones),
|
|
# not pop back up after every navigation.
|
|
|
|
def _is_hypr_floating(self) -> bool | None:
|
|
"""Check if this window is floating in Hyprland. None if not on Hyprland."""
|
|
win = hyprland.get_window(self.windowTitle())
|
|
if win is None:
|
|
return None # not Hyprland
|
|
return bool(win.get("floating"))
|
|
|
|
@staticmethod
|
|
def _compute_window_rect(
|
|
viewport: Viewport, content_aspect: float, screen,
|
|
avail_override: tuple[int, int, int, int] | None = None,
|
|
) -> tuple[int, int, int, int]:
|
|
"""Project a viewport onto a window rect for the given content aspect.
|
|
|
|
Symmetric across portrait/landscape: a 9:16 portrait and a 16:9
|
|
landscape with the same `long_side` have the same maximum edge
|
|
length. Proportional clamp shrinks both edges by the same factor
|
|
if either would exceed its 0.90-of-screen ceiling, preserving
|
|
aspect exactly. Pure function — no side effects, no widget
|
|
access, all inputs explicit so it's trivial to reason about.
|
|
|
|
``viewport.center_x``/``center_y`` hold the anchor point — the
|
|
window center in ``"center"`` mode, or the pinned corner in
|
|
corner modes. The anchor stays fixed; the window grows/shrinks
|
|
away from it.
|
|
|
|
*avail_override* is an (x, y, w, h) tuple that replaces
|
|
``screen.availableGeometry()`` — used on Hyprland where Qt
|
|
doesn't see Waybar's exclusive zone but ``hyprctl monitors -j``
|
|
reports it via the ``reserved`` array.
|
|
"""
|
|
if content_aspect >= 1.0: # landscape or square
|
|
w = viewport.long_side
|
|
h = viewport.long_side / content_aspect
|
|
else: # portrait
|
|
h = viewport.long_side
|
|
w = viewport.long_side * content_aspect
|
|
|
|
if avail_override:
|
|
ax, ay, aw, ah = avail_override
|
|
else:
|
|
_a = screen.availableGeometry()
|
|
ax, ay, aw, ah = _a.x(), _a.y(), _a.width(), _a.height()
|
|
cap_w = aw * 0.90
|
|
cap_h = ah * 0.90
|
|
scale = min(1.0, cap_w / w, cap_h / h)
|
|
w *= scale
|
|
h *= scale
|
|
|
|
anchor = viewport.anchor
|
|
if anchor == "tl":
|
|
x = viewport.center_x
|
|
y = viewport.center_y
|
|
elif anchor == "tr":
|
|
x = viewport.center_x - w
|
|
y = viewport.center_y
|
|
elif anchor == "bl":
|
|
x = viewport.center_x
|
|
y = viewport.center_y - h
|
|
elif anchor == "br":
|
|
x = viewport.center_x - w
|
|
y = viewport.center_y - h
|
|
else:
|
|
x = viewport.center_x - w / 2
|
|
y = viewport.center_y - h / 2
|
|
|
|
# Nudge onto screen if the window would land off-edge.
|
|
x = max(ax, min(x, ax + aw - w))
|
|
y = max(ay, min(y, ay + ah - h))
|
|
|
|
return (round(x), round(y), round(w), round(h))
|
|
|
|
def _build_viewport_from_current(
|
|
self, floating: bool | None, win: dict | None = None
|
|
) -> Viewport | None:
|
|
"""Build a viewport from the current window state, no caching.
|
|
|
|
Used in two cases:
|
|
1. First fit after open / F11 exit, when the persistent
|
|
`_viewport` is None and we need a starting value (the
|
|
`_pending_*` one-shots feed this path).
|
|
2. The "user moved the window externally" detection branch
|
|
in `_derive_viewport_for_fit`, when the cur-vs-last-dispatched
|
|
comparison shows drift > _DRIFT_TOLERANCE.
|
|
|
|
Returns None only if every source fails — Hyprland reports no
|
|
window AND non-Hyprland Qt geometry is also invalid.
|
|
"""
|
|
if floating is True:
|
|
if win is None:
|
|
win = hyprland.get_window(self.windowTitle())
|
|
if win and win.get("at") and win.get("size"):
|
|
wx, wy = win["at"]
|
|
ww, wh = win["size"]
|
|
ax, ay = anchor_point(wx, wy, ww, wh, self._anchor)
|
|
return Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(ww, wh)),
|
|
anchor=self._anchor,
|
|
)
|
|
if floating is None:
|
|
rect = self.geometry()
|
|
if rect.width() > 0 and rect.height() > 0:
|
|
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
|
|
return Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(rect.width(), rect.height())),
|
|
anchor=self._anchor,
|
|
)
|
|
return None
|
|
|
|
def _derive_viewport_for_fit(
|
|
self, floating: bool | None, win: dict | None = None
|
|
) -> Viewport | None:
|
|
"""Return the persistent viewport, updating it only on user action.
|
|
|
|
Three branches in priority order:
|
|
|
|
1. **First fit after open or F11 exit**: the `_pending_*`
|
|
one-shots are set. Seed `_viewport` from them and return.
|
|
This is the only path that overwrites the persistent
|
|
viewport unconditionally.
|
|
|
|
2. **Persistent viewport exists and is in agreement with
|
|
current window state**: return it unchanged. The compute
|
|
never reads its own output as input — sub-pixel drift
|
|
cannot accumulate here because we don't observe it.
|
|
|
|
3. **Persistent viewport exists but current state differs by
|
|
more than `_DRIFT_TOLERANCE`**: the user moved or resized
|
|
the window externally (Super+drag in Hyprland, corner-resize,
|
|
window manager intervention). Update the viewport from
|
|
current state — the user's new physical position IS the
|
|
new intent.
|
|
|
|
Wayland external moves don't fire Qt's `moveEvent`, so branch 3
|
|
is the only mechanism that captures Hyprland Super+drag. The
|
|
`_last_dispatched_rect` cache is what makes branch 2 stable —
|
|
without it, we'd have to read current state and compare to the
|
|
viewport's projection (the same code path that drifts).
|
|
|
|
`win` may be passed in by the caller to avoid an extra
|
|
`_hyprctl_get_window()` subprocess call (~3ms saved).
|
|
"""
|
|
# Branch 1: first fit after open or F11 exit
|
|
if self._first_fit_pending and self._pending_size and self._pending_position_restore:
|
|
pw, ph = self._pending_size
|
|
px, py = self._pending_position_restore
|
|
ax, ay = anchor_point(px, py, pw, ph, self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(pw, ph)),
|
|
anchor=self._anchor,
|
|
)
|
|
return self._viewport
|
|
|
|
# No persistent viewport yet AND no first-fit one-shots — defensive
|
|
# fallback. Build from current state and stash for next call.
|
|
if self._viewport is None:
|
|
self._viewport = self._build_viewport_from_current(floating, win)
|
|
return self._viewport
|
|
|
|
# Branch 2/3: persistent viewport exists. Check whether the user
|
|
# moved or resized the window externally since our last dispatch.
|
|
if floating is True and self._last_dispatched_rect is not None:
|
|
if win is None:
|
|
win = hyprland.get_window(self.windowTitle())
|
|
if win and win.get("at") and win.get("size"):
|
|
cur_x, cur_y = win["at"]
|
|
cur_w, cur_h = win["size"]
|
|
last_x, last_y, last_w, last_h = self._last_dispatched_rect
|
|
drift = max(
|
|
abs(cur_x - last_x),
|
|
abs(cur_y - last_y),
|
|
abs(cur_w - last_w),
|
|
abs(cur_h - last_h),
|
|
)
|
|
if drift > _DRIFT_TOLERANCE:
|
|
# External move/resize detected. Adopt current as intent.
|
|
ax, ay = anchor_point(cur_x, cur_y, cur_w, cur_h, self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(cur_w, cur_h)),
|
|
anchor=self._anchor,
|
|
)
|
|
|
|
return self._viewport
|
|
|
|
def _fit_to_content(self, content_w: int, content_h: int, _retry: int = 0) -> None:
|
|
"""Size window to fit content. Viewport-based: long_side preserved across navs.
|
|
|
|
Distinguishes "not on Hyprland" (Qt drives geometry, no aspect
|
|
lock available) from "on Hyprland but the window isn't visible
|
|
to hyprctl yet" (the very first call after a popout open races
|
|
the wm:openWindow event — `hyprctl clients -j` returns no entry
|
|
for our title for ~tens of ms). The latter case used to fall
|
|
through to a plain Qt resize and skip the keep_aspect_ratio
|
|
setprop entirely, so the *first* image popout always opened
|
|
without aspect locking and only subsequent navigations got the
|
|
right shape. Now we retry with a short backoff when on Hyprland
|
|
and the window isn't found, capped so a real "not Hyprland"
|
|
signal can't loop.
|
|
|
|
Math is now viewport-based: a Viewport (center + long_side) is
|
|
derived from current state, then projected onto a rect for the
|
|
new content aspect via `_compute_window_rect`. This breaks the
|
|
width-anchor ratchet that the previous version had — long_side
|
|
is symmetric across portrait and landscape, so navigating
|
|
P→L→P→L doesn't permanently shrink the landscape width.
|
|
See the plan at ~/.claude/plans/ancient-growing-lantern.md
|
|
for the full derivation.
|
|
"""
|
|
if self.isFullScreen() or content_w <= 0 or content_h <= 0:
|
|
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 = hyprland.get_window(self.windowTitle())
|
|
if win is None:
|
|
if _retry < 5:
|
|
QTimer.singleShot(
|
|
40,
|
|
lambda: self._fit_to_content(content_w, content_h, _retry + 1),
|
|
)
|
|
return
|
|
floating = bool(win.get("floating"))
|
|
else:
|
|
floating = None
|
|
if floating is False:
|
|
hyprland.resize(self.windowTitle(), 0, 0) # tiled: just set keep_aspect_ratio
|
|
return
|
|
aspect = content_w / content_h
|
|
screen = self.screen()
|
|
if screen is None:
|
|
return
|
|
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
|
|
# the one-shots would lose the saved position; leaving them
|
|
# set lets a subsequent fit retry.
|
|
return
|
|
avail_rect = None
|
|
if on_hypr and win:
|
|
avail_rect = hyprland.get_monitor_available_rect(win.get("monitor"))
|
|
x, y, w, h = self._compute_window_rect(viewport, aspect, screen, avail_override=avail_rect)
|
|
# Identical-rect skip. If the computed rect is exactly what
|
|
# we last dispatched, the window is already in that state and
|
|
# there's nothing for hyprctl (or setGeometry) to do. Skipping
|
|
# saves one subprocess.Popen + Hyprland's processing of the
|
|
# redundant resize/move dispatch — ~100-300ms of perceived
|
|
# latency on cached video clicks where the new content has the
|
|
# same aspect/long_side as the previous, which is common (back-
|
|
# to-back videos from the same source, image→video with matching
|
|
# aspect, re-clicking the same post). Doesn't apply on the very
|
|
# first fit after open (last_dispatched_rect is None) and the
|
|
# first dispatch always lands. Doesn't break drift detection
|
|
# because the comparison branch in _derive_viewport_for_fit
|
|
# already ran above and would have updated _viewport (and
|
|
# therefore the computed rect) if Hyprland reported drift.
|
|
if self._last_dispatched_rect == (x, y, w, h):
|
|
self._first_fit_pending = False
|
|
self._pending_position_restore = None
|
|
self._pending_size = None
|
|
return
|
|
# Reentrancy guard: set before any dispatch so the
|
|
# moveEvent/resizeEvent handlers (which fire on the non-Hyprland
|
|
# Qt fallback path) don't update the persistent viewport from
|
|
# our own programmatic geometry change.
|
|
self._applying_dispatch = True
|
|
try:
|
|
if floating is True:
|
|
# 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.
|
|
hyprland.resize_and_move(self.windowTitle(), w, h, x, y, win=win)
|
|
else:
|
|
# Non-Hyprland fallback: Qt drives geometry directly. Use
|
|
# setGeometry with the computed top-left rather than resize()
|
|
# so the window center stays put — Qt's resize() anchors
|
|
# top-left and lets the bottom-right move, which causes the
|
|
# popout center to drift toward the upper-left of the screen
|
|
# over repeated navigations.
|
|
self.setGeometry(QRect(x, y, w, h))
|
|
finally:
|
|
self._applying_dispatch = False
|
|
# Cache the dispatched rect so the next nav can compare current
|
|
# Hyprland state against it and detect external moves/resizes.
|
|
# This is the persistent-viewport's link back to reality without
|
|
# reading our own output every nav.
|
|
self._last_dispatched_rect = (x, y, w, h)
|
|
self._first_fit_pending = False
|
|
self._pending_position_restore = None
|
|
self._pending_size = None
|
|
|
|
def _show_overlay(self) -> None:
|
|
"""Show toolbar and video controls, restart auto-hide timer."""
|
|
if not self._ui_visible:
|
|
self._toolbar.show()
|
|
if self._stack.currentIndex() == 1:
|
|
self._video._controls_bar.show()
|
|
self._ui_visible = True
|
|
self._hide_timer.start()
|
|
|
|
def _hide_overlay(self) -> None:
|
|
"""Hide toolbar and video controls."""
|
|
self._toolbar.hide()
|
|
self._video._controls_bar.hide()
|
|
self._ui_visible = False
|
|
|
|
def eventFilter(self, obj, event):
|
|
from PySide6.QtCore import QEvent
|
|
from PySide6.QtWidgets import QLineEdit, QTextEdit, QSpinBox, QComboBox
|
|
if event.type() == QEvent.Type.KeyPress:
|
|
# Only intercept when slideshow is the active window
|
|
if not self.isActiveWindow():
|
|
return super().eventFilter(obj, event)
|
|
# Don't intercept keys when typing in text inputs
|
|
if isinstance(obj, (QLineEdit, QTextEdit, QSpinBox, QComboBox)):
|
|
return super().eventFilter(obj, event)
|
|
key = event.key()
|
|
mods = event.modifiers()
|
|
if key == Qt.Key.Key_P and mods & Qt.KeyboardModifier.ControlModifier:
|
|
self.privacy_requested.emit()
|
|
return True
|
|
elif key == Qt.Key.Key_H and mods & Qt.KeyboardModifier.ControlModifier:
|
|
if self._ui_visible:
|
|
self._hide_timer.stop()
|
|
self._hide_overlay()
|
|
else:
|
|
self._show_overlay()
|
|
return True
|
|
elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Q):
|
|
# Don't dispatch CloseRequested here — closeEvent
|
|
# dispatches it after saving geometry. The state
|
|
# machine sees the close exactly once and the
|
|
# geometry persistence (adapter-side concern) runs
|
|
# before the EmitClosed effect fires.
|
|
self.close()
|
|
return True
|
|
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
|
|
self._dispatch_and_apply(NavigateRequested(direction=-1))
|
|
return True
|
|
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
|
|
self._dispatch_and_apply(NavigateRequested(direction=1))
|
|
return True
|
|
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
|
|
self._dispatch_and_apply(NavigateRequested(direction=-self._grid_cols))
|
|
return True
|
|
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
|
|
self._dispatch_and_apply(NavigateRequested(direction=self._grid_cols))
|
|
return True
|
|
elif key in (Qt.Key.Key_B, Qt.Key.Key_F):
|
|
self.bookmark_requested.emit()
|
|
return True
|
|
elif key == Qt.Key.Key_S:
|
|
self.toggle_save_requested.emit()
|
|
return True
|
|
elif key == Qt.Key.Key_F11:
|
|
self._dispatch_and_apply(FullscreenToggled())
|
|
return True
|
|
elif key == Qt.Key.Key_Space and self._stack.currentIndex() == 1:
|
|
self._dispatch_and_apply(TogglePlayRequested())
|
|
return True
|
|
elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1:
|
|
# +/- keys are seek-relative, NOT slider-pin seeks. The
|
|
# state machine's SeekRequested is for slider-driven
|
|
# seeks. The +/- keys go straight to mpv via the
|
|
# legacy path; the dispatch path doesn't see them in
|
|
# 14a (commit 14b will route them through SeekRequested
|
|
# with a target_ms computed from current position).
|
|
self._video._seek_relative(1800)
|
|
return True
|
|
elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1:
|
|
self._video._seek_relative(-1800)
|
|
return True
|
|
if event.type() == QEvent.Type.Wheel and self.isActiveWindow():
|
|
# Horizontal tilt navigates between posts on either stack
|
|
tilt = event.angleDelta().x()
|
|
if tilt > 30:
|
|
self._dispatch_and_apply(NavigateRequested(direction=-1))
|
|
return True
|
|
if tilt < -30:
|
|
self._dispatch_and_apply(NavigateRequested(direction=1))
|
|
return True
|
|
# Vertical wheel adjusts volume on the video stack only
|
|
if self._stack.currentIndex() == 1:
|
|
self._vol_scroll_accum += event.angleDelta().y()
|
|
steps = self._vol_scroll_accum // 120
|
|
if steps:
|
|
self._vol_scroll_accum -= steps * 120
|
|
vol = max(0, min(100, self._video.volume + 5 * steps))
|
|
self._dispatch_and_apply(VolumeSet(value=vol))
|
|
self._video.volume = vol
|
|
self._show_overlay()
|
|
return True
|
|
if event.type() == QEvent.Type.MouseMove and self.isActiveWindow():
|
|
# Map cursor position to window coordinates
|
|
cursor_pos = self.mapFromGlobal(event.globalPosition().toPoint() if hasattr(event, 'globalPosition') else event.globalPos())
|
|
y = cursor_pos.y()
|
|
h = self.height()
|
|
zone = max(60, h // 10) # ~10% of window height, floor 60px
|
|
if y < zone:
|
|
self._toolbar.show()
|
|
self._hide_timer.start()
|
|
elif y > h - zone and self._stack.currentIndex() == 1:
|
|
self._video._controls_bar.show()
|
|
self._hide_timer.start()
|
|
self._ui_visible = self._toolbar.isVisible() or self._video._controls_bar.isVisible()
|
|
return super().eventFilter(obj, event)
|
|
|
|
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:
|
|
"""Enter fullscreen — capture windowed geometry first so F11 back can restore it.
|
|
|
|
Also capture the current windowed state into the persistent
|
|
`_viewport` so the F11-exit restore lands at the user's actual
|
|
pre-F11 position, not at a stale viewport from before they last
|
|
dragged the window. The drift detection in `_derive_viewport_for_fit`
|
|
only fires when `_last_dispatched_rect` is set AND a fit is being
|
|
computed — neither path catches the "user dragged the popout
|
|
with Super+drag and then immediately pressed F11" sequence,
|
|
because Hyprland Super+drag doesn't fire Qt's moveEvent and no
|
|
nav has happened to trigger a fit. Capturing fresh into
|
|
`_viewport` here makes the restore correct regardless.
|
|
"""
|
|
from PySide6.QtCore import QRect
|
|
win = hyprland.get_window(self.windowTitle())
|
|
if win and win.get("at") and win.get("size"):
|
|
x, y = win["at"]
|
|
w, h = win["size"]
|
|
self._windowed_geometry = QRect(x, y, w, h)
|
|
ax, ay = anchor_point(x, y, w, h, self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(w, h)),
|
|
anchor=self._anchor,
|
|
)
|
|
else:
|
|
self._windowed_geometry = self.frameGeometry()
|
|
rect = self._windowed_geometry
|
|
if rect.width() > 0 and rect.height() > 0:
|
|
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(rect.width(), rect.height())),
|
|
anchor=self._anchor,
|
|
)
|
|
self.showFullScreen()
|
|
|
|
def _exit_fullscreen(self) -> None:
|
|
"""Leave fullscreen — let the persistent viewport drive the restore.
|
|
|
|
With the Group B persistent viewport in place, F11 exit no longer
|
|
needs to re-arm the `_first_fit_pending` one-shots. The viewport
|
|
already holds the pre-fullscreen center + long_side from before
|
|
the user pressed F11 — fullscreen entry doesn't write to it,
|
|
and nothing during fullscreen does either (no `_fit_to_content`
|
|
runs while `isFullScreen()` is True). So the next deferred fit
|
|
after `showNormal()` reads the persistent viewport, computes the
|
|
new windowed rect for the current content's aspect, and dispatches
|
|
— landing at the pre-fullscreen CENTER with the new shape, which
|
|
also fixes the legacy F11-walks-toward-saved-top-left bug 1f as a
|
|
side effect of the Group B refactor.
|
|
|
|
We still need to invalidate `_last_dispatched_rect` because the
|
|
cached value is from the pre-fullscreen window, and after F11
|
|
Hyprland may report a different position before the deferred fit
|
|
catches up — we don't want the drift detector to think the user
|
|
moved the window externally during fullscreen.
|
|
"""
|
|
content_w, content_h = 0, 0
|
|
if self._stack.currentIndex() == 1:
|
|
mpv = self._video._mpv
|
|
if mpv:
|
|
try:
|
|
vp = mpv.video_params
|
|
if vp and vp.get('w') and vp.get('h'):
|
|
content_w, content_h = vp['w'], vp['h']
|
|
except Exception:
|
|
pass
|
|
else:
|
|
pix = self._viewer._pixmap
|
|
if pix and not pix.isNull():
|
|
content_w, content_h = pix.width(), pix.height()
|
|
FullscreenPreview._saved_fullscreen = False
|
|
# Invalidate the cache so the next fit doesn't false-positive on
|
|
# "user moved the window during fullscreen". The persistent
|
|
# viewport stays as-is and will drive the restore.
|
|
self._last_dispatched_rect = None
|
|
self.showNormal()
|
|
if content_w > 0 and content_h > 0:
|
|
# Defer to next event-loop tick so Qt's showNormal() is processed
|
|
# by Hyprland before our hyprctl batch fires. Without this defer
|
|
# the two race and the window lands at top-left.
|
|
QTimer.singleShot(0, lambda: self._fit_to_content(content_w, content_h))
|
|
|
|
def resizeEvent(self, event) -> None:
|
|
super().resizeEvent(event)
|
|
# Position floating overlays
|
|
w = self.centralWidget().width()
|
|
h = self.centralWidget().height()
|
|
tb_h = self._toolbar.sizeHint().height()
|
|
self._toolbar.setGeometry(0, 0, w, tb_h)
|
|
ctrl_h = self._video._controls_bar.sizeHint().height()
|
|
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
|
|
# long_side the user chose survives subsequent navigations.
|
|
#
|
|
# GATED TO NON-HYPRLAND. On Wayland (Hyprland included), Qt
|
|
# cannot know the window's absolute screen position — xdg-toplevel
|
|
# doesn't expose it to clients — so `self.geometry()` returns
|
|
# `QRect(0, 0, w, h)` regardless of where the compositor actually
|
|
# placed the window. If we let this branch run on Hyprland, every
|
|
# configure event from a hyprctl dispatch (or from the user's
|
|
# Super+drag, or from `showNormal()` exiting fullscreen) would
|
|
# corrupt the viewport center to ~(w/2, h/2) — a small positive
|
|
# number far from the screen center — and the next dispatch
|
|
# would project that bogus center, edge-nudge it, and land at
|
|
# the top-left. Bug observed during the Group B viewport rollout.
|
|
#
|
|
# The `_applying_dispatch` guard catches the synchronous
|
|
# non-Hyprland setGeometry path (where moveEvent fires inside
|
|
# the try/finally block). It does NOT catch the async Hyprland
|
|
# path because Popen returns instantly and the configure-event
|
|
# → moveEvent round-trip happens later. The Hyprland gate
|
|
# below is the actual fix; the `_applying_dispatch` guard
|
|
# remains for the non-Hyprland path.
|
|
#
|
|
# On Hyprland, external drags/resizes are picked up by the
|
|
# cur-vs-last-dispatched comparison in `_derive_viewport_for_fit`,
|
|
# which reads `hyprctl clients -j` (the only reliable absolute
|
|
# position source on Wayland).
|
|
import os
|
|
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
|
|
return
|
|
if self._applying_dispatch or self.isFullScreen():
|
|
return
|
|
rect = self.geometry()
|
|
if rect.width() > 0 and rect.height() > 0:
|
|
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=float(max(rect.width(), rect.height())),
|
|
anchor=self._anchor,
|
|
)
|
|
# Parallel state machine dispatch for the same event.
|
|
self._dispatch_and_apply(WindowResized(rect=(
|
|
rect.x(), rect.y(), rect.width(), rect.height(),
|
|
)))
|
|
|
|
def moveEvent(self, event) -> None:
|
|
super().moveEvent(event)
|
|
# Capture user drags into the persistent viewport on the
|
|
# non-Hyprland Qt path.
|
|
#
|
|
# GATED TO NON-HYPRLAND for the same reason as resizeEvent —
|
|
# `self.geometry()` is unreliable on Wayland. See the long
|
|
# comment in resizeEvent above for the full diagnosis. On
|
|
# Hyprland, drag detection happens via the cur-vs-last-dispatched
|
|
# comparison in `_derive_viewport_for_fit` instead.
|
|
import os
|
|
if os.environ.get("HYPRLAND_INSTANCE_SIGNATURE"):
|
|
return
|
|
if self._applying_dispatch or self.isFullScreen():
|
|
return
|
|
if self._viewport is None:
|
|
return
|
|
rect = self.geometry()
|
|
if rect.width() > 0 and rect.height() > 0:
|
|
# Move-only update: keep the existing long_side, just
|
|
# update the anchor point to where the window now sits.
|
|
ax, ay = anchor_point(rect.x(), rect.y(), rect.width(), rect.height(), self._anchor)
|
|
self._viewport = Viewport(
|
|
center_x=ax, center_y=ay,
|
|
long_side=self._viewport.long_side,
|
|
anchor=self._anchor,
|
|
)
|
|
# Parallel state machine dispatch for the same event.
|
|
self._dispatch_and_apply(WindowMoved(rect=(
|
|
rect.x(), rect.y(), rect.width(), rect.height(),
|
|
)))
|
|
|
|
def showEvent(self, event) -> None:
|
|
super().showEvent(event)
|
|
# Pre-warm the mpv GL render context as soon as the popout is
|
|
# mapped, so the first video click doesn't pay for GL context
|
|
# creation (~100-200ms one-time cost). The widget needs to be
|
|
# visible for `makeCurrent()` to succeed, which is what showEvent
|
|
# gives us. ensure_gl_init is idempotent — re-shows after a
|
|
# close/reopen are cheap no-ops.
|
|
try:
|
|
self._video._gl_widget.ensure_gl_init()
|
|
except Exception:
|
|
# If GL pre-warm fails (driver weirdness, headless test),
|
|
# play_file's lazy ensure_gl_init still runs as a fallback.
|
|
pass
|
|
|
|
def closeEvent(self, event) -> None:
|
|
from PySide6.QtWidgets import QApplication
|
|
# Save window state for next open BEFORE the state machine
|
|
# dispatch — main_window's _on_fullscreen_closed handler reads
|
|
# FullscreenPreview._saved_geometry / _saved_fullscreen, and
|
|
# the EmitClosed effect (applied below) emits self.closed,
|
|
# which triggers _on_fullscreen_closed. Geometry persistence
|
|
# has to land first or main_window reads stale values.
|
|
#
|
|
# Geometry is adapter-side concern, not state machine concern,
|
|
# so the state machine doesn't see it.
|
|
FullscreenPreview._saved_fullscreen = self.isFullScreen()
|
|
if not self.isFullScreen():
|
|
# On Hyprland, Qt doesn't know the real position — ask the WM
|
|
win = hyprland.get_window(self.windowTitle())
|
|
if win and win.get("at") and win.get("size"):
|
|
from PySide6.QtCore import QRect
|
|
x, y = win["at"]
|
|
w, h = win["size"]
|
|
FullscreenPreview._saved_geometry = QRect(x, y, w, h)
|
|
else:
|
|
FullscreenPreview._saved_geometry = self.frameGeometry()
|
|
QApplication.instance().removeEventFilter(self)
|
|
# Snapshot video position BEFORE StopMedia destroys it.
|
|
# _on_fullscreen_closed reads this via get_video_state() to
|
|
# seek the embedded preview to the same position.
|
|
self._close_position_ms = (
|
|
self._video.get_position_ms()
|
|
if self.is_video_active()
|
|
else 0
|
|
)
|
|
# NOW dispatch + apply CloseRequested. Effects are
|
|
# [StopMedia, EmitClosed]. StopMedia clears the media stack;
|
|
# EmitClosed emits self.closed which triggers main_window's
|
|
# _on_fullscreen_closed handler.
|
|
self._dispatch_and_apply(CloseRequested())
|
|
super().closeEvent(event)
|