Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
File renamed without changes
66 changes: 66 additions & 0 deletions tagstudio/src/qt/resource_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# 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[str, tuple[str, str]] = {} # dict[<id>, tuple[<filepath>,<mode>]]
_cache: dict[str, Any] = {}

# Initialize _map
with open(
Path(__file__).parent / "resources.json", mode="r", encoding="utf-8"
) as f:
json_map: dict = ujson.load(f)
for item in json_map.items():
_map[item[0]] = (item[1]["path"], item[1]["mode"])

logging.info(f"[ResourceManager] Resources Loaded: {_map}")
Copy link
Copy Markdown
Contributor

@yedpodtrzitko yedpodtrzitko Jun 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • this would be better to do in constructor, otherwise doing import of this class will have "side-effect" (ie. opening and loading from json file), similar way as doing import src.qt.resources_rc has side-effect of prefilling some QT resources

  • when using .items(), you can work with it as two variables (key, value), so you dont have to unpack it via numeric index (item[0] item[1]) later

-for item in json_map.items():
-    _map[item[0]] = (item[1]["path"], item[1]["mode"])
+ for key, value in json_map.items():
+    _map[key] = (value["path"], value["mode"])
  • to make this future-proof, I'd assign just the whole value instead of extracting specific keys in case there will be some other property in future. This will also mitigate a potential issue further on (see my other comment elsewhere)
-_map[key] = (value["path"], value["mode"])
+_map[key] = value

...and at that point you can do just this:

-json_map: dict = ujson.load(f)
-for item in json_map.items():
-   _map[item[0]] = (item[1]["path"], item[1]["mode"])
+_map = ujson.load(f)


def __init__(self) -> None:
pass

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, None)
if cached_res:
return cached_res
else:
path, mode = ResourceManager._map.get(id, None)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this will raise exception in case the resource is not present in ._map, because at that point the value you will get is None, and that cant be assigned into two variables path, mode:

>>> path, mode = None
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: cannot unpack non-iterable NoneType object

storing the data as dictionary inside _map instead of extracted properties would prevent this issue.

btw. the default fallback value of .get() is None, so no need to define that explicitly as the second parameter of .get()

assert dict().get('foo') is None

if mode in ["r", "rb"]:
with open((Path(__file__).parents[2] / "resources" / path), mode) as f:
data = f.read()

if mode == ["rb"]:
data = bytes(data)

ResourceManager._cache[id] = data
return data
elif 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")
18 changes: 18 additions & 0 deletions tagstudio/src/qt/resources.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
109 changes: 44 additions & 65 deletions tagstudio/src/qt/widgets/video_player.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,7 +20,6 @@
from PySide6.QtMultimediaWidgets import QGraphicsVideoItem
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene
from PySide6.QtGui import (
QInputMethodEvent,
QPen,
QColor,
QBrush,
Expand All @@ -29,38 +30,37 @@
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
from src.qt.resource_manager import ResourceManager

if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver


class VideoPlayer(QGraphicsView):
"""A simple video player for the TagStudio application."""
"""A basic video player."""

resolution = QSize(1280, 720)
hover_fix_timer = QTimer()
rm: ResourceManager = ResourceManager()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd initialize this class inside QtDriver instead of here, as this will be needed in other places in the codebase as well, and that way you'll have the same instance available everywhere. Since driver is passed here, then it would be used as self.driver.rm.play_icon instead of VideoPlayer.rm.play_icon

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))
Expand All @@ -82,6 +82,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,
Expand All @@ -91,44 +92,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)
Expand Down Expand Up @@ -157,37 +145,32 @@ 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()
self.updateControls()

def updateControls(self) -> None:
if self.player.audioOutput().isMuted():
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
self.mute_button.load(VideoPlayer.rm.volume_mute_icon)
else:
self.mute_button.load("./tagstudio/resources/volume_unmuted.svg")
self.mute_button.load(VideoPlayer.rm.volume_icon)

if self.player.isPlaying():
self.play_pause.load("./tagstudio/resources/pause.svg")
self.play_pause.load(VideoPlayer.rm.pause_icon)
else:
self.play_pause.load("./tagstudio/resources/play.svg")
self.play_pause.load(VideoPlayer.rm.play_icon)

def wheelEvent(self, event: QWheelEvent) -> None:
return
Expand Down Expand Up @@ -229,8 +212,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:
Expand All @@ -240,55 +225,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(VideoPlayer.rm.pause_icon)
self.mute_button.load(VideoPlayer.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(VideoPlayer.rm.play_icon)
else:
self.player.play()
self.play_pause.load("./tagstudio/resources/pause.svg")
self.play_pause.load(VideoPlayer.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(VideoPlayer.rm.volume_icon)
else:
self.player.audioOutput().setMuted(True)
self.mute_button.load("./tagstudio/resources/volume_muted.svg")
self.mute_button.load(VideoPlayer.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
Expand All @@ -297,7 +278,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
Expand All @@ -310,10 +290,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:
Expand Down Expand Up @@ -346,7 +326,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.
Expand All @@ -358,7 +337,6 @@ def resizeEvent(self, event: QResizeEvent) -> None:
)
)
return
# return super().resizeEvent(event)\


class VideoPreview(QGraphicsVideoItem):
Expand All @@ -367,7 +345,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)