"""Embedded preview pane: image + video, with toolbar and context menu.""" from __future__ import annotations from pathlib import Path from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QPixmap, QMouseEvent, QKeyEvent from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QStackedWidget, QPushButton, QMenu, QInputDialog, ) from .media.constants import _is_video from .media.image_viewer import ImageViewer from .media.video_player import VideoPlayer # -- Combined Preview (image + video) -- class ImagePreview(QWidget): """Combined media preview — auto-switches between image and video.""" close_requested = Signal() open_in_default = Signal() open_in_browser = Signal() save_to_folder = Signal(str) unsave_requested = Signal() bookmark_requested = Signal() # Bookmark-as: emitted when the user picks a bookmark folder from # the toolbar's Bookmark button submenu. Empty string = Unfiled. # Mirrors save_to_folder's shape so app.py can route it the same way. bookmark_to_folder = Signal(str) blacklist_tag_requested = Signal(str) blacklist_post_requested = Signal() navigate = Signal(int) # -1 = prev, +1 = next play_next_requested = Signal() # video ended in "Next" mode (wrap-aware) fullscreen_requested = Signal() def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self._folders_callback = None # Bookmark folders live in a separate name space (DB-backed); the # toolbar Bookmark-as submenu reads them via this callback so the # preview widget stays decoupled from the Database object. self._bookmark_folders_callback = None self._current_path: str | None = None self._current_post = None # Post object, set by app.py self._current_site_id = None # site_id for the current post self._is_saved = False # tracks library save state for context menu self._is_bookmarked = False # tracks bookmark state for the button submenu self._current_tags: dict[str, list[str]] = {} self._current_tag_list: list[str] = [] layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) # Action toolbar — above the media, in the layout. # 4px horizontal margins so the leftmost button (Bookmark) doesn't # sit flush against the preview splitter handle on the left. self._toolbar = QWidget() tb = QHBoxLayout(self._toolbar) tb.setContentsMargins(4, 1, 4, 1) tb.setSpacing(4) _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) tb.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) tb.addWidget(self._save_btn) self._bl_tag_btn = _icon_btn("\u2298", "_tb_bl_tag", "Blacklist a tag") self._bl_tag_btn.clicked.connect(self._show_bl_tag_menu) tb.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) tb.addWidget(self._bl_post_btn) tb.addStretch() self._popout_btn = _icon_btn("\u29c9", "_tb_popout", "Popout") self._popout_btn.clicked.connect(self.fullscreen_requested) tb.addWidget(self._popout_btn) self._toolbar.hide() # shown when a post is active layout.addWidget(self._toolbar) self._stack = QStackedWidget() layout.addWidget(self._stack, stretch=1) # Image viewer (index 0) self._image_viewer = ImageViewer() self._image_viewer.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._image_viewer.close_requested.connect(self.close_requested) self._stack.addWidget(self._image_viewer) # Video player (index 1). embed_controls=False keeps the # transport controls bar out of the VideoPlayer's own layout — # we reparent it below the stack a few lines down so the controls # sit *under* the media rather than overlaying it. self._video_player = VideoPlayer(embed_controls=False) self._video_player.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._video_player.play_next.connect(self.play_next_requested) self._stack.addWidget(self._video_player) # Place the video controls bar in the preview panel's own layout, # underneath the stack. The bar exists as a child of VideoPlayer # but is not in any layout (because of embed_controls=False); we # adopt it here as a sibling of the stack so it lays out cleanly # below the media rather than floating on top of it. The popout # uses its own separate VideoPlayer instance and reparents that # instance's controls bar to its own central widget as an overlay. self._stack_video_controls = self._video_player._controls_bar self._stack_video_controls.setParent(self) layout.addWidget(self._stack_video_controls) # Only visible when the stack is showing the video player. self._stack_video_controls.hide() self._stack.currentChanged.connect( lambda idx: self._stack_video_controls.setVisible(idx == 1) ) # Info label self._info_label = QLabel() self._info_label.setStyleSheet("padding: 2px 6px;") layout.addWidget(self._info_label) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self._on_context_menu) 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 _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 = menu.exec(self._bl_tag_btn.mapToGlobal(self._bl_tag_btn.rect().bottomLeft())) if action: self.blacklist_tag_requested.emit(action.text()) def _on_bookmark_clicked(self) -> None: """Toolbar Bookmark button — mirrors the browse-tab Bookmark-as submenu so the preview pane has the same one-click filing flow. When the post is already bookmarked, the button collapses to a flat unbookmark action (emits the same signal as before, the existing toggle in app.py handles the removal). When not yet bookmarked, a popup menu lets the user pick the destination bookmark folder — the chosen name is sent through bookmark_to_folder and app.py adds the folder + creates the bookmark. """ 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 = menu.exec(self._bookmark_btn.mapToGlobal(self._bookmark_btn.rect().bottomLeft())) 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_save_clicked(self) -> None: if self._save_btn.text() == "Unsave": self.unsave_requested.emit() return menu = QMenu(self) unsorted = menu.addAction("Unfiled") menu.addSeparator() folder_actions = {} 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 = menu.exec(self._save_btn.mapToGlobal(self._save_btn.rect().bottomLeft())) if not action: return if action == unsorted: 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 update_bookmark_state(self, bookmarked: 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)") def update_save_state(self, saved: bool) -> None: 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)") # Keep these for compatibility with app.py accessing them @property def _pixmap(self): return self._image_viewer._pixmap @property def _info_text(self): return self._image_viewer._info_text def set_folders_callback(self, callback) -> None: self._folders_callback = callback def set_bookmark_folders_callback(self, callback) -> None: """Wire the bookmark folder list source. Called once from app.py with self._db.get_folders. Kept separate from set_folders_callback because library and bookmark folders are independent name spaces. """ self._bookmark_folders_callback = callback def set_image(self, pixmap: QPixmap, info: str = "") -> None: self._video_player.stop() self._image_viewer.set_image(pixmap, info) self._stack.setCurrentIndex(0) self._info_label.setText(info) self._current_path = None self._toolbar.show() self._toolbar.raise_() def set_media(self, path: str, info: str = "") -> None: """Auto-detect and show image or video.""" self._current_path = path ext = Path(path).suffix.lower() if _is_video(path): self._image_viewer.clear() self._video_player.stop() self._video_player.play_file(path, info) self._stack.setCurrentIndex(1) self._info_label.setText(info) elif ext == ".gif": self._video_player.stop() self._image_viewer.set_gif(path, info) self._stack.setCurrentIndex(0) self._info_label.setText(info) else: self._video_player.stop() pix = QPixmap(path) if not pix.isNull(): self._image_viewer.set_image(pix, info) self._stack.setCurrentIndex(0) self._info_label.setText(info) self._toolbar.show() self._toolbar.raise_() def clear(self) -> None: self._video_player.stop() self._image_viewer.clear() self._info_label.setText("") self._current_path = None self._toolbar.hide() 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 = menu.addMenu("Save to Library") save_unsorted = save_menu.addAction("Unfiled") save_menu.addSeparator() save_folder_actions = {} 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...") unsave_action = None if self._is_saved: unsave_action = menu.addAction("Unsave from Library") menu.addSeparator() copy_image = menu.addAction("Copy File to Clipboard") open_action = menu.addAction("Open in Default App") browser_action = menu.addAction("Open in Browser") # Image-specific reset_action = None if self._stack.currentIndex() == 0: reset_action = menu.addAction("Reset View") popout_action = None if self._current_path: popout_action = menu.addAction("Popout") clear_action = menu.addAction("Clear Preview") 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 == copy_image: from PySide6.QtWidgets import QApplication from PySide6.QtGui import QPixmap as _QP pix = self._image_viewer._pixmap if pix and not pix.isNull(): QApplication.clipboard().setPixmap(pix) elif self._current_path: pix = _QP(self._current_path) if not pix.isNull(): QApplication.clipboard().setPixmap(pix) elif action == open_action: self.open_in_default.emit() elif action == browser_action: self.open_in_browser.emit() elif action == reset_action: self._image_viewer._fit_to_view() self._image_viewer.update() elif action == unsave_action: self.unsave_requested.emit() elif action == popout_action: self.fullscreen_requested.emit() elif action == clear_action: self.close_requested.emit() def mousePressEvent(self, event: QMouseEvent) -> None: if event.button() == Qt.MouseButton.RightButton: event.ignore() else: super().mousePressEvent(event) def wheelEvent(self, event) -> None: # Horizontal tilt navigates between posts on either stack tilt = event.angleDelta().x() if tilt > 30: self.navigate.emit(-1) return if tilt < -30: self.navigate.emit(1) return if self._stack.currentIndex() == 1: delta = event.angleDelta().y() if delta: vol = max(0, min(100, self._video_player.volume + (5 if delta > 0 else -5))) self._video_player.volume = vol else: super().wheelEvent(event) def keyPressEvent(self, event: QKeyEvent) -> None: if self._stack.currentIndex() == 0: self._image_viewer.keyPressEvent(event) elif event.key() == Qt.Key.Key_Space: self._video_player._toggle_play() elif event.key() == Qt.Key.Key_Period: self._video_player._seek_relative(1800) elif event.key() == Qt.Key.Key_Comma: self._video_player._seek_relative(-1800) elif event.key() in (Qt.Key.Key_Left, Qt.Key.Key_H): self.navigate.emit(-1) elif event.key() in (Qt.Key.Key_Right, Qt.Key.Key_L): self.navigate.emit(1) def resizeEvent(self, event) -> None: super().resizeEvent(event)