Video Player (#149)

* Basic Video Player

* Fixes and Comments

* Fixed Bug Where Video's Audio did not stop when switching to a Image.

* Redo on VideoPlayer. Now with rounded corners.

* Fixed size not being correct when first starting video player.

* Ruff Check Fix

* Fixed Sizing Issue, and added Autoplay option in right click menu.

* Autoplay Toggle and Fixed Issue with video not stoping after closing library.

* Ruff Format

* Suggested Changes Done

* Commented out useless code that cause first warning.

* Fixed Album Art Error

* Might have found solution to Autoplay Inconsistency

* Ruff Format

* Finally Fixed Autoplay Inconsistency

* Fixed Merge Conficts

* Requested Changes and Ruff Format

* Test for new check

* Fix for PySide App Test

* More typing fixes and a few other changes.

* Ruff Format

* MyPy Fix

* MyPy Fix

* Ruff Format

* MyPy Fix

* MyPy and Ruff Fix

* Code Clean-Up and Requests completed.

* Conflict Fixes

* MyPy Fix

* Confict Fix

It appears one of the commits from main fixed the autoplay issue.
This commit is contained in:
DrRetro
2024-05-29 16:58:09 -04:00
committed by GitHub
parent 6798ffd0a7
commit 9f630fe315
9 changed files with 416 additions and 10 deletions

View File

@@ -32,7 +32,8 @@ jobs:
libxcb-render-util0 \
libxcb-xinerama0 \
libopengl0 \
libxcb-cursor0
libxcb-cursor0 \
libpulse0
- name: Install dependencies
run: |

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg>

After

Width:  |  Height:  |  Size: 172 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M320-200v-560l440 280-440 280Z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M792-56 671-177q-25 16-53 27.5T560-131v-82q14-5 27.5-10t25.5-12L480-368v208L280-360H120v-240h128L56-792l56-56 736 736-56 56Zm-8-232-58-58q17-31 25.5-65t8.5-70q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 53-14.5 102T784-288ZM650-422l-90-90v-130q47 22 73.5 66t26.5 96q0 15-2.5 29.5T650-422ZM480-592 376-696l104-104v208Z"/></svg>

After

Width:  |  Height:  |  Size: 445 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#ffffff"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320Z"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View File

@@ -8,6 +8,7 @@ class SettingItems(str, enum.Enum):
LAST_LIBRARY = "last_library"
LIBS_LIST = "libs_list"
WINDOW_SHOW_LIBS = "window_show_libs"
AUTOPLAY = "autoplay_videos"
class Theme(str, enum.Enum):

View File

@@ -1,6 +1,6 @@
# Resource object code (Python 3)
# Created by: object code
# Created by: The Resource Compiler for Qt version 6.6.3
# Created by: The Resource Compiler for Qt version 6.5.1
# WARNING! All changes made in this file will be lost!
from PySide6 import QtCore
@@ -16232,15 +16232,15 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x96\x00\x00\x00\x00\x00\x01\x00\x03\xca\xbe\
\x00\x00\x01\x8a\xfb\xb4\xd6\xbe\
\x00\x00\x01\x8f\x10b\x06\xcd\
\x00\x00\x00b\x00\x00\x00\x00\x00\x01\x00\x03\xb8\x1a\
\x00\x00\x01\x8a\xfb\xc6t\x9f\
\x00\x00\x01\x8f\x10b\x06\xca\
\x00\x00\x00\xca\x00\x00\x00\x00\x00\x01\x00\x03\xe4\x99\
\x00\x00\x01\x8a\xfb\xb4\xc1\x95\
\x00\x00\x01\x8f\x10b\x06\xc8\
\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x8a\xfb\xc6\x86\xda\
\x00\x00\x01\x8f\x10b\x06\xce\
\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
\x00\x00\x01\x8e\xfd%\xc3\xc7\
\x00\x00\x01\x8f\x10b\x06\xcc\
"
def qInitResources():

View File

@@ -41,6 +41,7 @@ from src.qt.widgets.panel import PanelModal
from src.qt.widgets.text_box_edit import EditTextBox
from src.qt.widgets.text_line_edit import EditTextLine
from src.qt.widgets.item_thumb import ItemThumb
from src.qt.widgets.video_player import VideoPlayer
# Only import for type checking/autocompletion, will not be imported at runtime.
@@ -90,7 +91,8 @@ class PreviewPanel(QWidget):
self.preview_img.addAction(self.open_file_action)
self.preview_img.addAction(self.open_explorer_action)
self.preview_vid = VideoPlayer(driver)
self.preview_vid.hide()
self.thumb_renderer = ThumbRenderer()
self.thumb_renderer.updated.connect(
lambda ts, i, s: (self.preview_img.setIcon(i))
@@ -110,7 +112,9 @@ class PreviewPanel(QWidget):
image_layout.addWidget(self.preview_img)
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
image_layout.addWidget(self.preview_vid)
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
self.image_container.setMinimumSize(*self.img_button_size)
self.file_label = FileOpenerLabel("Filename")
self.file_label.setWordWrap(True)
self.file_label.setTextInteractionFlags(
@@ -378,6 +382,9 @@ class PreviewPanel(QWidget):
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.resizeVideo(adj_size)
self.preview_vid.setMaximumSize(adj_size)
self.preview_vid.setMinimumSize(adj_size)
# self.preview_img.setMinimumSize(adj_size)
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
@@ -460,7 +467,9 @@ class PreviewPanel(QWidget):
pass
for i, c in enumerate(self.containers):
c.setHidden(True)
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
self.selected = list(self.driver.selected)
self.add_field_button.setHidden(True)
@@ -468,6 +477,9 @@ class PreviewPanel(QWidget):
elif len(self.driver.selected) == 1:
# 1 Selected Entry
if self.driver.selected[0][0] == ItemType.ENTRY:
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
# If a new selection is made, update the thumbnail and filepath.
if not self.selected or self.selected != self.driver.selected:
@@ -513,6 +525,18 @@ class PreviewPanel(QWidget):
success, frame = video.read()
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(frame)
if success:
self.preview_img.hide()
self.preview_vid.play(
filepath, QSize(image.width, image.height)
)
self.resizeEvent(
QResizeEvent(
QSize(image.width, image.height),
QSize(image.width, image.height),
)
)
self.preview_vid.show()
# Stats for specific file types are displayed here.
if filepath.suffix.lower() in (
@@ -579,6 +603,9 @@ class PreviewPanel(QWidget):
# Multiple Selected Items
elif len(self.driver.selected) > 1:
self.preview_img.show()
self.preview_vid.stop()
self.preview_vid.hide()
if self.selected != self.driver.selected:
self.file_label.setText(f"{len(self.driver.selected)} Items Selected")
self.file_label.setCursor(Qt.CursorShape.ArrowCursor)

View File

@@ -0,0 +1,373 @@
import logging
import os
import typing
# os.environ["QT_MEDIA_BACKEND"] = "ffmpeg"
from PySide6.QtCore import (
Qt,
QSize,
QTimer,
QVariantAnimation,
QUrl,
QObject,
QEvent,
QRectF,
)
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput, QMediaDevices
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene
from PySide6.QtGui import (
QInputMethodEvent,
QPen,
QColor,
QBrush,
QResizeEvent,
QWheelEvent,
QAction,
QRegion,
QBitmap,
)
from PySide6.QtSvgWidgets import QSvgWidget
from PIL import Image
from src.qt.helpers.file_opener import FileOpenerHelper
from src.core.constants import VIDEO_TYPES, AUDIO_TYPES
from PIL import Image, ImageDraw
from src.core.enums import SettingItems
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
class VideoPlayer(QGraphicsView):
"""A simple video player for the TagStudio application."""
resolution = QSize(1280, 720)
hover_fix_timer = QTimer()
video_preview = None
play_pause = None
mute_button = None
content_visible = False
filepath = None
def __init__(self, driver: "QtDriver") -> None:
# Set up the base class.
super().__init__()
self.driver = driver
self.animation = QVariantAnimation(self)
self.animation.valueChanged.connect(
lambda value: self.setTintTransparency(value)
)
self.hover_fix_timer.timeout.connect(lambda: self.checkIfStillHovered())
self.hover_fix_timer.setSingleShot(True)
# Set up the video player.
self.installEventFilter(self)
self.setScene(QGraphicsScene(self))
self.player = QMediaPlayer(self)
self.player.mediaStatusChanged.connect(
lambda: self.checkMediaStatus(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)
# 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)),
)
# self.video_tint.setParentItem(self.video_preview)
# self.album_art = QGraphicsPixmapItem(self.video_preview)
# self.scene().addItem(self.album_art)
# self.album_art.setPixmap(
# QPixmap("./tagstudio/resources/qt/images/thumb_file_default_512.png")
# )
# self.album_art.setOpacity(0.0)
# Set up the buttons.
self.play_pause = QSvgWidget("./tagstudio/resources/pause.svg")
self.play_pause.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.play_pause.setMouseTracking(True)
self.play_pause.installEventFilter(self)
self.scene().addWidget(self.play_pause)
self.play_pause.resize(100, 100)
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("./tagstudio/resources/volume_muted.svg")
self.mute_button.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.mute_button.setMouseTracking(True)
self.mute_button.installEventFilter(self)
self.scene().addWidget(self.mute_button)
self.mute_button.resize(40, 40)
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.fullscreen_button = QSvgWidget('./tagstudio/resources/pause.svg', self)
# self.fullscreen_button.setMouseTracking(True)
# self.fullscreen_button.installEventFilter(self)
# self.scene().addWidget(self.fullscreen_button)
# self.fullscreen_button.resize(40, 40)
# self.fullscreen_button.move(self.fullscreen_button.size().width()/2, self.height() - self.fullscreen_button.size().height()/2)
# self.fullscreen_button.hide()
self.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
self.opener = FileOpenerHelper(filepath=self.filepath)
autoplay_action = QAction("Autoplay", self)
autoplay_action.setCheckable(True)
self.addAction(autoplay_action)
autoplay_action.setChecked(
self.driver.settings.value(SettingItems.AUTOPLAY, True, bool) # type: ignore
)
autoplay_action.triggered.connect(lambda: self.toggleAutoplay())
self.autoplay = autoplay_action
open_file_action = QAction("Open file", self)
open_file_action.triggered.connect(self.opener.open_file)
open_explorer_action = QAction("Open file in explorer", 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 toggleAutoplay(self) -> None:
self.driver.settings.setValue(SettingItems.AUTOPLAY, self.autoplay.isChecked())
self.driver.settings.sync()
def checkMediaStatus(self, media_status: QMediaPlayer.MediaStatus) -> None:
# logging.info(media_status)
if media_status == QMediaPlayer.MediaStatus.EndOfMedia:
# Switches current video to with video at filepath. Reason for this is because Pyside6 is dumb and 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))
# logging.info(f'Set source to {self.filepath}.')
# self.video_preview.setSize(self.resolution)
self.player.setPosition(0)
# logging.info(f'Set muted to true.')
if self.autoplay.isChecked():
# logging.info(self.driver.settings.value("autoplay_videos", True, bool))
self.player.play()
else:
# logging.info("Paused")
self.player.pause()
self.opener.set_filepath(self.filepath)
self.keepControlsInPlace()
self.updateControls()
def updateControls(self) -> None:
if self.player.audioOutput().isMuted():
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
else:
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
if self.player.isPlaying():
self.play_pause.load("./tagstudio/resources/pause.svg")
else:
self.play_pause.load("./tagstudio/resources/play.svg")
def wheelEvent(self, event: QWheelEvent) -> None:
return
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
# This chunk of code is for the video controls.
if (
obj == self.play_pause
and event.type() == QEvent.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton # type: ignore
):
if self.player.hasVideo():
self.pauseToggle()
if (
obj == self.mute_button
and event.type() == QEvent.Type.MouseButtonPress
and event.button() == Qt.MouseButton.LeftButton # type: ignore
):
if self.player.hasAudio():
self.muteToggle()
if (
obj == self.video_preview
and event.type() == QEvent.Type.GraphicsSceneHoverEnter
or event.type() == QEvent.Type.HoverEnter
):
if self.video_preview.isUnderMouse():
self.underMouse()
self.hover_fix_timer.start(10)
elif (
obj == self.video_preview
and event.type() == QEvent.Type.GraphicsSceneHoverLeave
or event.type() == QEvent.Type.HoverLeave
):
if not self.video_preview.isUnderMouse():
self.hover_fix_timer.stop()
self.releaseMouse()
return super().eventFilter(obj, event)
def checkIfStillHovered(self) -> None:
# Yet again, Pyside6 is dumb. I don't know why, but the HoverLeave event is not triggered sometimes and does not hide the controls.
# So, this is 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 setTintTransparency(self, value) -> None:
self.video_tint.setBrush(QBrush(QColor(0, 0, 0, value)))
def underMouse(self) -> bool:
# logging.info("under mouse")
self.animation.setStartValue(self.video_tint.brush().color().alpha())
self.animation.setEndValue(100)
self.animation.setDuration(500)
self.animation.start()
self.play_pause.show()
self.mute_button.show()
# self.fullscreen_button.show()
self.keepControlsInPlace()
self.updateControls()
# rcontent = self.contentsRect()
# self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
return super().underMouse()
def releaseMouse(self) -> None:
# logging.info("release mouse")
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()
# self.fullscreen_button.hide()
return super().releaseMouse()
def resetControlsToDefault(self) -> None:
# Resets the video controls to their default state.
self.play_pause.load("./tagstudio/resources/pause.svg")
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
def pauseToggle(self) -> None:
if self.player.isPlaying():
self.player.pause()
self.play_pause.load("./tagstudio/resources/play.svg")
else:
self.player.play()
self.play_pause.load("./tagstudio/resources/pause.svg")
def muteToggle(self) -> None:
if self.player.audioOutput().isMuted():
self.player.audioOutput().setMuted(False)
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
else:
self.player.audioOutput().setMuted(True)
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
def play(self, filepath: str, resolution: QSize) -> None:
# Sets the filepath and sends the current player position to the very end, so that the new video can be played.
# self.player.audioOutput().setMuted(True)
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.checkMediaStatus(QMediaPlayer.MediaStatus.EndOfMedia)
# logging.info(f"Successfully stopped.")
def stop(self) -> None:
self.filepath = None
self.player.stop()
def resizeVideo(self, new_size: QSize) -> None:
# Resizes the video preview to the new size.
self.video_preview.setSize(new_size)
self.video_tint.setRect(
0, 0, self.video_preview.size().width(), self.video_preview.size().height()
)
rcontent = self.contentsRect()
self.centerOn(self.video_preview)
self.roundCorners()
self.setSceneRect(0, 0, rcontent.width(), rcontent.height())
self.keepControlsInPlace()
def roundCorners(self) -> None:
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 keepControlsInPlace(self) -> None:
# Keeps the video controls in the places they should be.
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),
)
# self.fullscreen_button.move(-self.fullscreen_button.size().width()-10, self.height() - self.fullscreen_button.size().height()-10)
def resizeEvent(self, event: QResizeEvent) -> None:
# Keeps the video preview in the center of the screen.
self.centerOn(self.video_preview)
self.resizeVideo(
QSize(
int(self.video_preview.size().width()),
int(self.video_preview.size().height()),
)
)
return
# return super().resizeEvent(event)\
class VideoPreview(QGraphicsVideoItem):
def boundingRect(self):
return QRectF(0, 0, self.size().width(), self.size().height())
def paint(self, painter, option, widget):
# 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)