Jelajahi Sumber

refactor: remove subprocess in favor of dbus and wire up to javascript (#606)

* refactor: remove subprocess in favor of dbus and wire up to javascript

* refactor: bring back thumbnail loading

* fix: tests should build now

* fix: album art should be properly loaded into a texture now

* chore: formatting

* fix: build

* fix: get media status on startup and guard listener cleanup

* fix: ensure initialization of thumbnail loads pixels (if available)

* chore: deduplicate texture map parsing

* fix: parse usertextures in effect overrides

* chore: better resolving of pass textures overrides

* chore: codefactor fixes

* chore: last few things that needed adjustements

* chore: do not notify changes in metadata/album if the data is the same

* chore: formatting

* chore: load thumbnail on startup
Alexis Maiquez 3 bulan lalu
induk
melakukan
b016d7d1fd
38 mengubah file dengan 1321 tambahan dan 940 penghapusan
  1. 14 1
      CMakeLists.txt
  2. 59 0
      CMakeModules/FindDBus.cmake
  3. 15 1
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  4. 7 0
      src/WallpaperEngine/Application/WallpaperApplication.h
  5. 1 0
      src/WallpaperEngine/Data/Model/Object.h
  6. 3 27
      src/WallpaperEngine/Data/Parsers/MaterialParser.cpp
  7. 0 1
      src/WallpaperEngine/Data/Parsers/MaterialParser.h
  8. 11 32
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  9. 0 1
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  10. 31 0
      src/WallpaperEngine/Data/Parsers/TextureParser.cpp
  11. 4 0
      src/WallpaperEngine/Data/Parsers/TextureParser.h
  12. 7 3
      src/WallpaperEngine/FileSystem/Adapters/Directory.cpp
  13. 64 0
      src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp
  14. 30 0
      src/WallpaperEngine/FileSystem/Adapters/MediaCover.h
  15. 9 5
      src/WallpaperEngine/FileSystem/Adapters/Package.cpp
  16. 9 1
      src/WallpaperEngine/FileSystem/Container.cpp
  17. 5 0
      src/WallpaperEngine/FileSystem/Container.h
  18. 370 0
      src/WallpaperEngine/Media/DBusMediaSource.cpp
  19. 32 0
      src/WallpaperEngine/Media/DBusMediaSource.h
  20. 80 0
      src/WallpaperEngine/Media/MediaSource.cpp
  21. 62 0
      src/WallpaperEngine/Media/MediaSource.h
  22. 136 0
      src/WallpaperEngine/Render/AlbumTexture.cpp
  23. 43 0
      src/WallpaperEngine/Render/AlbumTexture.h
  24. 2 0
      src/WallpaperEngine/Render/CFBO.cpp
  25. 1 0
      src/WallpaperEngine/Render/CFBO.h
  26. 4 1
      src/WallpaperEngine/Render/CTexture.cpp
  27. 1 0
      src/WallpaperEngine/Render/CTexture.h
  28. 2 19
      src/WallpaperEngine/Render/CWallpaper.h
  29. 143 43
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  30. 6 1
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  31. 7 2
      src/WallpaperEngine/Render/RenderContext.cpp
  32. 8 2
      src/WallpaperEngine/Render/RenderContext.h
  33. 31 377
      src/WallpaperEngine/Render/TextureCache.cpp
  34. 9 0
      src/WallpaperEngine/Render/TextureCache.h
  35. 4 0
      src/WallpaperEngine/Render/TextureProvider.h
  36. 1 1
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  37. 100 396
      src/WallpaperEngine/Scripting/ScriptEngine.cpp
  38. 10 26
      src/WallpaperEngine/Scripting/ScriptEngine.h

+ 14 - 1
CMakeLists.txt

@@ -28,6 +28,7 @@ find_package(X11)
 find_package(OpenGL REQUIRED)
 find_package(GLEW REQUIRED)
 
+find_package(DBus REQUIRED)
 find_package(GLUT REQUIRED)
 find_package(ZLIB REQUIRED)
 find_package(SDL2 REQUIRED)
@@ -290,7 +291,8 @@ include_directories(
     ${PULSEAUDIO_INCLUDE_DIR}
     ${CEF_INCLUDE_PATH}
     ${CMAKE_SOURCE_DIR}
-    ${X11_INCLUDES})
+    ${X11_INCLUDES}
+    ${DBUS_INCLUDE_DIRS})
 
 set(COMMON_SOURCES
     src/External/MimeTypes/MimeTypes.cpp
@@ -324,6 +326,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/FileSystem/Adapters/Package.h
     src/WallpaperEngine/FileSystem/Adapters/Virtual.cpp
     src/WallpaperEngine/FileSystem/Adapters/Virtual.h
+    src/WallpaperEngine/FileSystem/Adapters/MediaCover.h
+    src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp
 
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.cpp
     src/WallpaperEngine/Audio/Drivers/Recorders/PulseAudioPlaybackRecorder.h
@@ -350,6 +354,11 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Input/Drivers/GLFWMouseInput.cpp
     src/WallpaperEngine/Input/Drivers/GLFWMouseInput.h
 
+    src/WallpaperEngine/Media/MediaSource.h
+    src/WallpaperEngine/Media/MediaSource.cpp
+    src/WallpaperEngine/Media/DBusMediaSource.h
+    src/WallpaperEngine/Media/DBusMediaSource.cpp
+
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.h
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariable.cpp
     src/WallpaperEngine/Render/Shaders/Variables/ShaderVariableFloat.h
@@ -417,6 +426,8 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/CObject.cpp
     src/WallpaperEngine/Render/CTexture.cpp
     src/WallpaperEngine/Render/CTexture.h
+    src/WallpaperEngine/Render/AlbumTexture.h
+    src/WallpaperEngine/Render/AlbumTexture.cpp
     src/WallpaperEngine/Render/TextureProvider.h
 
     src/WallpaperEngine/Render/Objects/CRenderable.cpp
@@ -577,6 +588,7 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     Freetype::Freetype
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
+    ${DBUS_LIBRARIES}
     kissfft
     glslang
     spirv-cross-core
@@ -602,6 +614,7 @@ if (BUILD_TESTING)
         Freetype::Freetype
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
+        ${DBUS_LIBRARIES}
         kissfft
         glslang
         spirv-cross-core

+ 59 - 0
CMakeModules/FindDBus.cmake

@@ -0,0 +1,59 @@
+# - Try to find DBus
+# Once done, this will define
+#
+#  DBUS_FOUND - system has DBus
+#  DBUS_INCLUDE_DIRS - the DBus include directories
+#  DBUS_LIBRARIES - link these to use DBus
+#
+# Copyright (C) 2012 Raphael Kubo da Costa <rakuco@webkit.org>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1.  Redistributions of source code must retain the above copyright
+#     notice, this list of conditions and the following disclaimer.
+# 2.  Redistributions in binary form must reproduce the above copyright
+#     notice, this list of conditions and the following disclaimer in the
+#     documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER AND ITS CONTRIBUTORS ``AS
+# IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR ITS
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS;
+# OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+# WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR
+# OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+FIND_PACKAGE(PkgConfig)
+PKG_CHECK_MODULES(PC_DBUS QUIET dbus-1)
+
+FIND_LIBRARY(DBUS_LIBRARIES
+    NAMES dbus-1
+    HINTS ${PC_DBUS_LIBDIR}
+          ${PC_DBUS_LIBRARY_DIRS}
+)
+
+FIND_PATH(DBUS_INCLUDE_DIR
+    NAMES dbus/dbus.h
+    HINTS ${PC_DBUS_INCLUDEDIR}
+          ${PC_DBUS_INCLUDE_DIRS}
+)
+
+GET_FILENAME_COMPONENT(_DBUS_LIBRARY_DIR ${DBUS_LIBRARIES} PATH)
+FIND_PATH(DBUS_ARCH_INCLUDE_DIR
+    NAMES dbus/dbus-arch-deps.h
+    HINTS ${PC_DBUS_INCLUDEDIR}
+          ${PC_DBUS_INCLUDE_DIRS}
+          ${_DBUS_LIBRARY_DIR}
+          ${DBUS_INCLUDE_DIR}
+    PATH_SUFFIXES include
+)
+
+SET(DBUS_INCLUDE_DIRS ${DBUS_INCLUDE_DIR} ${DBUS_ARCH_INCLUDE_DIR})
+
+INCLUDE(FindPackageHandleStandardArgs)
+FIND_PACKAGE_HANDLE_STANDARD_ARGS(DBUS REQUIRED_VARS DBUS_INCLUDE_DIRS DBUS_LIBRARIES)

+ 15 - 1
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -15,6 +15,8 @@
 #include "WallpaperEngine/Data/Model/Property.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Debugging/CallStack.h"
+#include "WallpaperEngine/FileSystem/Adapters/MediaCover.h"
+#include "WallpaperEngine/Media/DBusMediaSource.h"
 
 #if DEMOMODE
 #include "recording.h"
@@ -61,18 +63,27 @@ void CustomGLDebugCallback (
 }
 
 WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_context (context) {
+    this->initializeSubsystems ();
     this->loadBackgrounds ();
     this->setupProperties ();
     this->setupBrowser ();
     this->initializePlaylists ();
 }
 
+void WallpaperApplication::initializeSubsystems () {
+    // initialize player dbus (update every 2 seconds)
+    m_mediaSource = std::make_unique<WallpaperEngine::Media::DBusMediaSource> (std::chrono::milliseconds (2000));
+}
+
 AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string& bg) const {
     auto container = std::make_unique<Container> ();
 
     const std::filesystem::path path = bg;
 
+    container->registerAdapterFactory (std::make_unique<MediaCoverFactory> (*this->m_mediaSource));
+    container->mount ("$mediaThumbnail", "$mediaThumbnail");
     container->mount (path, "/");
+
     try {
 	container->mount (path / "scene.pkg", "/");
     } catch (std::runtime_error&) { }
@@ -710,7 +721,8 @@ void WallpaperApplication::setupAudio () {
 
 void WallpaperApplication::prepareOutputs () {
     // initialize render context
-    m_renderContext = std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this);
+    m_renderContext
+	= std::make_unique<WallpaperEngine::Render::RenderContext> (*m_videoDriver, *this, *this->m_mediaSource);
     // create a new background for each screen
 
     // set all the specific wallpapers required (skip span group synthetic keys)
@@ -875,6 +887,8 @@ void WallpaperApplication::render () {
 	g_Time = m_videoDriver->getRenderTime ();
 	// update audio recorder
 	m_audioDriver->update ();
+	// update the media source
+	m_mediaSource->update ();
 	// update input information
 	m_videoDriver->getInputContext ().update ();
 	// process driver events

+ 7 - 0
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -18,6 +18,7 @@
 #include "WallpaperEngine/WebBrowser/WebBrowserContext.h"
 
 #include "WallpaperEngine/Data/Model/Types.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 
 #include <set>
 
@@ -87,6 +88,11 @@ private:
      * @param bg
      */
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg) const;
+    /**
+     * Initializes subsystems required for application operation
+     */
+    void initializeSubsystems ();
+
     /**
      * Loads projects based off the settings
      */
@@ -169,6 +175,7 @@ private:
     std::unique_ptr<WallpaperEngine::Render::Drivers::VideoDriver> m_videoDriver = nullptr;
     std::unique_ptr<WallpaperEngine::Render::Drivers::Detectors::FullScreenDetector> m_fullScreenDetector = nullptr;
     std::unique_ptr<WallpaperEngine::WebBrowser::WebBrowserContext> m_browserContext = nullptr;
+    std::unique_ptr<WallpaperEngine::Media::MediaSource> m_mediaSource = nullptr;
     std::mt19937 m_playlistRng { std::random_device {}() };
     bool m_isPaused = false;
     bool m_screenShotTaken = false;

+ 1 - 0
src/WallpaperEngine/Data/Model/Object.h

@@ -59,6 +59,7 @@ struct ImageEffectPassOverride {
     ComboMap combos;
     ShaderConstantMap constants;
     TextureMap textures;
+    TextureMap usertextures;
     std::optional<std::string> shaderOverride; // Overrides MaterialPass::shader when set
 };
 

+ 3 - 27
src/WallpaperEngine/Data/Parsers/MaterialParser.cpp

@@ -1,5 +1,6 @@
 #include "MaterialParser.h"
 
+#include "TextureParser.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Parsers/ShaderConstantParser.h"
@@ -48,38 +49,13 @@ MaterialPassUniquePtr MaterialParser::parsePass (const JSON& it, const Project&
 	.depthtest = parseDepthtestMode (it.optional ("depthtest", std::string ("disabled"))),
 	.depthwrite = parseDepthwriteMode (it.optional ("depthwrite", std::string ("disabled"))),
 	.shader = it.require<std::string> ("shader", "Material pass must have a shader"),
-	.textures = textures.has_value () ? parseTextures (*textures) : TextureMap {},
-	.usertextures = usertextures.has_value () ? parseTextures (*usertextures) : TextureMap {},
+	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : TextureMap {},
+	.usertextures = usertextures.has_value () ? TextureParser::parseTextureMap (*usertextures) : TextureMap {},
 	.combos = combos.has_value () ? parseCombos (*combos) : ComboMap {},
 	.constants = constants.has_value () ? ShaderConstantParser::parse (*constants, project) : ShaderConstantMap {},
     });
 }
 
