mirror of
https://github.com/TagStudioDev/TagStudio.git
synced 2026-01-29 06:10:51 +00:00
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:
@@ -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)
|
||||
|
||||
43
src/tagstudio/qt/helpers/qslider_wrapper.py
Normal file
43
src/tagstudio/qt/helpers/qslider_wrapper.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user