From 65d88b9987e2c295c33d58abf26da30c1acf1795 Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Wed, 12 Jun 2024 23:20:17 -0700 Subject: [PATCH] Refactor `video_player.py` (Fix #270) (#274) * Refactor video_player.py - Move icons files to qt/images folder, some being renamed - Reduce icon loading to single initial import - Tweak icon dimensions and animation timings - Remove unnecessary commented code - Remove unused/duplicate imports - Add license info to file * Add basic ResourceManager, use in video_player.py * Revert tagstudio.spec changes * Change tuple usage to dicts * Move ResourceManager initialization steps * Fix errant list notation --- tagstudio/resources/{ => qt/images}/pause.svg | 0 tagstudio/resources/{ => qt/images}/play.svg | 0 .../images/volume.svg} | 0 .../images/volume_mute.svg} | 0 tagstudio/src/qt/resource_manager.py | 67 +++++++++++ tagstudio/src/qt/resources.json | 18 +++ tagstudio/src/qt/ts_qt.py | 2 + tagstudio/src/qt/widgets/video_player.py | 107 +++++++----------- 8 files changed, 129 insertions(+), 65 deletions(-) rename tagstudio/resources/{ => qt/images}/pause.svg (100%) rename tagstudio/resources/{ => qt/images}/play.svg (100%) rename tagstudio/resources/{volume_unmuted.svg => qt/images/volume.svg} (100%) rename tagstudio/resources/{volume_muted.svg => qt/images/volume_mute.svg} (100%) create mode 100644 tagstudio/src/qt/resource_manager.py create mode 100644 tagstudio/src/qt/resources.json diff --git a/tagstudio/resources/pause.svg b/tagstudio/resources/qt/images/pause.svg similarity index 100% rename from tagstudio/resources/pause.svg rename to tagstudio/resources/qt/images/pause.svg diff --git a/tagstudio/resources/play.svg b/tagstudio/resources/qt/images/play.svg similarity index 100% rename from tagstudio/resources/play.svg rename to tagstudio/resources/qt/images/play.svg diff --git a/tagstudio/resources/volume_unmuted.svg b/tagstudio/resources/qt/images/volume.svg similarity index 100% rename from tagstudio/resources/volume_unmuted.svg rename to tagstudio/resources/qt/images/volume.svg diff --git a/tagstudio/resources/volume_muted.svg b/tagstudio/resources/qt/images/volume_mute.svg similarity index 100% rename from tagstudio/resources/volume_muted.svg rename to tagstudio/resources/qt/images/volume_mute.svg diff --git a/tagstudio/src/qt/resource_manager.py b/tagstudio/src/qt/resource_manager.py new file mode 100644 index 000000000..0db8bb194 --- /dev/null +++ b/tagstudio/src/qt/resource_manager.py @@ -0,0 +1,67 @@ +# Copyright (C) 2024 Travis Abendshien (CyanVoxel). +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + +import logging +from pathlib import Path +from typing import Any + +import ujson + +logging.basicConfig(format="%(message)s", level=logging.INFO) + + +class ResourceManager: + """A resource manager for retrieving resources.""" + + _map: dict = {} + _cache: dict[str, Any] = {} + _initialized: bool = False + + def __init__(self) -> None: + # Load JSON resource map + if not ResourceManager._initialized: + with open( + Path(__file__).parent / "resources.json", mode="r", encoding="utf-8" + ) as f: + ResourceManager._map = ujson.load(f) + logging.info( + f"[ResourceManager] {len(ResourceManager._map.items())} resources registered" + ) + ResourceManager._initialized = True + + def get(self, id: str) -> Any: + """Get a resource from the ResourceManager. + This can include resources inside and outside of QResources, and will return + theme-respecting variations of resources if available. + + Args: + id (str): The name of the resource. + + Returns: + Any: The resource if found, else None. + """ + cached_res = ResourceManager._cache.get(id) + if cached_res: + return cached_res + else: + res: dict = ResourceManager._map.get(id) + if res.get("mode") in ["r", "rb"]: + with open( + (Path(__file__).parents[2] / "resources" / res.get("path")), + res.get("mode"), + ) as f: + data = f.read() + if res.get("mode") == "rb": + data = bytes(data) + ResourceManager._cache[id] = data + return data + elif res.get("mode") in ["qt"]: + # TODO: Qt resource loading logic + pass + + def __getattr__(self, __name: str) -> Any: + attr = self.get(__name) + if attr: + return attr + raise AttributeError(f"Attribute {id} not found") diff --git a/tagstudio/src/qt/resources.json b/tagstudio/src/qt/resources.json new file mode 100644 index 000000000..1f8663d37 --- /dev/null +++ b/tagstudio/src/qt/resources.json @@ -0,0 +1,18 @@ +{ + "play_icon": { + "path": "qt/images/play.svg", + "mode": "rb" + }, + "pause_icon": { + "path": "qt/images/pause.svg", + "mode": "rb" + }, + "volume_icon": { + "path": "qt/images/volume.svg", + "mode": "rb" + }, + "volume_mute_icon": { + "path": "qt/images/volume_mute.svg", + "mode": "rb" + } +} diff --git a/tagstudio/src/qt/ts_qt.py b/tagstudio/src/qt/ts_qt.py index 6da290005..e53f82123 100644 --- a/tagstudio/src/qt/ts_qt.py +++ b/tagstudio/src/qt/ts_qt.py @@ -70,6 +70,7 @@ from src.qt.main_window import Ui_MainWindow from src.qt.helpers.function_iterator import FunctionIterator from src.qt.helpers.custom_runnable import CustomRunnable +from src.qt.resource_manager import ResourceManager from src.qt.widgets.collage_icon import CollageIconRenderer from src.qt.widgets.panel import PanelModal from src.qt.widgets.thumb_renderer import ThumbRenderer @@ -164,6 +165,7 @@ def __init__(self, core: TagStudioCore, args): super().__init__() self.core: TagStudioCore = core self.lib = self.core.lib + self.rm: ResourceManager = ResourceManager() self.args = args self.frame_dict: dict = {} self.nav_frames: list[NavigationState] = [] diff --git a/tagstudio/src/qt/widgets/video_player.py b/tagstudio/src/qt/widgets/video_player.py index 1ff98f880..6bb860993 100644 --- a/tagstudio/src/qt/widgets/video_player.py +++ b/tagstudio/src/qt/widgets/video_player.py @@ -1,8 +1,10 @@ +# Licensed under the GPL-3.0 License. +# Created for TagStudio: https://github.com/CyanVoxel/TagStudio + import logging -import os -import typing -# os.environ["QT_MEDIA_BACKEND"] = "ffmpeg" +from pathlib import Path +import typing from PySide6.QtCore import ( Qt, @@ -18,7 +20,6 @@ from PySide6.QtMultimediaWidgets import QGraphicsVideoItem from PySide6.QtWidgets import QGraphicsView, QGraphicsScene from PySide6.QtGui import ( - QInputMethodEvent, QPen, QColor, QBrush, @@ -29,10 +30,7 @@ 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 @@ -41,26 +39,26 @@ class VideoPlayer(QGraphicsView): - """A simple video player for the TagStudio application.""" + """A basic video player.""" - 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.resolution = QSize(1280, 720) self.animation = QVariantAnimation(self) self.animation.valueChanged.connect( lambda value: self.setTintTransparency(value) ) + self.hover_fix_timer = QTimer() self.hover_fix_timer.timeout.connect(lambda: self.checkIfStillHovered()) 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)) @@ -82,6 +80,7 @@ def __init__(self, driver: "QtDriver") -> None: 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, @@ -91,44 +90,31 @@ def __init__(self, driver: "QtDriver") -> None: 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 = QSvgWidget() 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.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("./tagstudio/resources/volume_muted.svg") + self.mute_button = QSvgWidget() 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.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.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) @@ -157,22 +143,17 @@ def toggleAutoplay(self) -> None: 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. + # 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)) - # 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() @@ -180,14 +161,14 @@ def checkMediaStatus(self, media_status: QMediaPlayer.MediaStatus) -> None: def updateControls(self) -> None: if self.player.audioOutput().isMuted(): - self.mute_button.load("./tagstudio/resources/volume_muted.svg") + self.mute_button.load(self.driver.rm.volume_mute_icon) else: - self.mute_button.load("./tagstudio/resources/volume_unmuted.svg") + self.mute_button.load(self.driver.rm.volume_icon) if self.player.isPlaying(): - self.play_pause.load("./tagstudio/resources/pause.svg") + self.play_pause.load(self.driver.rm.pause_icon) else: - self.play_pause.load("./tagstudio/resources/play.svg") + self.play_pause.load(self.driver.rm.play_icon) def wheelEvent(self, event: QWheelEvent) -> None: return @@ -229,8 +210,10 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool: 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. + # 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: @@ -240,55 +223,51 @@ 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.setDuration(250) 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") + self.play_pause.load(self.driver.rm.pause_icon) + self.mute_button.load(self.driver.rm.volume_mute_icon) def pauseToggle(self) -> None: if self.player.isPlaying(): self.player.pause() - self.play_pause.load("./tagstudio/resources/play.svg") + self.play_pause.load(self.driver.rm.play_icon) else: self.player.play() - self.play_pause.load("./tagstudio/resources/pause.svg") + self.play_pause.load(self.driver.rm.pause_icon) def muteToggle(self) -> None: if self.player.audioOutput().isMuted(): self.player.audioOutput().setMuted(False) - self.mute_button.load("./tagstudio/resources/volume_unmuted.svg") + self.mute_button.load(self.driver.rm.volume_icon) else: self.player.audioOutput().setMuted(True) - self.mute_button.load("./tagstudio/resources/volume_muted.svg") + self.mute_button.load(self.driver.rm.volume_mute_icon) 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) + # Sets the filepath and sends the current player position to the very end, + # so that the new video can be played. logging.info(f"Playing {filepath}") self.resolution = resolution self.filepath = filepath @@ -297,7 +276,6 @@ def play(self, filepath: str, resolution: QSize) -> None: self.player.play() else: self.checkMediaStatus(QMediaPlayer.MediaStatus.EndOfMedia) - # logging.info(f"Successfully stopped.") def stop(self) -> None: self.filepath = None @@ -310,10 +288,10 @@ def resizeVideo(self, new_size: QSize) -> None: 0, 0, self.video_preview.size().width(), self.video_preview.size().height() ) - rcontent = self.contentsRect() + contents = self.contentsRect() self.centerOn(self.video_preview) self.roundCorners() - self.setSceneRect(0, 0, rcontent.width(), rcontent.height()) + self.setSceneRect(0, 0, contents.width(), contents.height()) self.keepControlsInPlace() def roundCorners(self) -> None: @@ -346,7 +324,6 @@ def keepControlsInPlace(self) -> None: 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. @@ -358,7 +335,6 @@ def resizeEvent(self, event: QResizeEvent) -> None: ) ) return - # return super().resizeEvent(event)\ class VideoPreview(QGraphicsVideoItem): @@ -367,7 +343,8 @@ def boundingRect(self): 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 + # 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)