From 3e7fad710efdcd9e3979737a8bc7be707477f4d7 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Mon, 17 Feb 2025 21:26:00 -0500 Subject: [PATCH 1/3] feat: merge media controls. Initial commit to merge audio/video files. There are still a few bugs around widget sizing that need fixing. --- tagstudio/src/qt/widgets/media_player.py | 334 +++++++++++++++--- .../src/qt/widgets/preview/preview_thumb.py | 84 ++--- tagstudio/src/qt/widgets/preview_panel.py | 9 +- 3 files changed, 322 insertions(+), 105 deletions(-) diff --git a/tagstudio/src/qt/widgets/media_player.py b/tagstudio/src/qt/widgets/media_player.py index b1d86071b..edaeaec1e 100644 --- a/tagstudio/src/qt/widgets/media_player.py +++ b/tagstudio/src/qt/widgets/media_player.py @@ -2,41 +2,114 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio -import logging import typing from pathlib import Path from time import gmtime -from typing import Any -from PySide6.QtCore import Qt, QUrl -from PySide6.QtGui import QIcon, QPixmap +from PIL import Image, ImageDraw +from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation, Signal +from PySide6.QtGui import QBitmap, QBrush, QColor, QPen, QRegion from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer +from PySide6.QtMultimediaWidgets import QGraphicsVideoItem +from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import ( - QGridLayout, - QHBoxLayout, + QGraphicsScene, + QGraphicsView, QLabel, - QPushButton, - QSizePolicy, QSlider, - QWidget, ) if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver -class MediaPlayer(QWidget): +class MediaPlayer(QGraphicsView): """A basic media player widget. Gives a basic control set to manage media playback. """ + clicked = Signal() + click_connected = False + + mouse_over_volume_slider = False + mouse_over_play_pause = False + mouse_over_mute_unmute = False + + video_preview = None + def __init__(self, driver: "QtDriver") -> None: super().__init__() self.driver = driver - self.setFixedHeight(50) + slider_style = """ + QSlider { + background: transparent; + } + + QSlider::groove:horizontal { + border: 1px solid #999999; + height: 2px; + margin: 2px 0; + border-radius: 2px; + } + + QSlider::handle:horizontal { + background: #6ea0ff; + border: 1px solid #5c5c5c; + width: 12px; + height: 12px; + margin: -6px 0; + border-radius: 6px; + } + + QSlider::add-page:horizontal { + background: #3f4144; + height: 2px; + margin: 2px 0; + border-radius: 2px; + } + + QSlider::sub-page:horizontal { + background: #6ea0ff; + height: 2px; + margin: 2px 0; + border-radius: 2px; + } + """ + + # setup the scene + self.installEventFilter(self) + self.setScene(QGraphicsScene(self)) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setStyleSheet("background: transparent;") + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.video_preview = VideoPreview() + self.video_preview.setAcceptHoverEvents(True) + self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton) + self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) + self.video_preview.installEventFilter(self) + + # self.scene().addItem(self.video_preview) + + # animation + self.animation = QVariantAnimation(self) + self.animation.valueChanged.connect(lambda value: self.set_tint_opacity(value)) + + # Set up the tint. + self.tint = self.scene().addRect( + 0, + 0, + self.size().width(), + self.size().height(), + QPen(QColor(0, 0, 0, 0)), + QBrush(QColor(0, 0, 0, 0)), + ) + # setup the player self.filepath: Path | None = None self.player = QMediaPlayer() self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) @@ -52,58 +125,159 @@ def __init__(self, driver: "QtDriver") -> None: self.player.positionChanged.connect(self.player_position_changed) self.player.mediaStatusChanged.connect(self.media_status_changed) self.player.playingChanged.connect(self.playing_changed) + self.player.hasVideoChanged.connect(self.has_video_changed) self.player.audioOutput().mutedChanged.connect(self.muted_changed) # Media controls - self.base_layout = QGridLayout(self) - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.base_layout.setSpacing(0) - - self.pslider = QSlider(self) + self.pslider = QSlider() self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) self.pslider.setSingleStep(1) self.pslider.setOrientation(Qt.Orientation.Horizontal) - + self.pslider.setStyleSheet(slider_style) self.pslider.sliderReleased.connect(self.slider_released) self.pslider.valueChanged.connect(self.slider_value_changed) - - self.media_btns_layout = QHBoxLayout() - - policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) - - self.play_pause = QPushButton("", self) - self.play_pause.setFlat(True) - self.play_pause.setSizePolicy(policy) - self.play_pause.clicked.connect(self.toggle_pause) - - self.load_play_pause_icon(playing=False) - - self.media_btns_layout.addWidget(self.play_pause) - - self.mute = QPushButton("", self) - self.mute.setFlat(True) - self.mute.setSizePolicy(policy) - self.mute.clicked.connect(self.toggle_mute) - + self.pslider.hide() + + self.play_pause = QSvgWidget() + self.play_pause.setCursor(Qt.CursorShape.PointingHandCursor) + self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True) + self.play_pause.setMouseTracking(True) + self.play_pause.installEventFilter(self) + self.load_toggle_play_icon(playing=False) + self.play_pause.resize(24, 24) + self.play_pause.hide() + + self.mute_unmute = QSvgWidget() + self.mute_unmute.setCursor(Qt.CursorShape.PointingHandCursor) + self.mute_unmute.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True) + self.mute_unmute.setMouseTracking(True) + self.mute_unmute.installEventFilter(self) self.load_mute_unmute_icon(muted=False) - - self.media_btns_layout.addWidget(self.mute) + self.mute_unmute.resize(24, 24) + self.mute_unmute.hide() self.volume_slider = QSlider() self.volume_slider.setOrientation(Qt.Orientation.Horizontal) - # set slider value to current volume self.volume_slider.setValue(int(self.player.audioOutput().volume() * 100)) self.volume_slider.valueChanged.connect(self.volume_slider_changed) - - self.media_btns_layout.addWidget(self.volume_slider) + self.volume_slider.setMaximumWidth(100) + self.volume_slider.setStyleSheet(slider_style) + self.volume_slider.hide() self.position_label = QLabel("0:00") - self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight) + self.position_label.setStyleSheet("background: transparent;font-size: 14px;") + self.position_label.hide() + + # font = QFont() + # font.setPointSize(12) + # self.position_label.setFont(font) + # self.position_label.setBrush(QBrush(Qt.GlobalColor.white)) + # self.position_label.hide() + + self.scene().addWidget(self.pslider) + self.scene().addWidget(self.play_pause) + self.scene().addWidget(self.mute_unmute) + self.scene().addWidget(self.volume_slider) + self.scene().addWidget(self.position_label) + + def set_video_output(self, video: QGraphicsVideoItem): + self.player.setVideoOutput(video) + + def apply_rounded_corners(self) -> None: + """Apply a rounded corner effect to the video player.""" + width: int = int(max(self.contentsRect().size().width(), 0)) + height: int = int(max(self.contentsRect().size().height(), 0)) + mask = Image.new( + "RGBA", + ( + width, + height, + ), + (0, 0, 0, 255), + ) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle( + (0, 0) + (width, height), + radius=8, + fill=(0, 0, 0, 0), + ) + final_mask = mask.getchannel("A").toqpixmap() + self.setMask(QRegion(QBitmap(final_mask))) + + def set_tint_opacity(self, opacity: int) -> None: + """Set the opacity of the video player's tint. + + Args: + opacity(int): The opacity value, from 0-255. + """ + self.tint.setBrush(QBrush(QColor(0, 0, 0, opacity))) + + def underMouse(self) -> bool: # noqa: N802 + self.animation.setStartValue(self.tint.brush().color().alpha()) + self.animation.setEndValue(100) + self.animation.setDuration(250) + self.animation.start() + self.pslider.show() + self.play_pause.show() + self.mute_unmute.show() + self.volume_slider.show() + self.position_label.show() + + return super().underMouse() + + def releaseMouse(self) -> None: # noqa: N802 + self.animation.setStartValue(self.tint.brush().color().alpha()) + self.animation.setEndValue(0) + self.animation.setDuration(500) + self.animation.start() + self.pslider.hide() + self.play_pause.hide() + self.mute_unmute.hide() + self.volume_slider.hide() + self.position_label.hide() + + return super().releaseMouse() + + def mouse_over_elements(self) -> bool: + return ( + self.mouse_over_play_pause + or self.mouse_over_mute_unmute + or self.mouse_over_volume_slider + ) - self.base_layout.addWidget(self.pslider, 0, 0, 1, 2) - self.base_layout.addLayout(self.media_btns_layout, 1, 0) - self.base_layout.addWidget(self.position_label, 1, 1) + def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 + """Manage events for the media player.""" + if ( + event.type() == QEvent.Type.MouseButtonPress + and event.button() == Qt.MouseButton.LeftButton # type: ignore + ): + if obj == self.play_pause: + self.toggle_play() + elif obj == self.mute_unmute: + self.toggle_mute() + elif self.mouse_over_elements() is False: # let someone else handle this event + self.clicked.emit() + elif event.type() is QEvent.Type.Enter: + if obj == self or obj == self.video_preview: + self.underMouse() + elif obj == self.mute_unmute: + self.mouse_over_mute_unmute = True + elif obj == self.play_pause: + self.mouse_over_play_pause = True + elif obj == self.volume_slider: + self.mouse_over_volume_slider = True + elif event.type() == QEvent.Type.Leave: + if obj == self or obj == self.video_preview: + self.releaseMouse() + elif obj == self.mute_unmute: + self.mouse_over_mute_unmute = False + elif obj == self.play_pause: + self.mouse_over_play_pause = False + elif obj == self.volume_slider: + self.mouse_over_volume_slider = False + + return super().eventFilter(obj, event) def format_time(self, ms: int) -> str: """Format the given time. @@ -128,8 +302,8 @@ def format_time(self, ms: int) -> str: else f"{time.tm_min}:{time.tm_sec:02}" ) - def toggle_pause(self) -> None: - """Toggle the pause state of the media.""" + def toggle_play(self) -> None: + """Toggle the playing state of the media.""" if self.player.isPlaying(): self.player.pause() self.is_paused = True @@ -145,11 +319,19 @@ def toggle_mute(self) -> None: self.player.audioOutput().setMuted(True) def playing_changed(self, playing: bool) -> None: - self.load_play_pause_icon(playing) + self.load_toggle_play_icon(playing) def muted_changed(self, muted: bool) -> None: self.load_mute_unmute_icon(muted) + def has_video_changed(self, video_available: bool) -> None: + if video_available: + self.scene().addItem(self.video_preview) + self.video_preview.setZValue(-1) + self.player.setVideoOutput(self.video_preview) + else: + self.scene().removeItem(self.video_preview) + def stop(self) -> None: """Clear the filepath and stop the player.""" self.filepath = None @@ -165,20 +347,14 @@ def play(self, filepath: Path) -> None: else: self.player.setSource(QUrl.fromLocalFile(self.filepath)) - def load_play_pause_icon(self, playing: bool) -> None: + def load_toggle_play_icon(self, playing: bool) -> None: icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon - self.set_icon(self.play_pause, icon) + self.play_pause.load(icon) + # self.set_icon(self.toggle_play, icon) def load_mute_unmute_icon(self, muted: bool) -> None: icon = self.driver.rm.volume_mute_icon if muted else self.driver.rm.volume_icon - self.set_icon(self.mute, icon) - - def set_icon(self, btn: QPushButton, icon: Any) -> None: - pix_map = QPixmap() - if pix_map.loadFromData(icon): - btn.setIcon(QIcon(pix_map)) - else: - logging.error("failed to load svg file") + self.mute_unmute.load(icon) def slider_value_changed(self, value: int) -> None: current = self.format_time(value) @@ -216,5 +392,47 @@ def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: duration = self.format_time(self.player.duration()) self.position_label.setText(f"{current} / {duration}") + def set_size(self, size: QSize) -> None: + self.setFixedSize(size) + self.scene().setSceneRect(0, 0, size.width(), size.height()) + + self.play_pause.move(0, int(self.scene().height() - self.play_pause.height())) + self.mute_unmute.move( + self.play_pause.width(), int(self.scene().height() - self.mute_unmute.height()) + ) + + pos_w = int(size.width() - self.position_label.width() - 5) + pos_h = int(size.height() - self.position_label.height() - 5) + self.position_label.move(pos_w, pos_h) + + self.pslider.setMinimumWidth(self.size().width() - 10) + self.pslider.setMaximumWidth(self.size().width() - 10) + self.pslider.move( + 3, int(self.scene().height() - self.play_pause.height() - self.pslider.height()) + ) + + pos_w = int(self.play_pause.width() + self.mute_unmute.width()) + pos_h = int(size.height() - self.mute_unmute.height() + 5) + self.volume_slider.move(pos_w, pos_h) + + if self.player.hasVideo(): + self.video_preview.setSize(self.size()) + + self.tint.setRect(0, 0, self.size().width(), self.size().height()) + self.apply_rounded_corners() + def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) + + +class VideoPreview(QGraphicsVideoItem): + def boundingRect(self): # noqa: N802 + return QRectF(0, 0, self.size().width(), self.size().height()) + + def paint(self, painter, option, widget=None) -> None: + # painter.brush().setColor(QColor(0, 0, 0, 255)) + # You can set any shape you want here. + # RoundedRect is the standard rectangle with rounded corners. + # With 2nd and 3rd parameter you can tweak the curve until you get what you expect + + super().paint(painter, option, widget) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 137b2dea7..750129afc 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -6,7 +6,6 @@ import time import typing from pathlib import Path -from warnings import catch_warnings import cv2 import rawpy @@ -15,8 +14,8 @@ from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, + QStackedLayout, QWidget, ) from src.core.library.alchemy.library import Library @@ -25,12 +24,10 @@ from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle -from src.qt.platform_strings import open_file_str, trash_term -from src.qt.resource_manager import ResourceManager +from src.qt.platform_strings import open_file_str from src.qt.translations import Translations from src.qt.widgets.media_player import MediaPlayer from src.qt.widgets.thumb_renderer import ThumbRenderer -from src.qt.widgets.video_player import VideoPlayer if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -51,16 +48,15 @@ def __init__(self, library: Library, driver: "QtDriver"): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 - image_layout = QHBoxLayout(self) + # image_layout = QHBoxLayout(self) + image_layout = QStackedLayout(self) + image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) image_layout.setContentsMargins(0, 0, 0, 0) self.open_file_action = QAction(self) Translations.translate_qobject(self.open_file_action, "file.open_file") self.open_explorer_action = QAction(open_file_str(), self) - self.delete_action = QAction(self) - Translations.translate_qobject( - self.delete_action, "trash.context.ambiguous", trash_term=trash_term() - ) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) @@ -68,7 +64,6 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) self.preview_gif = QLabel() self.preview_gif.setMinimumSize(*self.img_button_size) @@ -76,13 +71,11 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.preview_gif.hide() self.gif_buffer: QBuffer = QBuffer() - self.preview_vid = VideoPlayer(driver) - self.preview_vid.addAction(self.delete_action) - self.preview_vid.hide() + # self.preview_vid = VideoPlayer(driver) + # self.preview_vid.hide() self.thumb_renderer = ThumbRenderer(self.lib) self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) self.thumb_renderer.updated_ratio.connect( @@ -99,15 +92,19 @@ def __init__(self, library: Library, driver: "QtDriver"): ) self.media_player = MediaPlayer(driver) + self.media_player.set_size(QSize(*self.img_button_size)) self.media_player.hide() image_layout.addWidget(self.preview_img) image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) image_layout.addWidget(self.preview_gif) image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.preview_vid) - image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) + # image_layout.addItem(self.preview_vid) + # image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) + image_layout.addWidget(self.media_player) + image_layout.setAlignment(self.media_player, Qt.AlignmentFlag.AlignCenter) self.setMinimumSize(*self.img_button_size) + image_layout.setCurrentWidget(self.media_player) def set_image_ratio(self, ratio: float): self.image_ratio = ratio @@ -133,17 +130,24 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): 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_vid.resize_video(adj_size) - self.preview_vid.setMaximumSize(adj_size) - self.preview_vid.setMinimumSize(adj_size) + + # self.preview_vid.resize_video(adj_size) + # self.preview_vid.setMaximumSize(adj_size) + # self.preview_vid.setMinimumSize(adj_size) self.preview_gif.setMaximumSize(adj_size) self.preview_gif.setMinimumSize(adj_size) + + self.media_player.setMaximumSize(adj_size) + self.media_player.setMinimumSize(adj_size) + self.media_player.set_size(adj_size) proxy_style = RoundedPixmapStyle(radius=8) self.preview_gif.setStyle(proxy_style) - self.preview_vid.setStyle(proxy_style) + # self.preview_vid.setStyle(proxy_style) + self.media_player.setStyle(proxy_style) m = self.preview_gif.movie() if m: m.setScaledSize(adj_size) @@ -159,8 +163,9 @@ def switch_preview(self, preview: str): self.preview_img.hide() if preview != "video_legacy": - self.preview_vid.stop() - self.preview_vid.hide() + pass + # self.preview_vid.stop() + # self.preview_vid.hide() if preview != "media": self.media_player.stop() @@ -294,7 +299,8 @@ def _update_video_legacy(self, filepath: Path) -> dict: stats["width"] = image.width stats["height"] = image.height if success: - self.preview_vid.play(filepath_, QSize(image.width, image.height)) + self.media_player.play(filepath) + # self.preview_vid.play(filepath_, QSize(image.width, image.height)) self.update_image_size((image.width, image.height), image.width / image.height) self.resizeEvent( QResizeEvent( @@ -302,7 +308,8 @@ def _update_video_legacy(self, filepath: Path) -> dict: QSize(image.width, image.height), ) ) - self.preview_vid.show() + # self.preview_vid.show() + self.media_player.show() stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS) except cv2.error as e: @@ -364,7 +371,7 @@ def update_preview(self, filepath: Path, ext: str) -> dict: update_on_ratio_change=True, ) - with catch_warnings(record=True): + if self.preview_img.is_connected: self.preview_img.clicked.disconnect() self.preview_img.clicked.connect(lambda checked=False, path=filepath: open_file(path)) self.preview_img.is_connected = True @@ -372,35 +379,22 @@ def update_preview(self, filepath: Path, ext: str) -> dict: self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) + if self.media_player.click_connected: + self.media_player.clicked.disconnect() + + self.media_player.clicked.connect(lambda path=filepath: open_file(path)) + self.media_player.click_connected = True + 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.translate_formatted("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): """Completely hide the file preview.""" self.switch_preview("") - def stop_file_use(self): - """Stops the use of the currently previewed file. Used to release file permissions.""" - logger.info("[PreviewThumb] Stopping file use in video playback...") - # This swaps the video out for a placeholder so the previous video's file - # is no longer in use by this object. - self.preview_vid.play(str(ResourceManager.get_path("placeholder_mp4")), QSize(8, 8)) - self.preview_vid.hide() - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 self.update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py index ab1791a95..60784d1c9 100644 --- a/tagstudio/src/qt/widgets/preview_panel.py +++ b/tagstudio/src/qt/widgets/preview_panel.py @@ -118,7 +118,7 @@ def __init__(self, library: Library, driver: "QtDriver"): add_buttons_layout.addWidget(self.add_field_button) preview_layout.addWidget(self.thumb) - preview_layout.addWidget(self.thumb.media_player) + # preview_layout.addWidget(self.thumb.media_player) info_layout.addWidget(self.file_attrs) info_layout.addWidget(self.fields) @@ -211,4 +211,9 @@ def update_add_tag_button(self, entry_id: int = None): ) ) - self.add_tag_button.clicked.connect(self.add_tag_modal.show) + self.add_tag_button.clicked.connect( + lambda: ( + self.tag_search_panel.update_tags(), + self.add_tag_modal.show(), + ) + ) From c368429baf495841601c568a9d324f6049885862 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Sun, 23 Feb 2025 12:16:26 -0500 Subject: [PATCH 2/3] fix: center widgets in preview area Add widgets to a sublayout to allow for centering in a QStackedLayout. Remove references to the legacy video player in the thumb preview. --- tagstudio/src/qt/widgets/media_player.py | 66 +++++++++----- .../src/qt/widgets/preview/preview_thumb.py | 88 ++++++++++++------- 2 files changed, 100 insertions(+), 54 deletions(-) diff --git a/tagstudio/src/qt/widgets/media_player.py b/tagstudio/src/qt/widgets/media_player.py index edaeaec1e..3e68146dc 100644 --- a/tagstudio/src/qt/widgets/media_player.py +++ b/tagstudio/src/qt/widgets/media_player.py @@ -7,15 +7,22 @@ from time import gmtime from PIL import Image, ImageDraw -from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation, Signal -from PySide6.QtGui import QBitmap, QBrush, QColor, QPen, QRegion +from PySide6.QtCore import QEvent, QObject, QRectF, Qt, QUrl, QVariantAnimation, Signal +from PySide6.QtGui import ( + QBitmap, + QBrush, + QColor, + QFont, + QPen, + QRegion, + QResizeEvent, +) from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer from PySide6.QtMultimediaWidgets import QGraphicsVideoItem from PySide6.QtSvgWidgets import QSvgWidget from PySide6.QtWidgets import ( QGraphicsScene, QGraphicsView, - QLabel, QSlider, ) @@ -32,6 +39,9 @@ class MediaPlayer(QGraphicsView): clicked = Signal() click_connected = False + # These mouse_over_* variables are used to help + # determine if a mouse click should be handled + # by the media player or by some parent widget. mouse_over_volume_slider = False mouse_over_play_pause = False mouse_over_mute_unmute = False @@ -93,8 +103,6 @@ def __init__(self, driver: "QtDriver") -> None: self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) self.video_preview.installEventFilter(self) - # self.scene().addItem(self.video_preview) - # animation self.animation = QVariantAnimation(self) self.animation.valueChanged.connect(lambda value: self.set_tint_opacity(value)) @@ -165,21 +173,19 @@ def __init__(self, driver: "QtDriver") -> None: self.volume_slider.setStyleSheet(slider_style) self.volume_slider.hide() - self.position_label = QLabel("0:00") - self.position_label.setStyleSheet("background: transparent;font-size: 14px;") + self.position_label = self.scene().addText("0:00") self.position_label.hide() - # font = QFont() - # font.setPointSize(12) - # self.position_label.setFont(font) - # self.position_label.setBrush(QBrush(Qt.GlobalColor.white)) - # self.position_label.hide() + font = QFont() + font.setPointSize(11) + self.position_label.setFont(font) + self.position_label.setDefaultTextColor(QColor(255, 255, 255, 255)) + self.position_label.hide() self.scene().addWidget(self.pslider) self.scene().addWidget(self.play_pause) self.scene().addWidget(self.mute_unmute) self.scene().addWidget(self.volume_slider) - self.scene().addWidget(self.position_label) def set_video_output(self, video: QGraphicsVideoItem): self.player.setVideoOutput(video) @@ -250,7 +256,7 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 """Manage events for the media player.""" if ( event.type() == QEvent.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton # type: ignore + and event.button() == Qt.MouseButton.LeftButton # type: ignore ): if obj == self.play_pause: self.toggle_play() @@ -359,7 +365,8 @@ def load_mute_unmute_icon(self, muted: bool) -> None: def slider_value_changed(self, value: int) -> None: current = self.format_time(value) duration = self.format_time(self.player.duration()) - self.position_label.setText(f"{current} / {duration}") + self.position_label.setPlainText(f"{current} / {duration}") + self._move_position_label() def slider_released(self) -> None: was_playing = self.player.isPlaying() @@ -376,7 +383,8 @@ def player_position_changed(self, position: int) -> None: self.pslider.setValue(position) current = self.format_time(self.player.position()) duration = self.format_time(self.player.duration()) - self.position_label.setText(f"{current} / {duration}") + self.position_label.setPlainText(f"{current} / {duration}") + self._move_position_label() if self.player.duration() == position: self.player.pause() @@ -390,10 +398,12 @@ def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: current = self.format_time(self.player.position()) duration = self.format_time(self.player.duration()) - self.position_label.setText(f"{current} / {duration}") + self.position_label.setPlainText(f"{current} / {duration}") + self._move_position_label() + + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + size = event.size() - def set_size(self, size: QSize) -> None: - self.setFixedSize(size) self.scene().setSceneRect(0, 0, size.width(), size.height()) self.play_pause.move(0, int(self.scene().height() - self.play_pause.height())) @@ -401,9 +411,7 @@ def set_size(self, size: QSize) -> None: self.play_pause.width(), int(self.scene().height() - self.mute_unmute.height()) ) - pos_w = int(size.width() - self.position_label.width() - 5) - pos_h = int(size.height() - self.position_label.height() - 5) - self.position_label.move(pos_w, pos_h) + self._move_position_label() self.pslider.setMinimumWidth(self.size().width() - 10) self.pslider.setMaximumWidth(self.size().width() - 10) @@ -415,12 +423,24 @@ def set_size(self, size: QSize) -> None: pos_h = int(size.height() - self.mute_unmute.height() + 5) self.volume_slider.move(pos_w, pos_h) + self.video_preview.setSize(self.size()) if self.player.hasVideo(): - self.video_preview.setSize(self.size()) + self.centerOn(self.video_preview) self.tint.setRect(0, 0, self.size().width(), self.size().height()) self.apply_rounded_corners() + def _move_position_label(self): + """Convenience function for repositioning the position label. + + This is needed because the position label is not automatically + resized after changing the text. + """ + rect = self.position_label.boundingRect() + pos_w = int(self.size().width() - rect.width() - 2) + pos_h = int(self.size().height() - self.mute_unmute.size().height() - 2) + self.position_label.setPos(pos_w, pos_h) + def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 750129afc..228883adb 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -6,6 +6,7 @@ import time import typing from pathlib import Path +from warnings import catch_warnings import cv2 import rawpy @@ -14,6 +15,7 @@ from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent from PySide6.QtWidgets import ( + QHBoxLayout, QLabel, QStackedLayout, QWidget, @@ -24,7 +26,8 @@ from src.qt.helpers.file_tester import is_readable_video from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle -from src.qt.platform_strings import open_file_str +from src.qt.platform_strings import open_file_str, trash_term +from src.qt.resource_manager import ResourceManager from src.qt.translations import Translations from src.qt.widgets.media_player import MediaPlayer from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -48,7 +51,6 @@ def __init__(self, library: Library, driver: "QtDriver"): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 - # image_layout = QHBoxLayout(self) image_layout = QStackedLayout(self) image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) @@ -57,6 +59,10 @@ def __init__(self, library: Library, driver: "QtDriver"): self.open_file_action = QAction(self) Translations.translate_qobject(self.open_file_action, "file.open_file") self.open_explorer_action = QAction(open_file_str(), self) + self.delete_action = QAction(self) + Translations.translate_qobject( + self.delete_action, "trash.context.ambiguous", trash_term=trash_term() + ) self.preview_img = QPushButtonWrapper() self.preview_img.setMinimumSize(*self.img_button_size) @@ -64,6 +70,12 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) @@ -74,8 +86,10 @@ def __init__(self, library: Library, driver: "QtDriver"): self.preview_gif.hide() self.gif_buffer: QBuffer = QBuffer() - # self.preview_vid = VideoPlayer(driver) - # self.preview_vid.hide() + self.preview_gif_page = QWidget() + self.preview_img_page.setContentsMargins(0, 0, 0, 0) + self._stacked_page_setup(self.preview_gif_page, self.preview_gif) + self.thumb_renderer = ThumbRenderer(self.lib) self.thumb_renderer.updated.connect(lambda ts, i, s: (self.preview_img.setIcon(i))) self.thumb_renderer.updated_ratio.connect( @@ -92,19 +106,25 @@ def __init__(self, library: Library, driver: "QtDriver"): ) self.media_player = MediaPlayer(driver) - self.media_player.set_size(QSize(*self.img_button_size)) self.media_player.hide() - image_layout.addWidget(self.preview_img) - image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.preview_gif) - image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter) - # image_layout.addItem(self.preview_vid) - # image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter) - image_layout.addWidget(self.media_player) - image_layout.setAlignment(self.media_player, Qt.AlignmentFlag.AlignCenter) + self.media_player_page = QWidget() + self.preview_img_page.setContentsMargins(0, 0, 0, 0) + self._stacked_page_setup(self.media_player_page, self.media_player) + + image_layout.addWidget(self.preview_img_page) + image_layout.addWidget(self.preview_gif_page) + image_layout.addWidget(self.media_player_page) + self.setMinimumSize(*self.img_button_size) - image_layout.setCurrentWidget(self.media_player) + image_layout.setCurrentWidget(self.media_player_page) + + def _stacked_page_setup(self, page: QWidget, widget: QWidget): + 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): self.image_ratio = ratio @@ -134,19 +154,13 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): self.img_button_size = (int(adj_width), int(adj_height)) self.preview_img.setMaximumSize(adj_size) self.preview_img.setIconSize(adj_size) - - # self.preview_vid.resize_video(adj_size) - # self.preview_vid.setMaximumSize(adj_size) - # self.preview_vid.setMinimumSize(adj_size) self.preview_gif.setMaximumSize(adj_size) self.preview_gif.setMinimumSize(adj_size) self.media_player.setMaximumSize(adj_size) self.media_player.setMinimumSize(adj_size) - self.media_player.set_size(adj_size) proxy_style = RoundedPixmapStyle(radius=8) self.preview_gif.setStyle(proxy_style) - # self.preview_vid.setStyle(proxy_style) self.media_player.setStyle(proxy_style) m = self.preview_gif.movie() if m: @@ -162,12 +176,7 @@ def switch_preview(self, preview: str): if preview != "image" and preview != "media": self.preview_img.hide() - if preview != "video_legacy": - pass - # self.preview_vid.stop() - # self.preview_vid.hide() - - if preview != "media": + if preview not in ["media", "video_legacy"]: self.media_player.stop() self.media_player.hide() @@ -299,8 +308,7 @@ def _update_video_legacy(self, filepath: Path) -> dict: stats["width"] = image.width stats["height"] = image.height if success: - self.media_player.play(filepath) - # self.preview_vid.play(filepath_, QSize(image.width, image.height)) + self.media_player.show() self.update_image_size((image.width, image.height), image.width / image.height) self.resizeEvent( QResizeEvent( @@ -308,8 +316,7 @@ def _update_video_legacy(self, filepath: Path) -> dict: QSize(image.width, image.height), ) ) - # self.preview_vid.show() - self.media_player.show() + self.media_player.play(filepath) stats["duration"] = video.get(cv2.CAP_PROP_FRAME_COUNT) / video.get(cv2.CAP_PROP_FPS) except cv2.error as e: @@ -371,7 +378,7 @@ def update_preview(self, filepath: Path, ext: str) -> dict: update_on_ratio_change=True, ) - if self.preview_img.is_connected: + 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 @@ -389,12 +396,31 @@ def update_preview(self, filepath: Path, ext: str) -> dict: 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.translate_formatted("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): """Completely hide the file preview.""" self.switch_preview("") + def stop_file_use(self): + """Stops the use of the currently previewed file. Used to release file permissions.""" + logger.info("[PreviewThumb] Stopping file use in video playback...") + # This swaps the video out for a placeholder so the previous video's file + # is no longer in use by this object. + self.media_player.play(ResourceManager.get_path("placeholder_mp4")) + self.media_player.hide() + def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 self.update_image_size((self.size().width(), self.size().height())) return super().resizeEvent(event) From 5890a81d9df9f632655426e751181c493986398f Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Thu, 27 Feb 2025 22:16:40 -0500 Subject: [PATCH 3/3] fix: resolve commit suggestions. Subclass QSlider to handle click events and allow for easier seeking. Implement context menu along with autoplay setting for the media widget. Pause video when media player is clicked instead of opening file. --- tagstudio/src/qt/helpers/qslider_wrapper.py | 44 ++++++++++++ tagstudio/src/qt/widgets/media_player.py | 72 +++++++++++++------ .../src/qt/widgets/preview/preview_thumb.py | 6 -- 3 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 tagstudio/src/qt/helpers/qslider_wrapper.py diff --git a/tagstudio/src/qt/helpers/qslider_wrapper.py b/tagstudio/src/qt/helpers/qslider_wrapper.py new file mode 100644 index 000000000..50357b061 --- /dev/null +++ b/tagstudio/src/qt/helpers/qslider_wrapper.py @@ -0,0 +1,44 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider + + +class QClickSlider(QSlider): + """Custom QSlider wrapper. + + The purpose of this wrapper is to allow us to set slider positions + based on click events. + """ + + mouse_pressed = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def mousePressEvent(self, event): # noqa: N802 + """Overide to handle mouse clicks. + + Overriding the mousePressEvent allows us to seek + directly to the position the user clicked instead + of stepping. + """ + opt = QStyleOptionSlider() + self.initStyleOption(opt) + opt.subControls = QStyle.SubControl.SC_SliderGroove | QStyle.SubControl.SC_SliderHandle + handle_rect = self.style().subControlRect( + QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self + ) + + was_slider_clicked = handle_rect.contains(event.position().x(), event.position().y()) + + if was_slider_clicked: + super().mousePressEvent(event) + else: + self.setValue( + QStyle.sliderValueFromPosition( + self.minimum(), self.maximum(), event.x(), self.width() + ) + ) + self.mouse_pressed = True diff --git a/tagstudio/src/qt/widgets/media_player.py b/tagstudio/src/qt/widgets/media_player.py index 3e68146dc..ce5272068 100644 --- a/tagstudio/src/qt/widgets/media_player.py +++ b/tagstudio/src/qt/widgets/media_player.py @@ -7,16 +7,8 @@ from time import gmtime from PIL import Image, ImageDraw -from PySide6.QtCore import QEvent, QObject, QRectF, Qt, QUrl, QVariantAnimation, Signal -from PySide6.QtGui import ( - QBitmap, - QBrush, - QColor, - QFont, - QPen, - QRegion, - QResizeEvent, -) +from PySide6.QtCore import QEvent, QObject, QRectF, Qt, QUrl, QVariantAnimation +from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, QFont, QPen, QRegion, QResizeEvent from PySide6.QtMultimedia import QAudioOutput, QMediaDevices, QMediaPlayer from PySide6.QtMultimediaWidgets import QGraphicsVideoItem from PySide6.QtSvgWidgets import QSvgWidget @@ -25,6 +17,11 @@ QGraphicsView, QSlider, ) +from src.core.enums import SettingItems +from src.qt.helpers.file_opener import FileOpenerHelper +from src.qt.helpers.qslider_wrapper import QClickSlider +from src.qt.platform_strings import open_file_str +from src.qt.translations import Translations if typing.TYPE_CHECKING: from src.qt.ts_qt import QtDriver @@ -36,9 +33,6 @@ class MediaPlayer(QGraphicsView): Gives a basic control set to manage media playback. """ - clicked = Signal() - click_connected = False - # These mouse_over_* variables are used to help # determine if a mouse click should be handled # by the media player or by some parent widget. @@ -94,7 +88,11 @@ def __init__(self, driver: "QtDriver") -> None: self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setStyleSheet("background: transparent;") + self.setStyleSheet(""" + QGraphicsView { + background: transparent; + } + """) self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_preview = VideoPreview() @@ -137,7 +135,7 @@ def __init__(self, driver: "QtDriver") -> None: self.player.audioOutput().mutedChanged.connect(self.muted_changed) # Media controls - self.pslider = QSlider() + self.pslider = QClickSlider() self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) self.pslider.setSingleStep(1) @@ -165,7 +163,7 @@ def __init__(self, driver: "QtDriver") -> None: self.mute_unmute.resize(24, 24) self.mute_unmute.hide() - self.volume_slider = QSlider() + self.volume_slider = QClickSlider() self.volume_slider.setOrientation(Qt.Orientation.Horizontal) self.volume_slider.setValue(int(self.player.audioOutput().volume() * 100)) self.volume_slider.valueChanged.connect(self.volume_slider_changed) @@ -187,9 +185,36 @@ def __init__(self, driver: "QtDriver") -> None: self.scene().addWidget(self.mute_unmute) self.scene().addWidget(self.volume_slider) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + self.opener = FileOpenerHelper(filepath=self.filepath) + autoplay_action = QAction(self) + Translations.translate_qobject(autoplay_action, "media_player.autoplay") + autoplay_action.setCheckable(True) + self.addAction(autoplay_action) + autoplay_action.setChecked( + self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) # type: ignore + ) + autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) + self.autoplay = autoplay_action + + open_file_action = QAction(self) + Translations.translate_qobject(open_file_action, "file.open_file") + open_file_action.triggered.connect(self.opener.open_file) + + open_explorer_action = QAction(open_file_str(), self) + + open_explorer_action.triggered.connect(self.opener.open_explorer) + self.addAction(open_file_action) + self.addAction(open_explorer_action) + def set_video_output(self, video: QGraphicsVideoItem): self.player.setVideoOutput(video) + def toggle_autoplay(self) -> None: + """Toggle the autoplay state of the video.""" + self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked()) + self.driver.settings.sync() + def apply_rounded_corners(self) -> None: """Apply a rounded corner effect to the video player.""" width: int = int(max(self.contentsRect().size().width(), 0)) @@ -262,8 +287,8 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 self.toggle_play() elif obj == self.mute_unmute: self.toggle_mute() - elif self.mouse_over_elements() is False: # let someone else handle this event - self.clicked.emit() + elif self.mouse_over_elements() is False: + self.toggle_play() elif event.type() is QEvent.Type.Enter: if obj == self or obj == self.video_preview: self.underMouse() @@ -349,10 +374,14 @@ def play(self, filepath: Path) -> None: if not self.is_paused: self.player.stop() self.player.setSource(QUrl.fromLocalFile(self.filepath)) - self.player.play() + + if self.autoplay.isChecked(): + self.player.play() else: self.player.setSource(QUrl.fromLocalFile(self.filepath)) + self.opener.set_filepath(self.filepath) + def load_toggle_play_icon(self, playing: bool) -> None: icon = self.driver.rm.pause_icon if playing else self.driver.rm.play_icon self.play_pause.load(icon) @@ -378,7 +407,10 @@ def slider_released(self) -> None: self.player.pause() def player_position_changed(self, position: int) -> None: - if not self.pslider.isSliderDown(): + if self.pslider.mouse_pressed: + self.player.setPosition(self.pslider.value()) + self.pslider.mouse_pressed = False + elif not self.pslider.isSliderDown(): # User isn't using the slider, so update position in widgets. self.pslider.setValue(position) current = self.format_time(self.player.position()) diff --git a/tagstudio/src/qt/widgets/preview/preview_thumb.py b/tagstudio/src/qt/widgets/preview/preview_thumb.py index 228883adb..7902ee93b 100644 --- a/tagstudio/src/qt/widgets/preview/preview_thumb.py +++ b/tagstudio/src/qt/widgets/preview/preview_thumb.py @@ -386,12 +386,6 @@ def update_preview(self, filepath: Path, ext: str) -> dict: self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.preview_img.setCursor(Qt.CursorShape.PointingHandCursor) - if self.media_player.click_connected: - self.media_player.clicked.disconnect() - - self.media_player.clicked.connect(lambda path=filepath: open_file(path)) - self.media_player.click_connected = True - self.opener = FileOpenerHelper(filepath) self.open_file_action.triggered.connect(self.opener.open_file) self.open_explorer_action.triggered.connect(self.opener.open_explorer)