-std::map<int, std::string> MaterialParser::parseTextures (const JSON& it) {
-    std::map<int, std::string> result = {};
-
-    if (!it.is_array ()) {
-	return result;
-    }
-
-    int index = 0;
-
-    for (const auto& cur : it) {
-	if (!cur.is_null ()) {
-	    if (!cur.is_string ()) {
-		sLog.error ("Detected a non-string texture, most likely a special value: ", cur.dump ());
-		result.emplace (index, "");
-	    } else {
-		result.emplace (index, cur);
-	    }
-	}
-
-	index++;
-    }
-
-    return result;
-}
-
 std::map<std::string, int> MaterialParser::parseCombos (const JSON& it) {
     std::map<std::string, int> result = {};
 

+ 0 - 1
src/WallpaperEngine/Data/Parsers/MaterialParser.h

@@ -14,7 +14,6 @@ public:
     static MaterialUniquePtr parse (const JSON& it, const std::string& filename, const Project& project);
     static std::vector<MaterialPassUniquePtr> parsePasses (const JSON& it, const Project& project);
     static MaterialPassUniquePtr parsePass (const JSON& it, const Project& project);
-    static std::map<int, std::string> parseTextures (const JSON& it);
     static std::map<std::string, int> parseCombos (const JSON& it);
     static BlendingMode parseBlendMode (const std::string& mode);
     static CullingMode parseCullMode (const std::string& mode);

+ 11 - 32
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -4,6 +4,7 @@
 #include "ModelParser.h"
 
 #include "ShaderConstantParser.h"
+#include "TextureParser.h"
 #include "UserSettingParser.h"
 #include "WallpaperEngine/Data/Builders/ColorBuilder.h"
 #include "WallpaperEngine/Data/Model/Object.h"
@@ -167,16 +168,20 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
     );
 
     const auto instance = it.optional ("instance");
+
     if (instance.has_value () && instance->is_object () && !result->model->material->passes.empty ()) {
 	auto& firstPass = **result->model->material->passes.begin ();
 	const auto instanceTextures = instance->optional ("textures");
+
 	if (instanceTextures.has_value ()) {
-	    const auto parsed = parseTextureMap (*instanceTextures);
+	    const auto parsed = TextureParser::parseTextureMap (*instanceTextures);
 	    firstPass.textures.insert (parsed.begin (), parsed.end ());
 	}
+
 	const auto instanceUserTextures = instance->optional ("usertextures");
+
 	if (instanceUserTextures.has_value ()) {
-	    const auto parsed = parseTextureMap (*instanceUserTextures);
+	    const auto parsed = TextureParser::parseTextureMap (*instanceUserTextures);
 	    firstPass.usertextures.insert (parsed.begin (), parsed.end ());
 	}
     }
@@ -228,6 +233,7 @@ ImageEffectPassOverrideUniquePtr ObjectParser::parseEffectPass (const JSON& it,
     const auto& combos = it.optional ("combos");
     const auto& textures = it.optional ("textures");
     const auto& constants = it.optional ("constantshadervalues");
+    const auto& usertextures = it.optional ("usertextures");
 
     // TODO: PARSE CONSTANT SHADER VALUES AND FIND REFS?
     return std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
@@ -235,39 +241,12 @@ ImageEffectPassOverrideUniquePtr ObjectParser::parseEffectPass (const JSON& it,
 	.combos = combos.has_value () ? parseComboMap (combos.value ()) : ComboMap {},
 	.constants
 	= constants.has_value () ? ShaderConstantParser::parse (constants.value (), project) : ShaderConstantMap {},
-	.textures = textures.has_value () ? parseTextureMap (textures.value ()) : TextureMap {},
+	.textures = textures.has_value () ? TextureParser::parseTextureMap (textures.value ()) : TextureMap {},
+	.usertextures
+	= usertextures.has_value () ? TextureParser::parseTextureMap (usertextures.value ()) : TextureMap {},
     });
 }
 
