ソースを参照

added hotswap features, scaling, better video handling

UwU 2 ヶ月 前
親
コミット
653f97cc7a
34 ファイル変更、1186 行追加、486 行削除
  1. 3 1
      CMakeLists.txt
  2. 29 3
      README.md
  3. 115 18
      src/WallpaperEngine/Application/ApplicationContext.cpp
  4. 42 65
      src/WallpaperEngine/Application/ApplicationContext.h
  5. 6 0
      src/WallpaperEngine/Application/ApplicationState.h
  6. 398 68
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  7. 53 86
      src/WallpaperEngine/Application/WallpaperApplication.h
  8. 9 9
      src/WallpaperEngine/Data/Builders/ColorBuilder.cpp
  9. 2 0
      src/WallpaperEngine/Data/Model/Object.h
  10. 1 0
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  11. 2 0
      src/WallpaperEngine/Media/MediaSource.h
  12. 45 15
      src/WallpaperEngine/Render/CFBO.cpp
  13. 15 1
      src/WallpaperEngine/Render/CFBO.h
  14. 16 38
      src/WallpaperEngine/Render/CWallpaper.cpp
  15. 14 82
      src/WallpaperEngine/Render/CWallpaper.h
  16. 3 3
      src/WallpaperEngine/Render/FBOProvider.cpp
  17. 2 1
      src/WallpaperEngine/Render/FBOProvider.h
  18. 8 2
      src/WallpaperEngine/Render/Objects/CImage.cpp
  19. 7 1
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  20. 24 6
      src/WallpaperEngine/Render/Objects/CText.cpp
  21. 0 4
      src/WallpaperEngine/Render/Objects/CText.h
  22. 55 6
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  23. 6 0
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  24. 86 5
      src/WallpaperEngine/Render/WallpaperState.cpp
  25. 26 65
      src/WallpaperEngine/Render/WallpaperState.h
  26. 6 0
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  27. 2 0
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  28. 3 0
      src/WallpaperEngine/Render/Wallpapers/CVideo.h
  29. 44 0
      src/WallpaperEngine/Testing/Cases/CornerColor.cpp
  30. 1 0
      src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp
  31. 114 0
      src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
  32. 39 3
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
  33. 9 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
  34. 1 4
      src/main.cpp

+ 3 - 1
CMakeLists.txt

@@ -582,7 +582,9 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.cpp
         src/WallpaperEngine/Testing/Harnesses/RenderHarness.h
         src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp
-        src/WallpaperEngine/Testing/Cases/PropertyParser.cpp)
+        src/WallpaperEngine/Testing/Cases/PropertyParser.cpp
+        src/WallpaperEngine/Testing/Cases/ScalingModes.cpp
+        src/WallpaperEngine/Testing/Cases/CornerColor.cpp)
 endif()
 
 add_library(

+ 29 - 3
README.md

@@ -191,19 +191,25 @@ If you're one of those developers, feel free to open an issue to get your projec
 |--------|-------------|
 | `--silent` | Mute background audio |
 | `--volume <val>` | Set audio volume |
-| `--noautomute` | Don't mute when other apps play audio |
+| `--automute` | Mute when other apps play audio |
+| `--noautomute` | Don't mute when other apps play audio (default) |
 | `--no-audio-processing` | Disable audio reactive features |
 | `--fps <val>` | Limit frame rate |
 | `--window <XxYxWxH>` | Run in windowed mode with custom size/position |
 | `--screen-root <screen>` | Set as background for specific screen |
 | `--screen-span <screen-1>,<screen-2>,...` | Stretch a single wallpaper across multiple screens |
 | `--bg <id/path>` | Assign a background to a specific screen (use after `--screen-root`/`--screen-span`) |
-| `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, or `default` |
-| `--clamping <mode>` | Set texture clamping: `clamp`, `border`, `repeat` |
+| `--scaling <mode>` | Wallpaper scaling: `stretch`, `fit`, `fill`, `center`, or `default` |
+| `--zoom <factor>` | Manual zoom layered on top of `--scaling`, e.g. `1.5` (zoom in) or `0.5` (zoom out) |
+| `--clamp <mode>` | Set texture clamping: `clamp` (edge), `border`, `repeat`. Default: `border` |
+| `--corner-color <hex>` | Color shown outside the wallpaper's bounds when `--clamp` is `border` (the default), as `RRGGBB`/`RRGGBBAA`. Default: `000000` (opaque black) |
 | `--assets-dir <path>` | Set custom path for assets |
 | `--screenshot <file>` | Save screenshot (PNG, JPEG, BMP) |
 | `--list-properties` | Show customizable properties of a wallpaper |
 | `--set-property name=value` | Override a specific property |
+| `--list-objects` | List every object/layer a background has, with its id, name and type |
+| `--disable-object <id/name>` | Hide an object/layer (parallax layer, clock, particles, etc), repeatable |
+| `--enable-object <id/name>` | Force an object/layer to show even if the background hides it by default, repeatable |
 | `--disable-mouse` | Disable mouse interaction |
 | `--disable-parallax` | Disable parallax effect on backgrounds that support it |
 | `--no-fullscreen-pause` | Prevent pausing while fullscreen apps are running |
@@ -315,6 +321,26 @@ Any of these values can be modified with the --set-property switch. Say you want
 linux-wallpaperengine --set-property bloom=1 2370927443
 ```
 
+#### Disable or enable individual objects/layers
+
+Not every wallpaper exposes a property for each layer it has (e.g. a parallax background layer or a clock).
+`--list-objects` shows every object the background is made of, with its id, name and type:
+```bash
+linux-wallpaperengine --list-objects 2370927443
+```
+```
+Objects for default:
+  1 - Background (image)
+  2 - Clock (text)
+  3 - Rain (particle)
+```
+
+Use `--disable-object` (or `--enable-object`) with either the id or the name to toggle a specific layer,
+regardless of whether the wallpaper's author exposed a property for it. Both switches can be repeated:
+```
+linux-wallpaperengine --disable-object Clock --disable-object 3 2370927443
+```
+
 ---
 
 ## 🧪 Wayland & X11 Support

+ 115 - 18
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -3,6 +3,7 @@
 #include "Steam/FileSystem/FileSystem.h"
 #include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/CFBO.h"
 
 #include <algorithm>
 #include <cstdlib>
@@ -244,6 +245,36 @@ const ApplicationContext::PlaylistDefinition& ApplicationContext::getPlaylistFro
 
 ApplicationContext::ApplicationContext (int argc, char* argv[]) : m_argc (argc), m_argv (argv) { }
 
+namespace {
+bool matchesObjectToken (const std::string& token, int id, const std::string& name) {
+    if (token == name) {
+	return true;
+    }
+
+    try {
+	return std::stoi (token) == id;
+    } catch (const std::exception&) {
+	return false;
+    }
+}
+} // namespace
+
+std::optional<bool> ApplicationContext::resolveObjectVisibility (int id, const std::string& name) const {
+    for (const auto& token : this->settings.general.disabledObjects) {
+	if (matchesObjectToken (token, id, name)) {
+	    return false;
+	}
+    }
+
+    for (const auto& token : this->settings.general.enabledObjects) {
+	if (matchesObjectToken (token, id, name)) {
+	    return true;
+	}
+    }
+
+    return std::nullopt;
+}
+
 void ApplicationContext::loadSettingsFromArgv () {
     std::string lastScreen;
 
@@ -405,22 +436,16 @@ void ApplicationContext::loadSettingsFromArgv () {
 	    "Scaling mode to use when rendering the background, this applies to the previous --window, --screen-root, "
 	    "or --screen-span output, or the default background if no other background is specified"
 	)
-	.choices ("stretch", "fit", "fill", "default")
+	.choices ("stretch", "fit", "fill", "center", "default")
 	.action ([this, &lastScreen] (const std::string& value) -> void {
-	    WallpaperEngine::Render::WallpaperState::TextureUVsScaling mode;
-
-	    if (value == "stretch") {
-		mode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::StretchUVs;
-	    } else if (value == "fit") {
-		mode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::ZoomFitUVs;
-	    } else if (value == "fill") {
-		mode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::ZoomFillUVs;
-	    } else if (value == "default") {
-		mode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs;
-	    } else {
+	    const auto parsed = WallpaperEngine::Render::WallpaperState::parseScalingMode (value);
+
+	    if (!parsed.has_value ()) {
 		sLog.exception ("Invalid scaling mode: ", value);
 	    }
 
+	    const auto mode = *parsed;
+
 	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
 		this->settings.general.screenScalings[lastScreen] = mode;
 		// also update span group if targeting one
@@ -463,6 +488,59 @@ void ApplicationContext::loadSettingsFromArgv () {
 	})
 	.append ();
 
+    backgroundGroup.add_argument ("--zoom")
+	.help (
+	    "Manual zoom factor layered on top of --scaling (e.g. 1.5 zooms in 50%, 0.5 zooms out to half size), "
+	    "this applies to the previous --window, --screen-root, or --screen-span output, or the default "
+	    "background if no other background is specified"
+	)
+	.action ([this, &lastScreen] (const std::string& value) -> void {
+	    float zoom;
+
+	    try {
+		zoom = std::stof (value);
+	    } catch (const std::exception&) {
+		sLog.exception ("Invalid zoom value: ", value);
+	    }
+
+	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
+		this->settings.general.screenZooms[lastScreen] = zoom;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().zoom = zoom;
+		}
+	    } else {
+		this->settings.render.window.zoom = zoom;
+	    }
+	})
+	.append ();
+
+    backgroundGroup.add_argument ("--corner-color")
+	.help (
+	    "Color to show outside the wallpaper's bounds (Center/Fit letterboxing, zoomed-out scaling) when "
+	    "--clamp is border, as a hex RGB or RGBA value (e.g. \"000000\" or \"#1a1a1aff\"). Default: opaque "
+	    "black. This applies to the previous --window, --screen-root, or --screen-span output, or the "
+	    "default background if no other background is specified"
+	)
+	.action ([this, &lastScreen] (const std::string& value) -> void {
+	    const auto color = WallpaperEngine::Render::CFBO::parseColor (value);
+
+	    if (!color.has_value ()) {
+		sLog.exception ("Invalid corner color: ", value);
+	    }
+
+	    if (this->settings.render.mode == DESKTOP_BACKGROUND) {
+		this->settings.general.screenCornerColors[lastScreen] = *color;
+		// also update span group if targeting one
+		if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		    this->settings.general.spanGroups.back ().cornerColor = *color;
+		}
+	    } else {
+		this->settings.render.window.cornerColor = *color;
+	    }
+	})
+	.append ();
+
     backgroundGroup.add_argument ("--layer")
 	.help (
 	    "Wayland-only: which wlr-layer-shell layer to anchor the wallpaper to "
@@ -491,7 +569,7 @@ void ApplicationContext::loadSettingsFromArgv () {
 
     performanceGroup.add_argument ("-f", "--fps")
 	.help ("Limits the FPS to the given number, useful to keep battery consumption low")
-	.default_value (30)
+	.default_value (60)
 	.store_into (this->settings.render.maximumFPS);
 
     performanceGroup.add_argument ("--no-fullscreen-pause")
@@ -528,8 +606,13 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.audio.enabled = false; });
 
+    audioGroup.add_argument ("--automute")
+	.help ("Mutes the wallpaper's audio when another app is playing sound")
+	.flag ()
+	.action ([this] (const std::string& value) -> void { this->settings.audio.automute = true; });
+
     audioGroup.add_argument ("--noautomute")
-	.help ("Disables the automute when an app is playing sound")
+	.help ("Disables the automute when an app is playing sound (default)")
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.audio.automute = false; });
 
@@ -595,6 +678,24 @@ void ApplicationContext::loadSettingsFromArgv () {
 	})
 	.append ();
 
+    configurationGroup.add_argument ("--list-objects")
+	.help ("List all the objects/layers a background has, with their id, name and type")
+	.flag ()
+	.store_into (this->settings.general.onlyListObjects);
+
+    configurationGroup.add_argument ("--disable-object")
+	.help ("Hides an object/layer (parallax layer, clock, particles, etc), matched by id or name. Can be repeated")
+	.action ([this] (const std::string& value) -> void { this->settings.general.disabledObjects.push_back (value); }
+	)
+	.append ();
+
+    configurationGroup.add_argument ("--enable-object")
+	.help ("Forces an object/layer to show even if the background hides it by default, matched by id or name. "
+	       "Can be repeated")
+	.action ([this] (const std::string& value) -> void { this->settings.general.enabledObjects.push_back (value); }
+	)
+	.append ();
+
     auto& debuggingGroup = program.add_group ("Debugging options");
 
     debuggingGroup.add_argument ("-z", "--dump-structure")
@@ -702,11 +803,9 @@ void ApplicationContext::loadSettingsFromArgv () {
 	}
 
 	std::cout << buffer.str () << std::endl;
-	// perform some extra validation on the inputs
 	this->validateAssets ();
 	this->validateScreenshot ();
 
-	// setup application state
 	this->state.general.keepRunning = true;
 	this->state.audio.enabled = this->settings.audio.enabled;
 	this->state.audio.volume = this->settings.audio.volume;
