mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-02-10 20:02:29 +00:00
refactor(preview_thumb): mvc split (#978)
* refactor: basic split * fix: renaming and usage test didn't work for the tests * fix: tests * refactor: restructuring * refactor: further separation and lots of related changes * refactor: remove last reference to a widget from controller * refactor: address todo * fix: failing tests and mypy compaint * refactor: move control logic to controller * refactor: more readable button style * refactor: move existing code to view * refactor: move existing code to controller * fix: imports * refactor: make methods private by default * refactor: privatise fields * refactor: reduce code duplication * refactor: consolidate and sort display methods * refactor: remove needless setting of delete action text * refactor: extract control logic from _display_file * refactor: use MediaType for __switch_preview * fix: import in preview_panel_view.py * refactor: remove unnecessary wrapper on view side * refactor: move image data retrieval to control side * refactor: move audio / video specific code to the respective method * refactor: remove superfluos methods * refactor: this and that * refactor: use proper type instead of dict for file stats * refactor: extract gif parsing to controller * refactor: extract video size extraction to controller * doc: add rule of thumb to Qt MVC Style Guide * doc: change rule of thumb from note to tip --------- Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
This commit is contained in:
2
STYLE.md
2
STYLE.md
@@ -78,5 +78,7 @@ Observe the following key aspects of this example:
|
||||
- Defines the interface the callbacks
|
||||
- Enforces that UI events be handled
|
||||
|
||||
> [!TIP]
|
||||
> A good (non-exhaustive) rule of thumb is: If it requires a non-UI import, then it doesn't belong in the `*_view.py` file.
|
||||
|
||||
[^1]: For an explanation of the Model-View-Controller (MVC) Model, checkout this article: [MVC Framework Introduction](https://www.geeksforgeeks.org/mvc-framework-introduction/).
|
||||
|
||||
@@ -80,6 +80,21 @@ class MediaCategory:
|
||||
name: str
|
||||
is_iana: bool = False
|
||||
|
||||
def contains(self, ext: str, mime_fallback: bool = False) -> bool:
|
||||
"""Check if an extension is a member of this MediaCategory.
|
||||
|
||||
Args:
|
||||
ext (str): File extension with a leading "." and in all lowercase.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
"""
|
||||
if ext in self.extensions:
|
||||
return True
|
||||
elif mime_fallback and self.is_iana:
|
||||
mime_type: str | None = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type is not None and mime_type.startswith(self.media_type.value):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MediaCategories:
|
||||
"""Contain pre-made MediaCategory objects as well as methods to interact with them."""
|
||||
@@ -635,16 +650,11 @@ class MediaCategories:
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
"""
|
||||
media_types: set[MediaType] = set()
|
||||
# mime_guess: bool = False
|
||||
|
||||
for cat in MediaCategories.ALL_CATEGORIES:
|
||||
if ext in cat.extensions:
|
||||
if cat.contains(ext, mime_fallback):
|
||||
media_types.add(cat.media_type)
|
||||
elif mime_fallback and cat.is_iana:
|
||||
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type and mime_type.startswith(cat.media_type.value):
|
||||
media_types.add(cat.media_type)
|
||||
# mime_guess = True
|
||||
|
||||
return media_types
|
||||
|
||||
@staticmethod
|
||||
@@ -656,10 +666,4 @@ class MediaCategories:
|
||||
media_cat (MediaCategory): The MediaCategory to to check for extension membership.
|
||||
mime_fallback (bool): Flag to guess MIME type if no set matches are made.
|
||||
"""
|
||||
if ext in media_cat.extensions:
|
||||
return True
|
||||
elif mime_fallback and media_cat.is_iana:
|
||||
mime_type: str = mimetypes.guess_type(Path("x" + ext), strict=False)[0]
|
||||
if mime_type and mime_type.startswith(media_cat.media_type.value):
|
||||
return True
|
||||
return False
|
||||
return media_cat.contains(ext, mime_fallback)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QSize
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories
|
||||
from tagstudio.qt.helpers.file_opener import open_file
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
from tagstudio.qt.view.widgets.preview.preview_thumb_view import PreviewThumbView
|
||||
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(PreviewThumbView):
|
||||
__current_file: Path
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver"):
|
||||
super().__init__(library, driver)
|
||||
|
||||
self.__driver: QtDriver = driver
|
||||
|
||||
def __get_image_stats(self, filepath: Path) -> FileAttributeData:
|
||||
"""Get width and height of an image as dict."""
|
||||
stats = FileAttributeData()
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
if MediaCategories.IMAGE_RAW_TYPES.contains(ext, mime_fallback=True):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
|
||||
stats.width = image.width
|
||||
stats.height = image.height
|
||||
except (
|
||||
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
FileNotFoundError,
|
||||
):
|
||||
pass
|
||||
elif MediaCategories.IMAGE_RASTER_TYPES.contains(ext, mime_fallback=True):
|
||||
try:
|
||||
image = Image.open(str(filepath))
|
||||
stats.width = image.width
|
||||
stats.height = image.height
|
||||
except (
|
||||
DecompressionBombError,
|
||||
FileNotFoundError,
|
||||
NotImplementedError,
|
||||
UnidentifiedImageError,
|
||||
) as e:
|
||||
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
|
||||
elif MediaCategories.IMAGE_VECTOR_TYPES.contains(ext, mime_fallback=True):
|
||||
pass # TODO
|
||||
|
||||
return stats
|
||||
|
||||
def __get_gif_data(self, filepath: Path) -> tuple[bytes, tuple[int, int]] | None:
|
||||
"""Loads an animated image and returns gif data and size, if successful."""
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
try:
|
||||
image: Image.Image = Image.open(filepath)
|
||||
|
||||
if ext == ".apng":
|
||||
image_bytes_io = io.BytesIO()
|
||||
image.save(
|
||||
image_bytes_io,
|
||||
"GIF",
|
||||
lossless=True,
|
||||
save_all=True,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
image.close()
|
||||
image_bytes_io.seek(0)
|
||||
return (image_bytes_io.read(), (image.width, image.height))
|
||||
else:
|
||||
image.close()
|
||||
with open(filepath, "rb") as f:
|
||||
return (f.read(), (image.width, image.height))
|
||||
|
||||
except (UnidentifiedImageError, FileNotFoundError) as e:
|
||||
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
|
||||
return None
|
||||
|
||||
def __get_video_res(self, filepath: str) -> tuple[bool, QSize]:
|
||||
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
return (success, QSize(image.width, image.height))
|
||||
|
||||
def display_file(self, filepath: Path) -> FileAttributeData:
|
||||
"""Render a single file preview."""
|
||||
self.__current_file = filepath
|
||||
|
||||
ext = filepath.suffix.lower()
|
||||
|
||||
# Video
|
||||
if MediaCategories.VIDEO_TYPES.contains(ext, mime_fallback=True) and is_readable_video(
|
||||
filepath
|
||||
):
|
||||
size: QSize | None = None
|
||||
try:
|
||||
success, size = self.__get_video_res(str(filepath))
|
||||
if not success:
|
||||
size = None
|
||||
except cv2.error as e:
|
||||
logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e)
|
||||
|
||||
return self._display_video(filepath, size)
|
||||
# Audio
|
||||
elif MediaCategories.AUDIO_TYPES.contains(ext, mime_fallback=True):
|
||||
return self._display_audio(filepath)
|
||||
# Animated Images
|
||||
elif MediaCategories.IMAGE_ANIMATED_TYPES.contains(ext, mime_fallback=True):
|
||||
if (ret := self.__get_gif_data(filepath)) and (
|
||||
stats := self._display_gif(ret[0], ret[1])
|
||||
) is not None:
|
||||
return stats
|
||||
else:
|
||||
self._display_image(filepath)
|
||||
return self.__get_image_stats(filepath)
|
||||
# Other Types (Including Images)
|
||||
else:
|
||||
self._display_image(filepath)
|
||||
return self.__get_image_stats(filepath)
|
||||
|
||||
def _open_file_action_callback(self):
|
||||
open_file(self.__current_file)
|
||||
|
||||
def _open_explorer_action_callback(self):
|
||||
open_file(self.__current_file, file_manager=True)
|
||||
|
||||
def _delete_action_callback(self):
|
||||
if bool(self.__current_file):
|
||||
self.__driver.delete_files_callback(self.__current_file)
|
||||
|
||||
def _button_wrapper_callback(self):
|
||||
open_file(self.__current_file)
|
||||
@@ -1,3 +1,6 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import typing
|
||||
from warnings import catch_warnings
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ class QPushButtonWrapper(QPushButton):
|
||||
the warning that is triggered by disconnecting a signal that is not currently connected.
|
||||
"""
|
||||
|
||||
is_connected: bool
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.is_connected = False
|
||||
|
||||
@@ -879,7 +879,7 @@ class QtDriver(DriverMixin, QObject):
|
||||
for i, tup in enumerate(pending):
|
||||
e_id, f = tup
|
||||
if (origin_path == f) or (not origin_path):
|
||||
self.main_window.preview_panel.thumb_media_player_stop()
|
||||
self.main_window.preview_panel.preview_thumb.media_player.stop()
|
||||
if delete_file(self.lib.library_dir / f):
|
||||
self.main_window.status_bar.showMessage(
|
||||
Translations.format(
|
||||
|
||||
314
src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py
Normal file
314
src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py
Normal file
@@ -0,0 +1,314 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
|
||||
import structlog
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
|
||||
from PySide6.QtGui import QAction, QMovie, QPixmap, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStackedLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaType
|
||||
from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from tagstudio.qt.platform_strings import open_file_str, trash_term
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.media_player import MediaPlayer
|
||||
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData
|
||||
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class PreviewThumbView(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
__img_button_size: tuple[int, int]
|
||||
__image_ratio: float
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
|
||||
self.__img_button_size = (266, 266)
|
||||
self.__image_ratio = 1.0
|
||||
|
||||
self.__image_layout = QStackedLayout(self)
|
||||
self.__image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.__image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.__image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
open_file_action = QAction(Translations["file.open_file"], self)
|
||||
open_file_action.triggered.connect(self._open_file_action_callback)
|
||||
open_explorer_action = QAction(open_file_str(), self)
|
||||
open_explorer_action.triggered.connect(self._open_explorer_action_callback)
|
||||
delete_action = QAction(
|
||||
Translations.format("trash.context.singular", trash_term=trash_term()),
|
||||
self,
|
||||
)
|
||||
delete_action.triggered.connect(self._delete_action_callback)
|
||||
|
||||
self.__button_wrapper = QPushButton()
|
||||
self.__button_wrapper.setMinimumSize(*self.__img_button_size)
|
||||
self.__button_wrapper.setFlat(True)
|
||||
self.__button_wrapper.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.__button_wrapper.addAction(open_file_action)
|
||||
self.__button_wrapper.addAction(open_explorer_action)
|
||||
self.__button_wrapper.addAction(delete_action)
|
||||
self.__button_wrapper.clicked.connect(self._button_wrapper_callback)
|
||||
|
||||
# In testing, it didn't seem possible to center the widgets directly
|
||||
# on the QStackedLayout. Adding sublayouts allows us to center the widgets.
|
||||
self.__preview_img_page = QWidget()
|
||||
self.__stacked_page_setup(self.__preview_img_page, self.__button_wrapper)
|
||||
|
||||
self.__preview_gif = QLabel()
|
||||
self.__preview_gif.setMinimumSize(*self.__img_button_size)
|
||||
self.__preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.__preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.__preview_gif.addAction(open_file_action)
|
||||
self.__preview_gif.addAction(open_explorer_action)
|
||||
self.__preview_gif.addAction(delete_action)
|
||||
self.__gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.__preview_gif_page = QWidget()
|
||||
self.__stacked_page_setup(self.__preview_gif_page, self.__preview_gif)
|
||||
|
||||
self.__media_player = MediaPlayer(driver)
|
||||
self.__media_player.addAction(open_file_action)
|
||||
self.__media_player.addAction(open_explorer_action)
|
||||
self.__media_player.addAction(delete_action)
|
||||
|
||||
# Need to watch for this to resize the player appropriately.
|
||||
self.__media_player.player.hasVideoChanged.connect(
|
||||
self.__media_player_video_changed_callback
|
||||
)
|
||||
|
||||
self.__mp_max_size = QSize(*self.__img_button_size)
|
||||
|
||||
self.__media_player_page = QWidget()
|
||||
self.__stacked_page_setup(self.__media_player_page, self.__media_player)
|
||||
|
||||
self.__thumb_renderer = ThumbRenderer(library)
|
||||
self.__thumb_renderer.updated.connect(self.__thumb_renderer_updated_callback)
|
||||
self.__thumb_renderer.updated_ratio.connect(self.__thumb_renderer_updated_ratio_callback)
|
||||
|
||||
self.__image_layout.addWidget(self.__preview_img_page)
|
||||
self.__image_layout.addWidget(self.__preview_gif_page)
|
||||
self.__image_layout.addWidget(self.__media_player_page)
|
||||
|
||||
self.setMinimumSize(*self.__img_button_size)
|
||||
|
||||
self.hide_preview()
|
||||
|
||||
def _open_file_action_callback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _open_explorer_action_callback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _delete_action_callback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def _button_wrapper_callback(self):
|
||||
raise NotImplementedError
|
||||
|
||||
def __media_player_video_changed_callback(self, video: bool) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
def __thumb_renderer_updated_callback(
|
||||
self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path
|
||||
) -> None:
|
||||
self.__button_wrapper.setIcon(img)
|
||||
self.__mp_max_size = img.size()
|
||||
|
||||
def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None:
|
||||
self.__image_ratio = ratio
|
||||
self.__update_image_size(
|
||||
(
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
)
|
||||
)
|
||||
|
||||
def __stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
|
||||
layout = QHBoxLayout(page)
|
||||
layout.addWidget(widget)
|
||||
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
page.setLayout(layout)
|
||||
|
||||
def __update_image_size(self, size: tuple[int, int]) -> None:
|
||||
adj_width: float = size[0]
|
||||
adj_height: float = size[1]
|
||||
# Landscape
|
||||
if self.__image_ratio > 1:
|
||||
adj_height = size[0] * (1 / self.__image_ratio)
|
||||
# Portrait
|
||||
elif self.__image_ratio <= 1:
|
||||
adj_width = size[1] * self.__image_ratio
|
||||
|
||||
if adj_width > size[0]:
|
||||
adj_height = adj_height * (size[0] / adj_width)
|
||||
adj_width = size[0]
|
||||
elif adj_height > size[1]:
|
||||
adj_width = adj_width * (size[1] / adj_height)
|
||||
adj_height = size[1]
|
||||
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
|
||||
self.__img_button_size = (int(adj_width), int(adj_height))
|
||||
self.__button_wrapper.setMaximumSize(adj_size)
|
||||
self.__button_wrapper.setIconSize(adj_size)
|
||||
self.__preview_gif.setMaximumSize(adj_size)
|
||||
self.__preview_gif.setMinimumSize(adj_size)
|
||||
|
||||
if not self.__media_player.player.hasVideo():
|
||||
# ensure we do not exceed the thumbnail size
|
||||
mp_width = (
|
||||
adj_size.width()
|
||||
if adj_size.width() < self.__mp_max_size.width()
|
||||
else self.__mp_max_size.width()
|
||||
)
|
||||
mp_height = (
|
||||
adj_size.height()
|
||||
if adj_size.height() < self.__mp_max_size.height()
|
||||
else self.__mp_max_size.height()
|
||||
)
|
||||
mp_size = QSize(mp_width, mp_height)
|
||||
self.__media_player.setMinimumSize(mp_size)
|
||||
self.__media_player.setMaximumSize(mp_size)
|
||||
else:
|
||||
# have video, so just resize as normal
|
||||
self.__media_player.setMaximumSize(adj_size)
|
||||
self.__media_player.setMinimumSize(adj_size)
|
||||
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.__preview_gif.setStyle(proxy_style)
|
||||
self.__media_player.setStyle(proxy_style)
|
||||
m = self.__preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def __switch_preview(self, preview: MediaType | None) -> None:
|
||||
if preview in [MediaType.AUDIO, MediaType.VIDEO]:
|
||||
self.__media_player.show()
|
||||
self.__image_layout.setCurrentWidget(self.__media_player_page)
|
||||
else:
|
||||
self.__media_player.stop()
|
||||
self.__media_player.hide()
|
||||
|
||||
if preview in [MediaType.IMAGE, MediaType.AUDIO]:
|
||||
self.__button_wrapper.show()
|
||||
self.__image_layout.setCurrentWidget(
|
||||
self.__preview_img_page if preview == MediaType.IMAGE else self.__media_player_page
|
||||
)
|
||||
else:
|
||||
self.__button_wrapper.hide()
|
||||
|
||||
if preview == MediaType.IMAGE_ANIMATED:
|
||||
self.__preview_gif.show()
|
||||
self.__image_layout.setCurrentWidget(self.__preview_gif_page)
|
||||
else:
|
||||
if self.__preview_gif.movie():
|
||||
self.__preview_gif.movie().stop()
|
||||
self.__gif_buffer.close()
|
||||
self.__preview_gif.hide()
|
||||
|
||||
def __render_thumb(self, filepath: Path) -> None:
|
||||
self.__thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
def __update_media_player(self, filepath: Path) -> int:
|
||||
"""Display either audio or video.
|
||||
|
||||
Returns the duration of the audio / video.
|
||||
"""
|
||||
self.__media_player.play(filepath)
|
||||
return self.__media_player.player.duration() * 1000
|
||||
|
||||
def _display_video(self, filepath: Path, size: QSize | None) -> FileAttributeData:
|
||||
self.__switch_preview(MediaType.VIDEO)
|
||||
stats = FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
|
||||
if size is not None:
|
||||
stats.width = size.width()
|
||||
stats.height = size.height()
|
||||
|
||||
self.__image_ratio = stats.width / stats.height
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(stats.width, stats.height),
|
||||
QSize(stats.width, stats.height),
|
||||
)
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
def _display_audio(self, filepath: Path) -> FileAttributeData:
|
||||
self.__switch_preview(MediaType.AUDIO)
|
||||
self.__render_thumb(filepath)
|
||||
return FileAttributeData(duration=self.__update_media_player(filepath))
|
||||
|
||||
def _display_gif(self, gif_data: bytes, size: tuple[int, int]) -> FileAttributeData | None:
|
||||
"""Update the animated image preview from a filepath."""
|
||||
stats = FileAttributeData()
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
if self.__preview_gif.movie():
|
||||
self.__preview_gif.movie().stop()
|
||||
self.__gif_buffer.close()
|
||||
|
||||
stats.width = size[0]
|
||||
stats.height = size[1]
|
||||
|
||||
self.__image_ratio = stats.width / stats.height
|
||||
|
||||
self.__gif_buffer.setData(gif_data)
|
||||
movie = QMovie(self.__gif_buffer, QByteArray())
|
||||
self.__preview_gif.setMovie(movie)
|
||||
|
||||
# If the animation only has 1 frame, it isn't animated and shouldn't be treated as such
|
||||
if movie.frameCount() <= 1:
|
||||
return None
|
||||
|
||||
# The animation has more than 1 frame, continue displaying it as an animation
|
||||
self.__switch_preview(MediaType.IMAGE_ANIMATED)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(stats.width, stats.height),
|
||||
QSize(stats.width, stats.height),
|
||||
)
|
||||
)
|
||||
movie.start()
|
||||
|
||||
stats.duration = movie.frameCount() // 60
|
||||
|
||||
return stats
|
||||
|
||||
def _display_image(self, filepath: Path):
|
||||
"""Renders the given file as an image, no matter its media type."""
|
||||
self.__switch_preview(MediaType.IMAGE)
|
||||
self.__render_thumb(filepath)
|
||||
|
||||
def hide_preview(self) -> None:
|
||||
"""Completely hide the file preview."""
|
||||
self.__switch_preview(None)
|
||||
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self.__update_image_size((self.size().width(), self.size().height()))
|
||||
return super().resizeEvent(event)
|
||||
|
||||
@property
|
||||
def media_player(self) -> MediaPlayer:
|
||||
return self.__media_player
|
||||
@@ -1,3 +1,6 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import traceback
|
||||
import typing
|
||||
from pathlib import Path
|
||||
@@ -16,10 +19,10 @@ from tagstudio.core.enums import Theme
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.library.alchemy.models import Entry
|
||||
from tagstudio.core.palette import ColorType, UiColor, get_ui_color
|
||||
from tagstudio.qt.controller.widgets.preview.preview_thumb_controller import PreviewThumb
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.preview.field_containers import FieldContainers
|
||||
from tagstudio.qt.widgets.preview.file_attributes import FileAttributes
|
||||
from tagstudio.qt.widgets.preview.preview_thumb import PreviewThumb
|
||||
from tagstudio.qt.widgets.preview.file_attributes import FileAttributeData, FileAttributes
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
@@ -128,9 +131,6 @@ class PreviewPanelView(QWidget):
|
||||
def _set_selection_callback(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def thumb_media_player_stop(self):
|
||||
self.__thumb.media_player.stop()
|
||||
|
||||
def set_selection(self, selected: list[int], update_preview: bool = True):
|
||||
"""Render the panel widgets with the newest data from the Library.
|
||||
|
||||
@@ -160,7 +160,7 @@ class PreviewPanelView(QWidget):
|
||||
filepath: Path = self.lib.library_dir / entry.path
|
||||
|
||||
if update_preview:
|
||||
stats: dict = self.__thumb.update_preview(filepath)
|
||||
stats: FileAttributeData = self.__thumb.display_file(filepath)
|
||||
self.__file_attrs.update_stats(filepath, stats)
|
||||
self.__file_attrs.update_date_label(filepath)
|
||||
self._fields.update_from_entry(entry_id)
|
||||
@@ -206,3 +206,7 @@ class PreviewPanelView(QWidget):
|
||||
def field_containers_widget(self) -> FieldContainers: # needed for the tests
|
||||
"""Getter for the field containers widget."""
|
||||
return self._fields
|
||||
|
||||
@property
|
||||
def preview_thumb(self) -> PreviewThumb:
|
||||
return self.__thumb
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import os
|
||||
import platform
|
||||
import typing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
@@ -29,6 +30,13 @@ if typing.TYPE_CHECKING:
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileAttributeData:
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
duration: int | None = None
|
||||
|
||||
|
||||
class FileAttributes(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
@@ -131,10 +139,10 @@ class FileAttributes(QWidget):
|
||||
self.date_created_label.setHidden(True)
|
||||
self.date_modified_label.setHidden(True)
|
||||
|
||||
def update_stats(self, filepath: Path | None = None, stats: dict | None = None):
|
||||
def update_stats(self, filepath: Path | None = None, stats: FileAttributeData | None = None):
|
||||
"""Render the panel widgets with the newest data from the Library."""
|
||||
if not stats:
|
||||
stats = {}
|
||||
stats = FileAttributeData()
|
||||
|
||||
if not filepath:
|
||||
self.layout().setSpacing(0)
|
||||
@@ -179,16 +187,9 @@ class FileAttributes(QWidget):
|
||||
stats_label_text = ""
|
||||
ext_display: str = ""
|
||||
file_size: str = ""
|
||||
width_px_text: str = ""
|
||||
height_px_text: str = ""
|
||||
duration_text: str = ""
|
||||
font_family: str = ""
|
||||
|
||||
# Attempt to populate the stat variables
|
||||
width_px_text = stats.get("width", "")
|
||||
height_px_text = stats.get("height", "")
|
||||
duration_text = stats.get("duration", "")
|
||||
font_family = stats.get("font_family", "")
|
||||
ext_display = ext.upper()[1:] or filepath.stem.upper()
|
||||
if filepath:
|
||||
try:
|
||||
@@ -217,14 +218,14 @@ class FileAttributes(QWidget):
|
||||
elif file_size:
|
||||
stats_label_text += file_size
|
||||
|
||||
if width_px_text and height_px_text:
|
||||
if stats.width is not None and stats.height is not None:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
stats_label_text += f"{width_px_text} x {height_px_text} px"
|
||||
stats_label_text += f"{stats.width} x {stats.height} px"
|
||||
|
||||
if duration_text:
|
||||
if stats.duration is not None:
|
||||
stats_label_text = add_newline(stats_label_text)
|
||||
try:
|
||||
dur_str = str(timedelta(seconds=float(duration_text)))[:-7]
|
||||
dur_str = str(timedelta(seconds=float(stats.duration)))[:-7]
|
||||
if dur_str.startswith("0:"):
|
||||
dur_str = dur_str[2:]
|
||||
if dur_str.startswith("0"):
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
# Copyright (C) 2025 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import io
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, override
|
||||
from warnings import catch_warnings
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
import structlog
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt
|
||||
from PySide6.QtGui import QAction, QMovie, QResizeEvent
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget
|
||||
|
||||
from tagstudio.core.library.alchemy.library import Library
|
||||
from tagstudio.core.media_types import MediaCategories, MediaType
|
||||
from tagstudio.qt.helpers.file_opener import FileOpenerHelper, open_file
|
||||
from tagstudio.qt.helpers.file_tester import is_readable_video
|
||||
from tagstudio.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from tagstudio.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from tagstudio.qt.platform_strings import open_file_str, trash_term
|
||||
from tagstudio.qt.translations import Translations
|
||||
from tagstudio.qt.widgets.media_player import MediaPlayer
|
||||
from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tagstudio.qt.ts_qt import QtDriver
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class PreviewThumb(QWidget):
|
||||
"""The Preview Panel Widget."""
|
||||
|
||||
def __init__(self, library: Library, driver: "QtDriver") -> None:
|
||||
super().__init__()
|
||||
|
||||
self.is_connected = False
|
||||
self.lib = library
|
||||
self.driver: QtDriver = driver
|
||||
|
||||
self.img_button_size: tuple[int, int] = (266, 266)
|
||||
self.image_ratio: float = 1.0
|
||||
|
||||
self.image_layout = QStackedLayout(self)
|
||||
self.image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
self.image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.opener: FileOpenerHelper | None = None
|
||||
self.open_file_action = QAction(Translations["file.open_file"], self)
|
||||
self.open_explorer_action = QAction(open_file_str(), self)
|
||||
self.delete_action = QAction(
|
||||
Translations.format("trash.context.ambiguous", trash_term=trash_term()),
|
||||
self,
|
||||
)
|
||||
|
||||
self.preview_img = QPushButtonWrapper()
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
self.preview_img.setFlat(True)
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
self.preview_img.addAction(self.delete_action)
|
||||
|
||||
# In testing, it didn't seem possible to center the widgets directly
|
||||
# on the QStackedLayout. Adding sublayouts allows us to center the widgets.
|
||||
self.preview_img_page = QWidget()
|
||||
self._stacked_page_setup(self.preview_img_page, self.preview_img)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.preview_gif.addAction(self.open_file_action)
|
||||
self.preview_gif.addAction(self.open_explorer_action)
|
||||
self.preview_gif.addAction(self.delete_action)
|
||||
self.gif_buffer: QBuffer = QBuffer()
|
||||
|
||||
self.preview_gif_page = QWidget()
|
||||
self._stacked_page_setup(self.preview_gif_page, self.preview_gif)
|
||||
|
||||
self.media_player = MediaPlayer(driver)
|
||||
self.media_player.addAction(self.open_file_action)
|
||||
self.media_player.addAction(self.open_explorer_action)
|
||||
self.media_player.addAction(self.delete_action)
|
||||
|
||||
# Need to watch for this to resize the player appropriately.
|
||||
self.media_player.player.hasVideoChanged.connect(self._has_video_changed)
|
||||
|
||||
self.mp_max_size = QSize(*self.img_button_size)
|
||||
|
||||
self.media_player_page = QWidget()
|
||||
self._stacked_page_setup(self.media_player_page, self.media_player)
|
||||
|
||||
self.thumb_renderer = ThumbRenderer(self.lib)
|
||||
self.thumb_renderer.updated.connect(
|
||||
lambda ts, i, s: (
|
||||
self.preview_img.setIcon(i),
|
||||
self._set_mp_max_size(i.size()),
|
||||
)
|
||||
)
|
||||
self.thumb_renderer.updated_ratio.connect(
|
||||
lambda ratio: (
|
||||
self.set_image_ratio(ratio),
|
||||
self.update_image_size(
|
||||
(
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
),
|
||||
ratio,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
self.image_layout.addWidget(self.preview_img_page)
|
||||
self.image_layout.addWidget(self.preview_gif_page)
|
||||
self.image_layout.addWidget(self.media_player_page)
|
||||
|
||||
self.setMinimumSize(*self.img_button_size)
|
||||
|
||||
self.hide_preview()
|
||||
|
||||
def _set_mp_max_size(self, size: QSize) -> None:
|
||||
self.mp_max_size = size
|
||||
|
||||
def _has_video_changed(self, video: bool) -> None:
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
|
||||
def _stacked_page_setup(self, page: QWidget, widget: QWidget) -> None:
|
||||
layout = QHBoxLayout(page)
|
||||
layout.addWidget(widget)
|
||||
layout.setAlignment(widget, Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
page.setLayout(layout)
|
||||
|
||||
def set_image_ratio(self, ratio: float) -> None:
|
||||
self.image_ratio = ratio
|
||||
|
||||
def update_image_size(self, size: tuple[int, int], ratio: float | None = None) -> None:
|
||||
if ratio:
|
||||
self.set_image_ratio(ratio)
|
||||
|
||||
adj_width: float = size[0]
|
||||
adj_height: float = size[1]
|
||||
# Landscape
|
||||
if self.image_ratio > 1:
|
||||
adj_height = size[0] * (1 / self.image_ratio)
|
||||
# Portrait
|
||||
elif self.image_ratio <= 1:
|
||||
adj_width = size[1] * self.image_ratio
|
||||
|
||||
if adj_width > size[0]:
|
||||
adj_height = adj_height * (size[0] / adj_width)
|
||||
adj_width = size[0]
|
||||
elif adj_height > size[1]:
|
||||
adj_width = adj_width * (size[1] / adj_height)
|
||||
adj_height = size[1]
|
||||
|
||||
adj_size = QSize(int(adj_width), int(adj_height))
|
||||
|
||||
self.img_button_size = (int(adj_width), int(adj_height))
|
||||
self.preview_img.setMaximumSize(adj_size)
|
||||
self.preview_img.setIconSize(adj_size)
|
||||
self.preview_gif.setMaximumSize(adj_size)
|
||||
self.preview_gif.setMinimumSize(adj_size)
|
||||
|
||||
if not self.media_player.player.hasVideo():
|
||||
# ensure we do not exceed the thumbnail size
|
||||
mp_width = (
|
||||
adj_size.width()
|
||||
if adj_size.width() < self.mp_max_size.width()
|
||||
else self.mp_max_size.width()
|
||||
)
|
||||
mp_height = (
|
||||
adj_size.height()
|
||||
if adj_size.height() < self.mp_max_size.height()
|
||||
else self.mp_max_size.height()
|
||||
)
|
||||
mp_size = QSize(mp_width, mp_height)
|
||||
self.media_player.setMinimumSize(mp_size)
|
||||
self.media_player.setMaximumSize(mp_size)
|
||||
else:
|
||||
# have video, so just resize as normal
|
||||
self.media_player.setMaximumSize(adj_size)
|
||||
self.media_player.setMinimumSize(adj_size)
|
||||
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.preview_gif.setStyle(proxy_style)
|
||||
self.media_player.setStyle(proxy_style)
|
||||
m = self.preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def get_preview_size(self) -> tuple[int, int]:
|
||||
return (
|
||||
self.size().width(),
|
||||
self.size().height(),
|
||||
)
|
||||
|
||||
def switch_preview(self, preview: str) -> None:
|
||||
if preview in ["audio", "video"]:
|
||||
self.media_player.show()
|
||||
self.image_layout.setCurrentWidget(self.media_player_page)
|
||||
else:
|
||||
self.media_player.stop()
|
||||
self.media_player.hide()
|
||||
|
||||
if preview in ["image", "audio"]:
|
||||
self.preview_img.show()
|
||||
self.image_layout.setCurrentWidget(
|
||||
self.preview_img_page if preview == "image" else self.media_player_page
|
||||
)
|
||||
else:
|
||||
self.preview_img.hide()
|
||||
|
||||
if preview == "animated":
|
||||
self.preview_gif.show()
|
||||
self.image_layout.setCurrentWidget(self.preview_gif_page)
|
||||
else:
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
self.preview_gif.hide()
|
||||
|
||||
def _display_fallback_image(self, filepath: Path, ext: str) -> dict[str, int]:
|
||||
"""Renders the given file as an image, no matter its media type.
|
||||
|
||||
Useful for fallback scenarios.
|
||||
"""
|
||||
self.switch_preview("image")
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
return self._update_image(filepath)
|
||||
|
||||
def _update_image(self, filepath: Path) -> dict[str, int]:
|
||||
"""Update the static image preview from a filepath."""
|
||||
stats: dict[str, int] = {}
|
||||
ext = filepath.suffix.lower()
|
||||
self.switch_preview("image")
|
||||
|
||||
image: Image.Image | None = None
|
||||
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RAW_TYPES, mime_fallback=True
|
||||
):
|
||||
try:
|
||||
with rawpy.imread(str(filepath)) as raw:
|
||||
rgb = raw.postprocess()
|
||||
image = Image.new("L", (rgb.shape[1], rgb.shape[0]), color="black")
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except (
|
||||
rawpy._rawpy._rawpy.LibRawIOError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
rawpy._rawpy.LibRawFileUnsupportedError, # pyright: ignore[reportAttributeAccessIssue]
|
||||
FileNotFoundError,
|
||||
):
|
||||
pass
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_RASTER_TYPES, mime_fallback=True
|
||||
):
|
||||
try:
|
||||
image = Image.open(str(filepath))
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
except (
|
||||
DecompressionBombError,
|
||||
FileNotFoundError,
|
||||
NotImplementedError,
|
||||
UnidentifiedImageError,
|
||||
) as e:
|
||||
logger.error("[PreviewThumb] Could not get image stats", filepath=filepath, error=e)
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_VECTOR_TYPES, mime_fallback=True
|
||||
):
|
||||
pass
|
||||
|
||||
return stats
|
||||
|
||||
def _update_animation(self, filepath: Path, ext: str) -> dict[str, int]:
|
||||
"""Update the animated image preview from a filepath."""
|
||||
stats: dict[str, int] = {}
|
||||
|
||||
# Ensure that any movie and buffer from previous animations are cleared.
|
||||
if self.preview_gif.movie():
|
||||
self.preview_gif.movie().stop()
|
||||
self.gif_buffer.close()
|
||||
|
||||
try:
|
||||
image: Image.Image = Image.open(filepath)
|
||||
stats["width"] = image.width
|
||||
stats["height"] = image.height
|
||||
|
||||
self.update_image_size((image.width, image.height), image.width / image.height)
|
||||
if ext == ".apng":
|
||||
image_bytes_io = io.BytesIO()
|
||||
image.save(
|
||||
image_bytes_io,
|
||||
"GIF",
|
||||
lossless=True,
|
||||
save_all=True,
|
||||
loop=0,
|
||||
disposal=2,
|
||||
)
|
||||
image.close()
|
||||
image_bytes_io.seek(0)
|
||||
self.gif_buffer.setData(image_bytes_io.read())
|
||||
else:
|
||||
image.close()
|
||||
with open(filepath, "rb") as f:
|
||||
self.gif_buffer.setData(f.read())
|
||||
movie = QMovie(self.gif_buffer, QByteArray())
|
||||
self.preview_gif.setMovie(movie)
|
||||
|
||||
# If the animation only has 1 frame, display it like a normal image.
|
||||
if movie.frameCount() <= 1:
|
||||
self._display_fallback_image(filepath, ext)
|
||||
return stats
|
||||
|
||||
# The animation has more than 1 frame, continue displaying it as an animation
|
||||
self.switch_preview("animated")
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(stats["width"], stats["height"]),
|
||||
QSize(stats["width"], stats["height"]),
|
||||
)
|
||||
)
|
||||
movie.start()
|
||||
|
||||
stats["duration"] = movie.frameCount() // 60
|
||||
except (UnidentifiedImageError, FileNotFoundError) as e:
|
||||
logger.error("[PreviewThumb] Could not load animated image", filepath=filepath, error=e)
|
||||
return self._display_fallback_image(filepath, ext)
|
||||
|
||||
return stats
|
||||
|
||||
def _get_video_res(self, filepath: str) -> tuple[bool, QSize]:
|
||||
video = cv2.VideoCapture(filepath, cv2.CAP_FFMPEG)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
return (success, QSize(image.width, image.height))
|
||||
|
||||
def _update_media(self, filepath: Path, type: MediaType) -> dict[str, int]:
|
||||
stats: dict[str, int] = {}
|
||||
|
||||
self.media_player.play(filepath)
|
||||
|
||||
if type == MediaType.VIDEO:
|
||||
try:
|
||||
success, size = self._get_video_res(str(filepath))
|
||||
if success:
|
||||
self.update_image_size(
|
||||
(size.width(), size.height()), size.width() / size.height()
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(size.width(), size.height()),
|
||||
QSize(size.width(), size.height()),
|
||||
)
|
||||
)
|
||||
|
||||
stats["width"] = size.width()
|
||||
stats["height"] = size.height()
|
||||
|
||||
except cv2.error as e:
|
||||
logger.error("[PreviewThumb] Could not play video", filepath=filepath, error=e)
|
||||
|
||||
self.switch_preview("video" if type == MediaType.VIDEO else "audio")
|
||||
stats["duration"] = self.media_player.player.duration() * 1000
|
||||
return stats
|
||||
|
||||
def update_preview(self, filepath: Path) -> dict[str, int]:
|
||||
"""Render a single file preview."""
|
||||
ext = filepath.suffix.lower()
|
||||
stats: dict[str, int] = {}
|
||||
|
||||
# Video
|
||||
if MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.VIDEO_TYPES, mime_fallback=True
|
||||
) and is_readable_video(filepath):
|
||||
stats = self._update_media(filepath, MediaType.VIDEO)
|
||||
|
||||
# Audio
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.AUDIO_TYPES, mime_fallback=True
|
||||
):
|
||||
self._update_image(filepath)
|
||||
stats = self._update_media(filepath, MediaType.AUDIO)
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
# Animated Images
|
||||
elif MediaCategories.is_ext_in_category(
|
||||
ext, MediaCategories.IMAGE_ANIMATED_TYPES, mime_fallback=True
|
||||
):
|
||||
stats = self._update_animation(filepath, ext)
|
||||
|
||||
# Other Types (Including Images)
|
||||
else:
|
||||
# TODO: Get thumb renderer to return this stuff to pass on
|
||||
stats = self._update_image(filepath)
|
||||
|
||||
self.thumb_renderer.render(
|
||||
time.time(),
|
||||
filepath,
|
||||
(512, 512),
|
||||
self.devicePixelRatio(),
|
||||
update_on_ratio_change=True,
|
||||
)
|
||||
|
||||
with catch_warnings(record=True):
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path))
|
||||
self.preview_img.is_connected = True
|
||||
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
|
||||
self.opener = FileOpenerHelper(filepath)
|
||||
self.open_file_action.triggered.connect(self.opener.open_file)
|
||||
self.open_explorer_action.triggered.connect(self.opener.open_explorer)
|
||||
|
||||
with catch_warnings(record=True):
|
||||
self.delete_action.triggered.disconnect()
|
||||
|
||||
self.delete_action.setText(
|
||||
Translations.format("trash.context.singular", trash_term=trash_term())
|
||||
)
|
||||
self.delete_action.triggered.connect(
|
||||
lambda checked=False, f=filepath: self.driver.delete_files_callback(f)
|
||||
)
|
||||
self.delete_action.setEnabled(bool(filepath))
|
||||
|
||||
return stats
|
||||
|
||||
def hide_preview(self) -> None:
|
||||
"""Completely hide the file preview."""
|
||||
self.switch_preview("")
|
||||
|
||||
@override
|
||||
def resizeEvent(self, event: QResizeEvent) -> None:
|
||||
self.update_image_size((self.size().width(), self.size().height()))
|
||||
return super().resizeEvent(event)
|
||||
Reference in New Issue
Block a user