From 0a8d3921586ee9ef529e3ac507f170f7a917aa7f Mon Sep 17 00:00:00 2001 From: pax Date: Fri, 10 Apr 2026 15:03:42 -0500 Subject: [PATCH] refactor: extract PopoutController from main_window.py Move 5 popout lifecycle methods (_open_fullscreen_preview, _on_fullscreen_closed, _navigate_fullscreen, _update_fullscreen, _update_fullscreen_state) and 4 state attributes (_fullscreen_window, _popout_active, _info_was_visible, _right_splitter_sizes) into gui/popout_controller.py. Rename pass across ALL gui/ files: self._fullscreen_window -> self._popout_ctrl.window (or self._app._popout_ctrl.window in other controllers), self._popout_active -> self._popout_ctrl.is_active. Zero remaining references outside popout_controller.py. Extract build_video_sync_dict as a pure function for Phase 2 tests. main_window.py: 2145 -> 1935 lines. behavior change: none --- booru_viewer/gui/main_window.py | 250 +++----------------------- booru_viewer/gui/media_controller.py | 18 +- booru_viewer/gui/popout_controller.py | 204 +++++++++++++++++++++ booru_viewer/gui/privacy.py | 10 +- booru_viewer/gui/search_controller.py | 4 +- booru_viewer/gui/window_state.py | 2 +- 6 files changed, 241 insertions(+), 247 deletions(-) create mode 100644 booru_viewer/gui/popout_controller.py diff --git a/booru_viewer/gui/main_window.py b/booru_viewer/gui/main_window.py index 03b3f79..a6eb441 100644 --- a/booru_viewer/gui/main_window.py +++ b/booru_viewer/gui/main_window.py @@ -62,6 +62,7 @@ from .window_state import WindowStateController from .privacy import PrivacyController from .search_controller import SearchController from .media_controller import MediaController +from .popout_controller import PopoutController log = logging.getLogger("booru") @@ -127,6 +128,7 @@ class BooruApp(QMainWindow): self._privacy = PrivacyController(self) self._search_ctrl = SearchController(self) self._media_ctrl = MediaController(self) + self._popout_ctrl = PopoutController(self) self._main_window_save_timer = QTimer(self) self._main_window_save_timer.setSingleShot(True) self._main_window_save_timer.setInterval(300) @@ -353,7 +355,7 @@ class BooruApp(QMainWindow): self._preview.blacklist_post_requested.connect(self._blacklist_post_from_popout) self._preview.navigate.connect(self._navigate_preview) self._preview.play_next_requested.connect(self._on_video_end_next) - self._preview.fullscreen_requested.connect(self._open_fullscreen_preview) + self._preview.fullscreen_requested.connect(self._popout_ctrl.open) # Library folders come from the filesystem (subdirs of saved_dir), # not the bookmark folders DB table — those are separate concepts. from ..core.config import library_folders @@ -361,7 +363,6 @@ class BooruApp(QMainWindow): # Bookmark folders feed the toolbar Bookmark-as submenu, sourced # from the DB so it stays in sync with the bookmarks tab combo. self._preview.set_bookmark_folders_callback(self._db.get_folders) - self._fullscreen_window = None # Wide enough that the preview toolbar (Bookmark, Save, BL Tag, # BL Post, [stretch], Popout) has room to lay out all five buttons # at their fixed widths plus spacing without clipping the rightmost @@ -401,11 +402,6 @@ class BooruApp(QMainWindow): if self._db.get_setting_bool("info_panel_visible"): self._info_panel.show() - # Flag set during popout open/close so the splitter saver below - # doesn't persist the temporary [0, 0, 1000] state the popout - # uses to give the info panel the full right column. - self._popout_active = False - # Debounced saver for the right splitter (same pattern as main). self._right_splitter_save_timer = QTimer(self) self._right_splitter_save_timer.setSingleShot(True) @@ -667,37 +663,6 @@ class BooruApp(QMainWindow): self._media_ctrl.on_post_activated(index) - def _update_fullscreen(self, path: str, info: str) -> None: - """Sync the fullscreen window with the current preview media.""" - if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._preview._video_player.stop() - cp = self._preview._current_post - w = cp.width if cp else 0 - h = cp.height if cp else 0 - self._fullscreen_window.set_media(path, info, width=w, height=h) - show_full = self._stack.currentIndex() != 2 - self._fullscreen_window.set_toolbar_visibility( - bookmark=show_full, - save=True, - bl_tag=show_full, - bl_post=show_full, - ) - self._update_fullscreen_state() - - def _update_fullscreen_state(self) -> None: - """Update popout button states by mirroring the embedded preview.""" - if not self._fullscreen_window: - return - self._fullscreen_window.update_state( - self._preview._is_bookmarked, - self._preview._is_saved, - ) - post = self._preview._current_post - if post is not None: - self._fullscreen_window.set_post_tags( - post.tag_categories or {}, post.tag_list - ) - def _show_library_post(self, path: str) -> None: # Read actual image dimensions so the popout can pre-fit and # set keep_aspect_ratio. library_meta doesn't store w/h, so @@ -731,7 +696,7 @@ class BooruApp(QMainWindow): self._preview.update_save_state(True) # _update_fullscreen reads cp.width/cp.height from _current_post, # so it runs AFTER the Post is constructed with real dimensions. - self._update_fullscreen(path, Path(path).name) + self._popout_ctrl.update_media(path, Path(path).name) def _on_bookmark_selected(self, fav) -> None: self._status.showMessage(f"Bookmark #{fav.post_id}") @@ -771,7 +736,7 @@ class BooruApp(QMainWindow): # Try local cache first if fav.cached_path and Path(fav.cached_path).exists(): self._media_ctrl.set_preview_media(fav.cached_path, info) - self._update_fullscreen(fav.cached_path, info) + self._popout_ctrl.update_media(fav.cached_path, info) return # Try saved library — walk by post id; the file may live in any @@ -781,7 +746,7 @@ class BooruApp(QMainWindow): from ..core.config import find_library_files for path in find_library_files(fav.post_id, db=self._db): self._media_ctrl.set_preview_media(str(path), info) - self._update_fullscreen(str(path), info) + self._popout_ctrl.update_media(str(path), info) return # Download it @@ -824,8 +789,8 @@ class BooruApp(QMainWindow): path = derived if path is not None: self._preview._video_player.pause() - if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._fullscreen_window.pause_media() + if self._popout_ctrl.window and self._popout_ctrl.window.isVisible(): + self._popout_ctrl.window.pause_media() QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) else: self._status.showMessage("Bookmark not cached — open it first to download") @@ -837,8 +802,8 @@ class BooruApp(QMainWindow): current = self._preview._current_path if current and Path(current).exists(): self._preview._video_player.pause() - if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._fullscreen_window.pause_media() + if self._popout_ctrl.window and self._popout_ctrl.window.isVisible(): + self._popout_ctrl.window.pause_media() QDesktopServices.openUrl(QUrl.fromLocalFile(current)) return # Browse: original path. Removed the "open random cache file" @@ -988,7 +953,7 @@ class BooruApp(QMainWindow): ) bookmarked = bool(self._db.is_bookmarked(site_id, post.id)) self._preview.update_bookmark_state(bookmarked) - self._update_fullscreen_state() + self._popout_ctrl.update_state() # Refresh bookmarks tab if visible if self._stack.currentIndex() == 1: self._bookmarks_view.refresh() @@ -1036,7 +1001,7 @@ class BooruApp(QMainWindow): where = target or "Unfiled" self._status.showMessage(f"Bookmarked #{post.id} to {where}") self._preview.update_bookmark_state(True) - self._update_fullscreen_state() + self._popout_ctrl.update_state() # Refresh bookmarks tab if visible so the new entry appears. if self._stack.currentIndex() == 1: self._bookmarks_view.refresh() @@ -1080,7 +1045,7 @@ class BooruApp(QMainWindow): self._library_view.refresh() else: self._status.showMessage(f"#{post.id} not in library") - self._update_fullscreen_state() + self._popout_ctrl.update_state() def _blacklist_tag_from_popout(self, tag: str) -> None: reply = QMessageBox.question( @@ -1109,181 +1074,6 @@ class BooruApp(QMainWindow): self._status.showMessage(f"Post #{post.id} blacklisted") self._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url) - def _open_fullscreen_preview(self) -> None: - path = self._preview._current_path - if not path: - return - info = self._preview._info_label.text() - # Grab video position before clearing - video_pos = 0 - if self._preview._stack.currentIndex() == 1: - video_pos = self._preview._video_player.get_position_ms() - # Clear the main preview — popout takes over - # Hide preview, expand info panel into the freed space. - # Mark popout as active so the right splitter saver doesn't persist - # this transient layout (which would lose the user's real preferred - # sizes between sessions). - self._popout_active = True - self._info_was_visible = self._info_panel.isVisible() - self._right_splitter_sizes = self._right_splitter.sizes() - self._preview.clear() - self._preview.hide() - self._info_panel.show() - self._right_splitter.setSizes([0, 0, 1000]) - self._preview._current_path = path - # Populate info panel for the current post - idx = self._grid.selected_index - if 0 <= idx < len(self._posts): - self._info_panel.set_post(self._posts[idx]) - from .popout.window import FullscreenPreview - # Restore persisted window state - saved_geo = self._db.get_setting("slideshow_geometry") - saved_fs = self._db.get_setting_bool("slideshow_fullscreen") - if saved_geo: - parts = saved_geo.split(",") - if len(parts) == 4: - from PySide6.QtCore import QRect - FullscreenPreview._saved_geometry = QRect(*[int(p) for p in parts]) - FullscreenPreview._saved_fullscreen = saved_fs - else: - FullscreenPreview._saved_geometry = None - FullscreenPreview._saved_fullscreen = True - else: - FullscreenPreview._saved_fullscreen = True - cols = self._grid._flow.columns - show_actions = self._stack.currentIndex() != 2 - monitor = self._db.get_setting("slideshow_monitor") - self._fullscreen_window = FullscreenPreview(grid_cols=cols, show_actions=show_actions, monitor=monitor, parent=self) - self._fullscreen_window.navigate.connect(self._navigate_fullscreen) - self._fullscreen_window.play_next_requested.connect(self._on_video_end_next) - # Save signals are always wired — even in library mode, the - # popout's Save button is the only toolbar action visible (acting - # as Unsave for the file being viewed), and it has its own - # Save-to-Library submenu shape that matches the embedded preview. - from ..core.config import library_folders - self._fullscreen_window.set_folders_callback(library_folders) - self._fullscreen_window.save_to_folder.connect(self._save_from_preview) - self._fullscreen_window.unsave_requested.connect(self._unsave_from_preview) - if show_actions: - self._fullscreen_window.bookmark_requested.connect(self._bookmark_from_preview) - # Same Bookmark-as flow as the embedded preview — popout reuses - # the existing handler since both signals carry just a folder - # name and read the post from self._preview._current_post. - self._fullscreen_window.set_bookmark_folders_callback(self._db.get_folders) - self._fullscreen_window.bookmark_to_folder.connect(self._bookmark_to_folder_from_preview) - self._fullscreen_window.blacklist_tag_requested.connect(self._blacklist_tag_from_popout) - self._fullscreen_window.blacklist_post_requested.connect(self._blacklist_post_from_popout) - self._fullscreen_window.open_in_default.connect(self._open_preview_in_default) - self._fullscreen_window.open_in_browser.connect(self._open_preview_in_browser) - self._fullscreen_window.closed.connect(self._on_fullscreen_closed) - self._fullscreen_window.privacy_requested.connect(self._privacy.toggle) - # Set post tags for BL Tag menu - post = self._preview._current_post - if post: - self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list) - # Sync video player state from preview to popout via the - # popout's public sync_video_state method (replaces direct - # popout._video.* attribute writes). - pv = self._preview._video_player - self._fullscreen_window.sync_video_state( - volume=pv.volume, - mute=pv.is_muted, - autoplay=pv.autoplay, - loop_state=pv.loop_state, - ) - # Connect seek-after-load BEFORE set_media so we don't miss media_ready - if video_pos > 0: - self._fullscreen_window.connect_media_ready_once( - lambda: self._fullscreen_window.seek_video_to(video_pos) - ) - # Pre-fit dimensions for the popout video pre-fit optimization - # — `post` is the same `self._preview._current_post` referenced - # at line 2164 (set above), so reuse it without an extra read. - pre_w = post.width if post else 0 - pre_h = post.height if post else 0 - self._fullscreen_window.set_media(path, info, width=pre_w, height=pre_h) - # Always sync state — the save button is visible in both modes - # (library mode = only Save shown, browse/bookmarks = full toolbar) - # so its Unsave label needs to land before the user sees it. - self._update_fullscreen_state() - - def _on_fullscreen_closed(self) -> None: - # Persist popout window state to DB - if self._fullscreen_window: - from .popout.window import FullscreenPreview - fs = FullscreenPreview._saved_fullscreen - geo = FullscreenPreview._saved_geometry - self._db.set_setting("slideshow_fullscreen", "1" if fs else "0") - if geo: - self._db.set_setting("slideshow_geometry", f"{geo.x()},{geo.y()},{geo.width()},{geo.height()}") - # Restore preview and info panel visibility - self._preview.show() - if not getattr(self, '_info_was_visible', False): - self._info_panel.hide() - if hasattr(self, '_right_splitter_sizes'): - self._right_splitter.setSizes(self._right_splitter_sizes) - # Clear the popout-active flag now that the right splitter is back - # in its real shape — future splitterMoved events should persist. - self._popout_active = False - # Sync video player state from popout back to preview via - # the popout's public get_video_state method (replaces direct - # popout._video.* attribute reads + popout._stack.currentIndex - # check). The dict carries volume / mute / autoplay / loop_state - # / position_ms in one read. - video_pos = 0 - if self._fullscreen_window: - vstate = self._fullscreen_window.get_video_state() - pv = self._preview._video_player - pv.volume = vstate["volume"] - pv.is_muted = vstate["mute"] - pv.autoplay = vstate["autoplay"] - pv.loop_state = vstate["loop_state"] - video_pos = vstate["position_ms"] - # Restore preview with current media - path = self._preview._current_path - info = self._preview._info_label.text() - self._fullscreen_window = None - if path: - # Connect seek-after-load BEFORE set_media so we don't miss media_ready - if video_pos > 0: - def _seek_preview(): - self._preview._video_player.seek_to_ms(video_pos) - try: - self._preview._video_player.media_ready.disconnect(_seek_preview) - except RuntimeError: - pass - self._preview._video_player.media_ready.connect(_seek_preview) - self._preview.set_media(path, info) - - def _navigate_fullscreen(self, direction: int) -> None: - # Just navigate. Do NOT call _update_fullscreen here with the - # current_path even though earlier code did — for browse view, - # _current_path still holds the PREVIOUS post's path at this - # moment (the new post's path doesn't land until the async - # _load completes and _on_image_done fires). Calling - # _update_fullscreen with the stale path would re-load the - # OLD video in the popout, which then races mpv's eof-reached - # observer (mpv emits eof on the redundant `command('stop')` - # the reload performs). If the observer fires after play_file's - # _eof_pending=False reset, _handle_eof picks it up on the next - # poll tick and emits play_next in Loop=Next mode — auto- - # advancing past the ACTUAL next post the user wanted. Bug - # observed empirically: keyboard nav in popout sometimes - # skipped a post. - # - # The correct sync paths are already in place: - # - Browse: _navigate_preview → _on_post_activated → async - # _load → _on_image_done → _update_fullscreen(NEW_path) - # - Bookmarks: _navigate_preview → _on_bookmark_activated → - # _update_fullscreen(fav.cached_path) (sync, line 1683/1691) - # - Library: _navigate_preview → file_activated → - # _on_library_activated → _show_library_post → - # _update_fullscreen(path) (sync, line 1622) - # Each downstream path uses the *correct* new path. The - # additional call here was both redundant (bookmark/library) - # and racy/buggy (browse). - self._navigate_preview(direction) - def _close_preview(self) -> None: self._preview.clear() @@ -1423,8 +1213,8 @@ class BooruApp(QMainWindow): cp = str(cached_path_for(post.file_url)) if cp == self._preview._current_path: self._preview.clear() - if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._fullscreen_window.stop_media() + if self._popout_ctrl.window and self._popout_ctrl.window.isVisible(): + self._popout_ctrl.window.stop_media() self._status.showMessage(f"Blacklisted: {tag}") self._search_ctrl.remove_blacklisted_from_grid(tag=tag) elif action == bl_post_action: @@ -1631,7 +1421,7 @@ class BooruApp(QMainWindow): self._status.showMessage(f"Removed {len(posts)} from library") if self._stack.currentIndex() == 2: self._library_view.refresh() - self._update_fullscreen_state() + self._popout_ctrl.update_state() def _ensure_bookmarked(self, post: Post) -> None: """Bookmark a post if not already bookmarked.""" @@ -1704,8 +1494,8 @@ class BooruApp(QMainWindow): if path.exists(): # Pause any playing video before opening externally self._preview._video_player.pause() - if self._fullscreen_window and self._fullscreen_window.isVisible(): - self._fullscreen_window.pause_media() + if self._popout_ctrl.window and self._popout_ctrl.window.isVisible(): + self._popout_ctrl.window.pause_media() QDesktopServices.openUrl(QUrl.fromLocalFile(str(path))) else: self._status.showMessage("Image not cached yet — double-click to download first") @@ -2072,7 +1862,7 @@ class BooruApp(QMainWindow): bm_grid._thumbs[bm_idx].set_saved_locally(True) if self._stack.currentIndex() == 2: self._library_view.refresh() - self._update_fullscreen_state() + self._popout_ctrl.update_state() def _on_library_files_deleted(self, post_ids: list) -> None: """Library deleted files — clear saved dots on browse grid.""" @@ -2092,7 +1882,7 @@ class BooruApp(QMainWindow): def _on_batch_done(self, msg: str) -> None: self._status.showMessage(msg) - self._update_fullscreen_state() + self._popout_ctrl.update_state() if self._stack.currentIndex() == 1: self._bookmarks_view.refresh() if self._stack.currentIndex() == 2: diff --git a/booru_viewer/gui/media_controller.py b/booru_viewer/gui/media_controller.py index 80d1f6f..17b170f 100644 --- a/booru_viewer/gui/media_controller.py +++ b/booru_viewer/gui/media_controller.py @@ -82,8 +82,8 @@ class MediaController: post = self._app._posts[index] log.info(f"Preview: #{post.id} -> {post.file_url}") try: - if self._app._fullscreen_window: - self._app._fullscreen_window.force_mpv_pause() + if self._app._popout_ctrl.window: + self._app._popout_ctrl.window.force_mpv_pause() pmpv = self._app._preview._video_player._mpv if pmpv is not None: pmpv.pause = True @@ -154,7 +154,7 @@ class MediaController: def on_image_done(self, path: str, info: str) -> None: self._app._dl_progress.hide() - if self._app._fullscreen_window and self._app._fullscreen_window.isVisible(): + if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible(): self._app._preview._info_label.setText(info) self._app._preview._current_path = path else: @@ -163,22 +163,22 @@ class MediaController: idx = self._app._grid.selected_index if 0 <= idx < len(self._app._grid._thumbs): self._app._grid._thumbs[idx]._cached_path = path - self._app._update_fullscreen(path, info) + self._app._popout_ctrl.update_media(path, info) self.auto_evict_cache() def on_video_stream(self, url: str, info: str, width: int, height: int) -> None: - if self._app._fullscreen_window and self._app._fullscreen_window.isVisible(): + if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible(): self._app._preview._info_label.setText(info) self._app._preview._current_path = url - self._app._fullscreen_window.set_media(url, info, width=width, height=height) - self._app._update_fullscreen_state() + self._app._popout_ctrl.window.set_media(url, info, width=width, height=height) + self._app._popout_ctrl.update_state() else: self._app._preview._video_player.stop() self._app._preview.set_media(url, info) self._app._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...") def on_download_progress(self, downloaded: int, total: int) -> None: - popout_open = bool(self._app._fullscreen_window and self._app._fullscreen_window.isVisible()) + popout_open = bool(self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible()) if total > 0: if not popout_open: self._app._dl_progress.setRange(0, total) @@ -195,7 +195,7 @@ class MediaController: def set_preview_media(self, path: str, info: str) -> None: """Set media on preview or just info if popout is open.""" - if self._app._fullscreen_window and self._app._fullscreen_window.isVisible(): + if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible(): self._app._preview._info_label.setText(info) self._app._preview._current_path = path else: diff --git a/booru_viewer/gui/popout_controller.py b/booru_viewer/gui/popout_controller.py new file mode 100644 index 0000000..0672443 --- /dev/null +++ b/booru_viewer/gui/popout_controller.py @@ -0,0 +1,204 @@ +"""Popout (fullscreen preview) lifecycle, state sync, and geometry persistence.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .main_window import BooruApp + +log = logging.getLogger("booru") + + +# -- Pure functions (tested in tests/gui/test_popout_controller.py) -- + + +def build_video_sync_dict( + volume: int, + mute: bool, + autoplay: bool, + loop_state: int, + position_ms: int, +) -> dict: + """Build the video-state transfer dict used on popout open/close.""" + return { + "volume": volume, + "mute": mute, + "autoplay": autoplay, + "loop_state": loop_state, + "position_ms": position_ms, + } + + +# -- Controller -- + + +class PopoutController: + """Owns popout lifecycle, state sync, and geometry persistence.""" + + def __init__(self, app: BooruApp) -> None: + self._app = app + self._fullscreen_window = None + self._popout_active = False + self._info_was_visible = False + self._right_splitter_sizes: list[int] = [] + + @property + def window(self): + return self._fullscreen_window + + @property + def is_active(self) -> bool: + return self._popout_active + + # -- Open -- + + def open(self) -> None: + path = self._app._preview._current_path + if not path: + return + info = self._app._preview._info_label.text() + video_pos = 0 + if self._app._preview._stack.currentIndex() == 1: + video_pos = self._app._preview._video_player.get_position_ms() + self._popout_active = True + self._info_was_visible = self._app._info_panel.isVisible() + self._right_splitter_sizes = self._app._right_splitter.sizes() + self._app._preview.clear() + self._app._preview.hide() + self._app._info_panel.show() + self._app._right_splitter.setSizes([0, 0, 1000]) + self._app._preview._current_path = path + idx = self._app._grid.selected_index + if 0 <= idx < len(self._app._posts): + self._app._info_panel.set_post(self._app._posts[idx]) + from .popout.window import FullscreenPreview + saved_geo = self._app._db.get_setting("slideshow_geometry") + saved_fs = self._app._db.get_setting_bool("slideshow_fullscreen") + if saved_geo: + parts = saved_geo.split(",") + if len(parts) == 4: + from PySide6.QtCore import QRect + FullscreenPreview._saved_geometry = QRect(*[int(p) for p in parts]) + FullscreenPreview._saved_fullscreen = saved_fs + else: + FullscreenPreview._saved_geometry = None + FullscreenPreview._saved_fullscreen = True + else: + FullscreenPreview._saved_fullscreen = True + cols = self._app._grid._flow.columns + show_actions = self._app._stack.currentIndex() != 2 + monitor = self._app._db.get_setting("slideshow_monitor") + self._fullscreen_window = FullscreenPreview(grid_cols=cols, show_actions=show_actions, monitor=monitor, parent=self._app) + self._fullscreen_window.navigate.connect(self.navigate) + self._fullscreen_window.play_next_requested.connect(self._app._on_video_end_next) + from ..core.config import library_folders + self._fullscreen_window.set_folders_callback(library_folders) + self._fullscreen_window.save_to_folder.connect(self._app._save_from_preview) + self._fullscreen_window.unsave_requested.connect(self._app._unsave_from_preview) + if show_actions: + self._fullscreen_window.bookmark_requested.connect(self._app._bookmark_from_preview) + self._fullscreen_window.set_bookmark_folders_callback(self._app._db.get_folders) + self._fullscreen_window.bookmark_to_folder.connect(self._app._bookmark_to_folder_from_preview) + self._fullscreen_window.blacklist_tag_requested.connect(self._app._blacklist_tag_from_popout) + self._fullscreen_window.blacklist_post_requested.connect(self._app._blacklist_post_from_popout) + self._fullscreen_window.open_in_default.connect(self._app._open_preview_in_default) + self._fullscreen_window.open_in_browser.connect(self._app._open_preview_in_browser) + self._fullscreen_window.closed.connect(self.on_closed) + self._fullscreen_window.privacy_requested.connect(self._app._privacy.toggle) + post = self._app._preview._current_post + if post: + self._fullscreen_window.set_post_tags(post.tag_categories, post.tag_list) + pv = self._app._preview._video_player + self._fullscreen_window.sync_video_state( + volume=pv.volume, + mute=pv.is_muted, + autoplay=pv.autoplay, + loop_state=pv.loop_state, + ) + if video_pos > 0: + self._fullscreen_window.connect_media_ready_once( + lambda: self._fullscreen_window.seek_video_to(video_pos) + ) + pre_w = post.width if post else 0 + pre_h = post.height if post else 0 + self._fullscreen_window.set_media(path, info, width=pre_w, height=pre_h) + self.update_state() + + # -- Close -- + + def on_closed(self) -> None: + if self._fullscreen_window: + from .popout.window import FullscreenPreview + fs = FullscreenPreview._saved_fullscreen + geo = FullscreenPreview._saved_geometry + self._app._db.set_setting("slideshow_fullscreen", "1" if fs else "0") + if geo: + self._app._db.set_setting("slideshow_geometry", f"{geo.x()},{geo.y()},{geo.width()},{geo.height()}") + self._app._preview.show() + if not self._info_was_visible: + self._app._info_panel.hide() + if self._right_splitter_sizes: + self._app._right_splitter.setSizes(self._right_splitter_sizes) + self._popout_active = False + video_pos = 0 + if self._fullscreen_window: + vstate = self._fullscreen_window.get_video_state() + pv = self._app._preview._video_player + pv.volume = vstate["volume"] + pv.is_muted = vstate["mute"] + pv.autoplay = vstate["autoplay"] + pv.loop_state = vstate["loop_state"] + video_pos = vstate["position_ms"] + path = self._app._preview._current_path + info = self._app._preview._info_label.text() + self._fullscreen_window = None + if path: + if video_pos > 0: + def _seek_preview(): + self._app._preview._video_player.seek_to_ms(video_pos) + try: + self._app._preview._video_player.media_ready.disconnect(_seek_preview) + except RuntimeError: + pass + self._app._preview._video_player.media_ready.connect(_seek_preview) + self._app._preview.set_media(path, info) + + # -- Navigation -- + + def navigate(self, direction: int) -> None: + self._app._navigate_preview(direction) + + # -- State sync -- + + def update_media(self, path: str, info: str) -> None: + """Sync the popout with new media from browse/bookmark/library.""" + if self._fullscreen_window and self._fullscreen_window.isVisible(): + self._app._preview._video_player.stop() + cp = self._app._preview._current_post + w = cp.width if cp else 0 + h = cp.height if cp else 0 + self._fullscreen_window.set_media(path, info, width=w, height=h) + show_full = self._app._stack.currentIndex() != 2 + self._fullscreen_window.set_toolbar_visibility( + bookmark=show_full, + save=True, + bl_tag=show_full, + bl_post=show_full, + ) + self.update_state() + + def update_state(self) -> None: + """Update popout button states by mirroring the embedded preview.""" + if not self._fullscreen_window: + return + self._fullscreen_window.update_state( + self._app._preview._is_bookmarked, + self._app._preview._is_saved, + ) + post = self._app._preview._current_post + if post is not None: + self._fullscreen_window.set_post_tags( + post.tag_categories or {}, post.tag_list + ) diff --git a/booru_viewer/gui/privacy.py b/booru_viewer/gui/privacy.py index 0ae1a60..2d6b84d 100644 --- a/booru_viewer/gui/privacy.py +++ b/booru_viewer/gui/privacy.py @@ -46,11 +46,11 @@ class PrivacyController: # Delegate popout hide-and-pause to FullscreenPreview so it # can capture its own geometry for restore. self._popout_was_visible = bool( - self._app._fullscreen_window - and self._app._fullscreen_window.isVisible() + self._app._popout_ctrl.window + and self._app._popout_ctrl.window.isVisible() ) if self._popout_was_visible: - self._app._fullscreen_window.privacy_hide() + self._app._popout_ctrl.window.privacy_hide() else: self._overlay.hide() # Resume embedded preview video — unconditional resume, the @@ -62,5 +62,5 @@ class PrivacyController: # 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._app._fullscreen_window: - self._app._fullscreen_window.privacy_show() + if self._popout_was_visible and self._app._popout_ctrl.window: + self._app._popout_ctrl.window.privacy_show() diff --git a/booru_viewer/gui/search_controller.py b/booru_viewer/gui/search_controller.py index 3247d4b..1e10ba8 100644 --- a/booru_viewer/gui/search_controller.py +++ b/booru_viewer/gui/search_controller.py @@ -547,8 +547,8 @@ class SearchController: cp = str(cached_path_for(self._app._posts[i].file_url)) if cp == self._app._preview._current_path: self._app._preview.clear() - if self._app._fullscreen_window and self._app._fullscreen_window.isVisible(): - self._app._fullscreen_window.stop_media() + if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible(): + self._app._popout_ctrl.window.stop_media() break for i in reversed(to_remove): diff --git a/booru_viewer/gui/window_state.py b/booru_viewer/gui/window_state.py index 9a4a3d3..9c4f962 100644 --- a/booru_viewer/gui/window_state.py +++ b/booru_viewer/gui/window_state.py @@ -128,7 +128,7 @@ class WindowStateController: and we don't want that transient layout persisted as the user's preferred state. """ - if getattr(self._app, '_popout_active', False): + if self._app._popout_ctrl.is_active: return sizes = self._app._right_splitter.sizes() if len(sizes) == 3 and sum(sizes) > 0: