소스 검색

Add playlist support (#429)

* CMake 3.1 (2014) introduced the GLEW::GLEW imported target in FindGLEW. This provides a more robust linking target than relying on the old GLEW_LIBRARIES variable

* Make listener storage stable (std::list) with safe disconnect guards to prevent crashes on teardown.

* Add playlist definitions, CLI flag, and default playlist support (minutes-based delay, load from config.json).

* Implement playlist rotation with preflight/skip logic, window-mode playlists, safer switching, and refactored candidate selection.

* fix nitpick comments

* Adjust playlist timers across fullscreen pauses to respect updateOnPause semantics and avoid extra skips.

* reuse existing pause flow

* Refactor playlist config loading into helper methods for clarity and reduce complexity

* throw exception if playlist not found when --playlist is specified.

* move prefix check

* fix missing empty check before accessing playlist.items.front()

* Adjusted playlist and initial wallpaper setup to avoid mutating scaling/clamp maps during lookup.

* broke playlist parsing into small helpers: parsePlaylistSettings, collectPlaylistItems, and configFilePath/parseConfigJson were already there; buildPlaylistDefinition now just orchestrates and bails on empty items.

* the default playlist now registers with the path we actually loaded in window/explicit mode.

* removed unused variable
RFYates 9 달 전
부모
커밋
22cd4f1392

+ 236 - 0
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -2,10 +2,14 @@
 
 #include "Steam/FileSystem/FileSystem.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Data/JSON.h"
 
+#include <algorithm>
 #include <cstdlib>
 #include <cstring>
+#include <fstream>
 #include <iostream>
+#include <string_view>
 
 #include <argparse/argparse.hpp>
 
@@ -13,6 +17,216 @@
 #define APP_DIRECTORY "wallpaper_engine"
 
 using namespace WallpaperEngine::Application;
+using WallpaperEngine::Data::JSON::JSON;
+
+std::filesystem::path ApplicationContext::resolvePlaylistItemPath (const std::string& raw) const {
+    if (raw.empty ())
+        return {};
+
+    std::string cleaned = raw;
+
+    
+
+    constexpr std::string_view windowsPrefix = "\\\\?\\";
+
+    if (cleaned.rfind (windowsPrefix, 0) == 0)
+        cleaned = cleaned.substr (windowsPrefix.length ());
+
+    std::replace (cleaned.begin (), cleaned.end (), '\\', '/');
+
+    if (cleaned.size () > 1 && cleaned[1] == ':')
+        cleaned = cleaned.substr (2);
+
+    if (!cleaned.empty () && cleaned.front () != '/')
+        cleaned.insert (cleaned.begin (), '/');
+
+    std::filesystem::path path = std::filesystem::path (cleaned).lexically_normal ();
+
+    if (std::filesystem::is_regular_file (path))
+        path = path.parent_path ();
+
+    return path;
+}
+
+std::filesystem::path ApplicationContext::configFilePath () const {
+    try {
+        return Steam::FileSystem::appDirectory (APP_DIRECTORY, "") / "config.json";
+    } catch (std::runtime_error&) {
+        sLog.exception ("Cannot locate wallpaper engine installation to read config.json");
+        return {};
+    }
+}
+
+std::optional<JSON> ApplicationContext::parseConfigJson (const std::filesystem::path& path) const {
+    if (path.empty ())
+        return std::nullopt;
+
+    std::ifstream configFile (path);
+
+    if (!configFile.is_open ()) {
+        sLog.exception ("Cannot open wallpaper engine config file at ", path);
+        return std::nullopt;
+    }
+
+    try {
+        return JSON::parse (configFile);
+    } catch (const std::exception& e) {
+        sLog.error ("Failed parsing wallpaper engine config.json: ", e.what ());
+        return std::nullopt;
+    }
+}
+
+std::optional<ApplicationContext::PlaylistDefinition>
+ApplicationContext::buildPlaylistDefinition (const JSON& playlistJson, const std::string& fallbackName) const {
+    PlaylistDefinition definition;
+    definition.name = playlistJson.optional<std::string> ("name", fallbackName);
+    definition.settings = this->parsePlaylistSettings (playlistJson);
+    definition.items = this->collectPlaylistItems (playlistJson, definition.name.empty () ? fallbackName : definition.name);
+
+    if (definition.items.empty ()) {
+        return std::nullopt;
+    }
+
+    if (definition.name.empty ()) {
+        if (fallbackName.empty ()) {
+            sLog.error ("Skipping playlist with no name");
+            return std::nullopt;
+        }
+
+        definition.name = fallbackName;
+    }
+
+    return definition;
+}
+
+ApplicationContext::PlaylistSettings ApplicationContext::parsePlaylistSettings (const JSON& playlistJson) const {
+    PlaylistSettings settings;
+    const auto settingsJson = playlistJson.optional ("settings");
+    settings.delayMinutes = settingsJson ? settingsJson->optional<uint32_t> ("delay", 60) : 60;
+    settings.mode = settingsJson ? settingsJson->optional<std::string> ("mode", "timer") : "timer";
+    settings.order = settingsJson ? settingsJson->optional<std::string> ("order", "sequential") : "sequential";
+    settings.updateOnPause = settingsJson ? settingsJson->optional<bool> ("updateonpause", false) : false;
+    settings.videoSequence = settingsJson ? settingsJson->optional<bool> ("videosequence", false) : false;
+    return settings;
+}
+
+std::vector<std::filesystem::path>
+ApplicationContext::collectPlaylistItems (const JSON& playlistJson, const std::string& name) const {
+    std::vector<std::filesystem::path> items;
+    const auto jsonItems = playlistJson.optional ("items");
+
+    if (!jsonItems.has_value () || !jsonItems->is_array ()) {
+        sLog.error ("Skipping playlist ", name, ": missing items");
+        return items;
+    }
+
+    for (const auto& rawItem : *jsonItems) {
+        if (!rawItem.is_string ())
+            continue;
+
+        auto resolvedPath = this->resolvePlaylistItemPath (rawItem.get<std::string> ());
+
+        if (resolvedPath.empty ())
+            continue;
+
+        if (!std::filesystem::exists (resolvedPath)) {
+            sLog.error ("Skipping playlist item not found: ", resolvedPath.string ());
+            continue;
+        }
+
+        items.push_back (resolvedPath);
+    }
+
+    if (items.empty ()) {
+        sLog.error ("Skipping playlist ", name, ": no usable items found");
+    }
+
+    return items;
+}
+
+void ApplicationContext::registerPlaylist (PlaylistDefinition&& definition) {
+    this->m_configPlaylists.insert_or_assign (definition.name, std::move (definition));
+}
+
+void ApplicationContext::loadPlaylistsFromConfig () {
+    if (this->m_loadedConfigPlaylists)
+        return;
+
+    this->m_loadedConfigPlaylists = true;
+
+    const auto configPath = this->configFilePath ();
+    const auto root = this->parseConfigJson (configPath);
+    if (!root.has_value ())
+        return;
+
+    const auto steamUser = root->optional ("steamuser");
+
+    if (!steamUser.has_value ()) {
+        sLog.exception ("Cannot find steamuser section in config.json");
+    }
+
+    auto addPlaylist = [this](const JSON& playlistJson, const std::string& fallbackName) {
+        auto definition = this->buildPlaylistDefinition (playlistJson, fallbackName);
+        if (!definition)
+            return;
+        this->registerPlaylist (std::move (*definition));
+    };
+
+    if (const auto general = steamUser->optional ("general")) {
+        if (const auto playlists = general->optional ("playlists")) {
+            for (const auto& playlist : *playlists) {
+                try {
+                    addPlaylist (playlist, playlist.optional<std::string> ("name", ""));
+                } catch (const std::exception& e) {
+                    sLog.error ("Failed parsing playlist: ", e.what ());
+                }
+            }
+        }
+    }
+
+    if (const auto wallpaperConfig = steamUser->optional ("wallpaperconfig")) {
+        if (const auto selected = wallpaperConfig->optional ("selectedwallpapers")) {
+            for (const auto& entry : selected->items ()) {
+                const auto playlist = entry.value ().optional ("playlist");
+
+                if (!playlist.has_value ())
+                    continue;
+
+                try {
+                    addPlaylist (*playlist, entry.key ());
+                } catch (const std::exception& e) {
+                    sLog.error ("Failed parsing playlist for ", entry.key (), ": ", e.what ());
+                }
+            }
+        }
+    }
+}
+
+const ApplicationContext::PlaylistDefinition&
+ApplicationContext::getPlaylistFromConfig (const std::string& name) {
+    if (!this->m_loadedConfigPlaylists) {
+        this->loadPlaylistsFromConfig ();
+    }
+
+    const auto cur = this->m_configPlaylists.find (name);
+
+    if (cur == this->m_configPlaylists.end ()) {
+        std::string available;
+
+        for (auto it = this->m_configPlaylists.begin (); it != this->m_configPlaylists.end (); ++it) {
+            available += it->first;
+            if (std::next (it) != this->m_configPlaylists.end ())
+                available += ", ";
+        }
+
+        const std::string availableText = available.empty () ? "" : std::string (". Available: ") + available;
+
+        sLog.exception ("Playlist not found in config.json: ", name, availableText);
+        throw std::runtime_error ("Playlist not found: " + name);
+    }
+
+    return cur->second;
+}
 
 ApplicationContext::ApplicationContext (int argc, char* argv []) :
     m_argc (argc),
@@ -89,6 +303,28 @@ ApplicationContext::ApplicationContext (int argc, char* argv []) :
                 this->settings.general.defaultBackground = translateBackground (value);
             })
             .append ();
+        backgroundGroup.add_argument ("--playlist")
+            .help ("Uses a playlist from wallpaper engine's config.json. If used after --screen-root it is applied to that screen, otherwise it is used in window mode.")
+            .action ([this, &lastScreen](const std::string& value) -> void {
+                const auto& playlist = this->getPlaylistFromConfig (value);
+
+                if (lastScreen.empty ()) {
+                    this->settings.general.defaultPlaylist = playlist;
+                    if (this->settings.general.defaultBackground.empty () && !playlist.items.empty ()) {
+                        this->settings.general.defaultBackground = playlist.items.front ();
+                    }
+                } else {
+                    this->settings.general.screenPlaylists [lastScreen] = playlist;
+                    if (!playlist.items.empty ()) {
+                        this->settings.general.screenBackgrounds [lastScreen] = playlist.items.front ();
+                    }
+
+                    if (this->settings.general.defaultBackground.empty () && !playlist.items.empty ()) {
+                        this->settings.general.defaultBackground = playlist.items.front ();
+                    }
+                }
+            })
+            .append ();
         backgroundGroup.add_argument ("--scaling")
             .help ("Scaling mode to use when rendering the background, this applies to the previous --window or --screen-root output, or the default background if no other background is specified")
             .choices ("stretch", "fit", "fill", "default")

+ 39 - 1
src/WallpaperEngine/Application/ApplicationContext.h

@@ -1,13 +1,16 @@
 #pragma once
 
+#include <cstdint>
 #include <filesystem>
 #include <map>
 #include <string>
+#include <optional>
 #include <vector>
 
 #include <glm/vec4.hpp>
 
 #include "ApplicationState.h"
+#include "WallpaperEngine/Data/JSON.h"
 
 #include "../Render/TextureProvider.h"
 #include "WallpaperEngine/Render/WallpaperState.h"
@@ -32,6 +35,20 @@ class ApplicationContext {
         EXPLICIT_WINDOW = 2,
     };
 
+    struct PlaylistSettings {
+        uint32_t delayMinutes = 60;
+        std::string mode = "timer";
+        std::string order = "sequential";
+        bool updateOnPause = false;
+        bool videoSequence = false;
+    };
+
+    struct PlaylistDefinition {
+        std::string name;
+        std::vector<std::filesystem::path> items;
+        PlaylistSettings settings;
+    };
+
     struct {
         /**
          * General settings
@@ -53,6 +70,10 @@ class ApplicationContext {
             std::map<std::string, WallpaperEngine::Render::WallpaperState::TextureUVsScaling> screenScalings;
             /** The clamping mode for different screens */
             std::map<std::string, TextureFlags> screenClamps;
+            /** Playlists selected per screen */
+            std::map<std::string, PlaylistDefinition> screenPlaylists;
+            /** Playlist used in window mode */
+            std::optional<PlaylistDefinition> defaultPlaylist;
         } general;
 
         /**
@@ -119,6 +140,8 @@ class ApplicationContext {
             .properties = {},
             .screenScalings = {},
             .screenClamps = {},
+            .screenPlaylists = {},
+            .defaultPlaylist = std::nullopt,
         },
         .render = {
             .mode = NORMAL_WINDOW,
@@ -175,5 +198,20 @@ class ApplicationContext {
      * @return
      */
     static std::filesystem::path translateBackground (const std::string& bgIdOrPath);