@@ -714,7 +813,6 @@ void ApplicationContext::loadSettingsFromArgv () {
 
 #if DEMOMODE
 	sLog.error ("WARNING: RUNNING IN DEMO MODE WILL STOP WALLPAPERS AFTER 5 SECONDS SO VIDEO CAN BE RECORDED");
-	// special settings for demomode
 	this->settings.render.maximumFPS = 30;
 	this->settings.screenshot.take = false;
 	this->settings.render.pauseOnFullscreen = false;
@@ -749,7 +847,6 @@ void ApplicationContext::validateAssets () {
     try {
 	this->settings.general.assets = Steam::FileSystem::appDirectory (APP_DIRECTORY, "assets");
     } catch (std::runtime_error&) {
-	// set current path as assets' folder
 	this->settings.general.assets = std::filesystem::canonical ("/proc/self/exe").parent_path () / "assets";
     }
 }

+ 42 - 65
src/WallpaperEngine/Application/ApplicationContext.h

@@ -19,24 +19,24 @@
 
 namespace WallpaperEngine::Application {
 using namespace WallpaperEngine::Data::Assets;
-/**
- * Application information as parsed off the command line arguments
- */
 class ApplicationContext {
 public:
     ApplicationContext (int argc, char* argv[]);
 
+    void loadSettingsFromArgv ();
+
     /**
-     * Parses the given argc and argv and builds settings for the app
+     * Resolves whether an object/layer should be force-shown or force-hidden based on the
+     * --disable-object/--enable-object overrides, matching id or name against either.
+     *
+     * @return true to force visible, false to force hidden, nullopt to leave the scene's own value alone
      */
-    void loadSettingsFromArgv ();
+    [[nodiscard]] std::optional<bool> resolveObjectVisibility (int id, const std::string& name) const;
 
     enum WINDOW_MODE {
-	/** Default window mode */
 	NORMAL_WINDOW = 0,
 	/** Draw to the window server desktop */
 	DESKTOP_BACKGROUND = 1,
-	/** Explicit window mode with specified geometry */
 	EXPLICIT_WINDOW = 2,
     };
 
@@ -71,33 +71,35 @@ public:
 	std::filesystem::path background;
 	WallpaperEngine::Render::WallpaperState::TextureUVsScaling scaling
 	    = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs;
-	TextureFlags clamp = TextureFlags_ClampUVs;
+	TextureFlags clamp = TextureFlags_ClampUVsBorder;
+	/** Manual zoom factor layered on top of the scaling mode, see --zoom */
+	float zoom = 1.0f;
+	/** Color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
+	glm::vec4 cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
     };
 
     struct {
-	/**
-	 * General settings
-	 */
+	// General settings
 	struct {
-	    /** If the user requested a list of properties for the given background */
 	    bool onlyListProperties;
-	    /** If the user requested a dump of the background structure */
+	    bool onlyListObjects;
 	    bool dumpStructure;
-	    /** If the user requested the particles to be deactivated */
 	    bool disableParticles;
-	    /** The path to the assets folder */
+	    /** Objects/layers to force-hide, matched by id or name */
+	    std::vector<std::string> disabledObjects;
+	    /** Objects/layers to force-show, matched by id or name */
+	    std::vector<std::string> enabledObjects;
 	    std::filesystem::path assets;
 	    /** Background to load (provided as the final argument) as fallback for multi-screen setups */
 	    std::filesystem::path defaultBackground;
-	    /** The backgrounds specified for different screens */
 	    std::map<std::string, std::filesystem::path> screenBackgrounds;
-	    /** Properties to change values for */
 	    std::map<std::string, std::string> properties;
-	    /** The scaling mode for different screens */
 	    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 */
+	    /** Manual zoom factor for different screens, layered on top of the scaling mode */
+	    std::map<std::string, float> screenZooms;
+	    /** Corner color for different screens, shown outside the wallpaper's bounds when clamp is border */
+	    std::map<std::string, glm::vec4> screenCornerColors;
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
@@ -105,15 +107,10 @@ public:
 	    std::vector<SpanGroup> spanGroups;
 	} general;
 
-	/**
-	 * Render settings
-	 */
+	// Render settings
 	struct {
-	    /** The mode to run the background in */
 	    WINDOW_MODE mode;
-	    /** Maximum FPS */
 	    int maximumFPS;
-	    /** Indicates if pausing should happen when something goes fullscreen */
 	    bool pauseOnFullscreen;
 	    /**
 	     * Wayland-only: if true, only consider fullscreen toplevels that are also activated.
@@ -125,7 +122,6 @@ public:
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     */
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
-	    /** Render debugging switches for scene compatibility work */
 	    struct {
 		bool baseOnly;
 		bool noSolidFinal;
@@ -136,70 +132,64 @@ public:
 	    } debug;
 
 	    struct {
-		/** The window size used in explicit window */
 		glm::ivec4 geometry;
 		TextureFlags clamp;
 		WallpaperEngine::Render::WallpaperState::TextureUVsScaling scalingMode;
+		/** Manual zoom factor layered on top of scalingMode, see --zoom */
+		float zoom;
+		/** Corner color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
+		glm::vec4 cornerColor;
 	    } window;
 
 	    struct {
-		/** Which wlr-layer-shell layer to use for desktop backgrounds */
 		WAYLAND_LAYER layer;
 	    } wayland;
 	} render;
 
-	/**
-	 * Audio settings
-	 */
+	// Audio settings
 	struct {
-	    /** If the audio system is enabled */
 	    bool enabled;
-	    /** Sound volume (0-128) */
+	    /** 0-128 */
 	    int volume;
-	    /** If the audio must be muted if something else is playing sound */
 	    bool automute;
-	    /** If audio processing can be enabled or not */
 	    bool audioprocessing;
 	} audio;
 
-	/**
-	 * Mouse input settings
-	 */
+	// Mouse input settings
 	struct {
-	    /** If the mouse movement is enabled */
 	    bool enabled;
-	    /** If the mouse parallax should be disabled */
 	    bool disableparallax;
 	} mouse;
 
-	/**
-	 * Screenshot settings
-	 */
+	// Screenshot settings
 	struct {
-	    /** If an screenshot should be taken */
 	    bool take;
-	    /** The frames to wait until the screenshot is taken */
+	    /** In frames, not seconds */
 	    uint32_t delay;
-	    /** The path to where the screenshot must be saved */
 	    std::filesystem::path path;
 	} screenshot;
     } settings = {
         .general = {
             .onlyListProperties = false,
+            .onlyListObjects = false,
             .dumpStructure = false,
+            .disabledObjects = {},
+            .enabledObjects = {},
             .assets = "",
             .defaultBackground = "",
             .screenBackgrounds = {},
             .properties = {},
             .screenScalings = {},
             .screenClamps = {},
+            .screenZooms = {},
+            .screenCornerColors = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
             .spanGroups = {},
         },
         .render = {
             .mode = NORMAL_WINDOW,
-            .maximumFPS = 30,
+            .maximumFPS = 60,
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
@@ -213,8 +203,10 @@ public:
 	            },
             .window = {
                 .geometry = {},
-                .clamp = TextureFlags_ClampUVs,
+                .clamp = TextureFlags_ClampUVsBorder,
                 .scalingMode = WallpaperEngine::Render::WallpaperState::TextureUVsScaling::DefaultUVs,
+                .zoom = 1.0f,
+                .cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f },
             },
             .wayland = {
                 .layer = WAYLAND_LAYER_BOTTOM,
@@ -223,7 +215,7 @@ public:
         .audio = {
             .enabled = true,
             .volume = 15,
-            .automute = true,
+            .automute = false,
             .audioprocessing = true,
         },
         .mouse = {
@@ -243,27 +235,12 @@ public:
     [[nodiscard]] char** getArgv () const;
 
 private:
-    /** Program argument count on startup */
     int m_argc;
-    /** Program arguments on startup */
     char** m_argv;
 
-    /**
-     * Validates the assets folder and ensures a valid one is present
-     */
     void validateAssets ();
-
-    /**
-     * Validates the screenshot settings
-     */
     void validateScreenshot () const;
 
-    /**
-     * Validates a background parameter and returns the real bgIdOrPath to it
-     *
-     * @param bgIdOrPath
-     * @return
-     */
     static std::filesystem::path translateBackground (const std::string& bgIdOrPath);
 
     void loadPlaylistsFromConfig ();

+ 6 - 0
src/WallpaperEngine/Application/ApplicationState.h

@@ -20,5 +20,11 @@ public:
     struct {
 	bool enabled;
     } mouse {};
+
+    struct {
+	/** If true, xray-style effects (Wallpaper Engine's built-in "effects/xray") render fully revealed
+	 * everywhere instead of following the mouse pointer */
+	bool fullReveal;
+    } xray {};
 };
 } // namespace WallpaperEngine::Application

+ 398 - 68
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -8,6 +8,7 @@
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Drivers/VideoFactories.h"
 #include "WallpaperEngine/Render/RenderContext.h"
+#include "WallpaperEngine/Render/Wallpapers/CVideo.h"
 
 #include "WallpaperEngine/Data/Dumpers/StringPrinter.h"
 #include "WallpaperEngine/Data/Parsers/ProjectParser.h"
@@ -69,12 +70,12 @@ WallpaperApplication::WallpaperApplication (ApplicationContext& context) : m_con
     this->initializeSubsystems ();
     this->loadBackgrounds ();
     this->setupProperties ();
+    this->listObjects ();
     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));
 }
 
@@ -101,22 +102,15 @@ AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string
 	sLog.exception ("Cannot find a valid assets folder, resolved to ", this->m_context.settings.general.assets);
     }
 
-    // mount the current directory as root
     try {
 	container->mount (std::filesystem::current_path (), "/");
     } catch (std::runtime_error&) { }
 
     auto& vfs = container->getVFS ();
 
-    //
-    // Had to get a little creative with the effects to achieve the same bloom effect without any custom code
-    // these virtual files are loaded by an image in the scene that takes current _rt_FullFrameBuffer and
-    // applies the bloom effect to render it out to the screen
-    //
-
-    // add the effect file for screen bloom
-
-    // add some model for the image element even if it's going to waste rendering cycles
+    // Had to get a little creative with the effects to achieve the same bloom effect without any custom code:
+    // these virtual files are loaded by an image in the scene that takes the current _rt_FullFrameBuffer and
+    // applies the bloom effect to render it out to the screen.
     vfs.add (
 	"effects/wpenginelinux/bloomeffect.json",
 	{ { "name", "camerabloom_wpengine_linux" },
@@ -145,6 +139,7 @@ AssetLocatorUniquePtr WallpaperApplication::setupAssetLocator (const std::string
 	  } }
     );
 
+    // Wastes a render pass on an image element that exists only to host the bloom material above
     vfs.add ("models/wpenginelinux.json", { { "material", "materials/wpenginelinux.json" } });
 
     vfs.add (
@@ -450,13 +445,13 @@ void WallpaperApplication::advancePlaylist (
 	    : 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
-		)
+	    auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+		*this->m_backgrounds[screen]->wallpaper, *this->m_renderContext, *this->m_audioContext,
+		this->m_browserContext.get (), scaling, clamp
 	    );
+	    wallpaper->setZoom (this->resolveScreenZoom (screen));
+	    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+	    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 	}
 
 	this->m_context.settings.general.screenBackgrounds[screen] = nextPath;
@@ -502,6 +497,107 @@ void WallpaperApplication::updatePlaylists () {
     }
 }
 
