diff --git a/.github/workflows/apprun.yaml b/.github/workflows/apprun.yaml
index a56802af..36608b00 100644
--- a/.github/workflows/apprun.yaml
+++ b/.github/workflows/apprun.yaml
@@ -32,7 +32,8 @@ jobs:
libxcb-render-util0 \
libxcb-xinerama0 \
libopengl0 \
- libxcb-cursor0
+ libxcb-cursor0 \
+ libpulse0
- name: Install dependencies
run: |
diff --git a/tagstudio/resources/pause.svg b/tagstudio/resources/pause.svg
new file mode 100644
index 00000000..f7777470
--- /dev/null
+++ b/tagstudio/resources/pause.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tagstudio/resources/play.svg b/tagstudio/resources/play.svg
new file mode 100644
index 00000000..3d5f6506
--- /dev/null
+++ b/tagstudio/resources/play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tagstudio/resources/volume_muted.svg b/tagstudio/resources/volume_muted.svg
new file mode 100644
index 00000000..5ce8f0a4
--- /dev/null
+++ b/tagstudio/resources/volume_muted.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tagstudio/resources/volume_unmuted.svg b/tagstudio/resources/volume_unmuted.svg
new file mode 100644
index 00000000..d240d7ae
--- /dev/null
+++ b/tagstudio/resources/volume_unmuted.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/tagstudio/src/core/enums.py b/tagstudio/src/core/enums.py
index c406a523..a0c626bf 100644
--- a/tagstudio/src/core/enums.py
+++ b/tagstudio/src/core/enums.py
@@ -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):
diff --git a/tagstudio/src/qt/resources_rc.py b/tagstudio/src/qt/resources_rc.py
index f11bf7fb..518bd2ec 100644
--- a/tagstudio/src/qt/resources_rc.py
+++ b/tagstudio/src/qt/resources_rc.py
@@ -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():
diff --git a/tagstudio/src/qt/widgets/preview_panel.py b/tagstudio/src/qt/widgets/preview_panel.py
index 3d529068..bb475aa5 100644
--- a/tagstudio/src/qt/widgets/preview_panel.py
+++ b/tagstudio/src/qt/widgets/preview_panel.py
@@ -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)
diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py
new file mode 100644
index 00000000..1ff98f88
--- /dev/null
+++ b/tagstudio/src/qt/widgets/video_player.py
@@ -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)