+
+    void loadPlaylistsFromConfig ();
+    std::filesystem::path resolvePlaylistItemPath (const std::string& raw) const;
+    std::filesystem::path configFilePath () const;
+    std::optional<WallpaperEngine::Data::JSON::JSON> parseConfigJson (const std::filesystem::path& path) const;
+    PlaylistSettings parsePlaylistSettings (const WallpaperEngine::Data::JSON::JSON& playlistJson) const;
+    std::vector<std::filesystem::path> collectPlaylistItems (const WallpaperEngine::Data::JSON::JSON& playlistJson,
+                                                             const std::string& name) const;
+    std::optional<PlaylistDefinition> buildPlaylistDefinition (const WallpaperEngine::Data::JSON::JSON& playlistJson,
+                                                               const std::string& fallbackName) const;
+    void registerPlaylist (PlaylistDefinition&& definition);
+    [[nodiscard]] const PlaylistDefinition& getPlaylistFromConfig (const std::string& name);
+
+    std::map<std::string, PlaylistDefinition> m_configPlaylists;
+    bool m_loadedConfigPlaylists = false;
 };
-} // namespace WallpaperEngine::Application
+} // namespace WallpaperEngine::Application

+ 285 - 4
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -19,6 +19,8 @@
 #include "recording.h"
 #endif /* DEMOMODE */
 
+#include <algorithm>
+#include <numeric>
 #include <unistd.h>
 #define STB_IMAGE_WRITE_IMPLEMENTATION
 #include <stb_image_write.h>
@@ -39,6 +41,7 @@ WallpaperApplication::WallpaperApplication (ApplicationContext& context) :
     this->loadBackgrounds ();
     this->setupProperties ();
     this->setupBrowser();
