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/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py new file mode 100644 index 000000000..7745171de --- /dev/null +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -0,0 +1,43 @@ +# Copyright (C) 2025 +# 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 + + +class QClickSlider(QSlider): + """Custom QSlider wrapper. + + The purpose of this wrapper is to allow us to set slider positions + based on click events. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @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 + of stepping. + """ + opt = QStyleOptionSlider() + self.initStyleOption(opt) + handle_rect = self.style().subControlRect( + QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle, self + ) + + was_slider_clicked = handle_rect.contains(int(ev.position().x()), int(ev.position().y())) + + if not was_slider_clicked: + self.setValue( + QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), ev.x(), self.width()) + ) + self.mouse_pressed = True + + super().mousePressEvent(ev) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 1f42066e3..342daa6be 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -2,41 +2,147 @@ # 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 typing import override -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 +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, - QPushButton, QSizePolicy, QSlider, QWidget, ) +from tagstudio.qt.helpers.qslider_wrapper import QClickSlider +from tagstudio.qt.translations import Translations + 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. """ + video_preview: "VideoPreview | None" = 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; + } + + 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 + self.installEventFilter(self) + self.setScene(QGraphicsScene(self)) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setStyleSheet(""" + QGraphicsView { + 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) + + # 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 +158,203 @@ 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.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 = QSlider(self) + self.pslider = QClickSlider() 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() + + 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(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(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) - self.media_btns_layout.addWidget(self.mute) + retain_policy = QSizePolicy() + retain_policy.setRetainSizeWhenHidden(True) - self.volume_slider = QSlider() + self.volume_slider = QClickSlider() 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.volume_slider.setStyleSheet(slider_style) + self.volume_slider.setSizePolicy(retain_policy) + self.volume_slider.hide() - self.media_btns_layout.addWidget(self.volume_slider) + 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() + + master_layout.addWidget(self.sub_controls, 1, 0) self.position_label = QLabel("0:00") - self.position_label.setAlignment(Qt.AlignmentFlag.AlignRight) + 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() + + self.scene().addWidget(self.master_controls) + + self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) + autoplay_action = QAction(Translations["media_player.autoplay"], self) + autoplay_action.setCheckable(True) + self.addAction(autoplay_action) + 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.loop) + loop_action.triggered.connect(lambda: self.toggle_loop()) + self.loop = loop_action + + # start the player muted + self.player.audioOutput().setMuted(True) + + 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.autoplay = self.autoplay.isChecked() + self.driver.settings.save() + + def toggle_loop(self) -> None: + 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.""" + 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))) - 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 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.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() + + @override + def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: + """Manage events for the media player.""" + if ( + arg__2.type() == QEvent.Type.MouseButtonPress + and arg__2.button() == Qt.MouseButton.LeftButton # type: ignore + ): + if arg__1 == self.play_pause: + self.toggle_play() + elif arg__1 == self.mute_unmute: + self.toggle_mute() + else: + self.toggle_play() + elif arg__2.type() is QEvent.Type.Enter: + if arg__1 == self or arg__1 == self.video_preview: + self.underMouse() + elif arg__1 == self.mute_unmute: + self.volume_slider.show() + elif arg__2.type() == QEvent.Type.Leave: + if arg__1 == self or arg__1 == self.video_preview: + self.releaseMouse() + elif arg__1 == self.sub_controls: + self.volume_slider.hide() + + return super().eventFilter(arg__1, arg__2) def format_time(self, ms: int) -> str: """Format the given time. @@ -128,8 +379,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 +396,21 @@ 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 not self.video_preview: + return + 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 @@ -161,24 +422,19 @@ 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)) - 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) 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) @@ -202,10 +458,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: @@ -215,6 +467,61 @@ 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()) + + # occupy entire scene width + self.master_controls.setMinimumWidth(size.width()) + self.master_controls.setMaximumWidth(size.width()) + + self.master_controls.move(0, int(self.scene().height() - self.master_controls.height())) + + ps_w = self.master_controls.width() - 5 + self.pslider.setMinimumWidth(ps_w) + self.pslider.setMaximumWidth(ps_w) + + # 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) + + 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() + + @override + def resizeEvent(self, event: QResizeEvent) -> None: + self._update_controls(event.size()) def volume_slider_changed(self, position: int) -> None: self.player.audioOutput().setVolume(position / 100) + + +class VideoPreview(QGraphicsVideoItem): + @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. + # 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 9d5e82ac1..a94d918d5 100644 --- a/src/tagstudio/qt/widgets/preview/preview_thumb.py +++ b/src/tagstudio/qt/widgets/preview/preview_thumb.py @@ -14,10 +14,10 @@ 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 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 @@ -27,7 +27,6 @@ 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,8 +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 = QHBoxLayout(self) - 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) @@ -66,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) @@ -73,14 +79,31 @@ 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.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_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), @@ -94,17 +117,27 @@ def __init__(self, library: Library, driver: "QtDriver"): ) ) - self.media_player = MediaPlayer(driver) - 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) + self.image_layout.addWidget(self.preview_img_page) + self.image_layout.addWidget(self.preview_gif_page) + self.image_layout.addWidget(self.media_player_page) + self.setMinimumSize(*self.img_button_size) + self.hide_preview() + + def _set_mp_max_size(self, size: QSize) -> None: + self.mp_max_size = size + + def _has_video_changed(self, video: bool) -> None: + self.update_image_size((self.size().width(), self.size().height())) + + def _stacked_page_setup(self, page: QWidget, widget: QWidget): + 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 @@ -129,17 +162,36 @@ def update_image_size(self, size: tuple[int, int], ratio: float | None = 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_gif.setMaximumSize(adj_size) self.preview_gif.setMinimumSize(adj_size) + + if not self.media_player.player.hasVideo(): + # ensure we do not exceed the thumbnail size + mp_width = ( + adj_size.width() + if adj_size.width() < self.mp_max_size.width() + else self.mp_max_size.width() + ) + mp_height = ( + adj_size.height() + if adj_size.height() < self.mp_max_size.height() + else self.mp_max_size.height() + ) + mp_size = QSize(mp_width, mp_height) + self.media_player.setMinimumSize(mp_size) + self.media_player.setMaximumSize(mp_size) + else: + # have video, so just resize as normal + self.media_player.setMaximumSize(adj_size) + self.media_player.setMinimumSize(adj_size) + proxy_style = RoundedPixmapStyle(radius=8) self.preview_gif.setStyle(proxy_style) - self.preview_vid.setStyle(proxy_style) + self.media_player.setStyle(proxy_style) m = self.preview_gif.movie() if m: m.setScaledSize(adj_size) @@ -151,18 +203,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 != "video_legacy": - self.preview_vid.stop() - self.preview_vid.hide() - - if preview != "media": + if preview in ["audio", "video"]: + self.media_player.show() + self.image_layout.setCurrentWidget(self.media_player_page) + else: self.media_player.stop() self.media_player.hide() - if preview != "animated": + if preview in ["image", "audio"]: + self.preview_img.show() + self.image_layout.setCurrentWidget( + self.preview_img_page if preview == "image" else self.media_player_page + ) + else: + self.preview_img.hide() + + if preview == "animated": + self.preview_gif.show() + self.image_layout.setCurrentWidget(self.preview_gif_page) + else: if self.preview_gif.movie(): self.preview_gif.movie().stop() self.gif_buffer.close() @@ -181,7 +240,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: @@ -220,8 +278,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: @@ -273,7 +329,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: @@ -282,43 +337,39 @@ 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") + 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)) - 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.preview_vid.play(filepath_, QSize(image.width, image.height)) - 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.preview_vid.show() + def _update_media(self, filepath: Path, type: MediaType) -> dict: + stats: dict = {} - 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) + self.media_player.play(filepath) - return stats + 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()), + ) + ) - def _update_media(self, filepath: Path) -> dict: - stats: dict = {} - self.switch_preview("media") + stats["width"] = size.width() + stats["height"] = size.height() - self.preview_img.show() - self.media_player.show() - self.media_player.play(filepath) + 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 @@ -326,18 +377,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, @@ -399,8 +450,7 @@ def stop_file_use(self): 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() + self.media_player.play(ResourceManager.get_path("placeholder_mp4")) def resizeEvent(self, event: QResizeEvent) -> None: # noqa: N802 self.update_image_size((self.size().width(), self.size().height())) diff --git a/src/tagstudio/qt/widgets/preview_panel.py b/src/tagstudio/qt/widgets/preview_panel.py index 5af5c5bc0..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) diff --git a/src/tagstudio/qt/widgets/video_player.py b/src/tagstudio/qt/widgets/video_player.py deleted file mode 100644 index 9ea82243b..000000000 --- a/src/tagstudio/qt/widgets/video_player.py +++ /dev/null @@ -1,344 +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.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.autoplay) - 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.autoplay = self.autoplay.isChecked() - self.driver.settings.save() - - 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) diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index a877c0435..012cd9866 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -187,6 +187,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}",