diff --git a/src/tagstudio/qt/helpers/qslider_wrapper.py b/src/tagstudio/qt/helpers/qslider_wrapper.py index 7745171d..7aaf9f64 100644 --- a/src/tagstudio/qt/helpers/qslider_wrapper.py +++ b/src/tagstudio/qt/helpers/qslider_wrapper.py @@ -4,9 +4,12 @@ from typing import override +import structlog from PySide6.QtGui import QMouseEvent from PySide6.QtWidgets import QSlider, QStyle, QStyleOptionSlider +logger = structlog.get_logger(__name__) + class QClickSlider(QSlider): """Custom QSlider wrapper. @@ -35,9 +38,14 @@ class QClickSlider(QSlider): was_slider_clicked = handle_rect.contains(int(ev.position().x()), int(ev.position().y())) if not was_slider_clicked: + self.setSliderDown(True) self.setValue( QStyle.sliderValueFromPosition(self.minimum(), self.maximum(), ev.x(), self.width()) ) - self.mouse_pressed = True super().mousePressEvent(ev) + + @override + def mouseReleaseEvent(self, ev: QMouseEvent) -> None: + self.setSliderDown(False) + return super().mouseReleaseEvent(ev) diff --git a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py index e3d73313..ffeae996 100644 --- a/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py +++ b/src/tagstudio/qt/view/widgets/preview/preview_thumb_view.py @@ -95,8 +95,6 @@ class PreviewThumbView(QWidget): self.__media_player_video_changed_callback ) - self.__mp_max_size = QSize(*self.__img_button_size) - self.__media_player_page = QWidget() self.__stacked_page_setup(self.__media_player_page, self.__media_player) @@ -131,7 +129,6 @@ class PreviewThumbView(QWidget): self, _timestamp: float, img: QPixmap, _size: QSize, _path: Path ) -> None: self.__button_wrapper.setIcon(img) - self.__mp_max_size = img.size() def __thumb_renderer_updated_ratio_callback(self, ratio: float) -> None: self.__image_ratio = ratio @@ -174,25 +171,8 @@ class PreviewThumbView(QWidget): 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) + self.__media_player.setMaximumSize(adj_size) + self.__media_player.setMinimumSize(adj_size) proxy_style = RoundedPixmapStyle(radius=8) self.__preview_gif.setStyle(proxy_style) diff --git a/src/tagstudio/qt/widgets/media_player.py b/src/tagstudio/qt/widgets/media_player.py index 9d4eaf7b..99910858 100644 --- a/src/tagstudio/qt/widgets/media_player.py +++ b/src/tagstudio/qt/widgets/media_player.py @@ -7,20 +7,31 @@ from pathlib import Path from time import gmtime from typing import override +import structlog 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.QtGui import ( + QAction, + QBitmap, + QBrush, + QColor, + QLinearGradient, + QMouseEvent, + 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, + QVBoxLayout, QWidget, ) @@ -30,6 +41,8 @@ from tagstudio.qt.translations import Translations if typing.TYPE_CHECKING: from tagstudio.qt.ts_qt import QtDriver +logger = structlog.get_logger(__name__) + class MediaPlayer(QGraphicsView): """A basic media player widget. @@ -46,70 +59,41 @@ class MediaPlayer(QGraphicsView): 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; + margin-right: 6px; } QSlider::add-page:horizontal { - background: #3f4144; - height: 2px; - margin: 2px 0; - border-radius: 2px; + background: #65000000; + border-radius: 3px; + border-style: solid; + border-width: 1px; + border-color: #65444444; } QSlider::sub-page:horizontal { - background: #6ea0ff; - height: 2px; - margin: 2px 0; - border-radius: 2px; + background: #88FFFFFF; + border-radius: 3px; } - QSlider::groove:vertical { - border: 1px solid #999999; - width: 2px; - margin: 0 2px; - border-radius: 2px; + QSlider::groove:horizontal { + background: transparent; + height: 6px; } - QSlider::handle:vertical { - background: #6ea0ff; - border: 1px solid #5c5c5c; + QSlider::handle:horizontal { + background: #FFFFFF; width: 12px; - height: 12px; - margin: 0 -6px; + margin: -3px 0; 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 + fixed_policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + retain_policy = QSizePolicy() + retain_policy.setRetainSizeWhenHidden(True) + self.filepath: Path | None = None + + # Graphics Scene self.installEventFilter(self) self.setScene(QGraphicsScene(self)) self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) @@ -121,6 +105,7 @@ class MediaPlayer(QGraphicsView): border: none; } """) + self.setObjectName("mediaPlayer") self.setAlignment(Qt.AlignmentFlag.AlignCenter) self.video_preview = VideoPreview() @@ -129,67 +114,60 @@ class MediaPlayer(QGraphicsView): 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(), + 12, QPen(QColor(0, 0, 0, 0)), QBrush(QColor(0, 0, 0, 0)), ) - # setup the player - self.filepath: Path | None = None + # Player self.player = QMediaPlayer() self.player.setAudioOutput(QAudioOutput(QMediaDevices().defaultAudioOutput(), self.player)) + self.is_paused = ( + False # Q MediaPlayer.PlaybackState shows StoppedState when changing tracks + ) - # Used to keep track of play state. - # It would be nice if we could use QMediaPlayer.PlaybackState, - # but this will always show StoppedState when changing - # tracks. Therefore, we wouldn't know if the previous - # state was paused or playing - self.is_paused = False - - # Subscribe to player events from MediaPlayer 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.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) + # Media Controls + self.controls = QWidget() + self.controls.setObjectName("controls") + root_layout = QVBoxLayout(self.controls) + root_layout.setContentsMargins(6, 0, 6, 0) + root_layout.setSpacing(6) + self.controls.setStyleSheet("background: transparent;") + self.controls.setMinimumHeight(48) - 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.pslider.hide() + self.timeline_slider = QClickSlider() + self.timeline_slider.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.timeline_slider.setTickPosition(QSlider.TickPosition.NoTicks) + self.timeline_slider.setSingleStep(1) + self.timeline_slider.setOrientation(Qt.Orientation.Horizontal) + self.timeline_slider.setStyleSheet(slider_style) + self.timeline_slider.sliderReleased.connect(self.slider_released) + self.timeline_slider.valueChanged.connect(self.slider_value_changed) + self.timeline_slider.hide() + self.timeline_slider.setFixedHeight(12) - 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) + root_layout.addWidget(self.timeline_slider) + root_layout.setAlignment(self.timeline_slider, Qt.AlignmentFlag.AlignBottom) 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) + sub_layout.setContentsMargins(0, 0, 0, 6) self.sub_controls.setStyleSheet("background: transparent;") + self.sub_controls.setMinimumHeight(16) self.play_pause = QSvgWidget() self.play_pause.setCursor(Qt.CursorShape.PointingHandCursor) @@ -218,9 +196,6 @@ class MediaPlayer(QGraphicsView): 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)) @@ -228,24 +203,20 @@ class MediaPlayer(QGraphicsView): self.volume_slider.setStyleSheet(slider_style) self.volume_slider.setSizePolicy(retain_policy) self.volume_slider.hide() + self.volume_slider.setMinimumWidth(32) 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.setStyleSheet("color: #ffffff;") - master_layout.addWidget(self.position_label, 1, 1) - master_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight) + self.position_label.setStyleSheet("color: white;") + sub_layout.addWidget(self.position_label) + root_layout.setAlignment(self.position_label, Qt.AlignmentFlag.AlignRight) self.position_label.hide() - self.scene().addWidget(self.master_controls) + root_layout.addWidget(self.sub_controls) + self.scene().addWidget(self.controls) self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) autoplay_action = QAction(Translations["media_player.autoplay"], self) @@ -263,7 +234,6 @@ class MediaPlayer(QGraphicsView): self.loop = loop_action self.toggle_loop() - # start the player muted self.player.audioOutput().setMuted(True) def set_video_output(self, video: QGraphicsVideoItem): @@ -277,7 +247,6 @@ class MediaPlayer(QGraphicsView): def toggle_loop(self) -> None: self.driver.settings.loop = self.loop.isChecked() self.driver.settings.save() - self.player.setLoops(-1 if self.driver.settings.loop else 1) def apply_rounded_corners(self) -> None: @@ -307,26 +276,31 @@ class MediaPlayer(QGraphicsView): Args: opacity(int): The opacity value, from 0-255. """ - self.tint.setBrush(QBrush(QColor(0, 0, 0, opacity))) + gradient = QLinearGradient(0, 0, 0, self.height()) + gradient.setColorAt(0.8, QColor(0, 0, 0, 0)) + gradient.setColorAt(1, QColor(0, 0, 0, opacity)) + self.tint.setBrush(QBrush(gradient)) + @override def underMouse(self) -> bool: # noqa: N802 - self.animation.setStartValue(self.tint.brush().color().alpha()) - self.animation.setEndValue(100) - self.animation.setDuration(250) + self.animation.setStartValue(0) + self.animation.setEndValue(160) + self.animation.setDuration(125) self.animation.start() - self.pslider.show() + self.timeline_slider.show() self.play_pause.show() self.mute_unmute.show() self.position_label.show() return super().underMouse() + @override def releaseMouse(self) -> None: # noqa: N802 - self.animation.setStartValue(self.tint.brush().color().alpha()) + self.animation.setStartValue(160) self.animation.setEndValue(0) - self.animation.setDuration(500) + self.animation.setDuration(125) self.animation.start() - self.pslider.hide() + self.timeline_slider.hide() self.play_pause.hide() self.mute_unmute.hide() self.volume_slider.hide() @@ -334,6 +308,14 @@ class MediaPlayer(QGraphicsView): return super().releaseMouse() + @override + def mousePressEvent(self, event: QMouseEvent) -> None: + # Pause media if background is clicked, with buffer around controls + buffer: int = 6 + if event.y() < (self.height() - self.controls.height() - buffer): + self.toggle_play() + return super().mousePressEvent(event) + @override def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: """Manage events for the media player.""" @@ -345,8 +327,6 @@ class MediaPlayer(QGraphicsView): 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() @@ -369,9 +349,7 @@ class MediaPlayer(QGraphicsView): ms: Time in ms Returns: - A formatted time: - - "1:43" + A formatted time: "1:43" The formatted time will only include the hour if the provided time is at least 60 minutes. @@ -441,23 +419,27 @@ class MediaPlayer(QGraphicsView): self.mute_unmute.load(icon) def slider_value_changed(self, value: int) -> None: + if self.timeline_slider.isSliderDown(): + self.player.setPosition(value) + self.player.setPlaybackRate(0.0001) + current = self.format_time(value) duration = self.format_time(self.player.duration()) self.position_label.setText(f"{current} / {duration}") def slider_released(self) -> None: was_playing = self.player.isPlaying() - self.player.setPosition(self.pslider.value()) + self.player.setPosition(self.timeline_slider.value()) + self.player.setPlaybackRate(1) # Restore from slider_value_changed() - # Setting position causes the player to start playing again. - # We should reset back to initial state. + # Setting position causes the player to start playing again if not was_playing: self.player.pause() def player_position_changed(self, position: int) -> None: - if not self.pslider.isSliderDown(): - # User isn't using the slider, so update position in widgets. - self.pslider.setValue(position) + if not self.timeline_slider.isSliderDown(): + # User isn't using the slider, so update position in widgets + self.timeline_slider.setValue(position) current = self.format_time(self.player.position()) duration = self.format_time(self.player.duration()) self.position_label.setText(f"{current} / {duration}") @@ -465,8 +447,8 @@ class MediaPlayer(QGraphicsView): 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: - self.pslider.setMinimum(0) - self.pslider.setMaximum(self.player.duration()) + self.timeline_slider.setMinimum(0) + self.timeline_slider.setMaximum(self.player.duration()) current = self.format_time(self.player.position()) duration = self.format_time(self.player.duration()) @@ -475,24 +457,15 @@ class MediaPlayer(QGraphicsView): 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()) + # Occupy entire scene width + self.controls.setMinimumWidth(size.width()) + self.controls.setMaximumWidth(size.width()) - self.master_controls.move(0, int(self.scene().height() - self.master_controls.height())) + self.controls.move(0, int(self.scene().height() - self.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) + ps_w = self.controls.width() - 5 + self.timeline_slider.setMinimumWidth(ps_w) + self.timeline_slider.setMaximumWidth(ps_w) if self.video_preview: self.video_preview.setSize(self.size()) @@ -514,12 +487,3 @@ 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)