-TextureMap ObjectParser::parseTextureMap (const JSON& it) {
-    if (!it.is_array ()) {
-	return {};
-    }
-
-    TextureMap result = {};
-    int textureIndex = -1;
-
-    for (const auto& cur : it) {
-	textureIndex++;
-
-	if (cur.is_null ()) {
-	    continue;
-	} else if (cur.is_object ()) {
-	    const auto nameIt = cur.find ("name");
-	    if (nameIt != cur.end () && nameIt->is_string ()) {
-		result.emplace (textureIndex, nameIt->get<std::string> ());
-	    }
-	} else {
-	    std::string texName = cur;
-	    if (!texName.empty ()) {
-		result.emplace (textureIndex, texName);
-	    }
-	}
-    }
-
-    return result;
-}
-
 ComboMap ObjectParser::parseComboMap (const JSON& it) {
     if (!it.is_object ()) {
 	return {};

+ 0 - 1
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -29,7 +29,6 @@ private:
     static std::vector<ImageEffectPassOverrideUniquePtr>
     parseEffectPassOverrides (const JSON& it, const Project& project);
     static ImageEffectPassOverrideUniquePtr parseEffectPass (const JSON& it, const Project& project);
-    static TextureMap parseTextureMap (const JSON& it);
     static ComboMap parseComboMap (const JSON& it);
     static std::vector<ImageAnimationLayerUniquePtr> parseAnimationLayers (const JSON& it, const Project& project);
     static ImageAnimationLayerUniquePtr parseAnimationLayer (const JSON& it, const Project& project);

+ 31 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -122,6 +122,37 @@ FrameSharedPtr TextureParser::parseFrame (const BinaryReader& file) {
     return result;
 }
 
+TextureMap TextureParser::parseTextureMap (const JSON& it) {
+    if (!it.is_array ()) {
+	return {};
+    }
+
+    TextureMap result = {};
+    int textureIndex = -1;
+
+    for (const auto& cur : it) {
+	textureIndex++;
+
+	if (cur.is_null ()) {
+	    continue;
+	}
+
+	if (cur.is_object ()) {
+	    const auto nameIt = cur.find ("name");
+	    if (nameIt != cur.end () && nameIt->is_string ()) {
+		result.emplace (textureIndex, nameIt->get<std::string> ());
+	    }
+	} else if (cur.is_string ()) {
+	    std::string texName = cur;
+	    if (!texName.empty ()) {
+		result.emplace (textureIndex, texName);
+	    }
+	}
+    }
+
+    return result;
+}
+
 TextureFormat TextureParser::parseTextureFormat (uint32_t value) {
     switch (value) {
 	case TextureFormat_UNKNOWN:

+ 4 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.h

@@ -5,12 +5,15 @@
 #include <functional>
 #include <iostream>
 
+#include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Data/Utils/BinaryReader.h"
 
 namespace WallpaperEngine::Data::Parsers {
+using JSON = WallpaperEngine::Data::JSON::JSON;
 using namespace WallpaperEngine::Data::Utils;
 using namespace WallpaperEngine::Data::Assets;
+using namespace WallpaperEngine::Data::Model;
 
 class TextureParser {
 public:
@@ -22,6 +25,7 @@ public:
     static MipmapSharedPtr parseMipmap (const BinaryReader& file, const Texture& header);
     static FrameSharedPtr parseFrame (const BinaryReader& file);
     static FrameSharedPtr parseFrameV1 (const BinaryReader& file);
+    static TextureMap parseTextureMap (const JSON& it);
 
 private:
     static void parseTextureHeader (Texture& header, const BinaryReader& file);

+ 7 - 3
src/WallpaperEngine/FileSystem/Adapters/Directory.cpp

@@ -64,10 +64,14 @@ std::filesystem::path DirectoryAdapter::physicalPath (const std::filesystem::pat
 }
 
 bool DirectoryFactory::handlesMountpoint (const std::filesystem::path& path) const {
-    const auto finalpath = std::filesystem::canonical (path);
-    const auto status = std::filesystem::status (finalpath);
+    try {
+	const auto finalpath = std::filesystem::canonical (path);
+	const auto status = std::filesystem::status (finalpath);
 
-    return std::filesystem::exists (finalpath) && std::filesystem::is_directory (status);
+	return std::filesystem::exists (finalpath) && std::filesystem::is_directory (status);
+    } catch (std::filesystem::filesystem_error&) {
+	return false;
+    }
 }
 
 AdapterSharedPtr DirectoryFactory::create (const std::filesystem::path& path) const {

+ 64 - 0
src/WallpaperEngine/FileSystem/Adapters/MediaCover.cpp

@@ -0,0 +1,64 @@
+#include <filesystem>
+#include <fstream>
+#include <memory>
+
+#include "MediaCover.h"
+
+#include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+
+using namespace WallpaperEngine::FileSystem;
+using namespace WallpaperEngine::FileSystem::Adapters;
+
+ReadStreamSharedPtr MediaCoverAdapter::open (const std::filesystem::path& path) const {
+    if (path != "$mediaThumbnail") {
+	throw std::filesystem::filesystem_error (
+	    "MediaCoverAdapter only supports $mediaThumbnail", path, std::error_code ()
+	);
+    }
+
+    if (!source.getMediaInfo ().url.has_value ()) {
+	throw std::filesystem::filesystem_error ("Media source does not have a valid URL", path, std::error_code ());
+    }
+
+    std::string album = *source.getMediaInfo ().url;
+
+    if (album.starts_with ("file://")) {
+	album = album.substr (7);
+    } else {
+	throw std::filesystem::filesystem_error (
+	    "Only file:// URLs are supported for media covers", album, std::error_code ()
+	);
+    }
+
+    std::filesystem::path file = std::filesystem::absolute (album);
+
+    if (std::filesystem::exists (file) == false) {
+	throw std::filesystem::filesystem_error ("Media file does not exist", file, std::error_code ());
+    }
+
+    if (std::filesystem::is_regular_file (file) == false) {
+	throw std::filesystem::filesystem_error ("Media file is not a regular file", file, std::error_code ());
+    }
+
+    return std::make_shared<std::ifstream> (file);
+}
+
+bool MediaCoverAdapter::exists (const std::filesystem::path& path) const { return path == ""; }
+
+std::filesystem::path MediaCoverAdapter::physicalPath (const std::filesystem::path& path) const {
+    sLog.exception ("MediaCoverAdapter does not support realpath");
+}
+
+bool MediaCoverFactory::handlesMountpoint (const std::filesystem::path& path) const {
+    return path == "$mediaThumbnail";
+}
+
+AdapterSharedPtr MediaCoverFactory::create (const std::filesystem::path& path) const {
+    if (path != "$mediaThumbnail") {
+	sLog.exception ("MediaCoveradapter only supports $mediaThumbnail");
+    }
+
+    return std::make_unique<MediaCoverAdapter> (source);
+}

+ 30 - 0
src/WallpaperEngine/FileSystem/Adapters/MediaCover.h

@@ -0,0 +1,30 @@
+#pragma once
+
+#include <filesystem>
+#include <utility>
+
+#include "Types.h"
+
+namespace WallpaperEngine::Media {
+class MediaSource;
+}
+namespace WallpaperEngine::FileSystem::Adapters {
+struct MediaCoverFactory final : Factory {
+    MediaCoverFactory (Media::MediaSource& source) : source (source) { }
+
+    [[nodiscard]] bool handlesMountpoint (const std::filesystem::path& path) const override;
+    [[nodiscard]] AdapterSharedPtr create (const std::filesystem::path& path) const override;
+
+    Media::MediaSource& source;
+};
+
+struct MediaCoverAdapter final : Adapter {
+    explicit MediaCoverAdapter (Media::MediaSource& source) : source (source) { }
+
+    [[nodiscard]] ReadStreamSharedPtr open (const std::filesystem::path& path) const override;
+    [[nodiscard]] bool exists (const std::filesystem::path& path) const override;
+    [[nodiscard]] std::filesystem::path physicalPath (const std::filesystem::path& path) const override;
+
+    Media::MediaSource& source;
+};
+}

+ 9 - 5
src/WallpaperEngine/FileSystem/Adapters/Package.cpp

@@ -49,11 +49,15 @@ std::filesystem::path PackageAdapter::physicalPath (const std::filesystem::path&
 }
 
 bool PackageFactory::handlesMountpoint (const std::filesystem::path& path) const {
-    const auto finalpath = std::filesystem::canonical (path);
-    const auto status = std::filesystem::status (finalpath);
-
-    return std::filesystem::exists (finalpath) && std::filesystem::is_regular_file (status)
-	&& finalpath.extension () == ".pkg";
+    try {
+	const auto finalpath = std::filesystem::canonical (path);
+	const auto status = std::filesystem::status (finalpath);
+
+	return std::filesystem::exists (finalpath) && std::filesystem::is_regular_file (status)
+	    && finalpath.extension () == ".pkg";
+    } catch (std::filesystem::filesystem_error&) {
+	return false;
+    }
 }
 
 AdapterSharedPtr PackageFactory::create (const std::filesystem::path& path) const {

+ 9 - 1
src/WallpaperEngine/FileSystem/Container.cpp

@@ -36,7 +36,13 @@ Container::Container () {
 ReadStreamSharedPtr Container::read (const std::filesystem::path& path) const {
     const auto normalized = normalize_path (path);
 
-    return this->resolveAdapterForFile (path).open (normalized);
+    ReadStreamSharedPtr result = this->resolveAdapterForFile (path).open (normalized);
+
+    if (result->fail ()) {
+	throw std::runtime_error ("Failed to open file: " + normalized.string ());
+    }
+
+    return result;
 }
 
 std::string Container::readString (const std::filesystem::path& path) const {
@@ -94,3 +100,5 @@ Adapter& Container::resolveAdapterForFile (const std::filesystem::path& path) co
 	"Cannot find requested file in any of the mountpoints", path, std::error_code ()
     );
 }
+
+void Container::registerAdapterFactory (FactoryUniquePtr factory) { this->m_factories.push_back (std::move (factory)); }

+ 5 - 0
src/WallpaperEngine/FileSystem/Container.h

@@ -52,6 +52,11 @@ public:
      */
     VirtualAdapter& getVFS () const;
 
+    /**
+     * @param factory The factory to register for this container
+     */
+    void registerAdapterFactory (FactoryUniquePtr factory);
+
 private:
     /**
      * Searches for an adapter to handle the given file

+ 370 - 0
src/WallpaperEngine/Media/DBusMediaSource.cpp

@@ -0,0 +1,370 @@
+#include "DBusMediaSource.h"
+
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Logging/Log.h"
+
+using namespace WallpaperEngine::Media;
+
+DBusHandlerResult dbus_message_filter (DBusConnection* connection, DBusMessage* message, void* user_data) {
+    const auto mediaSource = static_cast<DBusMediaSource*> (user_data);
+
+    if (!dbus_message_is_signal (message, "org.freedesktop.DBus.Properties", "PropertiesChanged")) {
+	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+    }
+
+    const char* interface = nullptr;
+    DBusMessageIter iter;
+
+    dbus_message_iter_init (message, &iter);
+    dbus_message_iter_get_basic (&iter, &interface);
+
+    std::string iface = interface ?: "";
+
+    if (iface != "org.mpris.MediaPlayer2.Player") {
+	return DBUS_HANDLER_RESULT_NOT_YET_HANDLED;
+    }
+
+    DBusMessageIter changed;
+
+    dbus_message_iter_next (&iter);
+    dbus_message_iter_recurse (&iter, &changed);
+
+    while (dbus_message_iter_get_arg_type (&changed) == DBUS_TYPE_DICT_ENTRY) {
+	DBusMessageIter entry;
+	dbus_message_iter_recurse (&changed, &entry);
+
+	const char* key = nullptr;
+	dbus_message_iter_get_basic (&entry, &key);
+
+	std::string keyStr = key ?: "";
+
+	DBusMessageIter value;
+	dbus_message_iter_next (&entry);
+	dbus_message_iter_recurse (&entry, &value);
+
+	if (keyStr == "Metadata") {
+	    mediaSource->parseMetadata (value);
+	} else if (keyStr == "PlaybackStatus") {
+	    mediaSource->parsePlaybackStatus (value, dbus_message_get_sender (message));
+	}
+
+	dbus_message_iter_next (&changed);
+    }
+
+    return DBUS_HANDLER_RESULT_HANDLED;
+}
+
+DBusMediaSource::DBusMediaSource (std::chrono::milliseconds updateInterval) : MediaSource (updateInterval) {
+    DBusError err;
+
+    dbus_error_init (&err);
+
+    this->m_connection = dbus_bus_get (DBUS_BUS_SESSION, &err);
+
+    if (!this->m_connection) {
+	sLog.exception ("Could not connect to DBus: ", err.message);
+    }
+
+    dbus_connection_add_filter (this->m_connection, dbus_message_filter, this, nullptr);
+
+    dbus_bus_add_match (
+	this->m_connection, "type='signal',interface='org.freedesktop.DBus.Properties',member='PropertiesChanged'",
+	nullptr
+    );
+
+    dbus_connection_flush (this->m_connection);
+
+    this->detectPlayer ();
+    this->initialStatusFetch ();
+}
+
+DBusMediaSource::~DBusMediaSource () {
+    dbus_connection_remove_filter (this->m_connection, dbus_message_filter, this);
+
+    dbus_connection_unref (this->m_connection);
+}
+
+void DBusMediaSource::parseMetadata (DBusMessageIter& variant) {
+    DBusMessageIter dict;
+    dbus_message_iter_recurse (&variant, &dict);
+
+    bool metadataUpdate = false;
+    bool albumUpdate = false;
+
+    while (dbus_message_iter_get_arg_type (&dict) == DBUS_TYPE_DICT_ENTRY) {
+	DBusMessageIter entry;
+	dbus_message_iter_recurse (&dict, &entry);
+
+	const char* key = nullptr;
+	dbus_message_iter_get_basic (&entry, &key);
+
+	std::string keyStr = key ?: "";
+
+	DBusMessageIter value;
+	dbus_message_iter_next (&entry);
+	dbus_message_iter_recurse (&entry, &value);
+
+	if (keyStr == "xesam:title") {
+	    const char* title = nullptr;
+	    dbus_message_iter_get_basic (&value, &title);
+
+	    std::string titleStr = title ?: "";
+
+	    if (this->m_mediaInfo.title != titleStr) {
+		this->m_mediaInfo.title = titleStr;
+		metadataUpdate = true;
+	    }
+	} else if (keyStr == "xesam:artist") {
+	    DBusMessageIter arr;
+
+	    dbus_message_iter_recurse (&value, &arr);
+
+	    if (dbus_message_iter_get_arg_type (&arr) == DBUS_TYPE_STRING) {
+		const char* artist = nullptr;
+		dbus_message_iter_get_basic (&arr, &artist);
+
+		std::string artistStr = artist ?: "";
+
+		if (this->m_mediaInfo.artist != artistStr) {
+		    this->m_mediaInfo.artist = artistStr;
+		    metadataUpdate = true;
+		}
+	    }
+	} else if (keyStr == "xesam:album") {
+	    const char* album = nullptr;
+	    dbus_message_iter_get_basic (&value, &album);
+
+	    std::string albumStr = album ?: "";
+
+	    if (this->m_mediaInfo.album != albumStr) {
+		this->m_mediaInfo.album = albumStr;
+		albumUpdate = true;
+	    }
+	} else if (keyStr == "mpris:artUrl") {
+	    const char* artUrl = nullptr;
+	    dbus_message_iter_get_basic (&value, &artUrl);
+
+	    std::string artUrlStr = artUrl ?: "";
+
+	    if (artUrlStr.empty () && this->m_mediaInfo.url.has_value ()) {
+		this->m_mediaInfo.url.reset ();
+		albumUpdate = true;
+	    } else if (this->m_mediaInfo.url.has_value () && artUrlStr != *this->m_mediaInfo.url) {
+		this->m_mediaInfo.url = artUrlStr;
+		albumUpdate = true;
+	    } else if (!this->m_mediaInfo.url.has_value ()) {
+		this->m_mediaInfo.url = artUrlStr;
+		albumUpdate = true;
+	    }
+	} else if (keyStr == "mpris:length") {
+	    int64_t length = 0;
+	    dbus_message_iter_get_basic (&value, &length);
+
+	    if (this->m_mediaInfo.duration != length) {
+		this->m_mediaInfo.duration = length;
+		metadataUpdate = true;
+	    }
+	}
+
+	dbus_message_iter_next (&dict);
+    }
+
+    sLog.debug (
+	"Player metadata received: title=", this->m_mediaInfo.title, ",artist=", this->m_mediaInfo.artist,
+	",album=", this->m_mediaInfo.album, ",url=", this->m_mediaInfo.url.has_value () ? *this->m_mediaInfo.url : ""
+    );
+
+    if (metadataUpdate) {
+	this->fireMetadataListeners ();
+    }
+
+    if (albumUpdate) {
+	this->fireAlbumArtListeners ();
+    }
+}
+
+void DBusMediaSource::parsePlaybackStatus (DBusMessageIter& variant, const char* sender) {
+    const char* status = nullptr;
+    dbus_message_iter_get_basic (&variant, &status);
+    std::string statusStr = status ?: "";
+    PlaybackState newState = this->m_mediaInfo.playbackState;
+
+    if (statusStr == "Playing") {
+	if (sender != nullptr) {
+	    this->m_currentPlayer = sender;
+	}
+
+	newState = PlaybackState::Playing;
+    } else if (statusStr == "Paused") {
+	newState = PlaybackState::Paused;
+    } else {
+	newState = PlaybackState::Stopped;
+    }
+
+    if (newState != this->m_mediaInfo.playbackState) {
+	this->m_mediaInfo.playbackState = newState;
+	this->fireMetadataListeners ();
+    }
+}
+
+void DBusMediaSource::parsePosition (DBusMessageIter& variant) {
+    int64_t position = 0;
+    dbus_message_iter_get_basic (&variant, &position);
+
+    if (this->m_mediaInfo.position != position) {
+	this->m_mediaInfo.position = position;
+	this->fireMetadataListeners ();
+    }
+}
+
+void DBusMediaSource::update () {
+    this->MediaSource::update ();
+
+    // drain any dbus events
+    dbus_connection_read_write (this->m_connection, 0);
+
+    while (dbus_connection_dispatch (this->m_connection) == DBUS_DISPATCH_DATA_REMAINS)
+	;
+}
+
+DBusMessage* DBusMediaSource::dbusMessage (
+    const char* bus_name, const char* path, const char* interface, const char* method, const char* iface,
+    const char* prop
+) {
+    DBusError err;
+    dbus_error_init (&err);
+
+    DBusMessage* msg = dbus_message_new_method_call (bus_name, path, interface, method);
+    Data::Utils::ScopeGuard guard ([msg] { dbus_message_unref (msg); });
+
+    if (iface != nullptr && prop != nullptr) {
+	dbus_message_append_args (msg, DBUS_TYPE_STRING, &iface, DBUS_TYPE_STRING, &prop, DBUS_TYPE_INVALID);
+    }
+
+    DBusMessage* reply = dbus_connection_send_with_reply_and_block (m_connection, msg, -1, &err);
+
+    if (reply == nullptr) {
+	sLog.error ("DBus error: ", err.message, " (", err.name, ")");
+	return nullptr;
+    }
+
+    return reply;
+}
+
+void DBusMediaSource::detectPlayer () {
+    DBusMessage* reply
+	= this->dbusMessage ("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ListNames");
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    std::vector<std::string> players;
+    DBusMessageIter iter;
+    dbus_message_iter_init (reply, &iter);
+
+    if (dbus_message_iter_get_arg_type (&iter) != DBUS_TYPE_ARRAY) {
+	return;
+    }
+
+    DBusMessageIter array;
+    dbus_message_iter_recurse (&iter, &array);
+
+    while (dbus_message_iter_get_arg_type (&array) != DBUS_TYPE_INVALID) {
+	char* name;
+	dbus_message_iter_get_basic (&array, &name);
+
+	std::string service = name;
+
+	if (service.starts_with ("org.mpris.MediaPlayer2.")) {
+	    players.push_back (service);
+	}
+
+	dbus_message_iter_next (&array);
+    }
+
+    if (players.empty ()) {
+	return;
+    }
+
+    for (const auto& player : players) {
+	// also get playback status
+	reply = this->dbusMessage (
+	    player.c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	    "org.mpris.MediaPlayer2.Player", "PlaybackStatus"
+	);
+
+	if (reply == nullptr) {
+	    return;
+	}
+
+	Data::Utils::ScopeGuard guard2 ([reply] { dbus_message_unref (reply); });
+
+	DBusMessageIter outer;
+	dbus_message_iter_init (reply, &outer);
+	DBusMessageIter variant;
+	dbus_message_iter_recurse (&outer, &variant);
+
+	this->parsePlaybackStatus (variant, player.c_str ());
+
+	if (this->m_currentPlayer.has_value ()) {
+	    break;
+	}
+    }
+}
+
+void DBusMediaSource::initialStatusFetch () {
+    if (this->m_currentPlayer.has_value () == false) {
+	return;
+    }
+
+    DBusMessage* reply = this->dbusMessage (
+	this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	"org.mpris.MediaPlayer2.Player", "Metadata"
+    );
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    DBusMessageIter outer;
+    dbus_message_iter_init (reply, &outer);
+
+    DBusMessageIter variant;
+    dbus_message_iter_recurse (&outer, &variant);
+
+    this->parseMetadata (variant);
+}
+
+void DBusMediaSource::performUpdate () {
+    // nothing to do if no player is detected
+    if (!this->m_currentPlayer.has_value ()) {
+	return;
+    }
+
+    DBusMessage* reply = this->dbusMessage (
+	this->m_currentPlayer.value ().c_str (), "/org/mpris/MediaPlayer2", "org.freedesktop.DBus.Properties", "Get",
+	"org.mpris.MediaPlayer2.Player", "Position"
+    );
+
+    if (reply == nullptr) {
+	return;
+    }
+
+    Data::Utils::ScopeGuard guard ([reply] { dbus_message_unref (reply); });
+    DBusMessageIter outer;
+    dbus_message_iter_init (reply, &outer);
+
+    DBusMessageIter variant;
+    dbus_message_iter_recurse (&outer, &variant);
+
+    dbus_int64_t position = 0;
+    dbus_message_iter_get_basic (&variant, &position);
+
+    if (this->m_mediaInfo.position != position) {
+	this->m_mediaInfo.position = position;
+	this->fireMetadataListeners ();
+    }
+}

+ 32 - 0
src/WallpaperEngine/Media/DBusMediaSource.h

@@ -0,0 +1,32 @@
+#pragma once
+
+#include "MediaSource.h"
+#include <dbus/dbus.h>
+
+namespace WallpaperEngine::Media {
+class DBusMediaSource : public MediaSource {
+public:
+    explicit DBusMediaSource (std::chrono::milliseconds updateInterval);
+    ~DBusMediaSource () override;
+
+    void parseMetadata (DBusMessageIter& variant);
+    void parsePlaybackStatus (DBusMessageIter& variant, const char* sender);
+    void parsePosition (DBusMessageIter& variant);
+
+    void update () override;
+
+protected:
+    void performUpdate () override;
+    void initialStatusFetch ();
+    void detectPlayer ();
+
+    DBusMessage* dbusMessage (
+	const char* bus_name, const char* path, const char* interface, const char* method, const char* iface = nullptr,
+	const char* prop = nullptr
+    );
+
+    std::optional<std::string> m_currentPlayer = std::nullopt;
+
+    DBusConnection* m_connection;
+};
+}

+ 80 - 0
src/WallpaperEngine/Media/MediaSource.cpp

@@ -0,0 +1,80 @@
+#include "MediaSource.h"
+
+#include <ranges>
+
+using namespace WallpaperEngine::Media;
+
+MediaSource::MediaSource (std::chrono::milliseconds updateInterval) :
+    m_aliveFlag (std::make_unique<bool> (true)), m_mediaInfo (
+						     {
+							 .playbackState = PlaybackState::Stopped,
+							 .title = "",
+							 .artist = "",
+							 .url = std::nullopt,
+							 .duration = 0.0f,
+							 .position = 0.0f,
+							 .available = false,
+						     }
+						 ),
+    m_updateInterval (updateInterval) { }
+
+MediaSource::~MediaSource () {
+    if (this->m_aliveFlag) {
+	*m_aliveFlag = false;
+    }
+
+    m_albumArtListeners.clear ();
+    m_metadataListeners.clear ();
+}
+
+void MediaSource::update () {
+    if (std::chrono::steady_clock::now () <= m_nextUpdate) {
+	return;
+    }
+
+    this->performUpdate ();
+}
+
+std::function<void ()> MediaSource::addMetadataListener (std::function<void (const MediaInfo&)> listener) {
+    int listenerId = ++m_metadataListenerId;
+
+    m_metadataListeners.emplace (listenerId, listener);
+
+    auto alive = m_aliveFlag;
+
+    return [this, listenerId, alive] () {
+	if (!alive || !*alive) {
+	    return;
+	}
+
+	m_metadataListeners.erase (listenerId);
+    };
+}
+
+std::function<void ()> MediaSource::addAlbumArtListener (std::function<void (const MediaInfo&)> listener) {
+    int listenerId = ++m_albumArtListenerId;
+
+    m_albumArtListeners.emplace (listenerId, listener);
+
+    auto alive = m_aliveFlag;
+
+    return [this, listenerId, alive] () {
+	if (!alive || !*alive) {
+	    return;
+	}
+
+	m_albumArtListeners.erase (listenerId);
+    };
+}
+
+void MediaSource::fireMetadataListeners () {
+    for (auto& listener : m_metadataListeners | std::views::values) {
+	listener (m_mediaInfo);
+    }
+}
+
+void MediaSource::fireAlbumArtListeners () {
+    for (auto& listener : m_albumArtListeners | std::views::values) {
+	listener (m_mediaInfo);
+    }
+}

+ 62 - 0
src/WallpaperEngine/Media/MediaSource.h

@@ -0,0 +1,62 @@
+#pragma once
+
+#include <chrono>
+#include <functional>
+#include <map>
+#include <string>
+
+namespace WallpaperEngine::Media {
+class MediaSource {
+public:
+    enum PlaybackState {
+	Stopped = 0,
+	Playing = 1,
+	Paused = 2,
+    };
+
+    struct MediaInfo {
+	PlaybackState playbackState;
+	std::string title;
+	std::string artist;
+	std::string album;
+	std::optional<std::string> url;
+	double duration;
+	double position;
+	bool available;
+    };
+
+    explicit MediaSource (std::chrono::milliseconds updateInterval);
+    virtual ~MediaSource ();
+
+    virtual void update ();
+
+    /**
+     * @param listener The callback to call when media info changes
+     *
+     * @return A function that can be called to remove the listener
+     */
+    std::function<void ()> addMetadataListener (std::function<void (const MediaInfo&)> listener);
+    /**
+     * @param listener The callback to call when media info changes
+     *
+     * @return A function that can be called to remove the listener
+     */
+    std::function<void ()> addAlbumArtListener (std::function<void (const MediaInfo&)> listener);
+
+    [[nodiscard]] const MediaInfo& getMediaInfo () const { return m_mediaInfo; }
+
+protected:
+    void fireMetadataListeners ();
+    void fireAlbumArtListeners ();
+    virtual void performUpdate () = 0;
+
+    std::shared_ptr<bool> m_aliveFlag;
+    uint32_t m_metadataListenerId = 0;
+    std::map<uint32_t, std::function<void (MediaInfo&)>> m_metadataListeners;
+    uint32_t m_albumArtListenerId = 0;
+    std::map<uint32_t, std::function<void (MediaInfo&)>> m_albumArtListeners;
+    MediaInfo m_mediaInfo;
+    std::chrono::milliseconds m_updateInterval;
+    std::chrono::time_point<std::chrono::steady_clock> m_nextUpdate;
+};
+}

+ 136 - 0
src/WallpaperEngine/Render/AlbumTexture.cpp

@@ -0,0 +1,136 @@
+#include "AlbumTexture.h"
+
+#include "RenderContext.h"
+#include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Data/Utils/ScopeGuard.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+#include "stb_image.h"
+
+using namespace WallpaperEngine::Render;
+
+int albumtexture_read (void* user, char* data, int size) {
+    auto* stream = static_cast<ReadStream*> (user);
+
+    stream->read (data, size);
+
+    return stream->gcount ();
+}
+
+void albumtexture_skip (void* user, int n) {
+    auto* stream = static_cast<ReadStream*> (user);
+    stream->seekg (n, std::ios::cur);
+}
+
+int albumtexture_eof (void* user) { return static_cast<ReadStream*> (user)->eof (); }
+
+stbi_io_callbacks album_texture_callbacks
+    = { .read = albumtexture_read, .skip = albumtexture_skip, .eof = albumtexture_eof };
+
+AlbumTexture::AlbumTexture (RenderContext& context) : Helpers::ContextAware (context) {
+    // setup a basic texture with clamping and no mipmaps
+    this->m_resolution = glm::vec4 (1.0f, 1.0f, 1.0f, 1.0f);
+
+    glGenTextures (1, &this->m_textureID);
+    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 0);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+
+    glTexParameterf (GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
+}
+
+AlbumTexture::~AlbumTexture () { glDeleteTextures (1, &this->m_textureID); }
+
+GLuint AlbumTexture::getTextureID (uint32_t imageIndex) const { return this->m_textureID; }
+uint32_t AlbumTexture::getTextureWidth (uint32_t imageIndex) const { return this->m_width; }
+uint32_t AlbumTexture::getTextureHeight (uint32_t imageIndex) const { return this->m_height; }
+uint32_t AlbumTexture::getRealWidth () const { return this->m_width; }
+uint32_t AlbumTexture::getRealHeight () const { return this->m_height; }
+TextureFormat AlbumTexture::getFormat () const { return TextureFormat_ARGB8888; }
+uint32_t AlbumTexture::getFlags () const { return TextureFlags_NoFlags; }
+const std::vector<FrameSharedPtr>& AlbumTexture::getFrames () const { return this->m_frames; }
+const glm::vec4* AlbumTexture::getResolution () const { return &this->m_resolution; }
+bool AlbumTexture::isAnimated () const { return false; }
+uint32_t AlbumTexture::getSpritesheetCols () const { return 1; }
+uint32_t AlbumTexture::getSpritesheetRows () const { return 1; }
+uint32_t AlbumTexture::getSpritesheetFrames () const { return 1; }
+float AlbumTexture::getSpritesheetDuration () const { return 0.0f; }
+
+void AlbumTexture::incrementUsageCount () const { }
+void AlbumTexture::decrementUsageCount () const { }
+void AlbumTexture::update () const { }
+
+void AlbumTexture::copyContents (const TextureProvider& other) const noexcept {
+    // fallback to gpu -> cpu -> gpu copy
+    // RGBA8 texture: 4 bytes per pixel
+    size_t bufferSize = other.getTextureWidth (0) * other.getTextureHeight (0) * 4;
+
+    uint8_t* buffer = new uint8_t[bufferSize];
+
+    // Read the source texture
+    glBindTexture (GL_TEXTURE_2D, other.getTextureID (0));
+    glGetnTexImage (GL_TEXTURE_2D, 0, GL_RGBA, GL_UNSIGNED_BYTE, bufferSize, buffer);
+
+    // Upload into another texture
+    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+    glTexImage2D (
+	GL_TEXTURE_2D, 0, GL_RGBA8, other.getTextureWidth (0), other.getTextureHeight (0), 0, GL_RGBA, GL_UNSIGNED_BYTE,
+	buffer
+    );
+
+    delete[] buffer;
+
+    // copy over the important metadata
+    this->m_width = other.getTextureWidth (0);
+    this->m_height = other.getTextureHeight (0);
+    this->m_resolution = *other.getResolution ();
+}
+
+void AlbumTexture::load () const {
+    this->m_width = 0;
+    this->m_height = 0;
+
+    for (const auto& project : this->getContext ().getApp ().getBackgrounds () | std::views::values) {
+	try {
+	    // try to open the file in any of the asset locators
+	    auto contents = project->assetLocator->read ("$mediaThumbnail");
+
+	    int width, height, channels;
+
+	    auto* dataptr
+		= stbi_load_from_callbacks (&album_texture_callbacks, contents.get (), &width, &height, &channels, 4);
+
+	    if (dataptr == nullptr) {
+		continue;
+	    }
+
+	    ScopeGuard guard ([dataptr] { stbi_image_free (dataptr); });
+
+	    if (width == 0 || height == 0) {
+		continue;
+	    }
+
+	    this->m_width = width;
+	    this->m_height = height;
+	    this->m_resolution = glm::vec4 (this->m_width, this->m_height, this->m_width, this->m_height);
+
+	    // setup texture contents
+	    glBindTexture (GL_TEXTURE_2D, this->m_textureID);
+	    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_RGBA, GL_UNSIGNED_BYTE, dataptr);
+	    return;
+	} catch (AssetLoadException&) {
+	    // this is expected if the thumbnail is not available
+	}
+    }
+}
+
+bool AlbumTexture::isReady () const {
+    // these are only ready to be rendered if their content's are present
+    return this->m_width > 0 && this->m_height > 0;
+}

+ 43 - 0
src/WallpaperEngine/Render/AlbumTexture.h

@@ -0,0 +1,43 @@
+#pragma once
+
+#include "Helpers/ContextAware.h"
+#include "TextureProvider.h"
+#include "WallpaperEngine/Media/MediaSource.h"
+
+namespace WallpaperEngine::Render {
+class AlbumTexture : public TextureProvider, public Helpers::ContextAware {
+public:
+    explicit AlbumTexture (RenderContext& context);
+    ~AlbumTexture () override;
+
+    [[nodiscard]] GLuint getTextureID (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getTextureWidth (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getTextureHeight (uint32_t imageIndex) const override;
+    [[nodiscard]] uint32_t getRealWidth () const override;
+    [[nodiscard]] uint32_t getRealHeight () const override;
+    [[nodiscard]] TextureFormat getFormat () const override;
+    [[nodiscard]] uint32_t getFlags () const override;
+    [[nodiscard]] const std::vector<FrameSharedPtr>& getFrames () const override;
+    [[nodiscard]] const glm::vec4* getResolution () const override;
+    [[nodiscard]] bool isAnimated () const override;
+    [[nodiscard]] uint32_t getSpritesheetCols () const override;
+    [[nodiscard]] uint32_t getSpritesheetRows () const override;
+    [[nodiscard]] uint32_t getSpritesheetFrames () const override;
+    [[nodiscard]] float getSpritesheetDuration () const override;
+
+    void incrementUsageCount () const override;
+    void decrementUsageCount () const override;
+    void update () const override;
+
+    void copyContents (const TextureProvider& other) const noexcept;
+    void load () const;
+    bool isReady () const override;
+
+private:
+    std::vector<FrameSharedPtr> m_frames;
+    mutable glm::vec4 m_resolution;
+    mutable uint32_t m_width = 0;
+    mutable uint32_t m_height = 0;
+    GLuint m_textureID = GL_NONE;
+};
+}

+ 2 - 0
src/WallpaperEngine/Render/CFBO.cpp

@@ -132,3 +132,5 @@ float CFBO::getSpritesheetDuration () const {
 void CFBO::incrementUsageCount () const { }
 void CFBO::decrementUsageCount () const { }
 void CFBO::update () const { }
+// FBOs are always ready
+bool CFBO::isReady () const { return true; }

+ 1 - 0
src/WallpaperEngine/Render/CFBO.h

@@ -37,6 +37,7 @@ public:
     void incrementUsageCount () const override;
     void decrementUsageCount () const override;
     void update () const override;
+    bool isReady () const override;
 
 private:
     GLuint m_framebuffer = GL_NONE;

+ 4 - 1
src/WallpaperEngine/Render/CTexture.cpp

@@ -262,4 +262,7 @@ void CTexture::update () const {
     if (this->m_player) {
 	this->m_player->render ();
     }
-}
+}
+
+// CTextures are always ready to be rendered at all times
+bool CTexture::isReady () const { return true; }

+ 1 - 0
src/WallpaperEngine/Render/CTexture.h

@@ -58,6 +58,7 @@ public:
      * Some textures need to be updated
      */
     void update () const override;
+    bool isReady () const override;
 
 private:
     /**

+ 2 - 19
src/WallpaperEngine/Render/CWallpaper.h

@@ -11,6 +11,7 @@
 #include "WallpaperEngine/Render/RenderContext.h"
 
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 
 #include "FBOProvider.h"
 #include "WallpaperState.h"
@@ -33,7 +34,7 @@ using namespace WallpaperEngine::Audio;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::FileSystem;
 
-class CWallpaper : public Helpers::ContextAware, public FBOProvider {
+class CWallpaper : public Helpers::ContextAware, public FBOProvider, public TypeCaster {
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 public:
@@ -43,24 +44,6 @@ public:
 	glm::ivec4 totalBounds;
     };
 
-    template <class T> [[nodiscard]] const T* as () const {
-	if (is<T> ()) {
-	    return static_cast<const T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] T* as () {
-	if (is<T> ()) {
-	    return static_cast<T*> (this);
-	}
-
-	throw std::bad_cast ();
-    }
-
-    template <class T> [[nodiscard]] bool is () const { return typeid (*this) == typeid (T); }
-
     virtual ~CWallpaper () override;
 
     /**

+ 143 - 43
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -208,12 +208,39 @@ void CPass::setupRenderTexture () {
 
 std::shared_ptr<const TextureProvider> CPass::resolveTexture0 () {
     auto texture0 = this->resolveTexture (this->m_input, 0, this->m_input);
-    if (const auto texture0Override = this->m_textures.find (0); texture0Override != this->m_textures.end ()) {
-	texture0
-	    = texture0Override->second == nullptr ? (this->m_previousInput ?: this->m_input) : texture0Override->second;
+    const auto it = this->m_textures.find (0);
+
+    if (it == this->m_textures.end ()) {
+	return texture0;
+    }
+
+    auto& chain = it->second;
+
+    do {
+	texture0 = chain->texture;
+
+	if (texture0 == nullptr) {
+	    if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+		return this->m_previousInput;
+	    }
+
+	    if (this->m_input != nullptr && this->m_input->isReady ()) {
+		return this->m_input;
+	    }
+	} else if (texture0->isReady ()) {
+	    return texture0;
+	}
+
+	chain = chain->next;
+    } while (chain != nullptr);
+
+    // got to the end of the chain, use previous input or current input if available
+    if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+	return this->m_previousInput;
     }
 
-    return texture0;
+    // last resort, doesn't matter if the input is ready or not
+    return this->m_input;
 }
 
 CPass::TextureAnimationState
@@ -259,15 +286,41 @@ void CPass::bindTextureUnit (int index, const std::shared_ptr<const TextureProvi
 }
 
 void CPass::bindTextureOverrides (uint32_t currentTexture, std::shared_ptr<const TextureProvider>& texture0) const {
-    for (const auto& [index, expectedTexture] : this->m_textures) {
-	auto texture = expectedTexture == nullptr ? (this->m_previousInput ?: this->m_input) : expectedTexture;
-	if (texture == nullptr) {
-	    continue;
+    for (auto [index, chain] : this->m_textures) {
+	// find the expected texture
+	auto expectedTexture = chain->texture;
+
+	do {
+	    if (expectedTexture == nullptr) {
+		if (this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+		    expectedTexture = this->m_previousInput;
+		    break;
+		}
+
+		if (this->m_input != nullptr && this->m_input->isReady ()) {
+		    expectedTexture = this->m_input;
+		    break;
+		}
+	    } else if (expectedTexture->isReady ()) {
+		break;
+	    }
+
+	    chain = chain->next;
+	    expectedTexture = chain == nullptr ? nullptr : chain->texture;
+	} while (chain != nullptr);
+
+	if (expectedTexture == nullptr && this->m_previousInput != nullptr && this->m_previousInput->isReady ()) {
+	    expectedTexture = this->m_previousInput;
 	}
 
-	this->bindTextureUnit (index, texture, index == 0 ? currentTexture : 0);
+	if (expectedTexture == nullptr) {
+	    expectedTexture = this->m_input;
+	}
+
+	this->bindTextureUnit (index, expectedTexture, index == 0 ? currentTexture : 0);
+
 	if (index == 0) {
-	    texture0 = texture;
+	    texture0 = expectedTexture;
 	}
     }
 }
@@ -648,11 +701,15 @@ void CPass::setupTextureUniforms () {
     // and then try with fragment's and override any existing
     for (const auto& [index, textureName] : this->m_shader->getVertex ().getTextures ()) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    // create chain entry
+	    this->m_textures[index] = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = nullptr,
+	    });
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
 	}
@@ -660,11 +717,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_shader->getFragment ().getTextures ()) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for fragment shader ", ex.what ());
 	}
@@ -672,11 +735,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_pass.textures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for pass ", ex.what ());
 	}
@@ -684,11 +753,17 @@ void CPass::setupTextureUniforms () {
 
     for (const auto& [index, textureName] : this->m_pass.usertextures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve user texture ", textureName, " for pass ", ex.what ());
 	}
@@ -697,25 +772,50 @@ void CPass::setupTextureUniforms () {
     // override any texture
     for (const auto& [index, textureName] : this->m_override.textures) {
 	try {
-	    if (textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0) {
-		this->m_textures[index] = this->resolveFBO (textureName);
-	    } else if (!textureName.empty ()) {
-		this->m_textures[index] = this->getContext ().resolveTexture (textureName);
-	    }
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
 	} catch (std::runtime_error& ex) {
 	    sLog.error ("Cannot resolve texture ", textureName, " for override ", ex.what ());
 	}
     }
 
+    for (const auto& [index, textureName] : this->m_override.usertextures) {
+	try {
+	    auto texture = textureName.find ("_rt_") == 0 || textureName.find ("_alias_") == 0
+		? this->resolveFBO (textureName)
+		: this->getContext ().resolveTexture (textureName);
+
+	    const auto it = this->m_textures.find (index);
+	    const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+		.texture = texture,
+		.next = it != this->m_textures.end () ? it->second : nullptr,
+	    });
+
+	    this->m_textures[index] = chain;
+	} catch (std::runtime_error& ex) {
+	    sLog.error ("Cannot resolve user texture ", textureName, " for override ", ex.what ());
+	}
+    }
+
     // binds are set last as they're the most important to be set
     for (const auto& [index, bind] : this->m_binds) {
-	if (bind == "previous") {
-	    // use nullptr as indication for "previous" texture
-	    this->m_textures[index] = nullptr;
-	} else if (!bind.empty ()) {
-	    // a normal bind, search for the corresponding FBO and set it
-	    this->m_textures[index] = this->resolveFBO (bind);
-	}
+	const auto texture = bind == "previous" ? nullptr : this->resolveFBO (bind);
+	const auto it = this->m_textures.find (index);
+	const auto chain = std::make_shared<TextureChainEntry> (TextureChainEntry {
+	    .texture = texture,
+	    .next = it != this->m_textures.end () ? it->second : nullptr,
+	});
+
+	this->m_textures[index] = chain;
     }
 
     // resolve the main texture
@@ -738,7 +838,7 @@ void CPass::setupTextureUniforms () {
 
 	namestream << "g_Texture" << textureIndex << "Resolution";
 
-	texture = this->resolveTexture (expectedTexture, textureIndex, texture);
+	texture = this->resolveTexture (expectedTexture->texture, textureIndex, texture);
 	this->addUniform (namestream.str (), texture->getResolution ());
     }
 

+ 6 - 1
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -65,6 +65,11 @@ public:
     void addUniform (const std::string& name, const glm::mat4* value);
 
 private:
+    struct TextureChainEntry {
+	std::shared_ptr<const TextureProvider> texture;
+	std::shared_ptr<TextureChainEntry> next;
+    };
+
     enum UniformType {
 	Float = 0,
 	Matrix3 = 1,
@@ -188,7 +193,7 @@ private:
     /**
      * Contains the final map of textures to be used
      */
-    std::map<int, std::shared_ptr<const TextureProvider>> m_textures = {};
+    std::map<int, std::shared_ptr<TextureChainEntry>> m_textures = {};
 
     Render::Shaders::Shader* m_shader = nullptr;
 

+ 7 - 2
src/WallpaperEngine/Render/RenderContext.cpp

@@ -8,8 +8,11 @@
 #include "WallpaperEngine/Data/Model/Project.h"
 
 namespace WallpaperEngine::Render {
-RenderContext::RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app) :
-    m_driver (driver), m_app (app), m_textureCache (new TextureCache (*this)) { }
+RenderContext::RenderContext (
+    Drivers::VideoDriver& driver, WallpaperApplication& app, Media::MediaSource& mediaSource
+) :
+    m_driver (driver), m_app (app), m_mediaSource (mediaSource),
+    m_textureCache (std::make_unique<TextureCache> (*this)) { }
 
 void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
     viewport->makeCurrent ();
@@ -63,4 +66,6 @@ const std::map<std::string, std::shared_ptr<CWallpaper>>& RenderContext::getWall
     return this->m_wallpapers;
 }
 
+Media::MediaSource& RenderContext::getMediaSource () const { return this->m_mediaSource; }
+
 } // namespace WallpaperEngine::Render

+ 8 - 2
src/WallpaperEngine/Render/RenderContext.h

@@ -16,6 +16,9 @@ namespace WallpaperEngine {
 namespace Application {
     class WallpaperApplication;
 }
+namespace Media {
+    class MediaSource;
+}
 
 namespace Render {
     namespace Drivers {
@@ -32,7 +35,7 @@ namespace Render {
 
     class RenderContext {
     public:
-	RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app);
+	RenderContext (Drivers::VideoDriver& driver, WallpaperApplication& app, Media::MediaSource& mediaSource);
 
 	void render (Drivers::Output::OutputViewport* viewport);
 	void setWallpaper (const std::string& display, std::shared_ptr<CWallpaper> wallpaper);
@@ -43,6 +46,7 @@ namespace Render {
 	[[nodiscard]] const Drivers::Output::Output& getOutput () const;
 	[[nodiscard]] std::shared_ptr<const TextureProvider> resolveTexture (const std::string& name) const;
 	[[nodiscard]] const std::map<std::string, std::shared_ptr<CWallpaper>>& getWallpapers () const;
+	[[nodiscard]] Media::MediaSource& getMediaSource () const;
 
     private:
 	/** Video driver in use */
@@ -51,8 +55,10 @@ namespace Render {
 	std::map<std::string, std::shared_ptr<CWallpaper>> m_wallpapers = {};
 	/** App that holds the render context */
 	WallpaperApplication& m_app;
+	/** Source for the media playback information */
+	Media::MediaSource& m_mediaSource;
 	/** Texture cache for the render */
-	TextureCache* m_textureCache = nullptr;
+	std::unique_ptr<TextureCache> m_textureCache = nullptr;
     };
 } // namespace Render
 } // namespace WallpaperEngine

+ 31 - 377
src/WallpaperEngine/Render/TextureCache.cpp

@@ -1,5 +1,6 @@
 #include "TextureCache.h"
 
+#include "AlbumTexture.h"
 #include "WallpaperEngine/FileSystem/Container.h"
 
 #include "CTexture.h"
@@ -10,404 +11,52 @@
 #include "WallpaperEngine/Data/Parsers/TextureParser.h"
 
 #include <algorithm>
-#include <array>
 #include <chrono>
-#include <cstring>
 #include <filesystem>
 #include <fstream>
-#include <iterator>
-#include <optional>
-#include <poll.h>
-#include <signal.h>
-#include <spawn.h>
-#include <stb_image.h>
-#include <sys/wait.h>
-#include <unistd.h>
 
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::FileSystem;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Assets;
 
-extern char** environ;
+TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) {
+    // these textures are special cases, so make sure they're created only upon request
+    this->m_currentThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
-namespace {
-constexpr size_t kMaxThumbnailBytes = 16 * 1024 * 1024;
+#if !NDEBUG
+    glObjectLabel (GL_TEXTURE, this->m_currentThumbnail->getTextureID (0), -1, "$mediaThumbnail");
+#endif
 
-std::string trimTrailingNewlines (std::string value) {
-    while (!value.empty () && (value.back () == '\n' || value.back () == '\r')) {
-	value.pop_back ();
-    }
-    return value;
-}
-
-std::vector<char*> makeArgv (const std::string& program, const std::vector<std::string>& args) {
-    std::vector<char*> argv;
-    argv.reserve (args.size () + 2);
-    argv.push_back (const_cast<char*> (program.c_str ()));
-    for (const auto& arg : args) {
-	argv.push_back (const_cast<char*> (arg.c_str ()));
-    }
-    argv.push_back (nullptr);
-    return argv;
-}
-
-std::optional<pid_t>
-spawnProcessWithStdout (const std::string& program, const std::vector<std::string>& args, int& readFd) {
-    int outputPipe[2] {};
-    if (pipe (outputPipe) != 0) {
-	return std::nullopt;
-    }
-
-    posix_spawn_file_actions_t actions;
-    posix_spawn_file_actions_init (&actions);
-    posix_spawn_file_actions_adddup2 (&actions, outputPipe[1], STDOUT_FILENO);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[0]);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[1]);
-
-    auto argv = makeArgv (program, args);
-    pid_t pid = 0;
-    const int spawnResult = posix_spawnp (&pid, program.c_str (), &actions, nullptr, argv.data (), environ);
-    posix_spawn_file_actions_destroy (&actions);
-    close (outputPipe[1]);
-
-    if (spawnResult != 0) {
-	close (outputPipe[0]);
-	return std::nullopt;
-    }
-
-    readFd = outputPipe[0];
-    return pid;
-}
-
-bool waitForProcessUntil (pid_t pid, const std::chrono::steady_clock::time_point& deadline, int& status) {
-    while (std::chrono::steady_clock::now () < deadline) {
-	const pid_t waitResult = waitpid (pid, &status, WNOHANG);
-	if (waitResult == pid) {
-	    return true;
-	}
-	if (waitResult < 0) {
-	    return false;
-	}
-	usleep (1000);
-    }
-
-    kill (pid, SIGKILL);
-    waitpid (pid, &status, 0);
-    return false;
-}
-
-bool pollReadable (int readFd, const std::chrono::steady_clock::time_point& deadline) {
-    const auto remaining
-	= std::chrono::duration_cast<std::chrono::milliseconds> (deadline - std::chrono::steady_clock::now ());
-    if (remaining.count () <= 0) {
-	return false;
-    }
-
-    pollfd readPoll {
-	.fd = readFd,
-	.events = POLLIN,
-	.revents = 0,
-    };
-    const int pollResult = poll (&readPoll, 1, static_cast<int> (std::max (remaining.count (), int64_t { 1 })));
-    return pollResult > 0 && !(readPoll.revents & (POLLERR | POLLNVAL)) && (readPoll.revents & (POLLIN | POLLHUP));
-}
-
-std::string processReadFirstLine (const std::string& program, const std::vector<std::string>& args) {
-    constexpr auto timeout = std::chrono::milliseconds (300);
-    const auto deadline = std::chrono::steady_clock::now () + timeout;
-
-    int outputFd = -1;
-    const auto pid = spawnProcessWithStdout (program, args, outputFd);
-    if (!pid.has_value ()) {
-	return {};
-    }
-
-    std::array<char, 512> buffer {};
-    std::string output;
-    while (std::chrono::steady_clock::now () < deadline && pollReadable (outputFd, deadline)) {
-	const ssize_t bytesRead = read (outputFd, buffer.data (), buffer.size ());
-	if (bytesRead <= 0) {
-	    break;
-	}
-	output.append (buffer.data (), static_cast<size_t> (bytesRead));
-	const auto newline = output.find ('\n');
-	if (newline != std::string::npos) {
-	    output.erase (newline + 1);
-	    break;
-	}
-    }
-    close (outputFd);
+    this->m_previousThumbnail = std::make_shared<AlbumTexture> (this->getContext ());
 
-    int status = 0;
-    if (!waitForProcessUntil (*pid, deadline, status)) {
-	return {};
-    }
-    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
-	return {};
-    }
+#if !NDEBUG
+    glObjectLabel (GL_TEXTURE, this->m_previousThumbnail->getTextureID (0), -1, "$mediaPreviousThumbnail");
+#endif
 
-    return trimTrailingNewlines (output);
-}
+    // load the latest texture (if available)
+    this->m_currentThumbnail->load ();
 
-std::vector<char>
-processReadBytes (const std::string& program, const std::vector<std::string>& args, std::chrono::milliseconds timeout) {
-    const auto deadline = std::chrono::steady_clock::now () + timeout;
+    // add these to the cache and return the right one
+    this->store ("$mediaThumbnail", this->m_currentThumbnail);
+    this->store ("$mediaPreviousThumbnail", this->m_previousThumbnail);
 
-    int outputFd = -1;
-    const auto pid = spawnProcessWithStdout (program, args, outputFd);
-    if (!pid.has_value ()) {
-	return {};
-    }
+    this->m_mediaCallback = this->getContext ().getMediaSource ().addAlbumArtListener (
+	[this] (const Media::MediaSource::MediaInfo& data) {
+	    if (this->m_currentThumbnail->isReady ()) {
+		// copy over pixel data and setup the new texture with the new data
+		this->m_previousThumbnail->copyContents (*this->m_currentThumbnail);
+	    }
 
-    std::vector<char> output;
-    std::array<char, 4096> buffer {};
-    bool tooLarge = false;
-    while (std::chrono::steady_clock::now () < deadline && pollReadable (outputFd, deadline)) {
-	const ssize_t bytesRead = read (outputFd, buffer.data (), buffer.size ());
-	if (bytesRead <= 0) {
-	    break;
+	    // load the next image
+	    this->m_currentThumbnail->load ();
 	}
-	if (output.size () + static_cast<size_t> (bytesRead) > kMaxThumbnailBytes) {
-	    tooLarge = true;
-	    break;
-	}
-	output.insert (output.end (), buffer.begin (), buffer.begin () + bytesRead);
-    }
-    close (outputFd);
-
-    if (tooLarge) {
-	kill (*pid, SIGKILL);
-	waitpid (*pid, nullptr, 0);
-	return {};
-    }
-
-    int status = 0;
-    if (!waitForProcessUntil (*pid, deadline, status)) {
-	return {};
-    }
-    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
-	return {};
-    }
-
-    return output;
+    );
 }
 
-std::vector<char> readFileBytes (const std::filesystem::path& path) {
-    std::error_code sizeError;
-    const auto size = std::filesystem::file_size (path, sizeError);
-    if (!sizeError && size > kMaxThumbnailBytes) {
-	return {};
-    }
-
-    std::ifstream stream (path, std::ios::binary);
-    if (!stream) {
-	return {};
-    }
-
-    std::vector<char> output;
-    output.reserve (!sizeError ? static_cast<size_t> (std::min<uintmax_t> (size, kMaxThumbnailBytes)) : 0);
-    std::array<char, 4096> buffer {};
-    while (stream && output.size () < kMaxThumbnailBytes) {
-	const auto remaining = kMaxThumbnailBytes - output.size ();
-	stream.read (buffer.data (), static_cast<std::streamsize> (std::min (buffer.size (), remaining)));
-	const auto bytesRead = stream.gcount ();
-	if (bytesRead <= 0) {
-	    break;
-	}
-	output.insert (output.end (), buffer.begin (), buffer.begin () + bytesRead);
-    }
-    if (stream && output.size () >= kMaxThumbnailBytes) {
-	return {};
-    }
-    return output;
-}
-
-FIF detectImageFormat (const std::vector<char>& bytes) {
-    const auto* data = reinterpret_cast<const unsigned char*> (bytes.data ());
-    if (bytes.size () >= 4 && data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G') {
-	return FIF_PNG;
-    }
-    if (bytes.size () >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) {
-	return FIF_JPEG;
-    }
-    if (bytes.size () >= 12 && std::memcmp (data, "RIFF", 4) == 0 && std::memcmp (data + 8, "WEBP", 4) == 0) {
-	return FIF_WEBP;
-    }
-    return FIF_UNKNOWN;
-}
-
-int hexValue (char value) {
-    if (value >= '0' && value <= '9') {
-	return value - '0';
-    }
-    if (value >= 'a' && value <= 'f') {
-	return value - 'a' + 10;
-    }
-    if (value >= 'A' && value <= 'F') {
-	return value - 'A' + 10;
-    }
-    return -1;
-}
-
-std::optional<std::string> percentDecode (const std::string& value) {
-    std::string result;
-    result.reserve (value.size ());
-    for (size_t index = 0; index < value.size (); index++) {
-	if (value[index] != '%') {
-	    result.push_back (value[index]);
-	    continue;
-	}
-	if (index + 2 >= value.size ()) {
-	    return std::nullopt;
-	}
-	const int high = hexValue (value[index + 1]);
-	const int low = hexValue (value[index + 2]);
-	if (high < 0 || low < 0) {
-	    return std::nullopt;
-	}
-	result.push_back (static_cast<char> ((high << 4) | low));
-	index += 2;
-    }
-    return result;
-}
-
-std::optional<std::filesystem::path> parseLocalFileUri (const std::string& uri) {
-    if (uri.rfind ("file:", 0) != 0) {
-	return std::nullopt;
-    }
-
-    std::string rest = uri.substr (5);
-    std::string pathPart;
-    if (rest.rfind ("//", 0) == 0) {
-	rest = rest.substr (2);
-	const auto slash = rest.find ('/');
-	const std::string authority = slash == std::string::npos ? rest : rest.substr (0, slash);
-	if (!authority.empty () && authority != "localhost") {
-	    return std::nullopt;
-	}
-	pathPart = slash == std::string::npos ? std::string () : rest.substr (slash);
-    } else {
-	pathPart = rest;
-    }
-
-    auto decoded = percentDecode (pathPart);
-    if (!decoded.has_value () || decoded->empty ()) {
-	return std::nullopt;
-    }
-    return std::filesystem::path (*decoded);
-}
-
-std::shared_ptr<const TextureProvider> tryCreateImageTexture (RenderContext& context, const std::vector<char>& bytes) {
-    if (bytes.empty ()) {
-	return nullptr;
-    }
-
-    int width = 0;
-    int height = 0;
-    int channels = 0;
-    if (!stbi_info_from_memory (
-	    reinterpret_cast<const stbi_uc*> (bytes.data ()), static_cast<int> (bytes.size ()), &width, &height,
-	    &channels
-	)) {
-	return nullptr;
-    }
-
-    auto mipmap = std::make_shared<Mipmap> ();
-    mipmap->width = static_cast<uint32_t> (width);
-    mipmap->height = static_cast<uint32_t> (height);
-    mipmap->uncompressedSize = static_cast<int> (bytes.size ());
-    mipmap->uncompressedData = std::make_unique<char[]> (bytes.size ());
-    std::copy (bytes.begin (), bytes.end (), mipmap->uncompressedData.get ());
-
-    auto texture = std::make_unique<Texture> ();
-    texture->containerVersion = ContainerVersion_TEXB0001;
-    texture->flags = TextureFlags_ClampUVs;
-    texture->width = static_cast<uint32_t> (width);
-    texture->height = static_cast<uint32_t> (height);
-    texture->textureWidth = static_cast<uint32_t> (width);
-    texture->textureHeight = static_cast<uint32_t> (height);
-    texture->format = TextureFormat_ARGB8888;
-    texture->freeImageFormat = detectImageFormat (bytes);
-    texture->imageCount = 1;
-    texture->images.emplace (0, MipmapList { mipmap });
-
-    return std::make_shared<CTexture> (context, std::move (texture));
-}
-
-std::shared_ptr<const TextureProvider> tryLoadMediaThumbnailUrl (RenderContext& context, const std::string& artUrl) {
-    std::vector<char> bytes;
-    if (artUrl.rfind ("file:", 0) == 0) {
-	if (const auto path = parseLocalFileUri (artUrl); path.has_value ()) {
-	    bytes = readFileBytes (*path);
-	}
-    } else if (artUrl.rfind ("http://", 0) == 0 || artUrl.rfind ("https://", 0) == 0) {
-	bytes = processReadBytes (
-	    "curl", { "-L", "-s", "--max-time", "3", "--output", "-", artUrl }, std::chrono::milliseconds (3500)
-	);
-    }
-
-    return tryCreateImageTexture (context, bytes);
-}
-
-struct MediaThumbnailState {
-    RenderContext* context = nullptr;
-    std::string currentArtUrl;
-    std::shared_ptr<const TextureProvider> currentTexture = nullptr;
-    std::shared_ptr<const TextureProvider> previousTexture = nullptr;
-    std::chrono::steady_clock::time_point lastMetadataPoll = {};
-    bool hasMetadataPoll = false;
-};
-
-MediaThumbnailState& mediaThumbnailState () {
-    static MediaThumbnailState state;
-    return state;
-}
-
-std::shared_ptr<const TextureProvider> tryLoadMediaThumbnail (RenderContext& context, bool previous) {
-    auto& state = mediaThumbnailState ();
-    if (state.context != &context) {
-	state = MediaThumbnailState { .context = &context };
-    }
-
-    constexpr auto metadataPollInterval = std::chrono::milliseconds (750);
-    const auto now = std::chrono::steady_clock::now ();
-    if (state.hasMetadataPoll && now - state.lastMetadataPoll < metadataPollInterval) {
-	return previous ? state.previousTexture : state.currentTexture;
-    }
-    state.lastMetadataPoll = now;
-    state.hasMetadataPoll = true;
-
-    const std::string artUrl = processReadFirstLine ("playerctl", { "metadata", "mpris:artUrl" });
-    if (artUrl.empty ()) {
-	return previous ? state.previousTexture : nullptr;
-    }
-
-    if (artUrl != state.currentArtUrl) {
-	auto texture = tryLoadMediaThumbnailUrl (context, artUrl);
-	if (texture != nullptr) {
-	    state.previousTexture = state.currentTexture;
-	    state.currentTexture = texture;
-	    state.currentArtUrl = artUrl;
-	}
-    }
-
-    return previous ? state.previousTexture : state.currentTexture;
-}
-}
-
-TextureCache::TextureCache (RenderContext& context) : Helpers::ContextAware (context) { }
+TextureCache::~TextureCache () { this->m_mediaCallback (); }
 
 std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string& filename) {
-    // Media thumbnails are dynamic, so avoid caching the texture by name.
-    if (filename == "$mediaThumbnail" || filename == "$mediaPreviousThumbnail") {
-	auto texture = tryLoadMediaThumbnail (this->getContext (), filename == "$mediaPreviousThumbnail");
-	if (texture == nullptr) {
-	    texture = this->resolve ("util/white");
-	}
-	return texture;
-    }
-
     if (const auto found = this->m_textureCache.find (filename); found != this->m_textureCache.end ()) {
 	return found->second;
     }
@@ -428,6 +77,10 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	    auto parsedTexture = TextureParser::parse (stream, filename, metadataLoader);
 	    auto texture = std::make_shared<CTexture> (this->getContext (), std::move (parsedTexture));
 
+#if !NDEBUG
+	    glObjectLabel (GL_TEXTURE, texture->getTextureID (0), -1, filename.c_str ());
+#endif
+
 	    this->store (filename, texture);
 
 	    return texture;
@@ -436,6 +89,7 @@ std::shared_ptr<const TextureProvider> TextureCache::resolve (const std::string&
 	}
     }
 
+    // TODO: FILL IN WITH A CHECKERED PATTERN TEXTURE INSTEAD?
     throw AssetLoadException ("Cannot find file", filename, std::error_code ());
 }
 

+ 9 - 0
src/WallpaperEngine/Render/TextureCache.h

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <functional>
 #include <map>
 #include <memory>
 #include <string>
@@ -11,6 +12,7 @@
 using namespace WallpaperEngine::Render;
 
 namespace WallpaperEngine::Render {
+class AlbumTexture;
 namespace Helpers {
     class ContextAware;
 }
@@ -20,6 +22,7 @@ class RenderContext;
 class TextureCache final : Helpers::ContextAware {
 public:
     explicit TextureCache (RenderContext& context);
+    ~TextureCache () override;
 
     /**
      * Checks if the given texture was already loaded and returns it
@@ -39,7 +42,13 @@ public:
     void store (const std::string& name, std::shared_ptr<const TextureProvider> texture);
 
 private:
+    /** The previous album thumbnail texture */
+    std::shared_ptr<const AlbumTexture> m_previousThumbnail = nullptr;
+    /** The current album thumbnail texture */
+    std::shared_ptr<const AlbumTexture> m_currentThumbnail = nullptr;
     /** Cached textures */
     std::map<std::string, std::shared_ptr<const TextureProvider>> m_textureCache = {};
+    /** The callback to de-register media events */
+    std::function<void ()> m_mediaCallback;
 };
 } // namespace WallpaperEngine::Render

+ 4 - 0
src/WallpaperEngine/Render/TextureProvider.h

@@ -78,6 +78,10 @@ public:
      * @return Duration of spritesheet animation in seconds
      */
     [[nodiscard]] virtual float getSpritesheetDuration () const = 0;
+    /**
+     * @return If this texture is ready to be used or not
+     */
+    virtual bool isReady () const = 0;
 
     /**
      * Increments the usage count of the texture

+ 1 - 1
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -30,7 +30,7 @@ CScene::CScene (
     auto scene = wallpaper.as<Scene> ();
 
     // setup scripting engine
-    this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this);
+    this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
     // setup the scene camera
     this->m_camera = std::make_unique<Camera> (*this, scene->camera);
 

+ 100 - 396
src/WallpaperEngine/Scripting/ScriptEngine.cpp

@@ -45,6 +45,31 @@ extern char** environ;
 extern float g_Time;
 extern float g_TimeLast;
 
+void scriptengine_dump (JSContext* ctx, JSValueConst obj) {
+    JSPropertyEnum* props;
+    uint32_t len;
+
+    if (JS_GetOwnPropertyNames (ctx, &props, &len, obj, JS_GPN_STRING_MASK | JS_GPN_SYMBOL_MASK) < 0) {
+	return;
+    }
+
+    for (uint32_t i = 0; i < len; ++i) {
+	const char* name = JS_AtomToCString (ctx, props[i].atom);
+
+	JSValue val = JS_GetProperty (ctx, obj, props[i].atom);
+
+	const char* value_str = JS_ToCString (ctx, val);
+
+	printf ("%s = %s\n", name, value_str ? value_str : "<non-string>");
+
+	JS_FreeCString (ctx, value_str);
+	JS_FreeValue (ctx, val);
+	JS_FreeCString (ctx, name);
+    }
+
+    js_free (ctx, props);
+}
+
 JSModuleDef* scriptengine_module_loader (JSContext* ctx, const char* module, void* opaque) {
     const auto* scriptEngine = static_cast<ScriptEngine*> (opaque);
 
@@ -151,189 +176,19 @@ static void jsToDynamicValue (JSContext* ctx, JSValue val, DynamicValue& source)
     }
 }
 
-namespace {
-std::string trimTrailingNewlines (std::string value) {
-    while (!value.empty () && (value.back () == '\n' || value.back () == '\r')) {
-	value.pop_back ();
-    }
-    return value;
-}
-
-std::vector<std::string> splitTabs (const std::string& value) {
-    std::vector<std::string> result;
-    size_t start = 0;
-    while (start <= value.size ()) {
-	const size_t end = value.find ('\t', start);
-	if (end == std::string::npos) {
-	    result.emplace_back (value.substr (start));
-	    break;
-	}
-	result.emplace_back (value.substr (start, end - start));
-	start = end + 1;
-    }
-    return result;
-}
-
-double parseDoubleOrZero (const std::string& value) {
-    try {
-	return std::stod (value);
-    } catch (...) {
-	return 0.0;
-    }
-}
-
-std::optional<pid_t>
-spawnProcessWithStdout (const std::string& program, const std::vector<std::string>& args, int& readFd) {
-    int outputPipe[2] {};
-    if (pipe (outputPipe) != 0) {
-	return std::nullopt;
-    }
-
-    posix_spawn_file_actions_t actions;
-    posix_spawn_file_actions_init (&actions);
-    posix_spawn_file_actions_adddup2 (&actions, outputPipe[1], STDOUT_FILENO);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[0]);
-    posix_spawn_file_actions_addclose (&actions, outputPipe[1]);
-
-    std::vector<char*> argv;
-    argv.reserve (args.size () + 2);
-    argv.push_back (const_cast<char*> (program.c_str ()));
-    for (const auto& arg : args) {
-	argv.push_back (const_cast<char*> (arg.c_str ()));
-    }
-    argv.push_back (nullptr);
-
-    pid_t pid = 0;
-    const int spawnResult = posix_spawnp (&pid, program.c_str (), &actions, nullptr, argv.data (), environ);
-    posix_spawn_file_actions_destroy (&actions);
-    close (outputPipe[1]);
-
-    if (spawnResult != 0) {
-	close (outputPipe[0]);
-	return std::nullopt;
-    }
-
-    readFd = outputPipe[0];
-    return pid;
-}
-
-bool readFirstLineUntil (int readFd, const std::chrono::steady_clock::time_point& deadline, std::string& output) {
-    std::array<char, 512> buffer {};
-    while (std::chrono::steady_clock::now () < deadline) {
-	const auto remaining
-	    = std::chrono::duration_cast<std::chrono::milliseconds> (deadline - std::chrono::steady_clock::now ());
-	pollfd readPoll {
-	    .fd = readFd,
-	    .events = POLLIN,
-	    .revents = 0,
-	};
-
-	const int pollResult = poll (&readPoll, 1, static_cast<int> (std::max (remaining.count (), int64_t { 1 })));
-	if (pollResult == 0) {
-	    return false;
-	}
-	if (pollResult < 0) {
-	    if (errno == EINTR) {
-		continue;
-	    }
-	    return false;
-	}
-	if (readPoll.revents & (POLLERR | POLLNVAL)) {
-	    return false;
-	}
-	if (!(readPoll.revents & POLLIN)) {
-	    return (readPoll.revents & POLLHUP) != 0;
-	}
-
-	const ssize_t bytesRead = read (readFd, buffer.data (), buffer.size ());
-	if (bytesRead <= 0) {
-	    return true;
-	}
-	output.append (buffer.data (), static_cast<size_t> (bytesRead));
-	if (const auto newline = output.find ('\n'); newline != std::string::npos) {
-	    output.erase (newline + 1);
-	    return true;
-	}
-    }
-
-    return false;
-}
-
-bool waitForProcessUntil (pid_t pid, const std::chrono::steady_clock::time_point& deadline, int& status) {
-    while (std::chrono::steady_clock::now () < deadline) {
-	const pid_t waitResult = waitpid (pid, &status, WNOHANG);
-	if (waitResult == pid) {
-	    return true;
-	}
-	if (waitResult < 0) {
-	    return false;
-	}
-	usleep (1000);
-    }
-
-    kill (pid, SIGKILL);
-    waitpid (pid, &status, 0);
-    return false;
-}
-
-std::optional<std::string> processReadFirstLine (const std::string& program, const std::vector<std::string>& args) {
-    constexpr auto timeout = std::chrono::milliseconds (300);
-    const auto deadline = std::chrono::steady_clock::now () + timeout;
-
-    int outputFd = -1;
-    const auto pid = spawnProcessWithStdout (program, args, outputFd);
-    if (!pid.has_value ()) {
-	return std::string {};
-    }
-
-    std::string output;
-    const bool readCompleted = readFirstLineUntil (outputFd, deadline, output);
-    close (outputFd);
-
-    int status = 0;
-    if (!readCompleted || !waitForProcessUntil (*pid, deadline, status)) {
-	return std::nullopt;
-    }
-    if (!WIFEXITED (status) || WEXITSTATUS (status) != 0) {
-	return std::string {};
-    }
-
-    return trimTrailingNewlines (output);
-}
-
-std::string formatMediaTime (double seconds) {
-    const auto totalSeconds = static_cast<int> (std::max (0.0, std::floor (seconds)));
-    const int hours = totalSeconds / 3600;
-    const int minutes = (totalSeconds % 3600) / 60;
-    const int remainingSeconds = totalSeconds % 60;
-
-    std::ostringstream stream;
-    if (hours > 0) {
-	stream << hours << ":";
-	if (minutes < 10) {
-	    stream << "0";
-	}
-    }
-    stream << minutes << ":";
-    if (remainingSeconds < 10) {
-	stream << "0";
-    }
-    stream << remainingSeconds;
-    return stream.str ();
-}
+ScriptEngine::ScriptEngine (Wallpapers::CScene& scene, Media::MediaSource& mediaSource) :
+    m_scene (scene), m_mediaSource (mediaSource) {
+    this->m_unregisterMediaUpdateCallback
+	= mediaSource.addMetadataListener ([this] (const Media::MediaSource::MediaInfo& info) {
+	      this->notifyMediaUpdate (info);
+	  });
 
-std::string shortenMediaText (const std::string& value, size_t maxLength) {
-    if (value.size () <= maxLength) {
-	return value;
-    }
-    if (maxLength <= 3) {
-	return value.substr (0, maxLength);
-    }
-    return value.substr (0, maxLength - 3) + "...";
-}
-}
+    this->m_unregisterAlbumArtUpdateCallback
+	= mediaSource.addAlbumArtListener ([this] (const Media::MediaSource::MediaInfo& info) {
+	      // TODO: SEPARATE THESE INTO THEIR OWN UPDATES SO JS ONLY RECEIVES THE MEANINGFUL UPDATES
+	      this->notifyMediaUpdate (info);
+	  });
 
-ScriptEngine::ScriptEngine (Wallpapers::CScene& scene) : m_scene (scene) {
     this->m_runtime = JS_NewRuntime ();
 
     if (!this->m_runtime) {
@@ -394,9 +249,7 @@ ScriptEngine::ScriptEngine (Wallpapers::CScene& scene) : m_scene (scene) {
 }
 
 ScriptEngine::~ScriptEngine () {
-    if (this->m_mediaPollFuture.valid ()) {
-	this->m_mediaPollFuture.wait ();
-    }
+    this->m_unregisterMediaUpdateCallback ();
 
     for (const auto& module : this->m_scriptModules | std::views::values) {
 	JS_FreeValue (this->m_context, module.module);
@@ -454,218 +307,6 @@ void ScriptEngine::installBuiltins () {
     this->m_builtinsInstalled = true;
 }
 
-void ScriptEngine::refreshMediaState () {
-    auto buildMediaState = [] () -> std::optional<MediaState> {
-	const auto line = processReadFirstLine (
-	    "playerctl",
-	    { "metadata", "--format",
-	      "{{status}}\t{{title}}\t{{artist}}\t{{mpris:length}}\t{{position}}\t{{mpris:artUrl}}" }
-	);
-
-	if (!line.has_value ()) {
-	    return std::nullopt;
-	}
-
-	MediaState next;
-	if (line->empty ()) {
-	    next.available = false;
-	    next.playbackState = 0;
-	    next.status = "Stopped";
-	    return next;
-	}
-
-	const auto parts = splitTabs (*line);
-	next.available = true;
-	next.status = parts.size () > 0 ? parts[0] : "";
-	next.title = parts.size () > 1 ? parts[1] : "";
-	next.artist = parts.size () > 2 ? parts[2] : "";
-	next.duration = (parts.size () > 3 ? parseDoubleOrZero (parts[3]) : 0.0) / 1000000.0;
-	next.position = (parts.size () > 4 ? parseDoubleOrZero (parts[4]) : 0.0) / 1000000.0;
-	next.artUrl = parts.size () > 5 ? parts[5] : "";
-
-	if (next.status == "Playing") {
-	    next.playbackState = 1;
-	} else if (next.status == "Paused") {
-	    next.playbackState = 2;
-	} else {
-	    next.playbackState = 0;
-	}
-
-	return next;
-    };
-
-    if (this->m_mediaPollFuture.valid ()
-	&& this->m_mediaPollFuture.wait_for (std::chrono::milliseconds (0)) == std::future_status::ready) {
-	try {
-	    auto next = this->m_mediaPollFuture.get ();
-	    if (next.has_value ()) {
-		this->m_mediaState = std::move (*next);
-		if (std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
-		    if (!this->m_mediaState.available) {
-			sLog.out ("Media debug: no playerctl media state");
-		    } else {
-			sLog.out (
-			    "Media debug: status=", this->m_mediaState.status, " title=", this->m_mediaState.title,
-			    " artist=", this->m_mediaState.artist, " duration=", this->m_mediaState.duration,
-			    " position=", this->m_mediaState.position
-			);
-		    }
-		}
-	    }
-	} catch (const std::exception& e) {
-	    if (std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
-		sLog.out ("Media debug: playerctl media poll failed: ", e.what ());
-	    }
-	}
-    }
-
-    if (this->m_mediaPollFuture.valid ()) {
-	return;
-    }
-
-    const auto now = std::chrono::steady_clock::now ();
-    if (this->m_lastMediaPoll.time_since_epoch ().count () != 0
-	&& now - this->m_lastMediaPoll < std::chrono::milliseconds (750)) {
-	return;
-    }
-    this->m_lastMediaPoll = now;
-    this->m_mediaPollFuture = std::async (std::launch::async, buildMediaState);
-}
-
-static JSValue makeVec3Object (JSContext* ctx, float x, float y, float z) {
-    JSValue result = JS_NewObject (ctx);
-    JS_SetPropertyStr (ctx, result, "x", JS_NewFloat64 (ctx, x));
-    JS_SetPropertyStr (ctx, result, "y", JS_NewFloat64 (ctx, y));
-    JS_SetPropertyStr (ctx, result, "z", JS_NewFloat64 (ctx, z));
-    return result;
-}
-
-static bool
-callModuleFunction (JSContext* ctx, JSValue module, const char* name, JSValue event, const char* logContext) {
-    JSValue function = JS_GetPropertyStr (ctx, module, name);
-    if (JS_IsFunction (ctx, function)) {
-	JSValue args[] = { event };
-	JSValue result = JS_Call (ctx, function, JS_UNDEFINED, 1, args);
-	if (JS_IsException (result)) {
-	    logJSException (ctx, logContext);
-	}
-	JS_FreeValue (ctx, result);
-	JS_FreeValue (ctx, function);
-	return true;
-    }
-    JS_FreeValue (ctx, function);
-    return false;
-}
-
-void ScriptEngine::dispatchMediaEvents (JSValue module, const void* bindingKey) {
-    this->refreshMediaState ();
-    JSContext* ctx = this->m_context;
-
-    const std::string propertiesSignature = this->m_mediaState.title + "\n" + this->m_mediaState.artist;
-    if (this->m_lastMediaProperties.find (bindingKey) == this->m_lastMediaProperties.end ()
-	|| this->m_lastMediaProperties[bindingKey] != propertiesSignature) {
-	this->m_lastMediaProperties[bindingKey] = propertiesSignature;
-	JSValue propertiesEvent = JS_NewObject (ctx);
-	JS_SetPropertyStr (ctx, propertiesEvent, "title", JS_NewString (ctx, this->m_mediaState.title.c_str ()));
-	JS_SetPropertyStr (ctx, propertiesEvent, "artist", JS_NewString (ctx, this->m_mediaState.artist.c_str ()));
-	JS_SetPropertyStr (ctx, propertiesEvent, "albumTitle", JS_NewString (ctx, ""));
-	const bool calledProperties
-	    = callModuleFunction (ctx, module, "mediaPropertiesChanged", propertiesEvent, "mediaPropertiesChanged");
-	if (calledProperties && std::getenv ("LWE_MEDIA_DEBUG") != nullptr) {
-	    sLog.out (
-		"Media debug: dispatched properties key=", reinterpret_cast<uintptr_t> (bindingKey),
-		" title=", this->m_mediaState.title, " artist=", this->m_mediaState.artist
-	    );
-	}
-	JS_FreeValue (ctx, propertiesEvent);
-    }
-
-    const std::string playbackSignature = std::to_string (this->m_mediaState.playbackState);
-    if (this->m_lastMediaPlayback[bindingKey] != playbackSignature) {
-	this->m_lastMediaPlayback[bindingKey] = playbackSignature;
-	JSValue event = JS_NewObject (ctx);
-	JS_SetPropertyStr (ctx, event, "state", JS_NewInt32 (ctx, this->m_mediaState.playbackState));
-	callModuleFunction (ctx, module, "mediaPlaybackChanged", event, "mediaPlaybackChanged");
-	JS_FreeValue (ctx, event);
-    }
-
-    const int roundedPosition = static_cast<int> (std::max (0.0, this->m_mediaState.position));
-    const std::string timelineSignature
-	= std::to_string (roundedPosition) + "\n" + std::to_string (this->m_mediaState.duration);
-    if (this->m_lastMediaTimeline.find (bindingKey) == this->m_lastMediaTimeline.end ()
-	|| this->m_lastMediaTimeline[bindingKey] != timelineSignature) {
-	this->m_lastMediaTimeline[bindingKey] = timelineSignature;
-	JSValue timelineEvent = JS_NewObject (ctx);
-	JS_SetPropertyStr (ctx, timelineEvent, "position", JS_NewFloat64 (ctx, this->m_mediaState.position));
-	JS_SetPropertyStr (ctx, timelineEvent, "duration", JS_NewFloat64 (ctx, this->m_mediaState.duration));
-	callModuleFunction (ctx, module, "mediaTimelineChanged", timelineEvent, "mediaTimelineChanged");
-	JS_FreeValue (ctx, timelineEvent);
-    }
-
-    if (this->m_lastMediaThumbnail[bindingKey] != this->m_mediaState.artUrl) {
-	this->m_lastMediaThumbnail[bindingKey] = this->m_mediaState.artUrl;
-	JSValue event = JS_NewObject (ctx);
-	JS_SetPropertyStr (ctx, event, "url", JS_NewString (ctx, this->m_mediaState.artUrl.c_str ()));
-	JS_SetPropertyStr (ctx, event, "primaryColor", makeVec3Object (ctx, 0.12f, 0.12f, 0.12f));
-	JS_SetPropertyStr (ctx, event, "secondaryColor", makeVec3Object (ctx, 0.0f, 0.0f, 0.0f));
-	JS_SetPropertyStr (ctx, event, "tertiaryColor", makeVec3Object (ctx, 0.25f, 0.25f, 0.25f));
-	JS_SetPropertyStr (ctx, event, "highContrastColor", makeVec3Object (ctx, 1.0f, 1.0f, 1.0f));
-	callModuleFunction (ctx, module, "mediaThumbnailChanged", event, "mediaThumbnailChanged");
-	JS_FreeValue (ctx, event);
-    }
-}
-
-void ScriptEngine::callModuleWithProps (JSContext* ctx, JSValue module, const char* name, JSValue propsObj) const {
-    JSValue function = JS_GetPropertyStr (ctx, module, name);
-    if (JS_IsFunction (ctx, function)) {
-	JSValue args[] = { propsObj };
-	JSValue result = JS_Call (ctx, function, JS_UNDEFINED, 1, args);
-	if (JS_IsException (result)) {
-	    logJSException (ctx, name);
-	}
-	JS_FreeValue (ctx, result);
-    }
-    JS_FreeValue (ctx, function);
-}
-
-void ScriptEngine::runIntervals (JSContext* ctx, JSValue globalObj, const std::string& bindingKeyString) const {
-    JSValue runIntervals = JS_GetPropertyStr (ctx, globalObj, "__weRunIntervals");
-    if (JS_IsFunction (ctx, runIntervals)) {
-	JSValue args[] = { JS_NewString (ctx, bindingKeyString.c_str ()) };
-	JSValue intervalResult = JS_Call (ctx, runIntervals, JS_UNDEFINED, 1, args);
-	if (JS_IsException (intervalResult)) {
-	    logJSException (ctx, "setInterval");
-	}
-	JS_FreeValue (ctx, args[0]);
-	JS_FreeValue (ctx, intervalResult);
-    }
-    JS_FreeValue (ctx, runIntervals);
-}
-
-DynamicValueUniquePtr ScriptEngine::fallbackTextValue (const std::string& scriptSource) const {
-    auto fallback = std::make_unique<DynamicValue> ();
-    if (scriptSource.find ("event.title") != std::string::npos) {
-	fallback->update (shortenMediaText (this->m_mediaState.title, 32), DynamicValue::UpdateSource::Script);
-    } else if (scriptSource.find ("event.artist") != std::string::npos) {
-	fallback->update (shortenMediaText (this->m_mediaState.artist, 28), DynamicValue::UpdateSource::Script);
-    } else if (
-	scriptSource.find ("event.position") != std::string::npos
-	|| scriptSource.find ("displayPosition") != std::string::npos
-    ) {
-	fallback->update (formatMediaTime (this->m_mediaState.position), DynamicValue::UpdateSource::Script);
-    } else if (scriptSource.find ("event.duration") != std::string::npos) {
-	fallback->update (formatMediaTime (this->m_mediaState.duration), DynamicValue::UpdateSource::Script);
-    }
-    return fallback;
-}
-
-void ScriptEngine::applyTextFallback (DynamicValue& value, const std::string& scriptSource) const {
-    auto fallback = this->fallbackTextValue (scriptSource);
-    if (fallback->getType () != DynamicValue::Null) {
-	value.update (*fallback, DynamicValue::UpdateSource::Script);
-    }
-}
-
 // ---------------------------------------------------------------------------
 // Layer-script API (Phase 2)
 // ---------------------------------------------------------------------------
@@ -995,4 +636,67 @@ void ScriptEngine::tick () {
 
 	jsToDynamicValue (this->m_context, result, module.value);
     }
+}
+
+void ScriptEngine::notifyMediaUpdate (const Media::MediaSource::MediaInfo& media) {
+    JSContext* ctx = this->m_context;
+
+    DynamicValue primaryColorValue (glm::vec3 (0.12f, 0.12f, 0.12f));
+    DynamicValue secondaryColorValue (glm::vec3 (0.0f, 0.0f, 0.0f));
+    DynamicValue tertiaryColorValue (glm::vec3 (0.25f, 0.25f, 0.25f));
+    DynamicValue highContrastColorValue (glm::vec3 (1.0f, 1.0f, 1.0f));
+
+    // TODO: PROCESS THESE COLORS INSTEAD OF HARDCODING THEM
+    JSValue primaryColor = this->m_adapters.vec3->instantiate (primaryColorValue, true);
+    JSValue secondaryColor = this->m_adapters.vec3->instantiate (secondaryColorValue, true);
+    JSValue tertiaryColor = this->m_adapters.vec3->instantiate (tertiaryColorValue, true);
+    JSValue highContrastColor = this->m_adapters.vec3->instantiate (highContrastColorValue, true);
+
+    JSValue propertiesEvent = JS_NewObject (ctx);
+
+    // set properties
+    JS_SetPropertyStr (ctx, propertiesEvent, "title", JS_NewString (ctx, media.title.c_str ()));
+    JS_SetPropertyStr (ctx, propertiesEvent, "artist", JS_NewString (ctx, media.artist.c_str ()));
+    JS_SetPropertyStr (ctx, propertiesEvent, "albumTitle", JS_NewString (ctx, media.album.c_str ()));
+
+    JSValue playbackEvent = JS_NewObject (ctx);
+
+    JS_SetPropertyStr (ctx, playbackEvent, "state", JS_NewInt32 (ctx, media.playbackState));
+
+    JSValue mediaTimelineEvent = JS_NewObject (ctx);
+
+    JS_SetPropertyStr (ctx, mediaTimelineEvent, "position", JS_NewFloat64 (ctx, media.position));
+    JS_SetPropertyStr (ctx, mediaTimelineEvent, "duration", JS_NewFloat64 (ctx, media.duration));
+
+    JSValue mediaThumbnailEvent = JS_NewObject (ctx);
+
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "hasThumbnail", JS_NewBool (ctx, media.url.has_value ()));
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "primaryColor", primaryColor);
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "secondaryColor", secondaryColor);
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "tertiaryColor", tertiaryColor);
+    JS_SetPropertyStr (ctx, mediaThumbnailEvent, "highContrastColor", highContrastColor);
+
+    JSValue propertiesArgs[] = { propertiesEvent };
+    JSValue playbackArgs[] = { playbackEvent };
+    JSValue mediaTimelineArgs[] = { mediaTimelineEvent };
+    JSValue mediaThumbnailArgs[] = { mediaThumbnailEvent };
+
+    for (auto& module : this->m_scriptModules | std::views::values) {
+	// call all methods
+	JSValue result1 = this->call (module.module, 1, propertiesArgs, "mediaPropertiesChanged");
+	JSValue result2 = this->call (module.module, 1, playbackArgs, "mediaPlaybackChanged");
+	JSValue result3 = this->call (module.module, 1, mediaTimelineArgs, "mediaTimelineChanged");
+	JSValue result4 = this->call (module.module, 1, mediaThumbnailArgs, "mediaThumbnailChanged");
+
+	JS_FreeValue (ctx, result1);
+	JS_FreeValue (ctx, result2);
+	JS_FreeValue (ctx, result3);
+	JS_FreeValue (ctx, result4);
+    }
+
+    // free all created objects as we don't keep a ref to them anymore
+    JS_FreeValue (ctx, propertiesEvent);
+    JS_FreeValue (ctx, playbackEvent);
+    JS_FreeValue (ctx, mediaTimelineEvent);
+    JS_FreeValue (ctx, mediaThumbnailEvent);
 }

+ 10 - 26
src/WallpaperEngine/Scripting/ScriptEngine.h

@@ -18,7 +18,11 @@
 
 #include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Types.h"
+#include "WallpaperEngine/Media/MediaSource.h"
 
+namespace WallpaperEngine::Media {
+class MediaSource;
+}
 extern "C" {
 #include "quickjs.h"
 }
@@ -52,7 +56,7 @@ public:
     };
 
     ~ScriptEngine ();
-    ScriptEngine (Render::Wallpapers::CScene& scene);
+    ScriptEngine (Render::Wallpapers::CScene& scene, Media::MediaSource& mediaSource);
     ScriptEngine (const ScriptEngine&) = delete;
     ScriptEngine& operator= (const ScriptEngine&) = delete;
 
@@ -138,23 +142,8 @@ private:
     JSValue call (JSValue module, int argc, JSValueConst argv[], const char* name);
 
     void installBuiltins ();
-    void refreshMediaState ();
-    void dispatchMediaEvents (JSValue module, const void* bindingKey);
-    void callModuleWithProps (JSContext* ctx, JSValue module, const char* name, JSValue propsObj) const;
-    void runIntervals (JSContext* ctx, JSValue globalObj, const std::string& bindingKeyString) const;
-    DynamicValueUniquePtr fallbackTextValue (const std::string& scriptSource) const;
-    void applyTextFallback (DynamicValue& value, const std::string& scriptSource) const;
-
-    struct MediaState {
-	int playbackState = 0;
-	std::string status = "Stopped";
-	std::string title;
-	std::string artist;
-	std::string artUrl;
-	double duration = 0.0;
-	double position = 0.0;
-	bool available = false;
-    };
+
+    void notifyMediaUpdate (const Media::MediaSource::MediaInfo& media);
 
     // Installs globalThis.__layers and related helpers. Called lazily.
     void ensureLayerRegistry ();
@@ -177,15 +166,10 @@ private:
     ScriptLayerHandle m_nextLayerId = 1;
     bool m_layerRegistryReady = false;
     std::map<ScriptLayerHandle, bool> m_layerInitialized;
-    std::unordered_set<const void*> m_initializedModules = {};
-    std::unordered_map<const void*, std::string> m_lastMediaProperties = {};
-    std::unordered_map<const void*, std::string> m_lastMediaPlayback = {};
-    std::unordered_map<const void*, std::string> m_lastMediaTimeline = {};
-    std::unordered_map<const void*, std::string> m_lastMediaThumbnail = {};
-    std::chrono::steady_clock::time_point m_lastMediaPoll = {};
-    std::future<std::optional<MediaState>> m_mediaPollFuture = {};
-    MediaState m_mediaState = {};
     bool m_builtinsInstalled = false;
+    Media::MediaSource& m_mediaSource;
+    std::function<void ()> m_unregisterMediaUpdateCallback;
+    std::function<void ()> m_unregisterAlbumArtUpdateCallback;
 
     JSObjectAdapters m_adapters;
 };