From 817d8acd80d1b26f20cd8f9d680164e36bbb6441 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Mon, 17 Feb 2025 21:26:00 -0500 Subject: [PATCH 01/14] feat: merge media controls. Initial commit to merge audio/video files. There are still a few bugs around widget sizing that need fixing. --- src/tagstudio/qt/widgets/media_player.py | 334 +++++++++++++++--- .../qt/widgets/preview/preview_thumb.py | 84 ++--- src/tagstudio/qt/widgets/preview_panel.py | 9 +- 3 files changed, 322 insertions(+), 105 deletions(-) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 1f42066e3..ea6f27b21 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/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 tagstudio.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/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index fbc6c4853..da4798133 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/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 @@ -14,7 +13,7 @@ from PIL import Image, UnidentifiedImageError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QWidget +from PySide6.QtWidgets import QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -22,12 +21,10 @@ 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.resource_manager import ResourceManager +from tagstudio.qt.platform_strings import open_file_str from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.media_player import MediaPlayer from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer -from tagstudio.qt.widgets.video_player import VideoPlayer if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -48,15 +45,14 @@ 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(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) @@ -64,7 +60,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) @@ -72,13 +67,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( @@ -95,15 +88,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 @@ -129,17 +126,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) @@ -155,8 +159,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() @@ -290,7 +295,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( @@ -298,7 +304,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: @@ -360,7 +367,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 @@ -368,35 +375,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.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): """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/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py index 5af5c5bc0..7a78a6c10 100644 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ b/src/tagstudio/qt/widgets/preview_panel.py @@ -110,7 +110,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) @@ -203,4 +203,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 d3eef2e6ad69122fb327a5237cdb447758c59946 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Sun, 23 Feb 2025 12:16:26 -0500 Subject: [PATCH 02/14] 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. --- src/tagstudio/qt/widgets/media_player.py | 66 +++++++++----- .../qt/widgets/preview/preview_thumb.py | 89 ++++++++++++------- 2 files changed, 100 insertions(+), 55 deletions(-) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index ea6f27b21..23ff4a6f6 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/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/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index da4798133..9acbc3d1a 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/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 @@ -13,7 +14,7 @@ from PIL import Image, UnidentifiedImageError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QLabel, QStackedLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories @@ -21,7 +22,8 @@ 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 +from tagstudio.qt.platform_strings import open_file_str, trash_term +from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.translations import Translations from tagstudio.qt.widgets.media_player import MediaPlayer from tagstudio.qt.widgets.thumb_renderer import ThumbRenderer @@ -45,7 +47,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) @@ -53,6 +54,10 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) @@ -60,6 +65,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) @@ -70,8 +81,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( @@ -88,19 +101,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 @@ -130,19 +149,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: @@ -158,12 +171,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() @@ -295,8 +303,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( @@ -304,8 +311,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: @@ -367,7 +373,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 @@ -385,12 +391,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.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): """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 8cbaf5238c8129fb92f4a36bb17db9155d980071 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Thu, 27 Feb 2025 22:16:40 -0500 Subject: [PATCH 03/14] 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. --- src/tagstudio/qt/helpers/qslider_wrapper.py | 44 ++++++++++++ src/tagstudio/qt/widgets/media_player.py | 71 +++++++++++++------ .../qt/widgets/preview/preview_thumb.py | 6 -- 3 files changed, 95 insertions(+), 26 deletions(-) create mode 100644 src/tagstudio/qt/helpers/qslider_wrapper.py diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py new file mode 100644 index 000000000..50357b061 --- /dev/null +++ b/src/tagstudio/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/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 23ff4a6f6..cbd3e1651 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/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 @@ -26,6 +18,12 @@ QSlider, ) +from tagstudio.core.enums import SettingItems +from tagstudio.qt.helpers.file_opener import FileOpenerHelper +from tagstudio.qt.helpers.qslider_wrapper import QClickSlider +from tagstudio.qt.platform_strings import open_file_str +from tagstudio.qt.translations import Translations + if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver @@ -36,9 +34,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 +89,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 +136,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 +164,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 +186,34 @@ 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(Translations["media_player.autoplay"], self) + autoplay_action.setCheckable(True) + self.addAction(autoplay_action) + autoplay_action.setChecked( + self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) + ) + autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) + self.autoplay = autoplay_action + + open_file_action = QAction(Translations["media_player.autoplay"], self) + 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 +286,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 +373,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 +406,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/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 9acbc3d1a..f51389e56 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -381,12 +381,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) From 13b2ca1e1bdd4c2f348c8b920514af04cb68ecb3 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Wed, 5 Mar 2025 07:00:16 -0500 Subject: [PATCH 04/14] fix: start media muted Start video/audio muted on initial load of the media player. Remove code causing mypy issue. Add new method for getting slider click state. --- src/tagstudio/qt/helpers/qslider_wrapper.py | 14 +++++++++++++- src/tagstudio/qt/widgets/media_player.py | 6 ++++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py index 50357b061..ce4c98625 100644 --- a/src/tagstudio/qt/helpers/qslider_wrapper.py +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -26,7 +26,6 @@ def mousePressEvent(self, event): # noqa: N802 """ 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 ) @@ -42,3 +41,16 @@ def mousePressEvent(self, event): # noqa: N802 ) ) self.mouse_pressed = True + + def observe_mouse(self) -> bool: + """Getter for the mouse_pressed property. + + This will also clear the mouse_pressed property + if it is set. We do this so that consumers of slider + events can modify the sliders position. + """ + if self.mouse_pressed: + self.mouse_pressed = False + return True + + return False diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index cbd3e1651..3069d3b24 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -206,6 +206,9 @@ def __init__(self, driver: "QtDriver") -> None: self.addAction(open_file_action) self.addAction(open_explorer_action) + # start the player muted + self.player.audioOutput().setMuted(True) + def set_video_output(self, video: QGraphicsVideoItem): self.player.setVideoOutput(video) @@ -406,9 +409,8 @@ def slider_released(self) -> None: self.player.pause() def player_position_changed(self, position: int) -> None: - if self.pslider.mouse_pressed: + if self.pslider.observe_mouse(): 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) From ed98941aa2a23ddff9e10f4529bad70264e8425b Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Sun, 9 Mar 2025 10:32:36 -0400 Subject: [PATCH 05/14] refactor: use layouts instead of manual positioning. Add various layouts for positioning widgets instead of manually moving widgets. Change the volume slider orientation at smaller media sizes. --- src/tagstudio/qt/widgets/media_player.py | 183 +++++++++++++---------- 1 file changed, 108 insertions(+), 75 deletions(-) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 3069d3b24..e7f9c02bf 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -7,15 +7,20 @@ from time import gmtime from PIL import Image, ImageDraw -from PySide6.QtCore import QEvent, QObject, QRectF, Qt, QUrl, QVariantAnimation -from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, QFont, QPen, QRegion, QResizeEvent +from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation +from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, 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, + QGridLayout, + QHBoxLayout, + QLabel, + QSizePolicy, QSlider, + QWidget, ) from tagstudio.core.enums import SettingItems @@ -34,13 +39,6 @@ class MediaPlayer(QGraphicsView): Gives a basic control set to manage media playback. """ - # 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 - video_preview = None def __init__(self, driver: "QtDriver") -> None: @@ -81,6 +79,36 @@ def __init__(self, driver: "QtDriver") -> None: margin: 2px 0; border-radius: 2px; } + + QSlider::groove:vertical { + border: 1px solid #999999; + width: 2px; + margin: 0 2px; + border-radius: 2px; + } + + QSlider::handle:vertical { + background: #6ea0ff; + border: 1px solid #5c5c5c; + width: 12px; + height: 12px; + margin: 0 -6px; + border-radius: 6px; + } + + QSlider::add-page:vertical { + background: #6ea0ff; + width: 2px; + margin: 0 2px; + border-radius: 2px; + } + + QSlider::sup-page:vertical { + background: #3f4144; + width: 2px; + margin: 0 2px; + border-radius: 2px; + } """ # setup the scene @@ -136,6 +164,12 @@ def __init__(self, driver: "QtDriver") -> None: self.player.audioOutput().mutedChanged.connect(self.muted_changed) # Media controls + self.master_controls = QWidget() + master_layout = QGridLayout(self.master_controls) + master_layout.setContentsMargins(0, 0, 0, 0) + self.master_controls.setStyleSheet("background: transparent;") + self.master_controls.setMinimumHeight(75) + self.pslider = QClickSlider() self.pslider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.pslider.setTickPosition(QSlider.TickPosition.NoTicks) @@ -146,45 +180,72 @@ def __init__(self, driver: "QtDriver") -> None: self.pslider.valueChanged.connect(self.slider_value_changed) self.pslider.hide() + master_layout.addWidget(self.pslider, 0, 0, 0, 2) + master_layout.setAlignment(self.pslider, Qt.AlignmentFlag.AlignCenter) + + fixed_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + + self.sub_controls = QWidget() + self.sub_controls.setMouseTracking(True) + self.sub_controls.installEventFilter(self) + sub_layout = QHBoxLayout(self.sub_controls) + sub_layout.setContentsMargins(0, 0, 0, 0) + self.sub_controls.setStyleSheet("background: transparent;") + 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.resize(16, 16) + self.play_pause.setSizePolicy(fixed_policy) + self.play_pause.setStyleSheet("background: transparent;") self.play_pause.hide() + sub_layout.addWidget(self.play_pause) + sub_layout.setAlignment(self.play_pause, Qt.AlignmentFlag.AlignLeft) + 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.mute_unmute.resize(24, 24) + self.mute_unmute.resize(16, 16) + self.mute_unmute.setSizePolicy(fixed_policy) self.mute_unmute.hide() + sub_layout.addWidget(self.mute_unmute) + sub_layout.setAlignment(self.mute_unmute, Qt.AlignmentFlag.AlignLeft) + + retain_policy = QSizePolicy() + retain_policy.setRetainSizeWhenHidden(True) + 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) - self.volume_slider.setMaximumWidth(100) self.volume_slider.setStyleSheet(slider_style) + self.volume_slider.setSizePolicy(retain_policy) self.volume_slider.hide() - self.position_label = self.scene().addText("0:00") - self.position_label.hide() + sub_layout.addWidget(self.volume_slider) + sub_layout.setAlignment(self.volume_slider, Qt.AlignmentFlag.AlignLeft) + + # Adding a stretch here ensures the rest of the widgets + # in the sub_layout will not stretch to fill the remaining + # space. + sub_layout.addStretch() - font = QFont() - font.setPointSize(11) - self.position_label.setFont(font) - self.position_label.setDefaultTextColor(QColor(255, 255, 255, 255)) + master_layout.addWidget(self.sub_controls, 1, 0) + + self.position_label = QLabel("0:00") + master_layout.addWidget(self.position_label, 1, 1) + master_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight) 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.master_controls) self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) self.opener = FileOpenerHelper(filepath=self.filepath) @@ -254,7 +315,6 @@ def underMouse(self) -> bool: # noqa: N802 self.pslider.show() self.play_pause.show() self.mute_unmute.show() - self.volume_slider.show() self.position_label.show() return super().underMouse() @@ -272,13 +332,6 @@ def releaseMouse(self) -> None: # noqa: N802 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 - ) - def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 """Manage events for the media player.""" if ( @@ -289,26 +342,18 @@ 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: + else: self.toggle_play() 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 + self.volume_slider.show() 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 + elif obj == self.sub_controls: + self.volume_slider.hide() return super().eventFilter(obj, event) @@ -387,7 +432,6 @@ def play(self, filepath: Path) -> 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.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 @@ -396,8 +440,7 @@ 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.setPlainText(f"{current} / {duration}") - self._move_position_label() + self.position_label.setText(f"{current} / {duration}") def slider_released(self) -> None: was_playing = self.player.isPlaying() @@ -416,8 +459,7 @@ 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.setPlainText(f"{current} / {duration}") - self._move_position_label() + self.position_label.setText(f"{current} / {duration}") if self.player.duration() == position: self.player.pause() @@ -431,30 +473,29 @@ 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.setPlainText(f"{current} / {duration}") - self._move_position_label() - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - size = event.size() + self.position_label.setText(f"{current} / {duration}") + def _update_controls(self, size: QSize) -> None: 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()) - ) + # occupy entire scene width + self.master_controls.setMinimumWidth(size.width()) + self.master_controls.setMaximumWidth(size.width()) - self._move_position_label() + self.master_controls.move(0, int(self.scene().height() - self.master_controls.height())) - 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()) - ) + ps_w = self.master_controls.width() - 5 + self.pslider.setMinimumWidth(ps_w) + self.pslider.setMaximumWidth(ps_w) - 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) + # Changing the orientation of the volume slider to + # make it easier to use in smaller sizes. + orientation = self.volume_slider.orientation() + if size.width() <= 175 and orientation is Qt.Orientation.Horizontal: + self.volume_slider.setOrientation(Qt.Orientation.Vertical) + self.volume_slider.setMaximumHeight(30) + elif size.width() > 175 and orientation is Qt.Orientation.Vertical: + self.volume_slider.setOrientation(Qt.Orientation.Horizontal) self.video_preview.setSize(self.size()) if self.player.hasVideo(): @@ -463,16 +504,8 @@ def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 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 resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + self._update_controls(event.size()) def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) From 03b8c67875c0412f0014dc0d8dc426fdac1b2d4d Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Sun, 9 Mar 2025 10:39:14 -0400 Subject: [PATCH 06/14] fix: color position label white Fix position label color to white so it stays visible regardless of theme. --- src/tagstudio/qt/widgets/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index e7f9c02bf..9fd5dff7e 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -241,6 +241,7 @@ def __init__(self, driver: "QtDriver") -> None: master_layout.addWidget(self.sub_controls, 1, 0) self.position_label = QLabel("0:00") + self.position_label.setStyleSheet("color: #ffffff;") master_layout.addWidget(self.position_label, 1, 1) master_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight) self.position_label.hide() From b62db089e96fc58592dd3f26313d602e2d831df9 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Tue, 11 Mar 2025 13:13:12 -0500 Subject: [PATCH 07/14] fix: allow dragging slider after click --- src/tagstudio/qt/helpers/qslider_wrapper.py | 19 ++----------------- src/tagstudio/qt/widgets/media_player.py | 4 +--- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py index ce4c98625..178aba3dd 100644 --- a/src/tagstudio/qt/helpers/qslider_wrapper.py +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -12,8 +12,6 @@ class QClickSlider(QSlider): based on click events. """ - mouse_pressed = False - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -32,9 +30,7 @@ def mousePressEvent(self, event): # noqa: N802 was_slider_clicked = handle_rect.contains(event.position().x(), event.position().y()) - if was_slider_clicked: - super().mousePressEvent(event) - else: + if not was_slider_clicked: self.setValue( QStyle.sliderValueFromPosition( self.minimum(), self.maximum(), event.x(), self.width() @@ -42,15 +38,4 @@ def mousePressEvent(self, event): # noqa: N802 ) self.mouse_pressed = True - def observe_mouse(self) -> bool: - """Getter for the mouse_pressed property. - - This will also clear the mouse_pressed property - if it is set. We do this so that consumers of slider - events can modify the sliders position. - """ - if self.mouse_pressed: - self.mouse_pressed = False - return True - - return False + super().mousePressEvent(event) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 9fd5dff7e..91f565a13 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -453,9 +453,7 @@ def slider_released(self) -> None: self.player.pause() def player_position_changed(self, position: int) -> None: - if self.pslider.observe_mouse(): - self.player.setPosition(self.pslider.value()) - elif not self.pslider.isSliderDown(): + if 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()) From a134b969622209a78d01158d90b4cbd65763a7b9 Mon Sep 17 00:00:00 2001 From: csponge Date: Mon, 17 Mar 2025 09:20:17 -0400 Subject: [PATCH 08/14] Apply suggestions from code review fix: apply suggestions from code review. Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> --- src/tagstudio/qt/helpers/qslider_wrapper.py | 9 ++++---- src/tagstudio/qt/widgets/media_player.py | 23 ++++++++++++++------- src/tagstudio/qt/widgets/preview_panel.py | 8 +------ 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py index 178aba3dd..a38402f7a 100644 --- a/src/tagstudio/qt/helpers/qslider_wrapper.py +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -1,4 +1,4 @@ -# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Copyright (C) 2025 # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio @@ -15,8 +15,9 @@ class QClickSlider(QSlider): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - def mousePressEvent(self, event): # noqa: N802 - """Overide to handle mouse clicks. + @override + def mousePressEvent(self, ev: QMouseEvent): + """Override to handle mouse clicks. Overriding the mousePressEvent allows us to seek directly to the position the user clicked instead @@ -28,7 +29,7 @@ def mousePressEvent(self, event): # noqa: N802 QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self ) - was_slider_clicked = handle_rect.contains(event.position().x(), event.position().y()) + was_slider_clicked = handle_rect.contains(int(ev.position().x()), int(ev.position().y())) if not was_slider_clicked: self.setValue( diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 91f565a13..b69c10f7f 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -39,7 +39,7 @@ class MediaPlayer(QGraphicsView): Gives a basic control set to manage media playback. """ - video_preview = None + video_preview: "VideoPreview | None" = None def __init__(self, driver: "QtDriver") -> None: super().__init__() @@ -259,7 +259,7 @@ def __init__(self, driver: "QtDriver") -> None: autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action - open_file_action = QAction(Translations["media_player.autoplay"], self) + open_file_action = QAction(Translations["file.open_file"], self) open_file_action.triggered.connect(self.opener.open_file) open_explorer_action = QAction(open_file_str(), self) @@ -333,7 +333,8 @@ def releaseMouse(self) -> None: # noqa: N802 return super().releaseMouse() - def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 + @override + def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: """Manage events for the media player.""" if ( event.type() == QEvent.Type.MouseButtonPress @@ -404,6 +405,8 @@ def muted_changed(self, muted: bool) -> None: self.load_mute_unmute_icon(muted) def has_video_changed(self, video_available: bool) -> None: + if not self.video_preview: + return if video_available: self.scene().addItem(self.video_preview) self.video_preview.setZValue(-1) @@ -496,14 +499,16 @@ def _update_controls(self, size: QSize) -> None: elif size.width() > 175 and orientation is Qt.Orientation.Vertical: self.volume_slider.setOrientation(Qt.Orientation.Horizontal) - self.video_preview.setSize(self.size()) - if self.player.hasVideo(): - self.centerOn(self.video_preview) + if self.video_preview: + self.video_preview.setSize(self.size()) + if self.player.hasVideo(): + self.centerOn(self.video_preview) self.tint.setRect(0, 0, self.size().width(), self.size().height()) self.apply_rounded_corners() - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 + @override + def resizeEvent(self, event: QResizeEvent) -> None: self._update_controls(event.size()) def volume_slider_changed(self, position: int) -> None: @@ -511,9 +516,11 @@ def volume_slider_changed(self, position: int) -> None: class VideoPreview(QGraphicsVideoItem): - def boundingRect(self): # noqa: N802 + @override + def boundingRect(self): return QRectF(0, 0, self.size().width(), self.size().height()) + @override def paint(self, painter, option, widget=None) -> None: # painter.brush().setColor(QColor(0, 0, 0, 255)) # You can set any shape you want here. diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py index 7a78a6c10..dffb12f65 100644 --- a/src/tagstudio/qt/widgets/preview_panel.py +++ b/src/tagstudio/qt/widgets/preview_panel.py @@ -110,7 +110,6 @@ 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) info_layout.addWidget(self.file_attrs) info_layout.addWidget(self.fields) @@ -203,9 +202,4 @@ def update_add_tag_button(self, entry_id: int = None): ) ) - self.add_tag_button.clicked.connect( - lambda: ( - self.tag_search_panel.update_tags(), - self.add_tag_modal.show(), - ) - ) + self.add_tag_button.clicked.connect(self.add_tag_modal.show) From d3ca5b2063f7f04445095574b4034a50200bf62b Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Mon, 17 Mar 2025 11:29:39 -0400 Subject: [PATCH 09/14] fix: remove references to legacy video player. Combine the stats logic for video/audio into one method. Fix several issues after incorrectly implementing suggestions. --- src/tagstudio/qt/helpers/qslider_wrapper.py | 9 +- src/tagstudio/qt/widgets/media_player.py | 23 +- .../qt/widgets/preview/preview_thumb.py | 73 ++-- src/tagstudio/qt/widgets/video_player.py | 347 ------------------ 4 files changed, 54 insertions(+), 398 deletions(-) delete mode 100644 src/tagstudio/qt/widgets/video_player.py diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py index a38402f7a..7745171de 100644 --- a/src/tagstudio/qt/helpers/qslider_wrapper.py +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -2,6 +2,9 @@ # Licensed under the GPL-3.0 License. # Created for TagStudio: https://github.com/CyanVoxel/TagStudio +from typing import override + +from PySide6.QtGui import QMouseEvent from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider @@ -33,10 +36,8 @@ def mousePressEvent(self, ev: QMouseEvent): if not was_slider_clicked: self.setValue( - QStyle.sliderValueFromPosition( - self.minimum(), self.maximum(), event.x(), self.width() - ) + QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), ev.x(), self.width()) ) self.mouse_pressed = True - super().mousePressEvent(event) + super().mousePressEvent(ev) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index b69c10f7f..724b86902 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -5,6 +5,7 @@ import typing from pathlib import Path from time import gmtime +from typing import override from PIL import Image, ImageDraw from PySide6.QtCore import QEvent, QObject, QRectF, QSize, Qt, QUrl, QVariantAnimation @@ -337,27 +338,27 @@ def releaseMouse(self) -> None: # noqa: N802 def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: """Manage events for the media player.""" if ( - event.type() == QEvent.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton # type: ignore + arg__2.type() == QEvent.Type.MouseButtonPress + and arg__2.button() == Qt.MouseButton.LeftButton # type: ignore ): - if obj == self.play_pause: + if arg__1 == self.play_pause: self.toggle_play() - elif obj == self.mute_unmute: + elif arg__1 == self.mute_unmute: self.toggle_mute() else: self.toggle_play() - elif event.type() is QEvent.Type.Enter: - if obj == self or obj == self.video_preview: + elif arg__2.type() is QEvent.Type.Enter: + if arg__1 == self or arg__1 == self.video_preview: self.underMouse() - elif obj == self.mute_unmute: + elif arg__1 == self.mute_unmute: self.volume_slider.show() - elif event.type() == QEvent.Type.Leave: - if obj == self or obj == self.video_preview: + elif arg__2.type() == QEvent.Type.Leave: + if arg__1 == self or arg__1 == self.video_preview: self.releaseMouse() - elif obj == self.sub_controls: + elif arg__1 == self.sub_controls: self.volume_slider.hide() - return super().eventFilter(obj, event) + return super().eventFilter(arg__1, arg__2) def format_time(self, ms: int) -> str: """Format the given time. diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index f51389e56..ef2d8f3c1 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -17,7 +17,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaCategories +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 @@ -171,7 +171,7 @@ def switch_preview(self, preview: str): if preview != "image" and preview != "media": self.preview_img.hide() - if preview not in ["media", "video_legacy"]: + if preview != "media": self.media_player.stop() self.media_player.hide() @@ -290,42 +290,43 @@ def _update_animation(self, filepath: Path, ext: str) -> dict: return stats - def _update_video_legacy(self, filepath: Path) -> dict: - stats: dict = {} - filepath_ = str(filepath) - self.switch_preview("video_legacy") - - try: - video = cv2.VideoCapture(filepath_, cv2.CAP_FFMPEG) - success, frame = video.read() - frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - image = Image.fromarray(frame) - stats["width"] = image.width - stats["height"] = image.height - if success: - self.media_player.show() - self.update_image_size((image.width, image.height), image.width / image.height) - self.resizeEvent( - QResizeEvent( - QSize(image.width, image.height), - QSize(image.width, image.height), - ) - ) - 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: - logger.error("[PreviewThumb] Could not play video", filepath=filepath_, error=e) + 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)) - return stats - - def _update_media(self, filepath: Path) -> dict: + def _update_media(self, filepath: Path, type: MediaType) -> dict: stats: dict = {} + self.switch_preview("media") + self.media_player.play(filepath) + + match type: + case MediaType.AUDIO: + self.preview_img.show() + case 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.preview_img.show() self.media_player.show() - self.media_player.play(filepath) stats["duration"] = self.media_player.player.duration() * 1000 return stats @@ -334,18 +335,18 @@ def update_preview(self, filepath: Path, ext: str) -> dict: """Render a single file preview.""" stats: dict = {} - # Video (Legacy) + # Video if MediaCategories.is_ext_in_category( ext, MediaCategories.VIDEO_TYPES, mime_fallback=True ) and is_readable_video(filepath): - stats = self._update_video_legacy(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, ext) - stats = self._update_media(filepath) + stats = self._update_media(filepath, MediaType.AUDIO) self.thumb_renderer.render( time.time(), filepath, diff --git a/src/tagstudio/qt/widgets/video_player.py b/src/tagstudio/qt/widgets/video_player.py deleted file mode 100644 index 49e2e89cb..000000000 --- a/src/tagstudio/qt/widgets/video_player.py +++ /dev/null @@ -1,347 +0,0 @@ -# Licensed under the GPL-3.0 License. -# Created for TagStudio: https://github.com/CyanVoxel/TagStudio - -import logging -import typing - -from PIL import Image, ImageDraw -from PySide6.QtCore import ( - QEvent, - QObject, - QRectF, - QSize, - Qt, - QTimer, - QUrl, - QVariantAnimation, -) -from PySide6.QtGui import QAction, QBitmap, QBrush, QColor, 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 - -from tagstudio.core.enums import SettingItems -from tagstudio.qt.helpers.file_opener import FileOpenerHelper -from tagstudio.qt.platform_strings import open_file_str -from tagstudio.qt.translations import Translations - -if typing.TYPE_CHECKING: - from tagstudio.qt.ts_qt import QtDriver - - -class VideoPlayer(QGraphicsView): - """A basic video player.""" - - video_preview = None - play_pause = None - mute_button = None - filepath: str | None - - def __init__(self, driver: "QtDriver") -> None: - super().__init__() - self.driver = driver - self.resolution = QSize(1280, 720) - self.animation = QVariantAnimation(self) - self.animation.valueChanged.connect(lambda value: self.set_tint_opacity(value)) - self.hover_fix_timer = QTimer() - self.hover_fix_timer.timeout.connect(lambda: self.check_if_hovered()) - self.hover_fix_timer.setSingleShot(True) - self.content_visible = False - self.filepath = None - - # Set up the video player. - self.installEventFilter(self) - self.setScene(QGraphicsScene(self)) - self.player = QMediaPlayer(self) - self.player.mediaStatusChanged.connect( - lambda: self.check_media_status(self.player.mediaStatus()) - ) - self.video_preview = VideoPreview() - self.player.setVideoOutput(self.video_preview) - self.video_preview.setAcceptHoverEvents(True) - self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.RightButton) - self.video_preview.installEventFilter(self) - self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) - self.player.audioOutput().setMuted(True) - self.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.scene().addItem(self.video_preview) - self.video_preview.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) - - self.setStyleSheet("border-style:solid;border-width:0px;") - - # Set up the video tint. - self.video_tint = self.scene().addRect( - 0, - 0, - self.video_preview.size().width(), - self.video_preview.size().height(), - QPen(QColor(0, 0, 0, 0)), - QBrush(QColor(0, 0, 0, 0)), - ) - - # Set up the buttons. - self.play_pause = QSvgWidget() - self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True) - self.play_pause.setMouseTracking(True) - self.play_pause.installEventFilter(self) - self.scene().addWidget(self.play_pause) - self.play_pause.resize(72, 72) - self.play_pause.move( - int(self.width() / 2 - self.play_pause.size().width() / 2), - int(self.height() / 2 - self.play_pause.size().height() / 2), - ) - self.play_pause.hide() - - self.mute_button = QSvgWidget() - self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, on=True) - self.mute_button.setMouseTracking(True) - self.mute_button.installEventFilter(self) - self.scene().addWidget(self.mute_button) - self.mute_button.resize(32, 32) - self.mute_button.move( - int(self.width() - self.mute_button.size().width() / 2), - int(self.height() - self.mute_button.size().height() / 2), - ) - self.mute_button.hide() - - self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.opener = FileOpenerHelper(filepath=self.filepath) - autoplay_action = QAction(Translations["media_player.autoplay"], self) - autoplay_action.setCheckable(True) - self.addAction(autoplay_action) - autoplay_action.setChecked( - self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) - ) - autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) - self.autoplay = autoplay_action - - open_file_action = QAction(Translations["file.open_file"], self) - 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 close(self, *args, **kwargs) -> None: - self.player.stop() - super().close(*args, **kwargs) - - 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 check_media_status(self, media_status: QMediaPlayer.MediaStatus) -> None: - if media_status == QMediaPlayer.MediaStatus.EndOfMedia: - # Switches current video to with video at filepath. - # Reason for this is because Pyside6 can't handle setting a new source and freezes. - # Even if I stop the player before switching, it breaks. - # On the plus side, this adds infinite looping for the video preview. - self.player.stop() - self.player.setSource(QUrl().fromLocalFile(self.filepath)) - self.player.setPosition(0) - if self.autoplay.isChecked(): - self.player.play() - else: - self.player.pause() - self.opener.set_filepath(self.filepath) - self.reposition_controls() - self.update_controls() - - def update_controls(self) -> None: - """Update the icons of the video player controls.""" - if self.player.audioOutput().isMuted(): - self.mute_button.load(self.driver.rm.volume_mute_icon) - else: - self.mute_button.load(self.driver.rm.volume_icon) - - if self.player.isPlaying(): - self.play_pause.load(self.driver.rm.pause_icon) - else: - self.play_pause.load(self.driver.rm.play_icon) - - def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802 - """Manage events for the video player.""" - if ( - event.type() == QEvent.Type.MouseButtonPress - and event.button() == Qt.MouseButton.LeftButton # type: ignore - ): - if obj == self.play_pause and self.player.hasVideo(): - self.toggle_pause() - elif obj == self.mute_button and self.player.hasAudio(): - self.toggle_mute() - - elif obj == self.video_preview: - if event.type() in ( - QEvent.Type.GraphicsSceneHoverEnter, - QEvent.Type.HoverEnter, - ): - if self.video_preview.isUnderMouse(): - self.underMouse() - self.hover_fix_timer.start(10) - elif ( - event.type() in (QEvent.Type.GraphicsSceneHoverLeave, QEvent.Type.HoverLeave) - and not self.video_preview.isUnderMouse() - ): - self.hover_fix_timer.stop() - self.releaseMouse() - - return super().eventFilter(obj, event) - - def check_if_hovered(self) -> None: - """Check if the mouse is still hovering over the video player.""" - # Sometimes the HoverLeave event does not trigger and is unable to hide the video controls. - # As a workaround, this is called by a QTimer every 10ms - # to check if the mouse is still in the video preview. - if not self.video_preview.isUnderMouse(): - self.releaseMouse() - else: - self.hover_fix_timer.start(10) - - 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.video_tint.setBrush(QBrush(QColor(0, 0, 0, opacity))) - - def underMouse(self) -> bool: # noqa: N802 - self.animation.setStartValue(self.video_tint.brush().color().alpha()) - self.animation.setEndValue(100) - self.animation.setDuration(250) - self.animation.start() - self.play_pause.show() - self.mute_button.show() - self.reposition_controls() - self.update_controls() - - return super().underMouse() - - def releaseMouse(self) -> None: # noqa: N802 - self.animation.setStartValue(self.video_tint.brush().color().alpha()) - self.animation.setEndValue(0) - self.animation.setDuration(500) - self.animation.start() - self.play_pause.hide() - self.mute_button.hide() - - return super().releaseMouse() - - def reset_controls(self) -> None: - """Reset the video controls to their default state.""" - self.play_pause.load(self.driver.rm.pause_icon) - self.mute_button.load(self.driver.rm.volume_mute_icon) - - def toggle_pause(self) -> None: - """Toggle the pause state of the video.""" - if self.player.isPlaying(): - self.player.pause() - self.play_pause.load(self.driver.rm.play_icon) - else: - self.player.play() - self.play_pause.load(self.driver.rm.pause_icon) - - def toggle_mute(self) -> None: - """Toggle the mute state of the video.""" - if self.player.audioOutput().isMuted(): - self.player.audioOutput().setMuted(False) - self.mute_button.load(self.driver.rm.volume_icon) - else: - self.player.audioOutput().setMuted(True) - self.mute_button.load(self.driver.rm.volume_mute_icon) - - def play(self, filepath: str, resolution: QSize) -> None: - """Set the filepath and send the current player position to the very end. - - This is used so that the new video can be played. - """ - logging.info(f"Playing {filepath}") - self.resolution = resolution - self.filepath = filepath - if self.player.isPlaying(): - self.player.setPosition(self.player.duration()) - self.player.play() - else: - self.check_media_status(QMediaPlayer.MediaStatus.EndOfMedia) - - def stop(self) -> None: - self.filepath = None - self.player.stop() - - def resize_video(self, new_size: QSize) -> None: - """Resize the video player. - - Args: - new_size(QSize): The new size of the video player to set. - """ - self.video_preview.setSize(new_size) - self.video_tint.setRect( - 0, 0, self.video_preview.size().width(), self.video_preview.size().height() - ) - - contents = self.contentsRect() - self.centerOn(self.video_preview) - self.apply_rounded_corners() - self.setSceneRect(0, 0, contents.width(), contents.height()) - self.reposition_controls() - - 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=12, - fill=(0, 0, 0, 0), - ) - final_mask = mask.getchannel("A").toqpixmap() - self.setMask(QRegion(QBitmap(final_mask))) - - def reposition_controls(self) -> None: - """Reposition video controls to their intended locations.""" - self.play_pause.move( - int(self.width() / 2 - self.play_pause.size().width() / 2), - int(self.height() / 2 - self.play_pause.size().height() / 2), - ) - self.mute_button.move( - int(self.width() - self.mute_button.size().width() - 10), - int(self.height() - self.mute_button.size().height() - 10), - ) - - def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 - """Keep the video preview in the center of the screen.""" - self.centerOn(self.video_preview) - self.resize_video( - QSize( - int(self.video_preview.size().width()), - int(self.video_preview.size().height()), - ) - ) - - -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) From 690c1cc648d8023e2f3338f09a5bdfd50f8d8829 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Mon, 17 Mar 2025 18:47:17 -0400 Subject: [PATCH 10/14] fix: add loop setting and other actions. --- src/tagstudio/core/enums.py | 1 + src/tagstudio/qt/widgets/media_player.py | 35 ++++++++++--------- .../qt/widgets/preview/preview_thumb.py | 4 +++ src/tagstudio/resources/translations/en.json | 1 + 4 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index 2a11a12fd..074359eeb 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -16,6 +16,7 @@ class SettingItems(str, enum.Enum): WINDOW_SHOW_LIBS = "window_show_libs" SHOW_FILENAMES = "show_filenames" AUTOPLAY = "autoplay_videos" + LOOP_MEDIA = "loop_media" THUMB_CACHE_SIZE_LIMIT = "thumb_cache_size_limit" LANGUAGE = "language" diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 724b86902..7f0995456 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -25,9 +25,7 @@ ) from tagstudio.core.enums import SettingItems -from tagstudio.qt.helpers.file_opener import FileOpenerHelper from tagstudio.qt.helpers.qslider_wrapper import QClickSlider -from tagstudio.qt.platform_strings import open_file_str from tagstudio.qt.translations import Translations if typing.TYPE_CHECKING: @@ -250,7 +248,6 @@ def __init__(self, driver: "QtDriver") -> None: self.scene().addWidget(self.master_controls) self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - self.opener = FileOpenerHelper(filepath=self.filepath) autoplay_action = QAction(Translations["media_player.autoplay"], self) autoplay_action.setCheckable(True) self.addAction(autoplay_action) @@ -260,14 +257,14 @@ def __init__(self, driver: "QtDriver") -> None: autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action - open_file_action = QAction(Translations["file.open_file"], self) - 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) + loop_action = QAction(Translations["media_player.loop"], self) + loop_action.setCheckable(True) + self.addAction(loop_action) + loop_action.setChecked( + self.driver.settings.value(SettingItems.LOOP_MEDIA, defaultValue=True, type=bool) + ) + loop_action.triggered.connect(lambda: self.toggle_loop()) + self.loop = loop_action # start the player muted self.player.audioOutput().setMuted(True) @@ -280,6 +277,10 @@ def toggle_autoplay(self) -> None: self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked()) self.driver.settings.sync() + def toggle_loop(self) -> None: + self.driver.settings.setValue(SettingItems.LOOP_MEDIA, self.loop.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)) @@ -432,8 +433,6 @@ def play(self, filepath: Path) -> None: 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) @@ -464,10 +463,6 @@ def player_position_changed(self, position: int) -> None: duration = self.format_time(self.player.duration()) self.position_label.setText(f"{current} / {duration}") - if self.player.duration() == position: - self.player.pause() - self.player.setPosition(0) - def media_status_changed(self, status: QMediaPlayer.MediaStatus) -> None: # We can only set the slider duration once we know the size of the media if status == QMediaPlayer.MediaStatus.LoadedMedia and self.filepath is not None: @@ -477,6 +472,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}") + elif status == QMediaPlayer.MediaStatus.EndOfMedia: + self.player.setPosition(0) + if self.loop.isChecked(): + self.player.play() + else: + self.player.pause() def _update_controls(self, size: QSize) -> None: self.scene().setSceneRect(0, 0, size.width(), size.height()) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index ef2d8f3c1..21901e6fd 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -79,6 +79,7 @@ def __init__(self, library: Library, driver: "QtDriver"): self.preview_gif.addAction(self.open_file_action) self.preview_gif.addAction(self.open_explorer_action) self.preview_gif.hide() + self.preview_gif.addAction(self.delete_action) self.gif_buffer: QBuffer = QBuffer() self.preview_gif_page = QWidget() @@ -106,6 +107,9 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) + self.media_player.addAction(self.open_file_action) + self.media_player.addAction(self.open_explorer_action) + self.media_player.addAction(self.delete_action) image_layout.addWidget(self.preview_img_page) image_layout.addWidget(self.preview_gif_page) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index c85f18779..09372f4a1 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -183,6 +183,7 @@ "macros.running.dialog.new_entries": "Running Configured Macros on {count}/{total} New File Entries...", "macros.running.dialog.title": "Running Macros on New Entries", "media_player.autoplay": "Autoplay", + "media_player.loop": "Loop", "menu.delete_selected_files_ambiguous": "Move File(s) to {trash_term}", "menu.delete_selected_files_plural": "Move Files to {trash_term}", "menu.delete_selected_files_singular": "Move File to {trash_term}", From 4c69344d245dc4f5fb9c4130ae40ee3f34d12da6 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Wed, 19 Mar 2025 20:13:46 -0400 Subject: [PATCH 11/14] refactor: simplify widget state management. Make a single method to control widget state. Works with the main QStackLayout and cleans up widget state if it is needed (i.e., stopping the media player when switching to a different preview). --- .../qt/widgets/preview/preview_thumb.py | 104 +++++++----------- 1 file changed, 42 insertions(+), 62 deletions(-) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 21901e6fd..186c6e4cc 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget +from PySide6.QtWidgets import QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories, MediaType @@ -47,10 +47,10 @@ def __init__(self, library: Library, driver: "QtDriver"): self.img_button_size: tuple[int, int] = (266, 266) self.image_ratio: float = 1.0 - image_layout = QStackedLayout(self) - image_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - image_layout.setStackingMode(QStackedLayout.StackingMode.StackAll) - image_layout.setContentsMargins(0, 0, 0, 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.open_file_action = QAction(Translations["file.open_file"], self) self.open_explorer_action = QAction(open_file_str(), self) @@ -67,25 +67,15 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.hide() self.preview_gif.addAction(self.delete_action) self.gif_buffer: QBuffer = QBuffer() - 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( @@ -102,28 +92,17 @@ def __init__(self, library: Library, driver: "QtDriver"): ) self.media_player = MediaPlayer(driver) - self.media_player.hide() - - 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) self.media_player.addAction(self.open_file_action) self.media_player.addAction(self.open_explorer_action) self.media_player.addAction(self.delete_action) - image_layout.addWidget(self.preview_img_page) - image_layout.addWidget(self.preview_gif_page) - image_layout.addWidget(self.media_player_page) + self.image_layout.addWidget(self.preview_img) + self.image_layout.addWidget(self.preview_gif) + self.image_layout.addWidget(self.media_player) self.setMinimumSize(*self.img_button_size) - 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) + self.hide_preview() def set_image_ratio(self, ratio: float): self.image_ratio = ratio @@ -172,14 +151,25 @@ def get_preview_size(self) -> tuple[int, int]: ) def switch_preview(self, preview: str): - if preview != "image" and preview != "media": - self.preview_img.hide() - - if preview != "media": + if preview in ["audio", "video"]: + self.media_player.show() + self.image_layout.setCurrentWidget(self.media_player) + else: self.media_player.stop() self.media_player.hide() - if preview != "animated": + if preview in ["image", "audio"]: + self.preview_img.show() + self.image_layout.setCurrentWidget( + self.preview_img if preview == "image" else self.media_player + ) + else: + self.preview_img.hide() + + if preview == "animated": + self.preview_gif.show() + self.image_layout.setCurrentWidget(self.preview_gif) + else: if self.preview_gif.movie(): self.preview_gif.movie().stop() self.gif_buffer.close() @@ -198,7 +188,6 @@ def _display_fallback_image(self, filepath: Path, ext=str) -> dict: self.devicePixelRatio(), update_on_ratio_change=True, ) - self.preview_img.show() return self._update_image(filepath, ext) def _update_image(self, filepath: Path, ext: str) -> dict: @@ -237,8 +226,6 @@ def _update_image(self, filepath: Path, ext: str) -> dict: ): pass - self.preview_img.show() - return stats def _update_animation(self, filepath: Path, ext: str) -> dict: @@ -285,7 +272,6 @@ def _update_animation(self, filepath: Path, ext: str) -> dict: ) ) movie.start() - self.preview_gif.show() stats["duration"] = movie.frameCount() // 60 except UnidentifiedImageError as e: @@ -304,34 +290,29 @@ def _get_video_res(self, filepath: str) -> tuple[bool, QSize]: def _update_media(self, filepath: Path, type: MediaType) -> dict: stats: dict = {} - self.switch_preview("media") self.media_player.play(filepath) - match type: - case MediaType.AUDIO: - self.preview_img.show() - case 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()), - ) + 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) + stats["width"] = size.width() + stats["height"] = size.height() - self.media_player.show() + 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 @@ -413,7 +394,6 @@ def stop_file_use(self): # 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())) From 781c14e4df03a39c97357a7a697bcf4f7a2ca011 Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Thu, 20 Mar 2025 06:46:43 -0400 Subject: [PATCH 12/14] fix: add pages to QStackLayout to fix widget position. Fixes a regression in commit 4c6934. We need the pages to properly center the widgets in the QStackLayout. --- .../qt/widgets/preview/preview_thumb.py | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 186c6e4cc..264a557dc 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError from PySide6.QtCore import QBuffer, QByteArray, QSize, Qt from PySide6.QtGui import QAction, QMovie, QResizeEvent -from PySide6.QtWidgets import QLabel, QStackedLayout, QWidget +from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedLayout, QWidget from tagstudio.core.library.alchemy.library import Library from tagstudio.core.media_types import MediaCategories, MediaType @@ -67,6 +67,11 @@ def __init__(self, library: Library, driver: "QtDriver"): 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) @@ -76,6 +81,9 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.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( @@ -96,14 +104,24 @@ def __init__(self, library: Library, driver: "QtDriver"): self.media_player.addAction(self.open_explorer_action) self.media_player.addAction(self.delete_action) - self.image_layout.addWidget(self.preview_img) - self.image_layout.addWidget(self.preview_gif) - self.image_layout.addWidget(self.media_player) + self.media_player_page = QWidget() + self._stacked_page_setup(self.media_player_page, self.media_player) + + 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 _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 @@ -153,7 +171,7 @@ def get_preview_size(self) -> tuple[int, int]: def switch_preview(self, preview: str): if preview in ["audio", "video"]: self.media_player.show() - self.image_layout.setCurrentWidget(self.media_player) + self.image_layout.setCurrentWidget(self.media_player_page) else: self.media_player.stop() self.media_player.hide() @@ -161,14 +179,14 @@ def switch_preview(self, preview: str): if preview in ["image", "audio"]: self.preview_img.show() self.image_layout.setCurrentWidget( - self.preview_img if preview == "image" else self.media_player + 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) + self.image_layout.setCurrentWidget(self.preview_gif_page) else: if self.preview_gif.movie(): self.preview_gif.movie().stop() From 26a3a8c7d9fe5d471de652643c351d56aa42accf Mon Sep 17 00:00:00 2001 From: Colten Begle Date: Sat, 22 Mar 2025 14:01:24 -0400 Subject: [PATCH 13/14] fix: ensure media_player doesn't exceed maximum size if thumbnail. Fix and issue where the media_player would expand past the thumbnail on resize. --- .../qt/widgets/preview/preview_thumb.py | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/tagstudio/qt/widgets/preview/preview_thumb.py b/src/tagstudio/qt/widgets/preview/preview_thumb.py index 264a557dc..775d9106a 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -84,8 +84,26 @@ def __init__(self, library: Library, driver: "QtDriver"): 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.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), @@ -99,14 +117,6 @@ def __init__(self, library: Library, driver: "QtDriver"): ) ) - 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) - - self.media_player_page = QWidget() - self._stacked_page_setup(self.media_player_page, self.media_player) - self.image_layout.addWidget(self.preview_img_page) self.image_layout.addWidget(self.preview_gif_page) self.image_layout.addWidget(self.media_player_page) @@ -115,6 +125,12 @@ def __init__(self, library: Library, driver: "QtDriver"): 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): layout = QHBoxLayout(page) layout.addWidget(widget) @@ -153,8 +169,26 @@ def update_image_size(self, size: tuple[int, int], ratio: float = None): self.preview_gif.setMaximumSize(adj_size) self.preview_gif.setMinimumSize(adj_size) - self.media_player.setMaximumSize(adj_size) - self.media_player.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) From d9c1003874d094ca4cd747f683400cb62f01c79c Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sun, 30 Mar 2025 17:48:17 -0700 Subject: [PATCH 14/14] refactor: move settings to new system --- src/tagstudio/core/global_settings.py | 1 + src/tagstudio/qt/widgets/media_player.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/tagstudio/core/global_settings.py b/src/tagstudio/core/global_settings.py index 20c23685d..e1f04229f 100644 --- a/src/tagstudio/core/global_settings.py +++ b/src/tagstudio/core/global_settings.py @@ -44,6 +44,7 @@ class GlobalSettings(BaseModel): language: str = Field(default="en") open_last_loaded_on_startup: bool = Field(default=False) autoplay: bool = Field(default=False) + loop: bool = Field(default=True) show_filenames_in_grid: bool = Field(default=False) page_size: int = Field(default=500) show_filepath: ShowFilepathOption = Field(default=ShowFilepathOption.DEFAULT) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 7f0995456..342daa6be 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -24,7 +24,6 @@ QWidget, ) -from tagstudio.core.enums import SettingItems from tagstudio.qt.helpers.qslider_wrapper import QClickSlider from tagstudio.qt.translations import Translations @@ -251,18 +250,14 @@ def __init__(self, driver: "QtDriver") -> None: autoplay_action = QAction(Translations["media_player.autoplay"], self) autoplay_action.setCheckable(True) self.addAction(autoplay_action) - autoplay_action.setChecked( - self.driver.settings.value(SettingItems.AUTOPLAY, defaultValue=True, type=bool) - ) + autoplay_action.setChecked(self.driver.settings.autoplay) autoplay_action.triggered.connect(lambda: self.toggle_autoplay()) self.autoplay = autoplay_action loop_action = QAction(Translations["media_player.loop"], self) loop_action.setCheckable(True) self.addAction(loop_action) - loop_action.setChecked( - self.driver.settings.value(SettingItems.LOOP_MEDIA, defaultValue=True, type=bool) - ) + loop_action.setChecked(self.driver.settings.loop) loop_action.triggered.connect(lambda: self.toggle_loop()) self.loop = loop_action @@ -274,12 +269,12 @@ def set_video_output(self, video: QGraphicsVideoItem): 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() + self.driver.settings.autoplay = self.autoplay.isChecked() + self.driver.settings.save() def toggle_loop(self) -> None: - self.driver.settings.setValue(SettingItems.LOOP_MEDIA, self.loop.isChecked()) - self.driver.settings.sync() + self.driver.settings.loop = self.loop.isChecked() + self.driver.settings.save() def apply_rounded_corners(self) -> None: """Apply a rounded corner effect to the video player."""