ui: tweak media player style and behavior (#1025)

* ui: tweak media player style and behavior

* ui: change player overlay to gradient

* ui: add slight outline to player bar

* feat: realtime playback seeking

* fix(ui): click to jump to unbuffered media

* chore: organize imports

* refactor: remove unused code
This commit is contained in:
Travis Abendshien
2025-08-07 14:23:20 -07:00
committed by GitHub
parent 78e29a9a69
commit c235d4f727
3 changed files with 121 additions and 169 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)