Context menu now shows either Save to Library or Unsave from Library based on saved state, never both. behavior change: preview context menu shows either Save or Unsave.
445 lines
18 KiB
Python
445 lines
18 KiB
Python
"""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] = []
|
|
self._vol_scroll_accum = 0
|
|
|
|
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._is_saved:
|
|
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 = 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_image = menu.addAction("Copy File to Clipboard")
|
|
copy_url = menu.addAction("Copy Image URL")
|
|
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 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._current_path
|
|
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:
|
|
from PySide6.QtWidgets import QApplication
|
|
if self._current_post and self._current_post.file_url:
|
|
QApplication.clipboard().setText(self._current_post.file_url)
|
|
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:
|
|
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_player.volume + 5 * steps))
|
|
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)
|