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
This commit is contained in:
parent
20fc6f551e
commit
0a8d392158
@ -62,6 +62,7 @@ from .window_state import WindowStateController
|
|||||||
from .privacy import PrivacyController
|
from .privacy import PrivacyController
|
||||||
from .search_controller import SearchController
|
from .search_controller import SearchController
|
||||||
from .media_controller import MediaController
|
from .media_controller import MediaController
|
||||||
|
from .popout_controller import PopoutController
|
||||||
|
|
||||||
log = logging.getLogger("booru")
|
log = logging.getLogger("booru")
|
||||||
|
|
||||||
@ -127,6 +128,7 @@ class BooruApp(QMainWindow):
|
|||||||
self._privacy = PrivacyController(self)
|
self._privacy = PrivacyController(self)
|
||||||
self._search_ctrl = SearchController(self)
|
self._search_ctrl = SearchController(self)
|
||||||
self._media_ctrl = MediaController(self)
|
self._media_ctrl = MediaController(self)
|
||||||
|
self._popout_ctrl = PopoutController(self)
|
||||||
self._main_window_save_timer = QTimer(self)
|
self._main_window_save_timer = QTimer(self)
|
||||||
self._main_window_save_timer.setSingleShot(True)
|
self._main_window_save_timer.setSingleShot(True)
|
||||||
self._main_window_save_timer.setInterval(300)
|
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.blacklist_post_requested.connect(self._blacklist_post_from_popout)
|
||||||
self._preview.navigate.connect(self._navigate_preview)
|
self._preview.navigate.connect(self._navigate_preview)
|
||||||
self._preview.play_next_requested.connect(self._on_video_end_next)
|
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),
|
# Library folders come from the filesystem (subdirs of saved_dir),
|
||||||
# not the bookmark folders DB table — those are separate concepts.
|
# not the bookmark folders DB table — those are separate concepts.
|
||||||
from ..core.config import library_folders
|
from ..core.config import library_folders
|
||||||
@ -361,7 +363,6 @@ class BooruApp(QMainWindow):
|
|||||||
# Bookmark folders feed the toolbar Bookmark-as submenu, sourced
|
# Bookmark folders feed the toolbar Bookmark-as submenu, sourced
|
||||||
# from the DB so it stays in sync with the bookmarks tab combo.
|
# from the DB so it stays in sync with the bookmarks tab combo.
|
||||||
self._preview.set_bookmark_folders_callback(self._db.get_folders)
|
self._preview.set_bookmark_folders_callback(self._db.get_folders)
|
||||||
self._fullscreen_window = None
|
|
||||||
# Wide enough that the preview toolbar (Bookmark, Save, BL Tag,
|
# Wide enough that the preview toolbar (Bookmark, Save, BL Tag,
|
||||||
# BL Post, [stretch], Popout) has room to lay out all five buttons
|
# BL Post, [stretch], Popout) has room to lay out all five buttons
|
||||||
# at their fixed widths plus spacing without clipping the rightmost
|
# 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"):
|
if self._db.get_setting_bool("info_panel_visible"):
|
||||||
self._info_panel.show()
|
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).
|
# Debounced saver for the right splitter (same pattern as main).
|
||||||
self._right_splitter_save_timer = QTimer(self)
|
self._right_splitter_save_timer = QTimer(self)
|
||||||
self._right_splitter_save_timer.setSingleShot(True)
|
self._right_splitter_save_timer.setSingleShot(True)
|
||||||
@ -667,37 +663,6 @@ class BooruApp(QMainWindow):
|
|||||||
self._media_ctrl.on_post_activated(index)
|
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:
|
def _show_library_post(self, path: str) -> None:
|
||||||
# Read actual image dimensions so the popout can pre-fit and
|
# Read actual image dimensions so the popout can pre-fit and
|
||||||
# set keep_aspect_ratio. library_meta doesn't store w/h, so
|
# 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)
|
self._preview.update_save_state(True)
|
||||||
# _update_fullscreen reads cp.width/cp.height from _current_post,
|
# _update_fullscreen reads cp.width/cp.height from _current_post,
|
||||||
# so it runs AFTER the Post is constructed with real dimensions.
|
# 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:
|
def _on_bookmark_selected(self, fav) -> None:
|
||||||
self._status.showMessage(f"Bookmark #{fav.post_id}")
|
self._status.showMessage(f"Bookmark #{fav.post_id}")
|
||||||
@ -771,7 +736,7 @@ class BooruApp(QMainWindow):
|
|||||||
# Try local cache first
|
# Try local cache first
|
||||||
if fav.cached_path and Path(fav.cached_path).exists():
|
if fav.cached_path and Path(fav.cached_path).exists():
|
||||||
self._media_ctrl.set_preview_media(fav.cached_path, info)
|
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
|
return
|
||||||
|
|
||||||
# Try saved library — walk by post id; the file may live in any
|
# 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
|
from ..core.config import find_library_files
|
||||||
for path in find_library_files(fav.post_id, db=self._db):
|
for path in find_library_files(fav.post_id, db=self._db):
|
||||||
self._media_ctrl.set_preview_media(str(path), info)
|
self._media_ctrl.set_preview_media(str(path), info)
|
||||||
self._update_fullscreen(str(path), info)
|
self._popout_ctrl.update_media(str(path), info)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Download it
|
# Download it
|
||||||
@ -824,8 +789,8 @@ class BooruApp(QMainWindow):
|
|||||||
path = derived
|
path = derived
|
||||||
if path is not None:
|
if path is not None:
|
||||||
self._preview._video_player.pause()
|
self._preview._video_player.pause()
|
||||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
if self._popout_ctrl.window and self._popout_ctrl.window.isVisible():
|
||||||
self._fullscreen_window.pause_media()
|
self._popout_ctrl.window.pause_media()
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
||||||
else:
|
else:
|
||||||
self._status.showMessage("Bookmark not cached — open it first to download")
|
self._status.showMessage("Bookmark not cached — open it first to download")
|
||||||
@ -837,8 +802,8 @@ class BooruApp(QMainWindow):
|
|||||||
current = self._preview._current_path
|
current = self._preview._current_path
|
||||||
if current and Path(current).exists():
|
if current and Path(current).exists():
|
||||||
self._preview._video_player.pause()
|
self._preview._video_player.pause()
|
||||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
if self._popout_ctrl.window and self._popout_ctrl.window.isVisible():
|
||||||
self._fullscreen_window.pause_media()
|
self._popout_ctrl.window.pause_media()
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(current))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(current))
|
||||||
return
|
return
|
||||||
# Browse: original path. Removed the "open random cache file"
|
# 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))
|
bookmarked = bool(self._db.is_bookmarked(site_id, post.id))
|
||||||
self._preview.update_bookmark_state(bookmarked)
|
self._preview.update_bookmark_state(bookmarked)
|
||||||
self._update_fullscreen_state()
|
self._popout_ctrl.update_state()
|
||||||
# Refresh bookmarks tab if visible
|
# Refresh bookmarks tab if visible
|
||||||
if self._stack.currentIndex() == 1:
|
if self._stack.currentIndex() == 1:
|
||||||
self._bookmarks_view.refresh()
|
self._bookmarks_view.refresh()
|
||||||
@ -1036,7 +1001,7 @@ class BooruApp(QMainWindow):
|
|||||||
where = target or "Unfiled"
|
where = target or "Unfiled"
|
||||||
self._status.showMessage(f"Bookmarked #{post.id} to {where}")
|
self._status.showMessage(f"Bookmarked #{post.id} to {where}")
|
||||||
self._preview.update_bookmark_state(True)
|
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.
|
# Refresh bookmarks tab if visible so the new entry appears.
|
||||||
if self._stack.currentIndex() == 1:
|
if self._stack.currentIndex() == 1:
|
||||||
self._bookmarks_view.refresh()
|
self._bookmarks_view.refresh()
|
||||||
@ -1080,7 +1045,7 @@ class BooruApp(QMainWindow):
|
|||||||
self._library_view.refresh()
|
self._library_view.refresh()
|
||||||
else:
|
else:
|
||||||
self._status.showMessage(f"#{post.id} not in library")
|
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:
|
def _blacklist_tag_from_popout(self, tag: str) -> None:
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
@ -1109,181 +1074,6 @@ class BooruApp(QMainWindow):
|
|||||||
self._status.showMessage(f"Post #{post.id} blacklisted")
|
self._status.showMessage(f"Post #{post.id} blacklisted")
|
||||||
self._search_ctrl.remove_blacklisted_from_grid(post_url=post.file_url)
|
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:
|
def _close_preview(self) -> None:
|
||||||
self._preview.clear()
|
self._preview.clear()
|
||||||
|
|
||||||
@ -1423,8 +1213,8 @@ class BooruApp(QMainWindow):
|
|||||||
cp = str(cached_path_for(post.file_url))
|
cp = str(cached_path_for(post.file_url))
|
||||||
if cp == self._preview._current_path:
|
if cp == self._preview._current_path:
|
||||||
self._preview.clear()
|
self._preview.clear()
|
||||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
if self._popout_ctrl.window and self._popout_ctrl.window.isVisible():
|
||||||
self._fullscreen_window.stop_media()
|
self._popout_ctrl.window.stop_media()
|
||||||
self._status.showMessage(f"Blacklisted: {tag}")
|
self._status.showMessage(f"Blacklisted: {tag}")
|
||||||
self._search_ctrl.remove_blacklisted_from_grid(tag=tag)
|
self._search_ctrl.remove_blacklisted_from_grid(tag=tag)
|
||||||
elif action == bl_post_action:
|
elif action == bl_post_action:
|
||||||
@ -1631,7 +1421,7 @@ class BooruApp(QMainWindow):
|
|||||||
self._status.showMessage(f"Removed {len(posts)} from library")
|
self._status.showMessage(f"Removed {len(posts)} from library")
|
||||||
if self._stack.currentIndex() == 2:
|
if self._stack.currentIndex() == 2:
|
||||||
self._library_view.refresh()
|
self._library_view.refresh()
|
||||||
self._update_fullscreen_state()
|
self._popout_ctrl.update_state()
|
||||||
|
|
||||||
def _ensure_bookmarked(self, post: Post) -> None:
|
def _ensure_bookmarked(self, post: Post) -> None:
|
||||||
"""Bookmark a post if not already bookmarked."""
|
"""Bookmark a post if not already bookmarked."""
|
||||||
@ -1704,8 +1494,8 @@ class BooruApp(QMainWindow):
|
|||||||
if path.exists():
|
if path.exists():
|
||||||
# Pause any playing video before opening externally
|
# Pause any playing video before opening externally
|
||||||
self._preview._video_player.pause()
|
self._preview._video_player.pause()
|
||||||
if self._fullscreen_window and self._fullscreen_window.isVisible():
|
if self._popout_ctrl.window and self._popout_ctrl.window.isVisible():
|
||||||
self._fullscreen_window.pause_media()
|
self._popout_ctrl.window.pause_media()
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))
|
||||||
else:
|
else:
|
||||||
self._status.showMessage("Image not cached yet — double-click to download first")
|
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)
|
bm_grid._thumbs[bm_idx].set_saved_locally(True)
|
||||||
if self._stack.currentIndex() == 2:
|
if self._stack.currentIndex() == 2:
|
||||||
self._library_view.refresh()
|
self._library_view.refresh()
|
||||||
self._update_fullscreen_state()
|
self._popout_ctrl.update_state()
|
||||||
|
|
||||||
def _on_library_files_deleted(self, post_ids: list) -> None:
|
def _on_library_files_deleted(self, post_ids: list) -> None:
|
||||||
"""Library deleted files — clear saved dots on browse grid."""
|
"""Library deleted files — clear saved dots on browse grid."""
|
||||||
@ -2092,7 +1882,7 @@ class BooruApp(QMainWindow):
|
|||||||
|
|
||||||
def _on_batch_done(self, msg: str) -> None:
|
def _on_batch_done(self, msg: str) -> None:
|
||||||
self._status.showMessage(msg)
|
self._status.showMessage(msg)
|
||||||
self._update_fullscreen_state()
|
self._popout_ctrl.update_state()
|
||||||
if self._stack.currentIndex() == 1:
|
if self._stack.currentIndex() == 1:
|
||||||
self._bookmarks_view.refresh()
|
self._bookmarks_view.refresh()
|
||||||
if self._stack.currentIndex() == 2:
|
if self._stack.currentIndex() == 2:
|
||||||
|
|||||||
@ -82,8 +82,8 @@ class MediaController:
|
|||||||
post = self._app._posts[index]
|
post = self._app._posts[index]
|
||||||
log.info(f"Preview: #{post.id} -> {post.file_url}")
|
log.info(f"Preview: #{post.id} -> {post.file_url}")
|
||||||
try:
|
try:
|
||||||
if self._app._fullscreen_window:
|
if self._app._popout_ctrl.window:
|
||||||
self._app._fullscreen_window.force_mpv_pause()
|
self._app._popout_ctrl.window.force_mpv_pause()
|
||||||
pmpv = self._app._preview._video_player._mpv
|
pmpv = self._app._preview._video_player._mpv
|
||||||
if pmpv is not None:
|
if pmpv is not None:
|
||||||
pmpv.pause = True
|
pmpv.pause = True
|
||||||
@ -154,7 +154,7 @@ class MediaController:
|
|||||||
|
|
||||||
def on_image_done(self, path: str, info: str) -> None:
|
def on_image_done(self, path: str, info: str) -> None:
|
||||||
self._app._dl_progress.hide()
|
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._info_label.setText(info)
|
||||||
self._app._preview._current_path = path
|
self._app._preview._current_path = path
|
||||||
else:
|
else:
|
||||||
@ -163,22 +163,22 @@ class MediaController:
|
|||||||
idx = self._app._grid.selected_index
|
idx = self._app._grid.selected_index
|
||||||
if 0 <= idx < len(self._app._grid._thumbs):
|
if 0 <= idx < len(self._app._grid._thumbs):
|
||||||
self._app._grid._thumbs[idx]._cached_path = path
|
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()
|
self.auto_evict_cache()
|
||||||
|
|
||||||
def on_video_stream(self, url: str, info: str, width: int, height: int) -> None:
|
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._info_label.setText(info)
|
||||||
self._app._preview._current_path = url
|
self._app._preview._current_path = url
|
||||||
self._app._fullscreen_window.set_media(url, info, width=width, height=height)
|
self._app._popout_ctrl.window.set_media(url, info, width=width, height=height)
|
||||||
self._app._update_fullscreen_state()
|
self._app._popout_ctrl.update_state()
|
||||||
else:
|
else:
|
||||||
self._app._preview._video_player.stop()
|
self._app._preview._video_player.stop()
|
||||||
self._app._preview.set_media(url, info)
|
self._app._preview.set_media(url, info)
|
||||||
self._app._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...")
|
self._app._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...")
|
||||||
|
|
||||||
def on_download_progress(self, downloaded: int, total: int) -> None:
|
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 total > 0:
|
||||||
if not popout_open:
|
if not popout_open:
|
||||||
self._app._dl_progress.setRange(0, total)
|
self._app._dl_progress.setRange(0, total)
|
||||||
@ -195,7 +195,7 @@ class MediaController:
|
|||||||
|
|
||||||
def set_preview_media(self, path: str, info: str) -> None:
|
def set_preview_media(self, path: str, info: str) -> None:
|
||||||
"""Set media on preview or just info if popout is open."""
|
"""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._info_label.setText(info)
|
||||||
self._app._preview._current_path = path
|
self._app._preview._current_path = path
|
||||||
else:
|
else:
|
||||||
|
|||||||
204
booru_viewer/gui/popout_controller.py
Normal file
204
booru_viewer/gui/popout_controller.py
Normal file
@ -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
|
||||||
|
)
|
||||||
@ -46,11 +46,11 @@ class PrivacyController:
|
|||||||
# Delegate popout hide-and-pause to FullscreenPreview so it
|
# Delegate popout hide-and-pause to FullscreenPreview so it
|
||||||
# can capture its own geometry for restore.
|
# can capture its own geometry for restore.
|
||||||
self._popout_was_visible = bool(
|
self._popout_was_visible = bool(
|
||||||
self._app._fullscreen_window
|
self._app._popout_ctrl.window
|
||||||
and self._app._fullscreen_window.isVisible()
|
and self._app._popout_ctrl.window.isVisible()
|
||||||
)
|
)
|
||||||
if self._popout_was_visible:
|
if self._popout_was_visible:
|
||||||
self._app._fullscreen_window.privacy_hide()
|
self._app._popout_ctrl.window.privacy_hide()
|
||||||
else:
|
else:
|
||||||
self._overlay.hide()
|
self._overlay.hide()
|
||||||
# Resume embedded preview video — unconditional resume, the
|
# Resume embedded preview video — unconditional resume, the
|
||||||
@ -62,5 +62,5 @@ class PrivacyController:
|
|||||||
# also re-dispatches the captured geometry to Hyprland (Qt
|
# also re-dispatches the captured geometry to Hyprland (Qt
|
||||||
# show() alone doesn't preserve position on Wayland) and
|
# show() alone doesn't preserve position on Wayland) and
|
||||||
# resumes its video.
|
# resumes its video.
|
||||||
if self._popout_was_visible and self._app._fullscreen_window:
|
if self._popout_was_visible and self._app._popout_ctrl.window:
|
||||||
self._app._fullscreen_window.privacy_show()
|
self._app._popout_ctrl.window.privacy_show()
|
||||||
|
|||||||
@ -547,8 +547,8 @@ class SearchController:
|
|||||||
cp = str(cached_path_for(self._app._posts[i].file_url))
|
cp = str(cached_path_for(self._app._posts[i].file_url))
|
||||||
if cp == self._app._preview._current_path:
|
if cp == self._app._preview._current_path:
|
||||||
self._app._preview.clear()
|
self._app._preview.clear()
|
||||||
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._fullscreen_window.stop_media()
|
self._app._popout_ctrl.window.stop_media()
|
||||||
break
|
break
|
||||||
|
|
||||||
for i in reversed(to_remove):
|
for i in reversed(to_remove):
|
||||||
|
|||||||
@ -128,7 +128,7 @@ class WindowStateController:
|
|||||||
and we don't want that transient layout persisted as the user's
|
and we don't want that transient layout persisted as the user's
|
||||||
preferred state.
|
preferred state.
|
||||||
"""
|
"""
|
||||||
if getattr(self._app, '_popout_active', False):
|
if self._app._popout_ctrl.is_active:
|
||||||
return
|
return
|
||||||
sizes = self._app._right_splitter.sizes()
|
sizes = self._app._right_splitter.sizes()
|
||||||
if len(sizes) == 3 and sum(sizes) > 0:
|
if len(sizes) == 3 and sum(sizes) > 0:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user