+namespace {
+/** Everything a control-file request can carry; any field left unset means "don't touch this" */
+struct HotswapRequest {
+    std::optional<std::string> path;
+    /** True once a "layers=1" line was seen, meaning disabledObjects/enabledObjects below are a full replacement */
+    bool layersProvided = false;
+    std::vector<std::string> disabledObjects;
+    std::vector<std::string> enabledObjects;
+    std::optional<int> volume;
+    /** "on"/"off"/"toggle" (also "1"/"0"/"true"/"false" for on/off) */
+    std::optional<std::string> xray;
+    /** "stretch"/"fit"/"fill"/"center"/"default" */
+    std::optional<std::string> scaling;
+    /** floating-point zoom factor layered on top of scaling, e.g. "1.5" */
+    std::optional<std::string> zoom;
+    /** "on"/"off"/"toggle" (also "1"/"0"/"true"/"false" for on/off) */
+    std::optional<std::string> disableParallax;
+    /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
+    std::optional<std::string> cornerColor;
+};
+
+std::string trimHotswapToken (const std::string& value) {
+    std::size_t begin = 0;
+    std::size_t end = value.size ();
+
+    while (begin < end && std::isspace (static_cast<unsigned char> (value[begin]))) {
+	begin++;
+    }
+
+    while (end > begin && std::isspace (static_cast<unsigned char> (value[end - 1]))) {
+	end--;
+    }
+
+    return value.substr (begin, end - begin);
+}
+
+/**
+ * Parses the control file. Supports the original bare-path-on-one-line format for backwards
+ * compatibility, plus key=value lines (path/layers/disable-object/enable-object/volume/xray/scaling/zoom/
+ * disable-parallax/corner-color) so a single request can carry more than just the background path.
+ */
+HotswapRequest parseHotswapRequest (std::istream& file) {
+    HotswapRequest request;
+    std::string rawLine;
+
+    while (std::getline (file, rawLine)) {
+	const std::string line = trimHotswapToken (rawLine);
+
+	if (line.empty ()) {
+	    continue;
+	}
+
+	const auto separator = line.find ('=');
+
+	if (separator == std::string::npos) {
+	    // legacy format: the whole line is the new background path
+	    if (!request.path.has_value ()) {
+		request.path = line;
+	    }
+
+	    continue;
+	}
+
+	const std::string key = trimHotswapToken (line.substr (0, separator));
+	const std::string value = trimHotswapToken (line.substr (separator + 1));
+
+	if (key == "path") {
+	    request.path = value;
+	} else if (key == "layers") {
+	    request.layersProvided = true;
+	} else if (key == "disable-object") {
+	    request.layersProvided = true;
+	    request.disabledObjects.push_back (value);
+	} else if (key == "enable-object") {
+	    request.layersProvided = true;
+	    request.enabledObjects.push_back (value);
+	} else if (key == "volume") {
+	    try {
+		request.volume = std::stoi (value);
+	    } catch (const std::exception&) {
+		sLog.error ("Hotswap: ignoring invalid volume value: ", value);
+	    }
+	} else if (key == "xray") {
+	    request.xray = value;
+	} else if (key == "scaling") {
+	    request.scaling = value;
+	} else if (key == "zoom") {
+	    request.zoom = value;
+	} else if (key == "disable-parallax") {
+	    request.disableParallax = value;
+	} else if (key == "corner-color") {
+	    request.cornerColor = value;
+	} else {
+	    sLog.error ("Hotswap: ignoring unknown control file key: ", key);
+	}
+    }
+
+    return request;
+}
+} // namespace
+
 void WallpaperApplication::checkHotswapRequest () {
     if (!this->m_hotswapRequested.exchange (false)) {
 	return;
@@ -517,20 +613,51 @@ void WallpaperApplication::checkHotswapRequest () {
 	return;
     }
 
-    std::string newPath;
-    std::getline (file, newPath);
+    const auto request = parseHotswapRequest (file);
 
-    while (!newPath.empty () && std::isspace (static_cast<unsigned char> (newPath.back ()))) {
-	newPath.pop_back ();
+    if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
+	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
+	&& !request.disableParallax.has_value () && !request.cornerColor.has_value ()) {
+	sLog.error ("Hotswap requested but control file was empty");
+	return;
     }
 
-    if (newPath.empty ()) {
-	sLog.error ("Hotswap requested but control file was empty");
+    if (request.volume.has_value ()) {
+	this->applyVolumeHotswap (*request.volume);
+    }
+
+    if (request.xray.has_value ()) {
+	this->applyXrayHotswap (*request.xray);
+    }
+
+    if (request.scaling.has_value ()) {
+	this->applyScalingHotswap (*request.scaling);
+    }
+
+    if (request.zoom.has_value ()) {
+	this->applyZoomHotswap (*request.zoom);
+    }
+
+    if (request.disableParallax.has_value ()) {
+	this->applyParallaxHotswap (*request.disableParallax);
+    }
+
+    if (request.cornerColor.has_value ()) {
+	this->applyCornerColorHotswap (*request.cornerColor);
+    }
+
+    if (request.layersProvided) {
+	this->m_context.settings.general.disabledObjects = request.disabledObjects;
+	this->m_context.settings.general.enabledObjects = request.enabledObjects;
+    }
+
+    // volume/xray-only requests never touch the loaded project, so there's nothing left to reload
+    if (!request.path.has_value () && !request.layersProvided) {
 	return;
     }
 
-    if (!this->preflightWallpaper (newPath)) {
-	sLog.error ("Hotswap failed, invalid wallpaper at ", newPath);
+    if (request.path.has_value () && !this->preflightWallpaper (*request.path)) {
+	sLog.error ("Hotswap failed, invalid wallpaper at ", *request.path);
 	return;
     }
 
@@ -539,11 +666,17 @@ void WallpaperApplication::checkHotswapRequest () {
 	return;
     }
 
-    sLog.out ("Hotswapping wallpaper to ", newPath);
+    if (request.path.has_value ()) {
+	sLog.out ("Hotswapping wallpaper to ", *request.path);
+    } else {
+	sLog.out ("Hotswapping wallpaper layers");
+    }
 
     for (auto& [screen, background] : this->m_backgrounds) {
+	const std::string targetPath = request.path.value_or (this->resolveScreenBackgroundPath (screen));
+
 	try {
-	    auto project = this->loadBackground (newPath);
+	    auto project = this->loadBackground (targetPath);
 
 	    this->setupPropertiesForProject (*project);
 	    this->ensureBrowserForProject (*project);
@@ -560,36 +693,216 @@ void WallpaperApplication::checkHotswapRequest () {
 		: this->m_context.settings.render.window.clamp;
 
 	    if (this->m_renderContext) {
-		this->m_renderContext->setWallpaper (
-		    screen,
-		    WallpaperEngine::Render::CWallpaper::fromWallpaper (
-			*background->wallpaper, *this->m_renderContext, *this->m_audioContext,
-			this->m_browserContext.get (), scaling, clamp
-		    )
+		auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+		    *background->wallpaper, *this->m_renderContext, *this->m_audioContext,
+		    this->m_browserContext.get (), scaling, clamp
 		);
+		wallpaper->setZoom (this->resolveScreenZoom (screen));
+		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 	    }
 
-	    if (screen.starts_with ("span:")) {
-		for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
-		    if (!spanGroup.screens.empty () && "span:" + spanGroup.screens.front () == screen) {
-			spanGroup.background = newPath;
+	    if (request.path.has_value ()) {
+		if (screen.starts_with ("span:")) {
+		    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+			if (!spanGroup.screens.empty () && "span:" + spanGroup.screens.front () == screen) {
+			    spanGroup.background = *request.path;
+			}
 		    }
+		} else {
+		    this->m_context.settings.general.screenBackgrounds[screen] = *request.path;
 		}
-	    } else {
-		this->m_context.settings.general.screenBackgrounds[screen] = newPath;
 	    }
 	} catch (const std::exception& e) {
 	    sLog.error ("Hotswap failed for screen ", screen, ": ", e.what ());
 	}
     }
 
-    this->m_context.settings.general.defaultBackground = newPath;
+    if (request.path.has_value ()) {
+	this->m_context.settings.general.defaultBackground = *request.path;
+    }
+}
+
+std::string WallpaperApplication::resolveScreenBackgroundPath (const std::string& screen) const {
+    if (screen.starts_with ("span:")) {
+	for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	    if (!spanGroup.screens.empty () && "span:" + spanGroup.screens.front () == screen) {
+		return spanGroup.background.empty () ? this->m_context.settings.general.defaultBackground.string ()
+						      : spanGroup.background.string ();
+	    }
+	}
+
+	return this->m_context.settings.general.defaultBackground.string ();
+    }
+
+    const auto it = this->m_context.settings.general.screenBackgrounds.find (screen);
+
+    if (it != this->m_context.settings.general.screenBackgrounds.end () && !it->second.empty ()) {
+	return it->second.string ();
+    }
+
+    return this->m_context.settings.general.defaultBackground.string ();
+}
+
+float WallpaperApplication::resolveScreenZoom (const std::string& screen) const {
+    const auto it = this->m_context.settings.general.screenZooms.find (screen);
+
+    return it != this->m_context.settings.general.screenZooms.end () ? it->second
+								      : this->m_context.settings.render.window.zoom;
+}
+
+glm::vec4 WallpaperApplication::resolveScreenCornerColor (const std::string& screen) const {
+    const auto it = this->m_context.settings.general.screenCornerColors.find (screen);
+
+    return it != this->m_context.settings.general.screenCornerColors.end ()
+	? it->second
+	: this->m_context.settings.render.window.cornerColor;
+}
+
+void WallpaperApplication::applyVolumeHotswap (int volume) {
+    volume = std::max (0, std::min (volume, 128));
+
+    this->m_context.settings.audio.volume = volume;
+    this->m_context.state.audio.volume = volume;
+
+    if (!this->m_renderContext) {
+	return;
+    }
+
+    const double scaledVolume = this->m_context.settings.audio.enabled ? volume * 100.0 / 128.0 : 0.0;
+
+    for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	if (wallpaper->is<WallpaperEngine::Render::Wallpapers::CVideo> ()) {
+	    wallpaper->as<WallpaperEngine::Render::Wallpapers::CVideo> ()->setVolume (scaledVolume);
+	}
+    }
+
+    sLog.out ("Hotswap: applied volume ", volume, " live");
+}
+
+void WallpaperApplication::applyXrayHotswap (const std::string& value) {
+    bool newState;
+
+    if (value == "toggle") {
+	newState = !this->m_context.state.xray.fullReveal;
+    } else if (value == "on" || value == "1" || value == "true") {
+	newState = true;
+    } else if (value == "off" || value == "0" || value == "false") {
+	newState = false;
+    } else {
+	sLog.error ("Hotswap: ignoring invalid xray value: ", value);
+	return;
+    }
+
+    this->m_context.state.xray.fullReveal = newState;
+
+    sLog.out ("Hotswap: full xray ", newState ? "enabled" : "disabled", " live");
+}
+
+void WallpaperApplication::applyScalingHotswap (const std::string& value) {
+    const auto mode = WallpaperEngine::Render::WallpaperState::parseScalingMode (value);
+
+    if (!mode.has_value ()) {
+	sLog.error ("Hotswap: ignoring invalid scaling value: ", value);
+	return;
+    }
+
+    this->m_context.settings.render.window.scalingMode = *mode;
+
+    for (auto& [screen, scaling] : this->m_context.settings.general.screenScalings) {
+	scaling = *mode;
+    }
+
+    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	spanGroup.scaling = *mode;
+    }
+
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    wallpaper->setScalingMode (*mode);
+	}
+    }
+
+    sLog.out ("Hotswap: applied scaling mode ", value, " live");
+}
+
+void WallpaperApplication::applyZoomHotswap (const std::string& value) {
+    float zoom;
+
+    try {
+	zoom = std::stof (value);
+    } catch (const std::exception&) {
+	sLog.error ("Hotswap: ignoring invalid zoom value: ", value);
+	return;
+    }
+
+    this->m_context.settings.render.window.zoom = zoom;
+
+    for (auto& [screen, screenZoom] : this->m_context.settings.general.screenZooms) {
+	screenZoom = zoom;
+    }
+
+    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	spanGroup.zoom = zoom;
+    }
+
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    wallpaper->setZoom (zoom);
+	}
+    }
+
+    sLog.out ("Hotswap: applied zoom ", zoom, " live");
+}
+
+void WallpaperApplication::applyParallaxHotswap (const std::string& value) {
+    bool newState;
+
+    if (value == "toggle") {
+	newState = !this->m_context.settings.mouse.disableparallax;
+    } else if (value == "on" || value == "1" || value == "true") {
+	newState = true;
+    } else if (value == "off" || value == "0" || value == "false") {
+	newState = false;
+    } else {
+	sLog.error ("Hotswap: ignoring invalid disable-parallax value: ", value);
+	return;
+    }
+
+    this->m_context.settings.mouse.disableparallax = newState;
+
+    sLog.out ("Hotswap: parallax ", newState ? "force-disabled" : "enabled", " live");
+}
+
+void WallpaperApplication::applyCornerColorHotswap (const std::string& value) {
+    const auto color = WallpaperEngine::Render::CFBO::parseColor (value);
+
+    if (!color.has_value ()) {
+	sLog.error ("Hotswap: ignoring invalid corner color: ", value);
+	return;
+    }
+
+    this->m_context.settings.render.window.cornerColor = *color;
+
+    for (auto& [screen, screenColor] : this->m_context.settings.general.screenCornerColors) {
+	screenColor = *color;
+    }
+
+    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	spanGroup.cornerColor = *color;
+    }
+
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    wallpaper->setCornerColor (*color);
+	}
+    }
+
+    sLog.out ("Hotswap: applied corner color ", value, " live");
 }
 
 void WallpaperApplication::setupPropertiesForProject (const Project& project) {
-    // show properties if required
     for (const auto& [key, cur] : project.properties) {
-	// update the value of the property
 	auto override = this->m_context.settings.general.properties.find (key);
 
 	if (override != this->m_context.settings.general.properties.end ()) {
@@ -610,6 +923,42 @@ void WallpaperApplication::setupProperties () {
     }
 }
 
+void WallpaperApplication::listObjectsForProject (const std::string& background, const Project& project) const {
+    if (!project.wallpaper->is<Scene> ()) {
+	return;
+    }
+
+    const auto scene = project.wallpaper->as<Scene> ();
+
+    sLog.out ("Objects for ", background, ":");
+
+    for (const auto& object : scene->objects) {
+	std::string type = "unknown";
+
+	if (object->is<Image> ()) {
+	    type = "image";
+	} else if (object->is<Particle> ()) {
+	    type = "particle";
+	} else if (object->is<Text> ()) {
+	    type = "text";
+	} else if (object->is<Sound> ()) {
+	    type = "sound";
+	}
+
+	sLog.out ("  ", object->id, " - ", object->name, " (", type, ")");
+    }
+}
+
+void WallpaperApplication::listObjects () const {
+    if (!this->m_context.settings.general.onlyListObjects) {
+	return;
+    }
+
+    for (const auto& [background, info] : this->m_backgrounds) {
+	this->listObjectsForProject (background, *info);
+    }
+}
+
 void WallpaperApplication::setupBrowser () {
     bool anyWebProject = std::any_of (
 	this->m_backgrounds.begin (), this->m_backgrounds.end (),
@@ -618,7 +967,6 @@ void WallpaperApplication::setupBrowser () {
 	}
     );
 
-    // do not perform any initialization if no web background is present
     if (!anyWebProject || this->m_browserContext) {
 	return;
     }
@@ -646,10 +994,8 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
     int currentXOffset = 0;
 
     for (const auto& [screen, viewport] : this->m_renderContext->getOutput ().getViewports ()) {
-	// activate opengl context so we can read from the framebuffer
 	viewport->makeCurrent ();
 
-	// find the wallpaper for this screen to read from its FBO
 	const auto wallpaperIt = wallpapers.find (screen);
 	if (wallpaperIt == wallpapers.end ()) {
 	    sLog.error ("Cannot find wallpaper for screen ", screen);
@@ -667,13 +1013,11 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 	// ensure rendering is complete before reading
 	glFinish ();
 
-	// make room for storing the pixel of this viewport
 	const int readWidth = wallpaper->getWidth ();
 	const int readHeight = wallpaper->getHeight ();
 	const auto bufferSize = readWidth * readHeight * 3;
 	auto* buffer = new uint8_t[bufferSize];
 
-	// read the FBO data into the pixel buffer
 	glPixelStorei (GL_PACK_ALIGNMENT, 1);
 	if (GLEW_VERSION_4_5) {
 	    glReadnPixels (0, 0, readWidth, readHeight, GL_RGB, GL_UNSIGNED_BYTE, bufferSize, buffer);
@@ -681,7 +1025,6 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 	    glReadPixels (0, 0, readWidth, readHeight, GL_RGB, GL_UNSIGNED_BYTE, buffer);
 	}
 
-	// restore default framebuffer
 	glBindFramebuffer (GL_FRAMEBUFFER, 0);
 
 	if (const GLenum error = glGetError (); error != GL_NO_ERROR) {
@@ -798,19 +1141,15 @@ void WallpaperApplication::setupAudio () {
 	);
     }
 
-    // initialize sdl audio driver
     m_audioDriver = std::make_unique<WallpaperEngine::Audio::Drivers::SDLAudioDriver> (
 	this->m_context, *this->m_audioDetector, *this->m_audioRecorder
     );
-    // initialize audio context
     m_audioContext = std::make_unique<WallpaperEngine::Audio::AudioContext> (*m_audioDriver);
 }
 
 void WallpaperApplication::prepareOutputs () {
-    // initialize render context
     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)
     for (const auto& [background, info] : this->m_backgrounds) {
@@ -826,12 +1165,12 @@ void WallpaperApplication::prepareOutputs () {
 	    ? 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 (), scaling, clamp
-	    )
+	auto wallpaper = WallpaperEngine::Render::CWallpaper::fromWallpaper (
+	    *info->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), scaling, clamp
 	);
+	wallpaper->setZoom (this->resolveScreenZoom (background));
+	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
+	m_renderContext->setWallpaper (background, std::move (wallpaper));
     }
 
     // Set up span groups: one shared wallpaper per group, registered for each viewport
@@ -890,6 +1229,8 @@ void WallpaperApplication::prepareOutputs () {
 	    *bgIt->second->wallpaper, *m_renderContext, *m_audioContext, m_browserContext.get (), spanGroup.scaling,
 	    spanGroup.clamp
 	);
+	sharedWallpaper->setZoom (spanGroup.zoom);
+	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
 
 	// Convert to shared_ptr so it can be registered for multiple viewports
 	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));
@@ -963,22 +1304,15 @@ void WallpaperApplication::render () {
 
 	this->m_isPaused = false;
     } else {
-	// update g_Daytime
 	time (&seconds);
 	timeinfo = localtime (&seconds);
 	g_Daytime = static_cast<float> ((timeinfo->tm_hour * 60) + timeinfo->tm_min) / (24.0f * 60.0f);
 
-	// keep track of the previous frame's time
 	g_TimeLast = g_Time;
-	// calculate the current time value
 	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
 	m_videoDriver->dispatchEventQueue ();
 
 	if (m_videoDriver->closeRequested ()) {
@@ -1008,13 +1342,11 @@ void WallpaperApplication::render () {
 	    write_video_frame (pixels.data ());
 	    frame++;
 
-	    // stop after the given framecount
 	    if (frame >= FRAME_COUNT) {
 		this->m_context.state.general.keepRunning = false;
 	    }
 	}
 #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 ();
@@ -1058,7 +1390,6 @@ void WallpaperApplication::show () {
 }
 
 void WallpaperApplication::update (Render::Drivers::Output::OutputViewport* viewport) {
-    // render the scene
     m_renderContext->render (viewport);
 }
 
@@ -1085,7 +1416,6 @@ const WallpaperEngine::Render::Drivers::Output::Output& WallpaperApplication::ge
 
 void WallpaperApplication::setDestinationFramebuffer (GLuint framebuffer) {
     this->m_destinationFramebuffer = framebuffer;
-    // Update all wallpapers with the new destination framebuffer
     for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
 	wallpaper->setDestinationFramebuffer (framebuffer);
     };

+ 53 - 86
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -27,119 +27,44 @@ namespace WallpaperEngine::Application {
 
 using namespace WallpaperEngine::Assets;
 using namespace WallpaperEngine::Data::Model;
-/**
- * Small wrapper class over the actual wallpaper's main application skeleton
- */
 class WallpaperApplication {
 public:
     explicit WallpaperApplication (ApplicationContext& context);
 
-    /**
-     * Prepares the application for rendering.
-     */
     void setup ();
-    /**
-     * Renders a frame of the application.
-     */
     void render ();
-    /**
-     * Cleans up all the resources used by the application.
-     */
     static void cleanup ();
-    /**
-     * Shows the application until it's closed
-     */
     void show ();
-    /**
-     * Handles a OS signal sent to this PID
-     *
-     * @param signal
-     */
     void signal (int signal);
-    /**
-     * @return Maps screens to loaded backgrounds
-     */
+
     [[nodiscard]] const std::map<std::string, ProjectUniquePtr>& getBackgrounds () const;
-    /**
-     * @return The current application context
-     */
     [[nodiscard]] ApplicationContext& getContext () const;
-    /**
-     * Renders a frame
-     */
     void update (Render::Drivers::Output::OutputViewport* viewport);
-    /**
-     * Gets the output
-     */
     [[nodiscard]] const WallpaperEngine::Render::Drivers::Output::Output& getOutput () const;
-    /**
-     * Sets the destination framebuffer for rendering. If not called, the default framebuffer will be used.
-     */
+
+    /** If not called, the default framebuffer will be used */
     void setDestinationFramebuffer (GLuint framebuffer);
 
-    /**
-     * Gets the currently set destination framebuffer for rendering. If not set, returns 0 (the default framebuffer).
-     */
+    /** Returns 0 (the default framebuffer) if setDestinationFramebuffer() was never called */
     [[nodiscard]] GLuint getDestinationFramebuffer () const;
 
 private:
-    /**
-     * Sets up an asset locator for the given background
-     *
-     * @param bg
-     */
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg) const;
-    /**
-     * Initializes subsystems required for application operation
-     */
     void initializeSubsystems ();
-
-    /**
-     * Loads projects based off the settings
-     */
     void loadBackgrounds ();
-    /**
-     * Loads the given project
-     *
-     * @param bg
-     * @return
-     */
     [[nodiscard]] ProjectUniquePtr loadBackground (const std::string& bg);
-    /**
-     * Prepares all background's values and updates their properties if required
-     */
     void setupProperties ();
-    /**
-     * Updates the properties for the given background based on the current context
-     *
-     * @param project
-     */
     void setupPropertiesForProject (const Project& project);
-    /**
-     * Prepares CEF browser to be used
-     */
+
+    /** Prints objects/layers for every loaded background, triggered by --list-objects */
+    void listObjects () const;
+    void listObjectsForProject (const std::string& background, const Project& project) const;
+
     void setupBrowser ();
-    /**
-     * Prepares desktop environment-related things (like render, window, fullscreen detector, etc)
-     */
     void setupOutput ();
-    /**
-     * Prepares all audio-related things (like detector, output, etc)
-     */
     void setupAudio ();
-    /**
-     * Prepares the render-context of all the backgrounds so they can be displayed on the screen
-     */
     void prepareOutputs ();
-    /**
-     * Prepares output debugging for all opengl errors
-     */
     void setupOpenGLDebugging ();
-    /**
-     * Takes an screenshot of the background and saves it to the specified path
-     *
-     * @param filename
-     */
     void takeScreenshot (const std::filesystem::path& filename) const;
 
     struct ActivePlaylist {
@@ -163,9 +88,51 @@ private:
     void ensureBrowserForProject (const Project& project);
     bool makeAnyViewportCurrent () const;
 
-    /** The application context that contains the current app settings */
+    /**
+     * Pushes a volume change live to already-running video players and the SDL audio mixer,
+     * without touching the loaded projects. volume is 0-128, matching --volume.
+     */
+    void applyVolumeHotswap (int volume);
+
+    /**
+     * Pushes a full-xray toggle live to the renderer, without touching the loaded projects.
+     * value is "on"/"off"/"toggle" (also accepts "1"/"0"/"true"/"false" for on/off).
+     */
+    void applyXrayHotswap (const std::string& value);
+
+    /**
+     * Pushes a scaling mode change ("stretch"/"fit"/"fill"/"center"/"default") live to every
+     * currently rendered wallpaper, without reloading the loaded projects. Applies to all screens,
+     * same as the other hotswap setters below.
+     */
+    void applyScalingHotswap (const std::string& value);
+
+    /** Pushes a manual zoom factor (e.g. "1.5") live to every currently rendered wallpaper */
+    void applyZoomHotswap (const std::string& value);
+
+    /**
+     * Pushes a force-disable-parallax toggle live. This is a straight passthrough to
+     * settings.mouse.disableparallax, which every parallax-capable object (CImage/CText/CParticle) and
+     * CScene's mouse-follow logic already reads directly every frame, so no reload or per-wallpaper
+     * plumbing is needed at all. value is "on"/"off"/"toggle" (also "1"/"0"/"true"/"false").
+     */
+    void applyParallaxHotswap (const std::string& value);
+
+    /**
+     * Pushes a corner color change (hex RGB/RGBA, e.g. "000000" or "#1a1a1aff") live to every
+     * currently rendered wallpaper. Only visible where clamp mode is border.
+     */
+    void applyCornerColorHotswap (const std::string& value);
+
+    /**
+     * Figures out what background a given screen is currently showing, so a layers-only
+     * hotswap can reload it without the caller having to resend the path
+     */
+    [[nodiscard]] std::string resolveScreenBackgroundPath (const std::string& screen) const;
+    [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
+    [[nodiscard]] glm::vec4 resolveScreenCornerColor (const std::string& screen) const;
+
     ApplicationContext& m_context;
-    /** Maps screens to backgrounds */
     std::map<std::string, ProjectUniquePtr> m_backgrounds {};
     std::map<std::string, ActivePlaylist> m_activePlaylists {};
 

+ 9 - 9
src/WallpaperEngine/Data/Builders/ColorBuilder.cpp

@@ -2,8 +2,9 @@
 #include "VectorBuilder.h"
 
 #include <algorithm>
-#include <format>
 #include <glm/vec3.hpp>
+#include <iomanip>
+#include <sstream>
 
 const WallpaperEngine::Data::Model::Color WallpaperEngine::Data::Builders::ColorBuilder::White
     = WallpaperEngine::Data::Model::Color (1.0f, 1.0f, 1.0f, 1.0f);
@@ -27,15 +28,14 @@ WallpaperEngine::Data::Builders::ColorBuilder::parse (const std::string& value,
 	// expand short css notation into the right one
 	// support for css notation
 	if (number.size () == 3) {
-	    number = std::format (
-		"{}{}{}{}{}{}{:02x}", number.at (0), number.at (0), number.at (1), number.at (1), number.at (2),
-		number.at (2), static_cast<int> (alpha * 255)
-	    );
+	    std::ostringstream expanded;
+	    expanded << number.at (0) << number.at (0) << number.at (1) << number.at (1) << number.at (2)
+		     << number.at (2) << std::hex << std::setw (2) << std::setfill ('0')
+		     << static_cast<int> (alpha * 255);
+	    number = expanded.str ();
 	} else if (number.size () == 4) {
-	    number = std::format (
-		"{}{}{}{}{}{}{}{}", number.at (0), number.at (0), number.at (1), number.at (1), number.at (2),
-		number.at (2), number.at (3), number.at (3)
-	    );
+	    number = { number.at (0), number.at (0), number.at (1), number.at (1),
+		       number.at (2), number.at (2), number.at (3), number.at (3) };
 	} else if (number.size () != 6 && number.size () != 8) {
 	    sLog.exception ("Invalid CSS color notation for ", value);
 	}

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

@@ -606,6 +606,8 @@ struct TextData {
     UserSettingUniquePtr alpha;
     /** Whether the text is visible */
     UserSettingUniquePtr visible;
+    /** Parallax depth used for parallax scrolling */
+    UserSettingUniquePtr parallaxDepth;
     /** Horizontal alignment: "left", "center", "right" */
     std::string alignment;
     /** Vertical alignment: "top", "center", "bottom" */

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

@@ -136,6 +136,7 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
 	    .color = it.color ("color", project.properties, Builders::ColorBuilder::White),
 	    .alpha = it.user ("alpha", project.properties, 1.0f),
 	    .visible = it.user ("visible", project.properties, true),
+	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (0.0f)),
 	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
 	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
 	    .padding = it.optional ("padding", 0),

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

@@ -3,6 +3,8 @@
 #include <chrono>
 #include <functional>
 #include <map>
+#include <memory>
+#include <optional>
 #include <string>
 
 namespace WallpaperEngine::Media {

+ 45 - 15
src/WallpaperEngine/Render/CFBO.cpp

@@ -5,30 +5,28 @@ using namespace WallpaperEngine::Render;
 
 CFBO::CFBO (
     std::string name, const TextureFormat format, const uint32_t flags, const float scale, uint32_t realWidth,
-    uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight
+    uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight, const glm::vec4& borderColor
 ) : m_scale (scale), m_name (std::move (name)), m_format (format), m_flags (flags) {
-    // create an empty texture that'll be free'd so the FBO is transparent
     constexpr GLenum drawBuffers[1] = { GL_COLOR_ATTACHMENT0 };
-    // create the main framebuffer
     glGenFramebuffers (1, &this->m_framebuffer);
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_framebuffer);
-    // create the main texture
     glGenTextures (1, &this->m_texture);
-    // bind the new texture to set settings on it
     glBindTexture (GL_TEXTURE_2D, this->m_texture);
-    // give OpenGL an empty image
     glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, textureWidth, textureHeight, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
-    // label stuff for debugging
 #if !NDEBUG
     glObjectLabel (GL_TEXTURE, this->m_texture, -1, this->m_name.c_str ());
 #endif /* DEBUG */
-    // set filtering parameters, otherwise the texture is not rendered
     if (flags & TextureFlags_ClampUVs) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
     } else if (flags & TextureFlags_ClampUVsBorder) {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_BORDER);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_BORDER);
+	// Without this the border color defaults to transparent black, which combined with
+	// GL_LINEAR filtering smears/blends into the last edge texel right at the boundary -
+	// this makes out-of-bounds areas (Center/Fit letterboxing, zoomed-out scaling) a clean
+	// solid color instead.
+	glTexParameterfv (GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, &borderColor.x);
     } else {
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
 	glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
@@ -44,12 +42,9 @@ CFBO::CFBO (
 
     glTexParameterf (GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.0f);
 
-    // set the texture as the colour attachmend #0
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_texture, 0);
-    // finally set the list of draw buffers
     glDrawBuffers (1, drawBuffers);
 
-    // ensure first framebuffer is okay
     if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
 	sLog.exception ("Framebuffers are not properly set");
     }
@@ -64,7 +59,6 @@ CFBO::CFBO (
 
     this->m_resolution = { textureWidth, textureHeight, realWidth, realHeight };
 
-    // create the textureframe entries
     const auto frame = std::make_shared<Frame> ();
 
     frame->frameNumber = 0;
@@ -80,7 +74,6 @@ CFBO::CFBO (
 }
 
 CFBO::~CFBO () {
-    // free opengl texture and framebuffer
     glDeleteTextures (1, &this->m_texture);
     glDeleteFramebuffers (1, &this->m_framebuffer);
 }
@@ -132,5 +125,42 @@ 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; }
+bool CFBO::isReady () const { return true; }
+
+std::optional<glm::vec4> CFBO::parseColor (const std::string& value) {
+    std::string hex = value;
+
+    if (!hex.empty () && hex[0] == '#') {
+	hex = hex.substr (1);
+    }
+
+    if (hex.size () != 6 && hex.size () != 8) {
+	return std::nullopt;
+    }
+
+    unsigned long parsed;
+
+    try {
+	std::size_t consumed = 0;
+	parsed = std::stoul (hex, &consumed, 16);
+
+	if (consumed != hex.size ()) {
+	    return std::nullopt;
+	}
+    } catch (const std::exception&) {
+	return std::nullopt;
+    }
+
+    const bool hasAlpha = hex.size () == 8;
+    const float r = static_cast<float> ((parsed >> (hasAlpha ? 24 : 16)) & 0xFF) / 255.0f;
+    const float g = static_cast<float> ((parsed >> (hasAlpha ? 16 : 8)) & 0xFF) / 255.0f;
+    const float b = static_cast<float> ((parsed >> (hasAlpha ? 8 : 0)) & 0xFF) / 255.0f;
+    const float a = hasAlpha ? static_cast<float> (parsed & 0xFF) / 255.0f : 1.0f;
+
+    return glm::vec4 { r, g, b, a };
+}
+
+void CFBO::setBorderColor (const glm::vec4& color) const {
+    glBindTexture (GL_TEXTURE_2D, this->m_texture);
+    glTexParameterfv (GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, &color.x);
+}

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

@@ -1,5 +1,6 @@
 #pragma once
 
+#include <optional>
 #include <string>
 
 #include "TextureProvider.h"
@@ -11,10 +12,23 @@ class CFBO final : public TextureProvider {
 public:
     CFBO (
 	std::string name, const TextureFormat format, const uint32_t flags, const float scale, uint32_t realWidth,
-	uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight
+	uint32_t realHeight, uint32_t textureWidth, uint32_t textureHeight,
+	const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }
     );
     ~CFBO () override;
 
+    /**
+     * Parses a "RRGGBB" or "RRGGBBAA" hex color (an optional leading '#' is stripped), or
+     * std::nullopt if value isn't a valid hex color. Used for --corner-color and its hotswap key.
+     */
+    static std::optional<glm::vec4> parseColor (const std::string& value);
+
+    /**
+     * Updates the border color shown outside the wallpaper's bounds when its wrap mode is
+     * GL_CLAMP_TO_BORDER (see TextureFlags_ClampUVsBorder). Safe to call live, no reload needed.
+     */
+    void setBorderColor (const glm::vec4& color) const;
+
     [[nodiscard]] const std::string& getName () const;
     [[nodiscard]] const float& getScale () const;
     [[nodiscard]] TextureFormat getFormat () const override;

+ 16 - 38
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -15,7 +15,6 @@ CWallpaper::CWallpaper (
 ) :
     ContextAware (context), FBOProvider (nullptr), m_wallpaperData (wallpaperData), m_audioContext (audioContext),
     m_state (scalingMode, clampMode) {
-    // generate the VAO to stop opengl from complaining
     glGenVertexArrays (1, &this->m_vaoBuffer);
     glBindVertexArray (this->m_vaoBuffer);
 
@@ -37,11 +36,10 @@ CWallpaper::CWallpaper (
 }
 
 CWallpaper::~CWallpaper () {
-    // destroy shader programs
+    // programs here only ever have 2 shaders attached (vertex + fragment)
     GLuint attachedShaders[2];
     GLsizei attachedCount = 0;
 
-    // destroy shaders (we only attach 2 to each program)
     glGetAttachedShaders (this->m_shader, 2, &attachedCount, attachedShaders);
 
     for (auto i = 0; i < attachedCount; i++) {
@@ -50,7 +48,6 @@ CWallpaper::~CWallpaper () {
 
     glDeleteProgram (this->m_shader);
 
-    // destroy used buffers
     glDeleteBuffers (1, &this->m_texCoordBuffer);
     glDeleteBuffers (1, &this->m_positionBuffer);
     glDeleteVertexArrays (1, &this->m_vaoBuffer);
@@ -65,10 +62,8 @@ GLuint CWallpaper::getWallpaperFramebuffer () const { return this->m_sceneFBO->g
 GLuint CWallpaper::getWallpaperTexture () const { return this->m_sceneFBO->getTextureID (0); }
 
 void CWallpaper::setupShaders () {
-    // reserve shaders in OpenGL
     const GLuint vertexShaderID = glCreateShader (GL_VERTEX_SHADER);
 
-    // give shader's source code to OpenGL to be compiled
     const char* sourcePointer = "#version 330\n"
 				"precision highp float;\n"
 				"in vec3 a_Position;\n"
@@ -85,28 +80,20 @@ void CWallpaper::setupShaders () {
     GLint result = GL_FALSE;
     int infoLogLength = 0;
 
-    // ensure the vertex shader was correctly compiled
     glGetShaderiv (vertexShaderID, GL_COMPILE_STATUS, &result);
     glGetShaderiv (vertexShaderID, GL_INFO_LOG_LENGTH, &infoLogLength);
 
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
-	// ensure logBuffer ends with a \0
 	memset (logBuffer, 0, infoLogLength + 1);
-	// get information about the error
 	glGetShaderInfoLog (vertexShaderID, infoLogLength, nullptr, logBuffer);
-	// throw an exception about the issue
 	const std::string message = logBuffer;
-	// free the buffer
 	delete[] logBuffer;
-	// throw an exception
 	sLog.exception (message);
     }
 
-    // reserve shaders in OpenGL
     const GLuint fragmentShaderID = glCreateShader (GL_FRAGMENT_SHADER);
 
-    // give shader's source code to OpenGL to be compiled
     sourcePointer = "#version 330\n"
 		    "precision highp float;\n"
 		    "uniform sampler2D g_Texture0;\n"
@@ -122,31 +109,22 @@ void CWallpaper::setupShaders () {
     result = GL_FALSE;
     infoLogLength = 0;
 
-    // ensure the vertex shader was correctly compiled
     glGetShaderiv (fragmentShaderID, GL_COMPILE_STATUS, &result);
     glGetShaderiv (fragmentShaderID, GL_INFO_LOG_LENGTH, &infoLogLength);
 
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
-	// ensure logBuffer ends with a \0
 	memset (logBuffer, 0, infoLogLength + 1);
-	// get information about the error
 	glGetShaderInfoLog (fragmentShaderID, infoLogLength, nullptr, logBuffer);
-	// throw an exception about the issue
 	const std::string message = logBuffer;
-	// free the buffer
 	delete[] logBuffer;
-	// throw an exception
 	sLog.exception (message);
     }
 
-    // create the final program
     this->m_shader = glCreateProgram ();
-    // link the shaders together
     glAttachShader (this->m_shader, vertexShaderID);
     glAttachShader (this->m_shader, fragmentShaderID);
     glLinkProgram (this->m_shader);
-    // check that the shader was properly linked
     result = GL_FALSE;
     infoLogLength = 0;
 
@@ -155,26 +133,20 @@ void CWallpaper::setupShaders () {
 
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
-	// ensure logBuffer ends with a \0
 	memset (logBuffer, 0, infoLogLength + 1);
-	// get information about the error
 	glGetProgramInfoLog (this->m_shader, infoLogLength, nullptr, logBuffer);
-	// throw an exception about the issue
 	const std::string message = logBuffer;
-	// free the buffer
 	delete[] logBuffer;
-	// throw an exception
 	sLog.exception (message);
     }
 
-    // after being liked shaders can be dettached and deleted
+    // shaders can be detached and deleted once linked into the program
     glDetachShader (this->m_shader, vertexShaderID);
     glDetachShader (this->m_shader, fragmentShaderID);
 
     glDeleteShader (vertexShaderID);
     glDeleteShader (fragmentShaderID);
 
-    // get textures
     this->g_Texture0 = glGetUniformLocation (this->m_shader, "g_Texture0");
     this->a_Position = glGetAttribLocation (this->m_shader, "a_Position");
     this->a_TexCoord = glGetAttribLocation (this->m_shader, "a_TexCoord");
@@ -189,9 +161,7 @@ const CWallpaper::SpanInfo* CWallpaper::getSpanInfo () const {
 }
 
 void CWallpaper::updateUVs (const glm::ivec4& viewport, const bool vflip) {
-    // update UVs if something has changed, otherwise use old values
     if (this->m_state.hasChanged (viewport, vflip, this->getWidth (), this->getHeight ())) {
-	// Update wallpaper state
 	this->m_state.updateState (viewport, vflip, this->getWidth (), this->getHeight ());
     }
 }
@@ -282,12 +252,9 @@ void CWallpaper::render (
     glDisable (GL_BLEND);
     glDisable (GL_DEPTH_TEST);
     glDisable (GL_CULL_FACE);
-    // do not use any shader
     glUseProgram (this->m_shader);
-    // activate scene texture
     glActiveTexture (GL_TEXTURE0);
     glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
-    // set uniforms and attribs
     glEnableVertexAttribArray (this->a_TexCoord);
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texCoords), texCoords, GL_STATIC_DRAW);
@@ -298,7 +265,6 @@ void CWallpaper::render (
     glVertexAttribPointer (this->a_Position, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
 
     glUniform1i (this->g_Texture0, 0);
-    // write the framebuffer as is to the screen
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
     glDrawArrays (GL_TRIANGLES, 0, 6);
 
@@ -314,9 +280,9 @@ void CWallpaper::setupFramebuffers () {
     const uint32_t height = this->getHeight ();
     const uint32_t clamp = this->m_state.getClampingMode ();
 
-    // create framebuffer for the scene
     this->m_sceneFBO = this->create (
-	"_rt_FullFrameBuffer", TextureFormat_ARGB8888, clamp, 1.0, { width, height }, { width, height }
+	"_rt_FullFrameBuffer", TextureFormat_ARGB8888, clamp, 1.0, { width, height }, { width, height },
+	this->m_cornerColor
     );
 
     this->alias ("_rt_MipMappedFrameBuffer", "_rt_FullFrameBuffer");
@@ -326,6 +292,18 @@ AudioContext& CWallpaper::getAudioContext () const { return this->m_audioContext
 
 const WallpaperState& CWallpaper::getState () const { return this->m_state; }
 
+void CWallpaper::setScalingMode (WallpaperState::TextureUVsScaling mode) { this->m_state.setTextureUVsStrategy (mode); }
+
+void CWallpaper::setZoom (float zoom) { this->m_state.setZoom (zoom); }
+
+void CWallpaper::setCornerColor (const glm::vec4& color) {
+    this->m_cornerColor = color;
+
+    if (this->m_sceneFBO != nullptr) {
+	this->m_sceneFBO->setBorderColor (color);
+    }
+}
+
 std::shared_ptr<const CFBO> CWallpaper::findFBO (const std::string& name) const {
     const auto fbo = this->find (name);
 

+ 14 - 82
src/WallpaperEngine/Render/CWallpaper.h

@@ -38,7 +38,7 @@ class CWallpaper : public Helpers::ContextAware, public FBOProvider, public Type
     friend class WallpaperEngine::Application::WallpaperApplication;
 
 public:
-    /** Information for span-mode rendering: one wallpaper across multiple viewports */
+    /** One wallpaper shared and rendered across multiple viewports */
     struct SpanInfo {
 	/** Bounding box of the entire span group (x, y, width, height) in global desktop coordinates */
 	glm::ivec4 totalBounds;
@@ -46,97 +46,43 @@ public:
 
     virtual ~CWallpaper () override;
 
-    /**
-     * Performs a render pass of the wallpaper
-     */
     void render (
 	const glm::ivec4& viewport, const bool vflip, const glm::ivec2& globalPosition = { 0, 0 },
 	const glm::ivec2& logicalSize = { 0, 0 }
     );
 
-    /**
-     * Pause the renderer
-     */
     virtual void setPause (bool newState);
 
-    /**
-     * @return The container to resolve files for this wallpaper
-     */
     [[nodiscard]] const AssetLocator& getAssetLocator () const;
-
-    /**
-     * @return The current audio context for this wallpaper
-     */
     AudioContext& getAudioContext () const;
-
-    /**
-     * @return The wallpaper state
-     */
     [[nodiscard]] const WallpaperState& getState () const;
 
+    /** Changes the scaling mode live, without reloading the wallpaper (used by the hotswap control file) */
+    void setScalingMode (WallpaperState::TextureUVsScaling mode);
+
+    /** Changes the manual zoom factor live, without reloading the wallpaper (used by the hotswap control file) */
+    void setZoom (float zoom);
+
     /**
-     * @return The scene's framebuffer
+     * Changes the color shown outside the wallpaper's bounds (Center/Fit letterboxing, zoomed-out
+     * scaling) live, without reloading the wallpaper. Only visible when the clamp mode is border
+     * (the default) rather than clamp/repeat.
      */
+    void setCornerColor (const glm::vec4& color);
+
     [[nodiscard]] virtual GLuint getWallpaperFramebuffer () const;
-    /**
-     * @return The scene's texture
-     */
     [[nodiscard]] virtual GLuint getWallpaperTexture () const;
-    /**
-     * Searches the FBO list for the given FBO
-     *
-     * @param name
-     * @return
-     */
     [[nodiscard]] std::shared_ptr<const CFBO> findFBO (const std::string& name) const;
-
-    /**
-     * @return The main FBO of this wallpaper
-     */
     [[nodiscard]] std::shared_ptr<const CFBO> getFBO () const;
 
-    /**
-     * Updates the UVs coordinates if window/screen/vflip/projection has changed
-     */
     void updateUVs (const glm::ivec4& viewport, const bool vflip);
-
-    /**
-     * Updates the destination framebuffer for this wallpaper
-     *
-     * @param framebuffer
-     */
     void setDestinationFramebuffer (GLuint framebuffer);
-
-    /**
-     * Sets span info for this wallpaper, enabling span-mode rendering
-     */
     void setSpanInfo (const SpanInfo& spanInfo);
-
-    /**
-     * @return The span info if set, or nullptr
-     */
     [[nodiscard]] const SpanInfo* getSpanInfo () const;
 
-    /**
-     * @return The width of this wallpaper
-     */
     [[nodiscard]] virtual int getWidth () const = 0;
-
-    /**
-     * @return The height of this wallpaper
-     */
     [[nodiscard]] virtual int getHeight () const = 0;
 
-    /**
-     * Creates a new instance of CWallpaper based on the information provided by the read backgrounds
-     *
-     * @param wallpaper
-     * @param context
-     * @param audioContext
-     * @param scalingMode
-     *
-     * @return
-     */
     static std::unique_ptr<CWallpaper> fromWallpaper (
 	const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
 	WebBrowser::WebBrowserContext* browserContext, const WallpaperState::TextureUVsScaling& scalingMode,
@@ -149,47 +95,33 @@ protected:
 	const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
     );
 
-    /**
-     * Renders a frame of the wallpaper
-     */
     virtual void renderFrame (const glm::ivec4& viewport) = 0;
 
-    /**
-     * Setups OpenGL's framebuffers for ping-pong and scene rendering
-     */
     void setupFramebuffers ();
 
     const Wallpaper& m_wallpaperData;
 
     [[nodiscard]] const Wallpaper& getWallpaperData () const;
 
-    /** The FBO used for scene output */
     std::shared_ptr<const CFBO> m_sceneFBO = nullptr;
 
     GLuint m_vaoBuffer = GL_NONE;
 
 private:
-    /** The texture used for the scene output */
     GLuint m_texCoordBuffer = GL_NONE;
     GLuint m_positionBuffer = GL_NONE;
     GLuint m_shader = GL_NONE;
-    // shader variables
     GLint g_Texture0 = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_TexCoord = GL_NONE;
-    /** The framebuffer to draw the background to */
     GLuint m_destFramebuffer = GL_NONE;
-    /** Setups OpenGL's shaders for this wallpaper backbuffer */
     void setupShaders ();
-    /** List of FBOs registered for this wallpaper */
     std::map<std::string, std::shared_ptr<const CFBO>> m_fbos = {};
-    /** Audio context that is using this wallpaper */
     AudioContext& m_audioContext;
-    /** Current Wallpaper state */
     WallpaperState m_state;
-    /** Span info for multi-monitor spanning (optional) */
+    glm::vec4 m_cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
     std::optional<SpanInfo> m_spanInfo = std::nullopt;
-    /** Frame counter to avoid redundant renderFrame calls when shared across viewports */
+    // Avoids redundant renderFrame calls when the same wallpaper is shared across viewports (span mode)
     uint32_t m_lastRenderedFrame = UINT32_MAX;
 };
 } // namespace WallpaperEngine::Render

+ 3 - 3
src/WallpaperEngine/Render/FBOProvider.cpp

@@ -1,5 +1,4 @@
 #include "FBOProvider.h"
-#include <gmpxx.h>
 
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Data::Model;
@@ -17,10 +16,11 @@ std::shared_ptr<CFBO> FBOProvider::create (const FBO& base, uint32_t flags, cons
 
 std::shared_ptr<CFBO> FBOProvider::create (
     const std::string& name, TextureFormat format, uint32_t flags, float scale, glm::vec2 realSize,
-    glm::vec2 textureSize
+    glm::vec2 textureSize, const glm::vec4& borderColor
 ) {
     return this->m_fbos[name] = std::make_shared<CFBO> (
-	       name, TextureFormat_ARGB8888, flags, scale, realSize.x, realSize.y, textureSize.x, textureSize.y
+	       name, TextureFormat_ARGB8888, flags, scale, realSize.x, realSize.y, textureSize.x, textureSize.y,
+	       borderColor
 	   );
 }
 

+ 2 - 1
src/WallpaperEngine/Render/FBOProvider.h

@@ -1,6 +1,7 @@
 #pragma once
 
 #include <glm/vec2.hpp>
+#include <glm/vec4.hpp>
 
 #include "CFBO.h"
 #include "WallpaperEngine/Data/Model/Effect.h"
@@ -15,7 +16,7 @@ public:
     std::shared_ptr<CFBO> create (const FBO& base, uint32_t flags, glm::vec2 size);
     std::shared_ptr<CFBO> create (
 	const std::string& name, TextureFormat format, uint32_t flags, float scale, glm::vec2 realSize,
-	glm::vec2 textureSize
+	glm::vec2 textureSize, const glm::vec4& borderColor = { 0.0f, 0.0f, 0.0f, 1.0f }
     );
     std::shared_ptr<CFBO> alias (const std::string& newName, const std::string& original);
     [[nodiscard]] std::shared_ptr<CFBO> find (const std::string& name) const;

+ 8 - 2
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -861,7 +861,11 @@ void CImage::setupPasses () {
 }
 
 bool CImage::shouldRenderFinalPass (bool isLastPass) const {
-    if (!isLastPass || !this->getImage ().visible->value->getBool ()) {
+    const auto& appContext = this->getScene ().getContext ().getApp ().getContext ();
+    const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name);
+    const bool visible = visibility.value_or (this->getImage ().visible->value->getBool ());
+
+    if (!isLastPass || !visible) {
 	return false;
     }
 
@@ -921,7 +925,9 @@ void CImage::render () {
 	return;
     }
 
-    if (!this->getImage ().visible->value->getBool ()) {
+    const auto& appContext = this->getScene ().getContext ().getApp ().getContext ();
+    const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name);
+    if (!visibility.value_or (this->getImage ().visible->value->getBool ())) {
 	return;
     }
 

+ 7 - 1
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -170,7 +170,13 @@ void CParticle::setup () {
 }
 
 void CParticle::render () {
-    if (!m_initialized || !m_particle.visible->value->getBool ()) {
+    if (!m_initialized) {
+	return;
+    }
+
+    const auto& appContext = this->getScene ().getContext ().getApp ().getContext ();
+    const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name);
+    if (!visibility.value_or (m_particle.visible->value->getBool ())) {
 	return;
     }
 

+ 24 - 6
src/WallpaperEngine/Render/Objects/CText.cpp

@@ -26,8 +26,7 @@ using namespace WallpaperEngine::Render::Objects;
 using namespace WallpaperEngine::Render::Objects::Effects;
 
 namespace {
-// TODO: Phase 2 - load font from wallpaper's materials/fonts/ using AssetLocator
-// Phase 1 uses a system font instead of the font shipped by the wallpaper.
+// Fallback fonts, used only when the wallpaper's own font (loadEmbeddedFont) can't be loaded.
 const std::vector<std::string> kFontCandidates = {
     "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
     "/usr/share/fonts/TTF/DejaVuSans.ttf",
@@ -154,6 +153,7 @@ CText::CText (Wallpapers::CScene& scene, const Text& text) :
     this->registerProperty ("visible", *text.visible->value);
     this->registerProperty ("pointSize", *text.pointSize->value);
     this->registerProperty ("text", *text.text->value);
+    this->registerProperty ("parallaxDepth", *text.parallaxDepth->value);
 }
 
 CText::~CText () {
@@ -197,7 +197,6 @@ void CText::setup () {
     const bool scripted = m_text.text->value->getScriptSource ().has_value ();
     const auto& text = m_text.text->value->getString ();
 
-    // Nothing to render and no script to produce text later -> bail.
     if (text.empty () && !scripted) {
 	return;
     }
@@ -589,7 +588,9 @@ void CText::render () {
     if (!m_initialized) {
 	return;
     }
-    if (!m_text.visible->value->getBool ()) {
+    const auto& appContext = this->getScene ().getContext ().getApp ().getContext ();
+    const auto visibility = appContext.resolveObjectVisibility (this->getId (), this->getObject ().name);
+    if (!visibility.value_or (m_text.visible->value->getBool ())) {
 	return;
     }
 
@@ -645,9 +646,26 @@ void CText::render () {
     // updateScenePosition) of scene_h/2 - y rather than y - scene_h/2.
     const float scene_w = getScene ().getCamera ().getWidth ();
     const float scene_h = getScene ().getCamera ().getHeight ();
+
+    // Match CImage's parallax handling (CImage.cpp:updateScreenSpacePosition), applied here in
+    // the same pre-scale, canvas-space units as origin - other objects at the same parallaxDepth
+    // (e.g. a background box behind this text) use this exact formula, so text needs it too to
+    // stay visually locked to them. Added directly to gl_origin (not appended after the model
+    // matrix) so the offset isn't inadvertently multiplied by this object's own "scale".
+    glm::vec2 parallaxOffset = { 0.0f, 0.0f };
+    if (this->getScene ().getScene ().camera.parallax.enabled
+	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
+	const glm::vec2 depth = m_text.parallaxDepth->value->getVec2 ();
+	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
+	const float referenceSize = static_cast<float> (this->getScene ().getWidth ());
+	parallaxOffset.x = (depth.x + parallaxAmount) * displacement->x * referenceSize;
+	parallaxOffset.y = (depth.y + parallaxAmount) * displacement->y * referenceSize;
+    }
+
     const glm::vec3 gl_origin = {
-	origin.x + offsetX - scene_w * 0.5f,
-	scene_h * 0.5f - (origin.y + offsetY),
+	origin.x + offsetX - scene_w * 0.5f + parallaxOffset.x,
+	scene_h * 0.5f - (origin.y + offsetY) + parallaxOffset.y,
 	origin.z,
     };
 

+ 0 - 4
src/WallpaperEngine/Render/Objects/CText.h

@@ -58,15 +58,11 @@ public:
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
 
 private:
-    // Rebuilds the glyph texture (and matching quad geometry) from the given string.
-    // Reuses existing GL handles if already allocated, so this is safe to call
-    // every time the rendered text changes.
     void rebuildTextureFrom (const std::string& text);
     void uploadQuadVertices ();
     void buildPasses ();
     void destroyPasses ();
 
-    // setup() helpers (kept small to keep the setup flow linear).
     bool initFreeType ();
     bool loadEmbeddedFont ();
     bool loadSystemFont ();

+ 55 - 6
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -40,6 +40,37 @@ std::string textureSizeLabel (const std::shared_ptr<const TextureProvider>& text
 
     return std::to_string (texture->getRealWidth ()) + "x" + std::to_string (texture->getRealHeight ());
 }
+
+// shader used by Wallpaper Engine's built-in "X-Ray" interactive effect (effects/xray/effect.json)
+const std::string XRAY_EFFECT_SHADER = "effects/xray";
+
+// The xray fragment shader gates its reveal on a projector-style sample of a small halo sprite
+// (g_Texture2, e.g. "particle/halo_6") taken around the pointer; g_PointerScale only controls how
+// far that sample zooms into the sprite, not any on-screen radius, so there's no uniform value that
+// makes it cover the whole screen. Instead this patches the compiled fragment source to add a
+// g_XrayFullReveal uniform that bypasses the halo sample entirely, forcing the hidden layer
+// (g_Texture1) to blend in everywhere once toggled - see patchXrayFullRevealBypass() below.
+const std::string XRAY_MULTIPLY_UNIFORM = "uniform float g_Multiply;";
+const std::string XRAY_FULL_REVEAL_UNIFORM_DECL = "uniform float g_Multiply;\nuniform float g_XrayFullReveal;";
+const std::string XRAY_BLEND_LINE = "blend *= (blendSample.x * blendSample.y);";
+const std::string XRAY_BLEND_LINE_PATCHED = "blend *= mix (blendSample.x * blendSample.y, 1.0, g_XrayFullReveal);";
+
+// Returns false (leaving fragmentSource untouched) if the anchors weren't found, e.g. because a
+// different spirv-cross/glslang version formats the compiled output differently - callers should
+// treat that as "full xray toggle becomes a no-op" rather than fail the whole shader compile.
+bool patchXrayFullRevealBypass (std::string& fragmentSource) {
+    const auto blendPos = fragmentSource.find (XRAY_BLEND_LINE);
+    const auto uniformPos = fragmentSource.find (XRAY_MULTIPLY_UNIFORM);
+
+    if (blendPos == std::string::npos || uniformPos == std::string::npos) {
+	return false;
+    }
+
+    // replace the later occurrence first so the earlier one's position stays valid
+    fragmentSource.replace (blendPos, XRAY_BLEND_LINE.size (), XRAY_BLEND_LINE_PATCHED);
+    fragmentSource.replace (uniformPos, XRAY_MULTIPLY_UNIFORM.size (), XRAY_FULL_REVEAL_UNIFORM_DECL);
+    return true;
+}
 }
 
 CPass::CPass (
@@ -122,10 +153,9 @@ void CPass::setupRenderFramebuffer () const {
     // set the framebuffer we're drawing to
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_drawTo->getFramebuffer ());
 
-    // Private per-object FBOs (composite buffers, effect ping-pong targets, ...) are never
-    // cleared by anything else, so a blending (rather than replacing) pass would otherwise
-    // accumulate stale alpha/color into them frame after frame. The shared scene FBO is the
-    // exception: it accumulates every object drawn this frame and must not be touched here.
+    // Private per-object FBOs are never cleared elsewhere, so a blending pass would otherwise
+    // accumulate stale alpha across frames. The shared scene FBO must not be cleared here though,
+    // since it accumulates every object drawn this frame.
     if (this->m_drawTo != this->m_renderable.getScene ().getFBO ()) {
 	GLfloat previousClearColor[4] = {};
 	glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
@@ -467,6 +497,11 @@ void CPass::render () {
     // set the VAO for now
     glBindVertexArray (this->m_vao);
 
+    if (this->m_pass.shader == XRAY_EFFECT_SHADER) {
+	const bool fullReveal = this->getContext ().getApp ().getContext ().state.xray.fullReveal;
+	this->m_xrayFullReveal = fullReveal ? 1.0f : 0.0f;
+    }
+
     const auto& debug = this->getContext ().getApp ().getContext ().settings.render.debug;
     if (debug.passLog) {
 	sLog.out (
@@ -638,8 +673,16 @@ void CPass::setupShaders () {
 	this->m_override.textures, this->m_override.constants
     );
 
-    const auto [vertex, fragment]
-	= Shaders::GLSLContext::get ().toGlsl (this->m_shader->vertex (), this->m_shader->fragment ());
+    auto [vertex, fragment] = Shaders::GLSLContext::get ().toGlsl (this->m_shader->vertex (), this->m_shader->fragment ());
+
+    if (shaderName == XRAY_EFFECT_SHADER) {
+	this->m_xrayFullRevealPatched = patchXrayFullRevealBypass (fragment);
+
+	if (!this->m_xrayFullRevealPatched) {
+	    sLog.error ("Full xray toggle unavailable: couldn't find the expected reveal blend line in the "
+			"compiled effects/xray shader (spirv-cross output format may have changed)");
+	}
+    }
 
     // compile the shaders
     const GLuint vertexShaderID = compileShader (vertex.c_str (), GL_VERTEX_SHADER);
@@ -1011,6 +1054,12 @@ void CPass::setupShaderVariables () {
 	ShaderVariable* var = vertex == nullptr ? fragment : vertex;
 	this->addUniform (var, value->value.get ());
     }
+
+    // bind the full-reveal bypass uniform injected by patchXrayFullRevealBypass() (see setupShaders());
+    // a no-op if the patch didn't find its anchors, since the uniform then doesn't exist in the shader
+    if (this->m_pass.shader == XRAY_EFFECT_SHADER) {
+	this->addUniform ("g_XrayFullReveal", &this->m_xrayFullReveal);
+    }
 }
 
 // define some basic methods for the template

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

@@ -190,6 +190,12 @@ private:
     const glm::mat4* m_modelMatrix;
     const glm::mat4* m_viewProjectionMatrix;
 
+    // full xray support: 0.0/1.0 fed to the g_XrayFullReveal uniform injected by patchXrayFullRevealBypass(),
+    // updated each frame from state.xray.fullReveal (see render()); m_xrayFullRevealPatched records whether
+    // that injection actually found its anchors in the compiled shader, for diagnostics
+    float m_xrayFullReveal = 0.0f;
+    bool m_xrayFullRevealPatched = false;
+
     /**
      * Contains the final map of textures to be used
      */

+ 86 - 5
src/WallpaperEngine/Render/WallpaperState.cpp

@@ -8,15 +8,34 @@ using namespace WallpaperEngine::Render;
 WallpaperState::WallpaperState (const TextureUVsScaling& textureUVsMode, const uint32_t& clampMode) :
     m_textureUVsMode (textureUVsMode), m_clampingMode (clampMode) { }
 
+std::optional<WallpaperState::TextureUVsScaling> WallpaperState::parseScalingMode (const std::string& value) {
+    if (value == "stretch") {
+	return TextureUVsScaling::StretchUVs;
+    }
+    if (value == "fit") {
+	return TextureUVsScaling::ZoomFitUVs;
+    }
+    if (value == "fill") {
+	return TextureUVsScaling::ZoomFillUVs;
+    }
+    if (value == "center") {
+	return TextureUVsScaling::CenterUVs;
+    }
+    if (value == "default") {
+	return TextureUVsScaling::DefaultUVs;
+    }
+
+    return std::nullopt;
+}
+
 bool WallpaperState::hasChanged (
     const glm::ivec4& viewport, const bool& vflip, const int& projectionWidth, const int& projectionHeight
 ) const {
-    return this->m_viewport.width != viewport.z || this->m_viewport.height != viewport.w
+    return this->m_uvsDirty || this->m_viewport.width != viewport.z || this->m_viewport.height != viewport.w
 	|| this->m_projection.width != projectionWidth || this->m_projection.height != projectionHeight
 	|| this->m_vflip != vflip;
 }
 
-// Reset UVs to 0/1 values
 void WallpaperState::resetUVs () {
     this->m_UVs.ustart = 0;
     this->m_UVs.uend = 1;
@@ -30,7 +49,6 @@ void WallpaperState::resetUVs () {
     }
 }
 
-// Update Us coordinates for current viewport and projection
 void WallpaperState::updateUs (const int& projectionWidth, const int& projectionHeight) {
     const float viewportWidth = this->getViewportWidth ();
     const float viewportHeight = this->getViewportHeight ();
@@ -45,7 +63,6 @@ void WallpaperState::updateUs (const int& projectionWidth, const int& projection
     this->m_UVs.uend = right / newWidth;
 }
 
-// Update Vs coordinates for current viewport and projection
 void WallpaperState::updateVs (const int& projectionWidth, const int& projectionHeight) {
     const float viewportWidth = this->getViewportWidth ();
     const float viewportHeight = this->getViewportHeight ();
@@ -130,6 +147,31 @@ template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScal
     }
 }
 
+template <> void WallpaperState::updateTextureUVs<WallpaperState::TextureUVsScaling::CenterUVs> () {
+    this->resetUVs ();
+
+    const float viewportWidth = static_cast<float> (this->getViewportWidth ());
+    const float viewportHeight = static_cast<float> (this->getViewportHeight ());
+    const float projectionWidth = static_cast<float> (this->getProjectionWidth ());
+    const float projectionHeight = static_cast<float> (this->getProjectionHeight ());
+
+    // No scale factor applied at all: crop to native size if the wallpaper is bigger than the viewport,
+    // or overflow past [0,1] (relying on border clamping) to letterbox it if it's smaller.
+    const float uMargin = (1.0f - viewportWidth / projectionWidth) / 2.0f;
+    this->m_UVs.ustart = uMargin;
+    this->m_UVs.uend = 1.0f - uMargin;
+
+    const float vMargin = (1.0f - viewportHeight / projectionHeight) / 2.0f;
+
+    if (m_vflip) {
+	this->m_UVs.vstart = vMargin;
+	this->m_UVs.vend = 1.0f - vMargin;
+    } else {
+	this->m_UVs.vstart = 1.0f - vMargin;
+	this->m_UVs.vend = vMargin;
+    }
+}
+
 template <WallpaperState::TextureUVsScaling T> void WallpaperState::updateTextureUVs () {
     sLog.exception (
 	"Using generic template for scaling is not allowed. Write specialization template for your scaling mode.\
@@ -142,8 +184,39 @@ WallpaperState::TextureUVsScaling WallpaperState::getTextureUVsScaling () const
 uint32_t WallpaperState::getClampingMode () const { return this->m_clampingMode; }
 
 void WallpaperState::setTextureUVsStrategy (WallpaperState::TextureUVsScaling strategy) {
+    if (this->m_textureUVsMode == strategy) {
+	return;
+    }
+
     this->m_textureUVsMode = strategy;
+    this->m_uvsDirty = true;
+}
+
+float WallpaperState::getZoom () const { return this->m_zoom; }
+
+void WallpaperState::setZoom (float zoom) {
+    zoom = std::clamp (zoom, 0.1f, 5.0f);
+
+    if (this->m_zoom == zoom) {
+	return;
+    }
+
+    this->m_zoom = zoom;
+    this->m_uvsDirty = true;
+}
+
+// Shrinks (zoom > 1) or grows (zoom < 1) the already-computed UV window around its own center, on top of
+// whatever the scaling mode picked. Works regardless of axis direction (vflip swaps which of start/end is
+// larger) since it only depends on the midpoint and half-range, not their sign.
+namespace {
+void applyZoomToAxis (float& start, float& end, float zoom) {
+    const float center = (start + end) / 2.0f;
+    const float half = (end - start) / 2.0f;
+
+    start = center - half / zoom;
+    end = center + half / zoom;
 }
+} // namespace
 
 int WallpaperState::getViewportWidth () const { return this->m_viewport.width; }
 
@@ -161,8 +234,8 @@ void WallpaperState::updateState (
     this->m_vflip = vflip;
     this->m_projection.width = projectionWidth;
     this->m_projection.height = projectionHeight;
+    this->m_uvsDirty = false;
 
-    // Set texture UVs according to choosen scaling mode for this wallpaper
     switch (this->getTextureUVsScaling ()) {
 	case WallpaperState::TextureUVsScaling::StretchUVs:
 	    this->updateTextureUVs<WallpaperState::TextureUVsScaling::StretchUVs> ();
@@ -173,6 +246,9 @@ void WallpaperState::updateState (
 	case WallpaperState::TextureUVsScaling::ZoomFitUVs:
 	    this->updateTextureUVs<WallpaperState::TextureUVsScaling::ZoomFitUVs> ();
 	    break;
+	case WallpaperState::TextureUVsScaling::CenterUVs:
+	    this->updateTextureUVs<WallpaperState::TextureUVsScaling::CenterUVs> ();
+	    break;
 	case WallpaperState::TextureUVsScaling::DefaultUVs:
 	    this->updateTextureUVs<WallpaperState::TextureUVsScaling::DefaultUVs> ();
 	    break;
@@ -183,4 +259,9 @@ void WallpaperState::updateState (
 	    );
 	    break;
     }
+
+    if (this->m_zoom != 1.0f) {
+	applyZoomToAxis (this->m_UVs.ustart, this->m_UVs.uend, this->m_zoom);
+	applyZoomToAxis (this->m_UVs.vstart, this->m_UVs.vend, this->m_zoom);
+    }
 }

+ 26 - 65
src/WallpaperEngine/Render/WallpaperState.h

@@ -3,113 +3,74 @@
 #include <GL/glew.h>
 #include <GLFW/glfw3.h>
 #include <glm/vec4.hpp>
+#include <optional>
+#include <string>
 
 #include "TextureProvider.h"
 
 namespace WallpaperEngine::Render {
 using namespace WallpaperEngine::Data::Assets;
-/**
- * Represents current wallpaper state
- */
 class WallpaperState {
 public:
-    // Scaling modes. Defines how UVs coordinates are calculated.
     enum class TextureUVsScaling : uint8_t {
 	DefaultUVs,
 	ZoomFitUVs,
 	ZoomFillUVs,
 	StretchUVs,
+	// Native resolution, centered, no scaling - crops if bigger than the viewport, letterboxes if smaller
+	CenterUVs,
     };
 
-    WallpaperState (const TextureUVsScaling& textureUVsMode, const uint32_t& clampMode);
-
     /**
-     * Checks if any of the given values has changed
-     * @param viewport
-     * @param vflip
-     * @param projectionWidth
-     * @param projectionHeight
-     * @return
+     * Maps the CLI/hotswap scaling names ("stretch", "fit", "fill", "center", "default") to their enum value,
+     * or std::nullopt if value isn't a known scaling name
      */
+    static std::optional<TextureUVsScaling> parseScalingMode (const std::string& value);
+
+    WallpaperState (const TextureUVsScaling& textureUVsMode, const uint32_t& clampMode);
+
     [[nodiscard]] bool hasChanged (
 	const glm::ivec4& viewport, const bool& vflip, const int& projectionWidth, const int& projectionHeight
     ) const;
 
-    /**
-     * Resets UVs to 0/1 values.
-     */
     void resetUVs ();
-
-    /**
-     * Updates UVs coordinates for current viewport and projection
-     *
-     * @param projectionWidth
-     * @param projectionHeight
-     */
     void updateUs (const int& projectionWidth, const int& projectionHeight);
-
-    /**
-     * Updates Vs coordinates for current viewport and projection
-     *
-     * @param projectionWidth
-     * @param projectionHeight
-     */
     void updateVs (const int& projectionWidth, const int& projectionHeight);
 
-    /**
-     * @return Texture UV coordinates for current viewport and projection
-     */
     [[nodiscard]] auto getTextureUVs () const { return m_UVs; };
 
-    /**
-     * Updates UVs coordinates for current viewport and projection
-     */
     template <WallpaperState::TextureUVsScaling> void updateTextureUVs ();
 
-    // Updates state with provided values
     void updateState (
 	const glm::ivec4& viewport, const bool& vflip, const int& projectionWidth, const int& projectionHeight
     );
 
-    /**
-     * @return The texture scaling mode
-     */
     [[nodiscard]] TextureUVsScaling getTextureUVsScaling () const;
-
-    /**
-     * @return The texture clamping mode.
-     */
     [[nodiscard]] uint32_t getClampingMode () const;
 
     /**
-     * Sets the texture scaling mode
-     *
-     * @param strategy
+     * Can be called on a wallpaper that's already rendering (e.g. from a hotswap) - the next updateUVs()
+     * call will recompute UVs for the new mode even if the viewport hasn't changed since the last frame.
      */
     void setTextureUVsStrategy (TextureUVsScaling strategy);
 
-    /**
-     * @return The width of viewport
-     */
-    [[nodiscard]] int getViewportWidth () const;
+    [[nodiscard]] float getZoom () const;
 
     /**
-     * @return The height of viewport
+     * Manual zoom factor applied on top of whatever the scaling mode computes: > 1 crops in tighter
+     * (zoomed in), < 1 shows more / overflows past [0,1] relying on border clamping (zoomed out). Clamped to
+     * a sane range. Just like setTextureUVsStrategy(), can be called live and takes effect on the next frame
+     * even if the viewport hasn't changed.
      */
-    [[nodiscard]] int getViewportHeight () const;
+    void setZoom (float zoom);
 
-    /**
-     * @return The width of the projection
-     */
+    [[nodiscard]] int getViewportWidth () const;
+    [[nodiscard]] int getViewportHeight () const;
     [[nodiscard]] int getProjectionWidth () const;
-
-    /**
-     * @return The height of the projection
-     */
     [[nodiscard]] int getProjectionHeight () const;
 
 private:
-    // Cached UVs value for texture coordinates. No need to recalculate if viewport and projection haven't changed.
+    // Cached so UVs don't need to be recalculated if viewport and projection haven't changed
     struct {
 	float ustart;
 	float uend;
@@ -117,23 +78,23 @@ private:
 	float vend;
     } m_UVs {};
 
-    // Viewport for which UVs were calculated
     struct {
 	int width;
 	int height;
     } m_viewport {};
 
-    // Wallpaper dimensions
     struct {
 	int width;
 	int height;
     } m_projection {};
 
-    // Are Vs coordinates fliped
     bool m_vflip = false;
 
-    // Texture scaling mode
     TextureUVsScaling m_textureUVsMode = TextureUVsScaling::DefaultUVs;
     uint32_t m_clampingMode = TextureFlags_NoFlags;
+    float m_zoom = 1.0f;
+    // Set whenever the scaling mode or zoom changes live, so hasChanged() reports a change even if the
+    // viewport/projection stayed the same, forcing the UVs to be recomputed on the next frame
+    bool m_uvsDirty = false;
 };
-} // namespace WallpaperEngine::Render
+} // namespace WallpaperEngine::Render

+ 6 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -362,6 +362,12 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 	    continue;
 	}
 
+	const auto visibility
+	    = this->getContext ().getApp ().getContext ().resolveObjectVisibility (cur->getId (), cur->getObject ().name);
+	if (visibility.has_value () && !visibility.value ()) {
+	    continue;
+	}
+
 	cur->render ();
     }
 }

+ 2 - 0
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -53,6 +53,8 @@ void CVideo::renderFrame (const glm::ivec4& viewport) {
 
 const Data::Model::Video& CVideo::getVideo () const { return *this->getWallpaperData ().as<Data::Model::Video> (); }
 
+void CVideo::setVolume (double volume) { this->m_player->setVolume (volume); }
+
 void CVideo::setPause (bool newState) {
     if (newState) {
 	this->m_player->setPaused ();

+ 3 - 0
src/WallpaperEngine/Render/Wallpapers/CVideo.h

@@ -23,6 +23,9 @@ public:
 
     void setPause (bool newState) override;
 
+    /** Pushes a new volume (0-100, matching what GLPlayer expects) to the underlying mpv player without a reload */
+    void setVolume (double volume);
+
 protected:
     void renderFrame (const glm::ivec4& viewport) override;
 

+ 44 - 0
src/WallpaperEngine/Testing/Cases/CornerColor.cpp

@@ -0,0 +1,44 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Render/CFBO.h"
+
+using WallpaperEngine::Render::CFBO;
+
+TEST_CASE ("parseColor accepts 6-digit hex RGB, defaulting alpha to opaque") {
+    const auto color = CFBO::parseColor ("1a2b3c");
+
+    REQUIRE (color.has_value ());
+    CHECK (color->x == Catch::Approx (0x1a / 255.0f));
+    CHECK (color->y == Catch::Approx (0x2b / 255.0f));
+    CHECK (color->z == Catch::Approx (0x3c / 255.0f));
+    CHECK (color->w == Catch::Approx (1.0f));
+}
+
+TEST_CASE ("parseColor accepts 8-digit hex RGBA") {
+    const auto color = CFBO::parseColor ("1a2b3c80");
+
+    REQUIRE (color.has_value ());
+    CHECK (color->x == Catch::Approx (0x1a / 255.0f));
+    CHECK (color->y == Catch::Approx (0x2b / 255.0f));
+    CHECK (color->z == Catch::Approx (0x3c / 255.0f));
+    CHECK (color->w == Catch::Approx (0x80 / 255.0f));
+}
+
+TEST_CASE ("parseColor strips a leading #") {
+    const auto color = CFBO::parseColor ("#000000");
+
+    REQUIRE (color.has_value ());
+    CHECK (color->x == Catch::Approx (0.0f));
+    CHECK (color->y == Catch::Approx (0.0f));
+    CHECK (color->z == Catch::Approx (0.0f));
+    CHECK (color->w == Catch::Approx (1.0f));
+}
+
+TEST_CASE ("parseColor rejects malformed input") {
+    CHECK_FALSE (CFBO::parseColor ("").has_value ());
+    CHECK_FALSE (CFBO::parseColor ("fff").has_value ());
+    CHECK_FALSE (CFBO::parseColor ("gggggg").has_value ());
+    CHECK_FALSE (CFBO::parseColor ("1234567").has_value ());
+    CHECK_FALSE (CFBO::parseColor ("123456789").has_value ());
+}

+ 1 - 0
src/WallpaperEngine/Testing/Cases/MouseCoordinates.cpp

@@ -1,3 +1,4 @@
+#include <algorithm>
 #include <catch2/catch_test_macros.hpp>
 #include <cmath>
 #include <glm/glm.hpp>

+ 114 - 0
src/WallpaperEngine/Testing/Cases/ScalingModes.cpp

@@ -0,0 +1,114 @@
+#include <catch2/catch_approx.hpp>
+#include <catch2/catch_test_macros.hpp>
+
+#include "WallpaperEngine/Render/WallpaperState.h"
+
+using WallpaperEngine::Render::WallpaperState;
+
+TEST_CASE ("parseScalingMode maps CLI/hotswap names to their enum value") {
+    CHECK (WallpaperState::parseScalingMode ("stretch") == WallpaperState::TextureUVsScaling::StretchUVs);
+    CHECK (WallpaperState::parseScalingMode ("fit") == WallpaperState::TextureUVsScaling::ZoomFitUVs);
+    CHECK (WallpaperState::parseScalingMode ("fill") == WallpaperState::TextureUVsScaling::ZoomFillUVs);
+    CHECK (WallpaperState::parseScalingMode ("center") == WallpaperState::TextureUVsScaling::CenterUVs);
+    CHECK (WallpaperState::parseScalingMode ("default") == WallpaperState::TextureUVsScaling::DefaultUVs);
+    CHECK_FALSE (WallpaperState::parseScalingMode ("bogus").has_value ());
+}
+
+TEST_CASE ("Center scaling crops a wallpaper bigger than the viewport") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::CenterUVs, 0);
+
+    // 1000x1000 wallpaper on a 500x500 viewport: should show the centered 500x500 slice
+    state.updateState ({ 0, 0, 500, 500 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (0.25f));
+    CHECK (uend == Catch::Approx (0.75f));
+    CHECK (vend == Catch::Approx (0.25f));
+    CHECK (vstart == Catch::Approx (0.75f));
+}
+
+TEST_CASE ("Center scaling letterboxes a wallpaper smaller than the viewport") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::CenterUVs, 0);
+
+    // 500x500 wallpaper on a 1000x1000 viewport: UVs overflow past [0,1] so border clamping
+    // can paint the letterbox/pillarbox around the centered, unscaled image
+    state.updateState ({ 0, 0, 1000, 1000 }, false, 500, 500);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (-0.5f));
+    CHECK (uend == Catch::Approx (1.5f));
+    CHECK (vend == Catch::Approx (-0.5f));
+    CHECK (vstart == Catch::Approx (1.5f));
+}
+
+TEST_CASE ("Changing scaling mode live is picked up even if the viewport hasn't changed") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+
+    state.updateState ({ 0, 0, 500, 500 }, false, 1000, 1000);
+    CHECK_FALSE (state.hasChanged ({ 0, 0, 500, 500 }, false, 1000, 1000));
+
+    state.setTextureUVsStrategy (WallpaperState::TextureUVsScaling::CenterUVs);
+
+    CHECK (state.hasChanged ({ 0, 0, 500, 500 }, false, 1000, 1000));
+
+    state.updateState ({ 0, 0, 500, 500 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (0.25f));
+    CHECK (uend == Catch::Approx (0.75f));
+    CHECK_FALSE (state.hasChanged ({ 0, 0, 500, 500 }, false, 1000, 1000));
+}
+
+TEST_CASE ("setZoom clamps to a sane range") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+
+    CHECK (state.getZoom () == Catch::Approx (1.0f));
+
+    state.setZoom (100.0f);
+    CHECK (state.getZoom () == Catch::Approx (5.0f));
+
+    state.setZoom (0.0f);
+    CHECK (state.getZoom () == Catch::Approx (0.1f));
+}
+
+TEST_CASE ("Zooming in crops tighter around the center of whatever the scaling mode picked") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+    state.setZoom (2.0f);
+
+    state.updateState ({ 0, 0, 1000, 1000 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (0.25f));
+    CHECK (uend == Catch::Approx (0.75f));
+    CHECK (vstart == Catch::Approx (0.75f));
+    CHECK (vend == Catch::Approx (0.25f));
+}
+
+TEST_CASE ("Zooming out overflows past [0,1] so border clamping can reveal more of the wallpaper") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+    state.setZoom (0.5f);
+
+    state.updateState ({ 0, 0, 1000, 1000 }, false, 1000, 1000);
+
+    const auto [ustart, uend, vstart, vend] = state.getTextureUVs ();
+
+    CHECK (ustart == Catch::Approx (-0.5f));
+    CHECK (uend == Catch::Approx (1.5f));
+    CHECK (vstart == Catch::Approx (1.5f));
+    CHECK (vend == Catch::Approx (-0.5f));
+}
+
+TEST_CASE ("Changing zoom live is picked up even if the viewport hasn't changed") {
+    WallpaperState state (WallpaperState::TextureUVsScaling::StretchUVs, 0);
+
+    state.updateState ({ 0, 0, 1000, 1000 }, false, 1000, 1000);
+    CHECK_FALSE (state.hasChanged ({ 0, 0, 1000, 1000 }, false, 1000, 1000));
+
+    state.setZoom (2.0f);
+
+    CHECK (state.hasChanged ({ 0, 0, 1000, 1000 }, false, 1000, 1000));
+}

+ 39 - 3
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp

@@ -4,9 +4,12 @@
 
 #include <mpv/render_gl.h>
 #include <mpv/stream_cb.h>
+#include <vector>
 
 using namespace WallpaperEngine::VideoPlayback::MPV;
 
+std::unordered_map<std::filesystem::path, GLPlayer*> GLPlayer::s_activePlayers;
+
 void* get_proc_address (void* ctx, const char* name) {
     return static_cast<GLPlayer*> (ctx)->getContext ().getDriver ().getProcAddress (name);
 }
@@ -178,6 +181,16 @@ void GLPlayer::render () const {
 int GLPlayer::getWidth () const { return this->m_width; }
 int GLPlayer::getHeight () const { return this->m_height; }
 
+double GLPlayer::getPlaybackPosition () const {
+    double position = 0.0;
+
+    if (this->m_handle != nullptr) {
+	mpv_get_property (this->m_handle, "time-pos", MPV_FORMAT_DOUBLE, &position);
+    }
+
+    return position;
+}
+
 void GLPlayer::prepareGL () {
     if (!this->m_doWeOwnFramebuffer || this->m_fbo != GL_NONE) {
 	return;
@@ -264,12 +277,27 @@ void GLPlayer::play () {
     this->init ();
 
     if (this->m_file.has_value ()) {
-	// build the path to the video file
-	const char* command[] = { "loadfile", this->m_file.value ().c_str (), nullptr };
+	const auto& path = this->m_file.value ();
+
+	// if another player is already showing the same video (e.g. mirrored on another
+	// monitor), start around the same position instead of always restarting from zero
+	std::string startOption;
+	std::vector<const char*> command = { "loadfile", path.c_str (), "replace" };
+
+	if (const auto it = s_activePlayers.find (path); it != s_activePlayers.end ()) {
+	    if (const double position = it->second->getPlaybackPosition (); position > 0.0) {
+		startOption = "start=" + std::to_string (position);
+		command.push_back (startOption.c_str ());
+	    }
+	}
 
-	if (mpv_command (this->m_handle, command) < 0) {
+	command.push_back (nullptr);
+
+	if (mpv_command (this->m_handle, command.data ()) < 0) {
 	    sLog.exception ("Cannot load video to play");
 	}
+
+	s_activePlayers[path] = this;
     } else if (this->m_stream) {
 	this->m_stream.value ()->registerReadCallback (this->m_handle);
 
@@ -283,6 +311,14 @@ void GLPlayer::play () {
 }
 
 void GLPlayer::stop () {
+    // drop ourselves from the active players list, but only if we're still the one registered
+    // (a newer player for the same path may have already taken over the slot)
+    if (this->m_file.has_value ()) {
+	if (const auto it = s_activePlayers.find (this->m_file.value ()); it != s_activePlayers.end () && it->second == this) {
+	    s_activePlayers.erase (it);
+	}
+    }
+
     // clean up mpv and get it ready to start again at some point
     if (this->m_renderContext) {
 	mpv_render_context_free (this->m_renderContext);

+ 9 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h

@@ -7,6 +7,7 @@
 #include <mpv/client.h>
 #include <mpv/render.h>
 #include <string>
+#include <unordered_map>
 
 namespace WallpaperEngine::VideoPlayback::MPV {
 class GLPlayer : public Helpers::ContextAware {
@@ -51,6 +52,9 @@ public:
     int getWidth () const;
     int getHeight () const;
 
+    /** Current playback position in seconds, or 0 if playback hasn't started yet */
+    double getPlaybackPosition () const;
+
 private:
     void prepareGL ();
     void init ();
@@ -74,6 +78,11 @@ protected:
     std::optional<std::filesystem::path> m_file;
     std::optional<MemoryStreamProtocolUniquePtr> m_stream;
     uint32_t m_usageCount = 0;
+
+    // tracks the currently playing instance for each video path, so a player starting
+    // the same video another monitor is already showing can pick up around the same spot
+    // instead of always restarting from zero
+    static std::unordered_map<std::filesystem::path, GLPlayer*> s_activePlayers;
 };
 
 using GLPlayerUniquePtr = std::unique_ptr<GLPlayer>;

+ 1 - 4
src/main.cpp

@@ -49,20 +49,17 @@ int main (int argc, char* argv[]) {
 
 	app = new WallpaperEngine::Application::WallpaperApplication (appContext);
 
-	// halt if the list-properties option was specified
-	if (appContext.settings.general.onlyListProperties) {
+	if (appContext.settings.general.onlyListProperties || appContext.settings.general.onlyListObjects) {
 	    delete app;
 	    return 0;
 	}
 
-	// attach signals to gracefully stop
 	std::signal (SIGINT, signalhandler);
 	std::signal (SIGTERM, signalhandler);
 	std::signal (SIGKILL, signalhandler);
 	// SIGUSR1 triggers a wallpaper hotswap without stopping the process
 	std::signal (SIGUSR1, signalhandler);
 
-	// show the wallpaper application
 	app->show ();
 
 	// remove signal handlers before destroying app