feat(ui): merge media controls (#805)

* feat: merge media controls.

Initial commit to merge audio/video files. There are
still a few bugs around widget sizing that need fixing.

* 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.

* 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.

* 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.

* 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.

* fix: color position label white

Fix position label color to white so it stays visible
regardless of theme.

* fix: allow dragging slider after click

* Apply suggestions from code review

fix: apply suggestions from code review.

Co-authored-by: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>

* fix: remove references to legacy video player.

Combine the stats logic for video/audio into one method.

Fix several issues after incorrectly implementing suggestions.

* fix: add loop setting and other actions.

* 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).

* 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.

* 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.

* refactor: move settings to new system
This commit is contained in:
csponge
2025-03-30 22:23:24 -04:00
committed by GitHub
parent 27fb54ed65
commit 13afb0f664
7 changed files with 526 additions and 469 deletions

View File

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

View File

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

View File

@@ -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 @@ class MediaPlayer(QWidget):
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.pslider.hide()
self.media_btns_layout = QHBoxLayout()
master_layout.addWidget(self.pslider, 0, 0, 0, 2)
master_layout.setAlignment(self.pslider, Qt.AlignmentFlag.AlignCenter)
policy = QSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
fixed_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.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.load_play_pause_icon(playing=False)
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()
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)
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()
self.media_btns_layout.addWidget(self.mute)
sub_layout.addWidget(self.mute_unmute)
sub_layout.setAlignment(self.mute_unmute, Qt.AlignmentFlag.AlignLeft)
self.volume_slider = QSlider()
retain_policy = QSizePolicy()
retain_policy.setRetainSizeWhenHidden(True)
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.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)
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)))
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 @@ class MediaPlayer(QWidget):
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 @@ class MediaPlayer(QWidget):
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 @@ class MediaPlayer(QWidget):
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 @@ class MediaPlayer(QWidget):
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 @@ class MediaPlayer(QWidget):
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)

View File

@@ -14,10 +14,10 @@ import structlog
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.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
from tagstudio.qt.widgets.video_player import VideoPlayer
if typing.TYPE_CHECKING:
from tagstudio.qt.ts_qt import QtDriver
@@ -48,8 +47,10 @@ class PreviewThumb(QWidget):
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 @@ class PreviewThumb(QWidget):
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 @@ class PreviewThumb(QWidget):
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 @@ class PreviewThumb(QWidget):
)
)
self.media_player = MediaPlayer(driver)
self.media_player.hide()
self.image_layout.addWidget(self.preview_img_page)
self.image_layout.addWidget(self.preview_gif_page)
self.image_layout.addWidget(self.media_player_page)
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.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 @@ class PreviewThumb(QWidget):
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 @@ class PreviewThumb(QWidget):
)
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 @@ class PreviewThumb(QWidget):
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 @@ class PreviewThumb(QWidget):
):
pass
self.preview_img.show()
return stats
def _update_animation(self, filepath: Path, ext: str) -> dict:
@@ -273,7 +329,6 @@ class PreviewThumb(QWidget):
)
)
movie.start()
self.preview_gif.show()
stats["duration"] = movie.frameCount() // 60
except UnidentifiedImageError as e:
@@ -282,43 +337,39 @@ class PreviewThumb(QWidget):
return stats
def _update_video_legacy(self, filepath: Path) -> dict:
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))
def _update_media(self, filepath: Path, type: MediaType) -> 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.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()
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)
return stats
def _update_media(self, filepath: Path) -> dict:
stats: dict = {}
self.switch_preview("media")
self.preview_img.show()
self.media_player.show()
self.media_player.play(filepath)
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)
self.switch_preview("video" if type == MediaType.VIDEO else "audio")
stats["duration"] = self.media_player.player.duration() * 1000
return stats
@@ -326,18 +377,18 @@ class PreviewThumb(QWidget):
"""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 @@ class PreviewThumb(QWidget):
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()))

View File

@@ -110,7 +110,6 @@ class PreviewPanel(QWidget):
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)

View File

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

View File

@@ -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}",