+    this->initializePlaylists ();
 }
 
 AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string& bg) const {
@@ -200,7 +203,14 @@ AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string
 void WallpaperApplication::loadBackgrounds () {
     if (this->m_context.settings.render.mode == ApplicationContext::NORMAL_WINDOW ||
         this->m_context.settings.render.mode == ApplicationContext::EXPLICIT_WINDOW) {
-        this->m_backgrounds ["default"] = this->loadBackground (this->m_context.settings.general.defaultBackground);
+        auto path = this->m_context.settings.general.defaultBackground;
+
+        if (this->m_context.settings.general.defaultPlaylist.has_value () &&
+            !this->m_context.settings.general.defaultPlaylist->items.empty ()) {
+            path = this->m_context.settings.general.defaultPlaylist->items.front ();
+        }
+
+        this->m_backgrounds ["default"] = this->loadBackground (path);
         return;
     }
 
@@ -221,6 +231,250 @@ ProjectUniquePtr WallpaperApplication::loadBackground (const std::string& bg) {
     return WallpaperEngine::Data::Parsers::ProjectParser::parse (json, std::move(container));
 }
 
+std::vector<std::size_t> WallpaperApplication::buildPlaylistOrder (
+    const ApplicationContext::PlaylistDefinition& definition) {
+    std::vector<std::size_t> order (definition.items.size ());
+    std::iota (order.begin (), order.end (), 0);
+
+    if (definition.settings.order == "random") {
+        std::shuffle (order.begin (), order.end (), this->m_playlistRng);
+    }
+
+    return order;
+}
+
+void WallpaperApplication::initializePlaylists () {
+    const bool hasDefaultPlaylist = this->m_context.settings.general.defaultPlaylist.has_value ();
+    const bool hasScreenPlaylists = !this->m_context.settings.general.screenPlaylists.empty ();
+
+    if (!hasDefaultPlaylist && !hasScreenPlaylists)
+        return;
+
+    const auto now = std::chrono::steady_clock::now ();
+
+    auto registerPlaylist = [this, now](const std::string& key, const ApplicationContext::PlaylistDefinition& playlist,
+                                        std::optional<std::filesystem::path> currentPath) {
+        if (playlist.items.empty ())
+            return;
+
+        ActivePlaylist state;
+
+        state.definition = playlist;
+        state.order = this->buildPlaylistOrder (playlist);
+
+        if (state.order.empty ())
+            return;
+
+        if (currentPath.has_value ()) {
+            state.orderIndex = 0;
+
+            for (std::size_t i = 0; i < state.order.size (); i ++) {
+                if (playlist.items [state.order [i]] == currentPath.value ()) {
+                    state.orderIndex = i;
+                    break;
+                }
+            }
+        }
+
+        const uint32_t delayMinutes = std::max<uint32_t> (1, state.definition.settings.delayMinutes);
+        state.nextSwitch = now + std::chrono::minutes (delayMinutes);
+        state.lastUpdate = now;
+
+        this->m_activePlaylists.insert_or_assign (key, std::move (state));
+    };
+
+    if (hasDefaultPlaylist &&
+        (this->m_context.settings.render.mode == ApplicationContext::NORMAL_WINDOW ||
+         this->m_context.settings.render.mode == ApplicationContext::EXPLICIT_WINDOW)) {
+        const auto& playlist = this->m_context.settings.general.defaultPlaylist.value ();
+        const auto currentPath = playlist.items.empty ()
+            ? std::optional<std::filesystem::path> {this->m_context.settings.general.defaultBackground}
+            : std::optional<std::filesystem::path> {playlist.items.front ()};
+        registerPlaylist ("default", playlist, currentPath);
+    }
+
+    for (const auto& [screen, playlist] : this->m_context.settings.general.screenPlaylists) {
+        const auto current = this->m_context.settings.general.screenBackgrounds.find (screen);
+        const auto currentPath = current != this->m_context.settings.general.screenBackgrounds.end ()
+            ? std::optional<std::filesystem::path> {current->second}
+            : std::nullopt;
+        registerPlaylist (screen, playlist, currentPath);
+    }
+}
+
+void WallpaperApplication::ensureBrowserForProject (const Project& project) {
+    if (!project.wallpaper->is<Web> ())
+        return;
+
+    if (!this->m_browserContext) {
+        this->m_browserContext = std::make_unique <WebBrowser::WebBrowserContext> (*this);
+    }
+}
+
+bool WallpaperApplication::makeAnyViewportCurrent () const {
+    if (!this->m_renderContext)
+        return false;
+
+    const auto& viewports = this->m_renderContext->getOutput ().getViewports ();
+
+    if (viewports.empty ())
+        return false;
+
+    viewports.begin ()->second->makeCurrent ();
+    return true;
+}
+
+bool WallpaperApplication::preflightWallpaper (const std::string& path) {
+    try {
+        // avoid mutating state, just ensure project.json parses
+        auto container = this->setupAssetLocator (path);
+        const auto json = WallpaperEngine::Data::JSON::JSON::parse (container->readString ("project.json"));
+        if (!json.contains ("type") || !json.contains ("file")) {
+            sLog.error ("Preflight failed for ", path, ": missing required fields");
+            return false;
+        }
+        return true;
+    } catch (const std::exception& e) {
+        sLog.error ("Preflight failed for ", path, ": ", e.what ());
+        return false;
+    }
+}
+
+bool WallpaperApplication::selectNextCandidate (ActivePlaylist& playlist, std::size_t& outOrderIndex) {
+    if (playlist.order.empty ())
+        return false;
+
+    std::size_t attempts = 0;
+    std::size_t candidateOrderIndex = outOrderIndex;
+
+    while (attempts < playlist.order.size ()) {
+        const auto candidateIndex = playlist.order [candidateOrderIndex];
+
+        if (!playlist.failedIndices.contains (candidateIndex)) {
+            outOrderIndex = candidateOrderIndex;
+            return true;
+        }
+
+        attempts++;
+        candidateOrderIndex = (candidateOrderIndex + 1) % playlist.order.size ();
+    }
+
+    return false;
+}
+
+void WallpaperApplication::advancePlaylist (
+    const std::string& screen, ActivePlaylist& playlist,
+    const std::chrono::steady_clock::time_point& now) {
+    if (playlist.order.empty ())
+        return;
+
+    playlist.orderIndex = (playlist.orderIndex + 1) % playlist.order.size ();
+
+    if (playlist.orderIndex == 0 && playlist.definition.settings.order == "random") {
+        std::shuffle (playlist.order.begin (), playlist.order.end (), this->m_playlistRng);
+    }
+
+    std::size_t candidateOrderIndex = playlist.orderIndex;
+
+    if (!this->selectNextCandidate (playlist, candidateOrderIndex)) {
+        sLog.error ("All playlist items failed for ", screen, ", keeping current wallpaper");
+        const uint32_t delayMinutes = std::max<uint32_t> (1, playlist.definition.settings.delayMinutes);
+        playlist.nextSwitch = now + std::chrono::minutes (delayMinutes);
+        return;
+    }
+
+    const auto candidateIndex = playlist.order [candidateOrderIndex];
+    const auto& candidatePath = playlist.definition.items [candidateIndex];
+
+    if (!this->preflightWallpaper (candidatePath.string ())) {
+        playlist.failedIndices.insert (candidateIndex);
+
+        if (!this->selectNextCandidate (playlist, candidateOrderIndex)) {
+            sLog.error ("All playlist items failed for ", screen, ", keeping current wallpaper");
+            const uint32_t delayMinutes = std::max<uint32_t> (1, playlist.definition.settings.delayMinutes);
+            playlist.nextSwitch = now + std::chrono::minutes (delayMinutes);
+            return;
+        }
+    }
+
+    playlist.orderIndex = candidateOrderIndex;
+    const auto& nextPath = playlist.definition.items [playlist.order [playlist.orderIndex]];
+
+    bool loaded = false;
+
+    try {
+        if (!this->makeAnyViewportCurrent ()) {
+            sLog.error ("Cannot switch playlist on ", screen, ": no active viewport");
+            throw std::runtime_error ("No viewport available");
+        }
+
+        auto project = this->loadBackground (nextPath.string ());
+
+        this->setupPropertiesForProject (*project);
+        this->ensureBrowserForProject (*project);
+
+        this->m_backgrounds [screen] = std::move (project);
+
+        const auto scalingIt = this->m_context.settings.general.screenScalings.find (screen);
+        const auto clampIt = this->m_context.settings.general.screenClamps.find (screen);
+        const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
+                                 ? scalingIt->second
+                                 : this->m_context.settings.render.window.scalingMode;
+        const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
+                               ? clampIt->second
+                               : this->m_context.settings.render.window.clamp;
+
+        if (this->m_renderContext) {
+            this->m_renderContext->setWallpaper (
+                screen,
+                WallpaperEngine::Render::CWallpaper::fromWallpaper (
+                    *this->m_backgrounds [screen]->wallpaper,
+                    *this->m_renderContext, *this->m_audioContext, this->m_browserContext.get (),
+                    scaling,
+                    clamp
+                )
+            );
+        }
+
+        this->m_context.settings.general.screenBackgrounds [screen] = nextPath;
+        loaded = true;
+    } catch (const std::exception& e) {
+        sLog.error ("Failed to advance playlist on ", screen, ": ", e.what ());
+    }
+
+    if (!loaded) {
+        playlist.failedIndices.insert (playlist.order [playlist.orderIndex]);
+
+        // Keep current position; next timer tick will retry advancement
+        sLog.error ("Failed to load wallpaper for ", screen, ", will retry on next cycle");
+    }
+
+    const uint32_t delayMinutes = std::max<uint32_t> (1, playlist.definition.settings.delayMinutes);
+    playlist.nextSwitch = now + std::chrono::minutes (delayMinutes);
+}
+
+void WallpaperApplication::updatePlaylists () {
+    if (this->m_activePlaylists.empty ())
+        return;
+
+    const auto now = std::chrono::steady_clock::now ();
+
+    for (auto& [screen, playlist] : this->m_activePlaylists) {
+        playlist.lastUpdate = now;
+
+        if (playlist.definition.settings.mode != "timer")
+            continue;
+
+        if (playlist.definition.items.size () <= 1)
+            continue;
+
+        if (now < playlist.nextSwitch)
+            continue;
+
+        this->advancePlaylist (screen, playlist, now);
+    }
+}
+
 void WallpaperApplication::setupPropertiesForProject (const Project& project) {
     // show properties if required
     for (const auto& [key, cur] : project.properties) {
@@ -253,7 +507,7 @@ void WallpaperApplication::setupBrowser () {
     );
 
     // do not perform any initialization if no web background is present
-    if (!anyWebProject) {
+    if (!anyWebProject || this->m_browserContext) {
         return;
     }
 
@@ -379,12 +633,21 @@ void WallpaperApplication::prepareOutputs () {
 
     // set all the specific wallpapers required
     for (const auto& [background, info] : this->m_backgrounds) {
+        const auto scalingIt = this->m_context.settings.general.screenScalings.find (background);
+        const auto clampIt = this->m_context.settings.general.screenClamps.find (background);
+        const auto scaling = scalingIt != this->m_context.settings.general.screenScalings.end ()
+                                 ? scalingIt->second
+                                 : this->m_context.settings.render.window.scalingMode;
+        const auto clamp = clampIt != this->m_context.settings.general.screenClamps.end ()
+                               ? clampIt->second
+                               : this->m_context.settings.render.window.clamp;
+
         m_renderContext->setWallpaper (
             background,
             WallpaperEngine::Render::CWallpaper::fromWallpaper (
                 *info->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (),
-                this->m_context.settings.general.screenScalings [background],
-                this->m_context.settings.general.screenClamps [background]
+                scaling,
+                clamp
             )
         );
     }
@@ -471,12 +734,30 @@ void WallpaperApplication::show () {
 #endif /* DEMOMODE */
         // check for fullscreen windows and wait until there's none fullscreen
         if (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning) {
+            this->m_isPaused = true;
+            this->m_pauseStart = std::chrono::steady_clock::now ();
+
             m_renderContext->setPause (true);
             while (this->m_fullScreenDetector->anythingFullscreen () && this->m_context.state.general.keepRunning)
                 usleep (FULLSCREEN_CHECK_WAIT_TIME);
             m_renderContext->setPause (false);
+
+            // account for paused duration in playlist timers
+            const auto pausedNow = std::chrono::steady_clock::now ();
+            const auto pausedDuration = pausedNow - this->m_pauseStart;
+
+            for (auto& [_, playlist] : this->m_activePlaylists) {
+                if (!playlist.definition.settings.updateOnPause) {
+                    playlist.nextSwitch += pausedDuration;
+                    playlist.lastUpdate += pausedDuration;
+                }
+            }
+
+            this->m_isPaused = false;
         }
 
+        this->updatePlaylists ();
+
         if (!this->m_context.settings.screenshot.take || m_videoDriver->getFrameCounter () < this->m_context.settings.screenshot.delay)
             continue;
 

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

@@ -1,5 +1,8 @@
 #pragma once
 
+#include <chrono>
+#include <random>
+
 #include "WallpaperEngine/Application/ApplicationContext.h"
 #include "WallpaperEngine/Assets/AssetLocator.h"
 
@@ -16,6 +19,8 @@
 
 #include "WallpaperEngine/Data/Model/Types.h"
 
+#include <set>
+
 namespace WallpaperEngine::Application {
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
@@ -104,10 +109,30 @@ class WallpaperApplication {
      */
     void takeScreenshot (const std::filesystem::path& filename) const;
 
+    struct ActivePlaylist {
+        ApplicationContext::PlaylistDefinition definition;
+        std::vector<std::size_t> order;
+        std::size_t orderIndex = 0;
+        std::chrono::steady_clock::time_point nextSwitch;
+        std::chrono::steady_clock::time_point lastUpdate;
+        std::set<std::size_t> failedIndices;
+    };
+
+    void initializePlaylists ();
+    void updatePlaylists ();
+    void advancePlaylist (const std::string& screen, ActivePlaylist& playlist,
+                          const std::chrono::steady_clock::time_point& now);
+    bool selectNextCandidate (ActivePlaylist& playlist, std::size_t& outOrderIndex);
+    bool preflightWallpaper (const std::string& path);
+    std::vector<std::size_t> buildPlaylistOrder (const ApplicationContext::PlaylistDefinition& definition);
+    void ensureBrowserForProject (const Project& project);
+    bool makeAnyViewportCurrent () const;
+
     /** The application context that contains the current app settings */
     ApplicationContext& m_context;
     /** Maps screens to backgrounds */
     std::map<std::string, ProjectUniquePtr> m_backgrounds {};
+    std::map<std::string, ActivePlaylist> m_activePlaylists {};
 
     std::unique_ptr <WallpaperEngine::Audio::Drivers::Detectors::AudioPlayingDetector> m_audioDetector = nullptr;
     std::unique_ptr <WallpaperEngine::Audio::AudioContext> m_audioContext = nullptr;
@@ -117,5 +142,8 @@ class WallpaperApplication {
     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::mt19937 m_playlistRng {std::random_device {} ()};
+    bool m_isPaused = false;
+    std::chrono::steady_clock::time_point m_pauseStart {};
 };
 } // namespace WallpaperEngine::Application

+ 15 - 3
src/WallpaperEngine/Data/Model/DynamicValue.cpp

@@ -1,4 +1,5 @@
 #include "DynamicValue.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine::Data::Model;
 
@@ -39,6 +40,8 @@ DynamicValue::DynamicValue (bool value) {
 }
 
 DynamicValue::~DynamicValue () {
+    if (this->m_aliveFlag)
+        *this->m_aliveFlag = false;
     this->disconnect ();
     this->m_listeners.clear ();
 }
@@ -307,8 +310,11 @@ void DynamicValue::update () {
 
 std::function<void ()> DynamicValue::listen (const std::function<void (const DynamicValue&)>& callback) {
     const auto it = this->m_listeners.insert (this->m_listeners.end (), callback);
+    auto alive = this->m_aliveFlag;
 
-    return [this, it] {
+    return [this, it, alive] {
+        if (!alive || !*alive)
+            return;
         this->m_listeners.erase (it);
     };
 }
@@ -334,7 +340,13 @@ void DynamicValue::connect (DynamicValue* other) {
 
 void DynamicValue::disconnect () {
     for (const auto& deregister : this->m_connections) {
-        deregister ();
+        if (!deregister)
+            continue;
+        try {
+            deregister ();
+        } catch (...) {
+            sLog.error ("Exception during listener deregistration");
+        }
     }
 
     this->m_connections.clear ();
@@ -349,4 +361,4 @@ void DynamicValue::propagate () const {
     for (const auto& callback : this->m_listeners) {
         callback(*this);
     }
-}
+}

+ 7 - 4
src/WallpaperEngine/Data/Model/DynamicValue.h

@@ -1,9 +1,11 @@
 #pragma once
 
-#include <glm/glm.hpp>
-#include <string>
 #include <functional>
+#include <glm/glm.hpp>
+#include <list>
+#include <memory>
 #include <optional>
+#include <string>
 
 namespace WallpaperEngine::Data::Model {
 struct ConditionInfo {
@@ -108,7 +110,8 @@ class DynamicValue {
      */
     void propagate () const;
 
-    std::vector<std::function<void (const DynamicValue&)>> m_listeners = {};
+    std::shared_ptr<bool> m_aliveFlag = std::make_shared<bool> (true);
+    std::list<std::function<void (const DynamicValue&)>> m_listeners = {};
     std::vector<std::function<void ()>> m_connections = {};
 
     glm::ivec4 m_ivec4 = {};
@@ -124,4 +127,4 @@ class DynamicValue {
     UnderlyingType m_type = Null;
     std::optional<ConditionInfo> m_condition = std::nullopt;
 };
-}
+}