pax c8d38edf06 Don't intercept keys from text inputs in slideshow event filter
Space, arrow keys etc. now work normally in search bar and other
text fields while slideshow is open.
2026-04-04 20:02:57 -05:00

569 lines
20 KiB
Python

"""Full media preview — image viewer with zoom/pan and video player."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, QPoint, QPointF, Signal, QUrl
from PySide6.QtGui import QPixmap, QPainter, QWheelEvent, QMouseEvent, QKeyEvent, QColor, QMovie
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QMainWindow,
QStackedWidget, QPushButton, QSlider, QMenu, QInputDialog,
)
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from PySide6.QtMultimediaWidgets import QVideoWidget
from ..core.config import MEDIA_EXTENSIONS
VIDEO_EXTENSIONS = (".mp4", ".webm", ".mkv", ".avi", ".mov")
def _is_video(path: str) -> bool:
return Path(path).suffix.lower() in VIDEO_EXTENSIONS
class FullscreenPreview(QMainWindow):
"""Fullscreen media viewer with navigation — images, GIFs, and video."""
navigate = Signal(int) # -1 = prev, +1 = next
def __init__(self, grid_cols: int = 3, parent=None) -> None:
super().__init__(parent, Qt.WindowType.Window)
self.setWindowTitle("booru-viewer — Fullscreen")
self._grid_cols = grid_cols
self._stack = QStackedWidget()
self.setCentralWidget(self._stack)
self._viewer = ImageViewer()
self._viewer.close_requested.connect(self.close)
self._stack.addWidget(self._viewer)
self._video = VideoPlayer()
self._stack.addWidget(self._video)
from PySide6.QtWidgets import QApplication
QApplication.instance().installEventFilter(self)
self.showFullScreen()
def set_media(self, path: str, info: str = "") -> None:
ext = Path(path).suffix.lower()
if _is_video(path):
self._viewer.clear()
self._video.stop()
self._video.play_file(path, info)
self._stack.setCurrentIndex(1)
elif ext == ".gif":
self._video.stop()
self._viewer.set_gif(path, info)
self._stack.setCurrentIndex(0)
else:
self._video.stop()
pix = QPixmap(path)
if not pix.isNull():
self._viewer.set_image(pix, info)
self._stack.setCurrentIndex(0)
def eventFilter(self, obj, event):
from PySide6.QtCore import QEvent
from PySide6.QtWidgets import QLineEdit, QTextEdit, QSpinBox, QComboBox
if event.type() == QEvent.Type.KeyPress:
# Don't intercept keys when typing in text inputs
if isinstance(obj, (QLineEdit, QTextEdit, QSpinBox, QComboBox)):
return super().eventFilter(obj, event)
key = event.key()
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Q):
self.close()
return True
elif key in (Qt.Key.Key_Left, Qt.Key.Key_H):
self.navigate.emit(-1)
return True
elif key in (Qt.Key.Key_Right, Qt.Key.Key_L):
self.navigate.emit(1)
return True
elif key in (Qt.Key.Key_Up, Qt.Key.Key_K):
self.navigate.emit(-self._grid_cols)
return True
elif key in (Qt.Key.Key_Down, Qt.Key.Key_J):
self.navigate.emit(self._grid_cols)
return True
elif key == Qt.Key.Key_F11:
if self.isFullScreen():
self.showNormal()
else:
self.showFullScreen()
return True
elif key == Qt.Key.Key_Space and self._stack.currentIndex() == 1:
self._video._toggle_play()
return True
elif key == Qt.Key.Key_Period and self._stack.currentIndex() == 1:
self._video._seek_relative(5000)
return True
elif key == Qt.Key.Key_Comma and self._stack.currentIndex() == 1:
self._video._seek_relative(-5000)
return True
return super().eventFilter(obj, event)
def closeEvent(self, event) -> None:
from PySide6.QtWidgets import QApplication
QApplication.instance().removeEventFilter(self)
self._video.stop()
super().closeEvent(event)
# -- Image Viewer (zoom/pan) --
class ImageViewer(QWidget):
"""Zoomable, pannable image viewer."""
close_requested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._pixmap: QPixmap | None = None
self._movie: QMovie | None = None
self._zoom = 1.0
self._offset = QPointF(0, 0)
self._drag_start: QPointF | None = None
self._drag_offset = QPointF(0, 0)
self.setMouseTracking(True)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self._info_text = ""
def set_image(self, pixmap: QPixmap, info: str = "") -> None:
self._stop_movie()
self._pixmap = pixmap
self._zoom = 1.0
self._offset = QPointF(0, 0)
self._info_text = info
self._fit_to_view()
self.update()
def set_gif(self, path: str, info: str = "") -> None:
self._stop_movie()
self._movie = QMovie(path)
self._movie.frameChanged.connect(self._on_gif_frame)
self._movie.start()
self._info_text = info
# Set initial pixmap from first frame
self._pixmap = self._movie.currentPixmap()
self._zoom = 1.0
self._offset = QPointF(0, 0)
self._fit_to_view()
self.update()
def _on_gif_frame(self) -> None:
if self._movie:
self._pixmap = self._movie.currentPixmap()
self.update()
def _stop_movie(self) -> None:
if self._movie:
self._movie.stop()
self._movie = None
def clear(self) -> None:
self._stop_movie()
self._pixmap = None
self._info_text = ""
self.update()
def _fit_to_view(self) -> None:
if not self._pixmap:
return
vw, vh = self.width(), self.height()
pw, ph = self._pixmap.width(), self._pixmap.height()
if pw == 0 or ph == 0:
return
scale_w = vw / pw
scale_h = vh / ph
self._zoom = min(scale_w, scale_h, 1.0)
self._offset = QPointF(
(vw - pw * self._zoom) / 2,
(vh - ph * self._zoom) / 2,
)
def paintEvent(self, event) -> None:
p = QPainter(self)
pal = self.palette()
p.fillRect(self.rect(), pal.color(pal.ColorRole.Window))
if self._pixmap:
p.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
p.translate(self._offset)
p.scale(self._zoom, self._zoom)
p.drawPixmap(0, 0, self._pixmap)
p.resetTransform()
p.end()
def wheelEvent(self, event: QWheelEvent) -> None:
if not self._pixmap:
return
mouse_pos = event.position()
old_zoom = self._zoom
delta = event.angleDelta().y()
factor = 1.15 if delta > 0 else 1 / 1.15
self._zoom = max(0.1, min(self._zoom * factor, 20.0))
ratio = self._zoom / old_zoom
self._offset = mouse_pos - ratio * (mouse_pos - self._offset)
self.update()
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.MiddleButton:
self._fit_to_view()
self.update()
elif event.button() == Qt.MouseButton.LeftButton:
self._drag_start = event.position()
self._drag_offset = QPointF(self._offset)
self.setCursor(Qt.CursorShape.ClosedHandCursor)
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._drag_start is not None:
delta = event.position() - self._drag_start
self._offset = self._drag_offset + delta
self.update()
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
self._drag_start = None
self.setCursor(Qt.CursorShape.ArrowCursor)
def keyPressEvent(self, event: QKeyEvent) -> None:
if event.key() in (Qt.Key.Key_Escape, Qt.Key.Key_Q):
self.close_requested.emit()
elif event.key() == Qt.Key.Key_0:
self._fit_to_view()
self.update()
elif event.key() in (Qt.Key.Key_Plus, Qt.Key.Key_Equal):
self._zoom = min(self._zoom * 1.2, 20.0)
self.update()
elif event.key() == Qt.Key.Key_Minus:
self._zoom = max(self._zoom / 1.2, 0.1)
self.update()
else:
event.ignore()
def resizeEvent(self, event) -> None:
if self._pixmap:
self._fit_to_view()
self.update()
# -- Video Player --
class VideoPlayer(QWidget):
"""Video player with transport controls."""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Video surface
self._video_widget = QVideoWidget()
self._video_widget.setAutoFillBackground(True)
layout.addWidget(self._video_widget, stretch=1)
# Player
self._player = QMediaPlayer()
self._audio = QAudioOutput()
self._player.setAudioOutput(self._audio)
self._player.setVideoOutput(self._video_widget)
self._audio.setVolume(0.5)
# Controls bar
controls = QHBoxLayout()
controls.setContentsMargins(4, 2, 4, 2)
self._play_btn = QPushButton("Play")
self._play_btn.setFixedWidth(65)
self._play_btn.clicked.connect(self._toggle_play)
controls.addWidget(self._play_btn)
self._time_label = QLabel("0:00")
self._time_label.setFixedWidth(45)
controls.addWidget(self._time_label)
self._seek_slider = QSlider(Qt.Orientation.Horizontal)
self._seek_slider.setRange(0, 0)
self._seek_slider.sliderMoved.connect(self._seek)
controls.addWidget(self._seek_slider, stretch=1)
self._duration_label = QLabel("0:00")
self._duration_label.setFixedWidth(45)
controls.addWidget(self._duration_label)
self._vol_slider = QSlider(Qt.Orientation.Horizontal)
self._vol_slider.setRange(0, 100)
self._vol_slider.setValue(50)
self._vol_slider.setFixedWidth(80)
self._vol_slider.valueChanged.connect(self._set_volume)
controls.addWidget(self._vol_slider)
self._mute_btn = QPushButton("Mute")
self._mute_btn.setFixedWidth(80)
self._mute_btn.clicked.connect(self._toggle_mute)
controls.addWidget(self._mute_btn)
self._autoplay = True
self._autoplay_btn = QPushButton("Auto")
self._autoplay_btn.setFixedWidth(50)
self._autoplay_btn.setCheckable(True)
self._autoplay_btn.setChecked(True)
self._autoplay_btn.setToolTip("Auto-play videos when selected")
self._autoplay_btn.clicked.connect(self._toggle_autoplay)
controls.addWidget(self._autoplay_btn)
layout.addLayout(controls)
# Signals
self._player.positionChanged.connect(self._on_position)
self._player.durationChanged.connect(self._on_duration)
self._player.playbackStateChanged.connect(self._on_state)
self._player.mediaStatusChanged.connect(self._on_media_status)
self._player.errorOccurred.connect(self._on_error)
self._current_file: str | None = None
self._error_fired = False
def play_file(self, path: str, info: str = "") -> None:
self._current_file = path
self._error_fired = False
self._player.setLoops(QMediaPlayer.Loops.Infinite)
self._player.setSource(QUrl.fromLocalFile(path))
if self._autoplay:
self._player.play()
else:
self._player.pause()
def _toggle_autoplay(self, checked: bool = True) -> None:
self._autoplay = self._autoplay_btn.isChecked()
self._autoplay_btn.setText("Auto" if self._autoplay else "Man.")
def stop(self) -> None:
self._player.stop()
self._player.setSource(QUrl())
def _toggle_play(self) -> None:
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
self._player.pause()
else:
self._player.play()
def _seek(self, pos: int) -> None:
self._player.setPosition(pos)
def _seek_relative(self, ms: int) -> None:
pos = max(0, self._player.position() + ms)
self._player.setPosition(pos)
def _set_volume(self, val: int) -> None:
self._audio.setVolume(val / 100.0)
def _toggle_mute(self) -> None:
self._audio.setMuted(not self._audio.isMuted())
self._mute_btn.setText("Unmute" if self._audio.isMuted() else "Mute")
def _on_position(self, pos: int) -> None:
if not self._seek_slider.isSliderDown():
self._seek_slider.setValue(pos)
self._time_label.setText(self._fmt(pos))
def _on_duration(self, dur: int) -> None:
self._seek_slider.setRange(0, dur)
self._duration_label.setText(self._fmt(dur))
def _on_state(self, state) -> None:
if state == QMediaPlayer.PlaybackState.PlayingState:
self._play_btn.setText("Pause")
else:
self._play_btn.setText("Play")
def _on_media_status(self, status) -> None:
pass # Looping handled by QMediaPlayer.Loops.Infinite
def _on_error(self, error, msg: str = "") -> None:
if self._current_file and not self._error_fired:
self._error_fired = True
from PySide6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl.fromLocalFile(self._current_file))
@staticmethod
def _fmt(ms: int) -> str:
s = ms // 1000
m = s // 60
return f"{m}:{s % 60:02d}"
# -- 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)
favorite_requested = Signal()
navigate = Signal(int) # -1 = prev, +1 = next
fullscreen_requested = Signal()
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._folders_callback = None
self._current_path: str | None = None
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self._stack = QStackedWidget()
layout.addWidget(self._stack)
# Image viewer (index 0)
self._image_viewer = ImageViewer()
self._image_viewer.close_requested.connect(self.close_requested)
self._stack.addWidget(self._image_viewer)
# Video player (index 1)
self._video_player = VideoPlayer()
self._stack.addWidget(self._video_player)
# Info label
self._info_label = QLabel()
self._info_label.setStyleSheet("padding: 2px 6px;")
layout.addWidget(self._info_label)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._on_context_menu)
# 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_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
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)
def clear(self) -> None:
self._video_player.stop()
self._image_viewer.clear()
self._info_label.setText("")
self._current_path = None
def _on_context_menu(self, pos) -> None:
menu = QMenu(self)
fav_action = menu.addAction("Favorite")
save_menu = menu.addMenu("Save to Library")
save_unsorted = save_menu.addAction("Unsorted")
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...")
menu.addSeparator()
copy_image = None
if self._stack.currentIndex() == 0 and self._image_viewer._pixmap:
copy_image = menu.addAction("Copy Image 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")
slideshow_action = None
if self._current_path:
slideshow_action = menu.addAction("Slideshow Mode")
clear_action = menu.addAction("Clear Preview")
action = menu.exec(self.mapToGlobal(pos))
if not action:
return
if action == fav_action:
self.favorite_requested.emit()
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
QApplication.clipboard().setPixmap(self._image_viewer._pixmap)
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 == slideshow_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 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(5000)
elif event.key() == Qt.Key.Key_Comma:
self._video_player._seek_relative(-5000)
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)