瀏覽代碼

added automated tests, added playlist scheduling, added screen set up for playlists, added crash watching, added testing and linting, added custom thumbnails

UwU 5 天之前
父節點
當前提交
7662417671
共有 55 個文件被更改,包括 4003 次插入 和 615 次删除
  1. 19 0
      .oxlintrc.json
  2. 762 3
      package-lock.json
  3. 8 3
      package.json
  4. 4 0
      src/main/index.ts
  5. 31 6
      src/main/ipc/config.handlers.ts
  6. 2 0
      src/main/ipc/index.ts
  7. 11 30
      src/main/ipc/lwe.handlers.ts
  8. 15 11
      src/main/ipc/playlist.handlers.ts
  9. 24 0
      src/main/ipc/schedule.handlers.ts
  10. 4 0
      src/main/ipc/steam.handlers.ts
  11. 49 8
      src/main/ipc/wallpaper.handlers.ts
  12. 34 4
      src/main/services/config.service.ts
  13. 206 0
      src/main/services/display.service.ts
  14. 3 1
      src/main/services/library.service.ts
  15. 66 0
      src/main/services/lwe.service.test.ts
  16. 512 204
      src/main/services/lwe.service.ts
  17. 234 141
      src/main/services/playlist-player.service.ts
  18. 102 0
      src/main/services/schedule.service.ts
  19. 26 0
      src/main/services/steam.service.ts
  20. 64 0
      src/main/services/thumbnail.service.ts
  21. 28 4
      src/main/services/wallpaper-state.service.ts
  22. 62 40
      src/main/services/wallpaper.service.ts
  23. 4 0
      src/main/utils/paths.ts
  24. 76 19
      src/preload/index.ts
  25. 14 0
      src/renderer/src/App.tsx
  26. 39 0
      src/renderer/src/components/common/CompatBadge.tsx
  27. 74 17
      src/renderer/src/components/common/DetailSidebar.tsx
  28. 30 0
      src/renderer/src/components/common/ScreenSelect.tsx
  29. 17 7
      src/renderer/src/components/common/Toast.tsx
  30. 128 0
      src/renderer/src/components/common/sidebar/CompatSection.tsx
  31. 139 0
      src/renderer/src/components/common/sidebar/EngineFlagsSection.tsx
  32. 111 0
      src/renderer/src/components/common/sidebar/ThumbnailSection.tsx
  33. 27 29
      src/renderer/src/components/layout/StatusBar.tsx
  34. 7 5
      src/renderer/src/components/library/LibraryView.tsx
  35. 5 0
      src/renderer/src/components/library/WallpaperCard.tsx
  36. 2 0
      src/renderer/src/components/playlist/PlaylistItemRow.tsx
  37. 44 37
      src/renderer/src/components/playlist/PlaylistsView.tsx
  38. 64 0
      src/renderer/src/components/settings/DisplaySettings.tsx
  39. 173 0
      src/renderer/src/components/settings/PerformanceSettings.tsx
  40. 303 0
      src/renderer/src/components/settings/ScheduleSettings.tsx
  41. 12 39
      src/renderer/src/components/settings/SettingsView.tsx
  42. 4 4
      src/renderer/src/components/workshop/WorkshopBrowser.tsx
  43. 18 0
      src/renderer/src/constants/engineFlags.ts
  44. 38 0
      src/renderer/src/hooks/useDisplayTargets.ts
  45. 3 0
      src/renderer/src/utils/preview.ts
  46. 5 0
      src/renderer/src/utils/screens.ts
  47. 26 0
      src/shared/compat.test.ts
  48. 22 0
      src/shared/compat.ts
  49. 34 0
      src/shared/engineFlags.test.ts
  50. 62 0
      src/shared/engineFlags.ts
  51. 21 2
      src/shared/ipc-channels.ts
  52. 73 0
      src/shared/schedule.test.ts
  53. 56 0
      src/shared/schedule.ts
  54. 91 1
      src/shared/types.ts
  55. 15 0
      vitest.config.ts

+ 19 - 0
.oxlintrc.json

@@ -0,0 +1,19 @@
+{
+  "$schema": "./node_modules/oxlint/configuration_schema.json",
+  "plugins": ["typescript", "unicorn", "oxc", "react", "vitest"],
+  "categories": {
+    "correctness": "error",
+    "suspicious": "warn"
+  },
+  "rules": {
+    "react/rules-of-hooks": "error",
+    "react/exhaustive-deps": "warn",
+    "react/set-state-in-effect": "warn",
+    "react/preserve-manual-memoization": "off",
+    "react/react-in-jsx-scope": "off",
+    "unicorn/no-array-sort": "off",
+    "no-unused-vars": "warn",
+    "no-control-regex": "off"
+  },
+  "ignorePatterns": ["out/**", "dist/**", "node_modules/**", "patches/**"]
+}

+ 762 - 3
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "we-manager",
-  "version": "0.1.0",
+  "version": "0.7.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "we-manager",
-      "version": "0.1.0",
+      "version": "0.7.0",
       "license": "MIT",
       "dependencies": {
         "@tanstack/react-query": "^5.101.4",
@@ -27,9 +27,11 @@
         "electron": "^43.2.0",
         "electron-builder": "^26.15.3",
         "electron-vite": "^5.0.0",
+        "oxlint": "^1.85.0",
         "tailwindcss": "^4.3.3",
         "typescript": "^7.0.2",
-        "vite": "^7.3.6"
+        "vite": "^7.3.6",
+        "vitest": "^4.1.11"
       }
     },
     "node_modules/@babel/code-frame": {
@@ -1460,6 +1462,353 @@
         "url": "https://paulmillr.com/funding/"
       }
     },
+    "node_modules/@oxlint/binding-android-arm-eabi": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.85.0.tgz",
+      "integrity": "sha512-q2KO/Zso9UT+OMn0NF9ywn4E4t0MI3yxiDhNyhsQ7DyQJrC4FhFE4TXOi4bktFnOWXTMds8qZSbpv2XwRaNOBg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-android-arm64": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.85.0.tgz",
+      "integrity": "sha512-SxLN3ALjoT9NNdvpjEevGeHvfzTAFrF0NBYB5tzK7/GtCKMze3j1e/m/X2ozqGj2U9hfGG/dg/OG8vpVK4PiDA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-darwin-arm64": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.85.0.tgz",
+      "integrity": "sha512-Y/Sup/J4f0f9UGsSd/xyCNTeWL+gepO63GBdEDAfue9nBsnk9zMmnIXx1O6b1V8C90vB5nucYNZ0pbMXAp8zJA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-darwin-x64": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.85.0.tgz",
+      "integrity": "sha512-ApOSNC04ynpDTwvBD+//0wyfODRSbEzvRoKpX8teffmc27z8AockwSNeMXGJXn5KP85eahDgR/2llICWLkzcnw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-freebsd-x64": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.85.0.tgz",
+      "integrity": "sha512-bNrVrCOA/kHky3Tu79IXWXe5bhIgLXfUuUEDHlAGOHUk96MkvDZ1ecaQF19rwstrnaqfP1o9nBTqzIr9+ZHkUg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.85.0.tgz",
+      "integrity": "sha512-NUrzOJ1s/EqsVvfn2L/1D8Wro2LPIZUbihL8kOJLh5fEdGEN3rdOGUYq3HwnUIL8sjpoP+4N6RaGrgmMJnaMPw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.85.0.tgz",
+      "integrity": "sha512-UJXrAT3E/RWkEqXLIs2ehETja1qfgkPb+5gwLIIS+o/6cf+grHvoOXTa5997a/YNQfcJS0DRBTOfZt95cvOI1g==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm64-gnu": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.85.0.tgz",
+      "integrity": "sha512-lK40QLjI0HxigO7CjDDshEtfYIeiYS0020v5BHFPqN4uuQBQxd2K9LNom2dW15o9F1937quSCRVp4ZsVhdbYdg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-arm64-musl": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.85.0.tgz",
+      "integrity": "sha512-c2zbdBwGKreHXwRx3gWBuFGJxLhxgsg6YlZ+3H+RgRusU/UEV9jNwJ3HGYK+nRo0LvBa7mt6Kj86xoVotUo8cw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.85.0.tgz",
+      "integrity": "sha512-tlt/Hy8lZ97/lCPmCgw/B3k/mwh+BzaIPbPkldZEly7TwLmx0xe2CQcaW2g/rR0dOgS9JNGCZsMEqLhUNMGvaw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.85.0.tgz",
+      "integrity": "sha512-3tNR9Xey82X0zKuY1d8hJ6Rc9gwRDurmqGLnQZa5xqOXy8/YyiqFXjAtugkKLY82obOlpK1eSiDRlgcNPuxtIg==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-riscv64-musl": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.85.0.tgz",
+      "integrity": "sha512-wbGRd5PqCcjkJFHhZuZ2OBSUQY9czlQsoA/cQQB9JK/L9mC5MQgGoKAh+xd8QjA5V+0D3j+Qd1lAWn1I8zlelA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-s390x-gnu": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.85.0.tgz",
+      "integrity": "sha512-3Sn0kSrE4DPZCWV/8o+n4x3aFZxI9ulMnkYlwCbJ8eUVkwRK2IerohE/A/z3SNbCwoPFOCJmGE5Avrq0rrvdvQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-x64-gnu": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.85.0.tgz",
+      "integrity": "sha512-JY2pxxYfB62bAGfejljVCqc44etItehPuAyaeSAdMuEMtwNA00ggMnS66lC1oIhos6oOXUkuU6mZ9bpFh3BqWg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "glibc"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-linux-x64-musl": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.85.0.tgz",
+      "integrity": "sha512-5k74vZ6qJBjBHEOlBk9B/iv68Yu0F1Afw/vvT2ar6OGCqEeXLaSjXz2n/IPCbhLG22UoKoYEJTzpYraRdcp6PA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "libc": [
+        "musl"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-openharmony-arm64": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.85.0.tgz",
+      "integrity": "sha512-GbAl5qt5TCkPLXTaIISZJnugrcBhra6rodcXc9jYt620UtdsTt71NlNmJmm0frxzFpd54x/G+MkitEJA8I/BoA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-arm64-msvc": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.85.0.tgz",
+      "integrity": "sha512-kjmws5MK0et2swk4ND85D7NVQyDHw162i6whtZDLUA/lo6FQyBZDcmMRCMcVZcNrAhIaftVb00x9ChGDOjjNJA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-ia32-msvc": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.85.0.tgz",
+      "integrity": "sha512-eSsIJx9n4yxvOqYTZyPEMyEXRmE60XH7xGAU7i0Qbsn1lf6Za3CWJ9aRd82oSFKXaxhp+sA6/yMJVRIpLpna6A==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
+    "node_modules/@oxlint/binding-win32-x64-msvc": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.85.0.tgz",
+      "integrity": "sha512-pBebIPUpKKhWrhSMWhy8TdAZBewiXnfxmaAGxhzxM1068GagqFaTwgKlU6e+UyJ2sPR+VoHouhXuGJkQjsrDvA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      }
+    },
     "node_modules/@peculiar/asn1-schema": {
       "version": "2.8.0",
       "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
@@ -1882,6 +2231,13 @@
         "url": "https://github.com/sindresorhus/is?sponsor=1"
       }
     },
+    "node_modules/@standard-schema/spec": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+      "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@szmarczak/http-timer": {
       "version": "4.0.6",
       "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@@ -2251,6 +2607,17 @@
         "@types/responselike": "^1.0.0"
       }
     },
+    "node_modules/@types/chai": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+      "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/deep-eql": "*",
+        "assertion-error": "^2.0.1"
+      }
+    },
     "node_modules/@types/debug": {
       "version": "4.1.13",
       "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -2261,6 +2628,13 @@
         "@types/ms": "*"
       }
     },
+    "node_modules/@types/deep-eql": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+      "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@types/estree": {
       "version": "1.0.9",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2702,6 +3076,119 @@
         "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/@vitest/expect": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
+      "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@standard-schema/spec": "^1.1.0",
+        "@types/chai": "^5.2.2",
+        "@vitest/spy": "4.1.11",
+        "@vitest/utils": "4.1.11",
+        "chai": "^6.2.2",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/mocker": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
+      "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/spy": "4.1.11",
+        "estree-walker": "^3.0.3",
+        "magic-string": "^0.30.21"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "msw": "^2.4.9",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "msw": {
+          "optional": true
+        },
+        "vite": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vitest/pretty-format": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
+      "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/runner": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
+      "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/utils": "4.1.11",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/snapshot": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
+      "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.11",
+        "@vitest/utils": "4.1.11",
+        "magic-string": "^0.30.21",
+        "pathe": "^2.0.3"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/spy": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
+      "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
+    "node_modules/@vitest/utils": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
+      "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/pretty-format": "4.1.11",
+        "convert-source-map": "^2.0.0",
+        "tinyrainbow": "^3.1.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      }
+    },
     "node_modules/@xmldom/xmldom": {
       "version": "0.8.13",
       "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -2976,6 +3463,16 @@
         "node": ">=12.0.0"
       }
     },
+    "node_modules/assertion-error": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+      "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/async": {
       "version": "3.2.6",
       "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -3265,6 +3762,16 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/chai": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+      "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/chalk": {
       "version": "4.1.2",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -4069,6 +4576,13 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/es-module-lexer": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+      "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/es-object-atoms": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
@@ -4172,6 +4686,26 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/estree-walker": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+      "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "^1.0.0"
+      }
+    },
+    "node_modules/expect-type": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+      "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
     "node_modules/exponential-backoff": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -5591,6 +6125,20 @@
         "node": ">= 0.4"
       }
     },
+    "node_modules/obug": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+      "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+      "dev": true,
+      "funding": [
+        "https://github.com/sponsors/sxzz",
+        "https://opencollective.com/debug"
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
     "node_modules/once": {
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -5601,6 +6149,55 @@
         "wrappy": "1"
       }
     },
+    "node_modules/oxlint": {
+      "version": "1.85.0",
+      "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.85.0.tgz",
+      "integrity": "sha512-bc26s97nuvPj1ViyPsqmKecVkUWFMEdtayO8MaQ6oiLfs1pj94cQlZZhrh4BPNlr9HQosjhIlwgZKsfcwmcNgg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "oxlint": "bin/oxlint"
+      },
+      "engines": {
+        "node": "^20.19.0 || >=22.12.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/oxc-project"
+      },
+      "optionalDependencies": {
+        "@oxlint/binding-android-arm-eabi": "1.85.0",
+        "@oxlint/binding-android-arm64": "1.85.0",
+        "@oxlint/binding-darwin-arm64": "1.85.0",
+        "@oxlint/binding-darwin-x64": "1.85.0",
+        "@oxlint/binding-freebsd-x64": "1.85.0",
+        "@oxlint/binding-linux-arm-gnueabihf": "1.85.0",
+        "@oxlint/binding-linux-arm-musleabihf": "1.85.0",
+        "@oxlint/binding-linux-arm64-gnu": "1.85.0",
+        "@oxlint/binding-linux-arm64-musl": "1.85.0",
+        "@oxlint/binding-linux-ppc64-gnu": "1.85.0",
+        "@oxlint/binding-linux-riscv64-gnu": "1.85.0",
+        "@oxlint/binding-linux-riscv64-musl": "1.85.0",
+        "@oxlint/binding-linux-s390x-gnu": "1.85.0",
+        "@oxlint/binding-linux-x64-gnu": "1.85.0",
+        "@oxlint/binding-linux-x64-musl": "1.85.0",
+        "@oxlint/binding-openharmony-arm64": "1.85.0",
+        "@oxlint/binding-win32-arm64-msvc": "1.85.0",
+        "@oxlint/binding-win32-ia32-msvc": "1.85.0",
+        "@oxlint/binding-win32-x64-msvc": "1.85.0"
+      },
+      "peerDependencies": {
+        "oxlint-tsgolint": ">=7.0.2001",
+        "vite-plus": "*"
+      },
+      "peerDependenciesMeta": {
+        "oxlint-tsgolint": {
+          "optional": true
+        },
+        "vite-plus": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/p-cancelable": {
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -5647,6 +6244,13 @@
         "node": ">=8"
       }
     },
+    "node_modules/pathe": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+      "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/pe-library": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
@@ -6195,6 +6799,13 @@
         "node": ">=8"
       }
     },
+    "node_modules/siginfo": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+      "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+      "dev": true,
+      "license": "ISC"
+    },
     "node_modules/signal-exit": {
       "version": "3.0.7",
       "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
@@ -6267,6 +6878,13 @@
       "license": "BSD-3-Clause",
       "optional": true
     },
+    "node_modules/stackback": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+      "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/stat-mode": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz",
@@ -6277,6 +6895,13 @@
         "node": ">= 6"
       }
     },
+    "node_modules/std-env": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+      "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/steamworks.js": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/steamworks.js/-/steamworks.js-0.4.0.tgz",
@@ -6474,6 +7099,23 @@
         "semver": "bin/semver"
       }
     },
+    "node_modules/tinybench": {
+      "version": "2.9.0",
+      "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+      "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tinyexec": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+      "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "node_modules/tinyglobby": {
       "version": "0.2.17",
       "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6491,6 +7133,16 @@
         "url": "https://github.com/sponsors/SuperchupuDev"
       }
     },
+    "node_modules/tinyrainbow": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz",
+      "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
     "node_modules/tmp": {
       "version": "0.2.7",
       "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -7250,6 +7902,96 @@
         "@esbuild/win32-x64": "0.28.1"
       }
     },
+    "node_modules/vitest": {
+      "version": "4.1.11",
+      "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
+      "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@vitest/expect": "4.1.11",
+        "@vitest/mocker": "4.1.11",
+        "@vitest/pretty-format": "4.1.11",
+        "@vitest/runner": "4.1.11",
+        "@vitest/snapshot": "4.1.11",
+        "@vitest/spy": "4.1.11",
+        "@vitest/utils": "4.1.11",
+        "es-module-lexer": "^2.0.0",
+        "expect-type": "^1.3.0",
+        "magic-string": "^0.30.21",
+        "obug": "^2.1.1",
+        "pathe": "^2.0.3",
+        "picomatch": "^4.0.3",
+        "std-env": "^4.0.0-rc.1",
+        "tinybench": "^2.9.0",
+        "tinyexec": "^1.0.2",
+        "tinyglobby": "^0.2.15",
+        "tinyrainbow": "^3.1.0",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+        "why-is-node-running": "^2.3.0"
+      },
+      "bin": {
+        "vitest": "vitest.mjs"
+      },
+      "engines": {
+        "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/vitest"
+      },
+      "peerDependencies": {
+        "@edge-runtime/vm": "*",
+        "@opentelemetry/api": "^1.9.0",
+        "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+        "@vitest/browser-playwright": "4.1.11",
+        "@vitest/browser-preview": "4.1.11",
+        "@vitest/browser-webdriverio": "4.1.11",
+        "@vitest/coverage-istanbul": "4.1.11",
+        "@vitest/coverage-v8": "4.1.11",
+        "@vitest/ui": "4.1.11",
+        "happy-dom": "*",
+        "jsdom": "*",
+        "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@edge-runtime/vm": {
+          "optional": true
+        },
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@types/node": {
+          "optional": true
+        },
+        "@vitest/browser-playwright": {
+          "optional": true
+        },
+        "@vitest/browser-preview": {
+          "optional": true
+        },
+        "@vitest/browser-webdriverio": {
+          "optional": true
+        },
+        "@vitest/coverage-istanbul": {
+          "optional": true
+        },
+        "@vitest/coverage-v8": {
+          "optional": true
+        },
+        "@vitest/ui": {
+          "optional": true
+        },
+        "happy-dom": {
+          "optional": true
+        },
+        "jsdom": {
+          "optional": true
+        },
+        "vite": {
+          "optional": false
+        }
+      }
+    },
     "node_modules/webcrypto-core": {
       "version": "1.9.2",
       "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -7286,6 +8028,23 @@
         "node": "^18.17.0 || >=20.5.0"
       }
     },
+    "node_modules/why-is-node-running": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+      "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "siginfo": "^2.0.0",
+        "stackback": "0.0.2"
+      },
+      "bin": {
+        "why-is-node-running": "cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
     "node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

+ 8 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "we-manager",
-  "version": "0.7.0",
+  "version": "0.7.1",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",
@@ -12,7 +12,10 @@
     "build": "electron-vite build",
     "preview": "electron-vite preview",
     "dist": "npm run build && electron-builder",
-    "dist:linux": "npm run build && electron-builder --linux"
+    "dist:linux": "npm run build && electron-builder --linux",
+    "lint": "oxlint",
+    "test": "vitest run",
+    "typecheck": "tsc -p tsconfig.node.json --noEmit && tsc -p tsconfig.web.json --noEmit"
   },
   "dependencies": {
     "@tanstack/react-query": "^5.101.4",
@@ -33,9 +36,11 @@
     "electron": "^43.2.0",
     "electron-builder": "^26.15.3",
     "electron-vite": "^5.0.0",
+    "oxlint": "^1.85.0",
     "tailwindcss": "^4.3.3",
     "typescript": "^7.0.2",
-    "vite": "^7.3.6"
+    "vite": "^7.3.6",
+    "vitest": "^4.1.11"
   },
   "build": {
     "appId": "com.we-manager.app",

+ 4 - 0
src/main/index.ts

@@ -11,6 +11,8 @@ import { getTrayEnabled, getAutostartPlaylistId, getKillLweOnQuit } from './serv
 import { createTray } from './services/tray.service'
 import { initPlaylistPlayer, startPlaylist } from './services/playlist-player.service'
 import { killAllLweProcesses, getLweStatus } from './services/lwe.service'
+import { initDisplay } from './services/display.service'
+import { initSchedule } from './services/schedule.service'
 
 const startMinimized = process.argv.includes('--minimized')
 let isQuitting = false
@@ -113,6 +115,7 @@ app.whenReady().then(() => {
   startWatcher(win)
   startBackgroundSync(win)
   initDesktopIcons()
+  initDisplay(win)
   const resumed = initPlaylistPlayer(win)
 
   if (getTrayEnabled()) createTray(win)
@@ -125,6 +128,7 @@ app.whenReady().then(() => {
       console.error('[App] Failed to autostart playlist:', err)
     }
   }
+  initSchedule(win)
 })
 
 app.on('before-quit', (event) => {

+ 31 - 6
src/main/ipc/config.handlers.ts

@@ -1,6 +1,7 @@
 import { ipcMain, dialog, BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import type { SteamIdentity } from '@shared/types'
+import type { DisplayMode, EngineFlagPreset, EngineFlags, SteamIdentity } from '@shared/types'
+import { BUILT_IN_PRESETS } from '@shared/engineFlags'
 import {
   getConfiguredWorkshopPath,
   setConfiguredWorkshopPath,
@@ -41,13 +42,20 @@ import {
   getDisablePuppetAnimation,
   setDisablePuppetAnimation,
   getDisableAnimations,
-  setDisableAnimations,
   getSteamIdentity,
   setSteamIdentity,
   getIgnoredCreators,
   ignoreCreator,
-  unignoreCreator
+  unignoreCreator,
+  getGlobalEngineFlags,
+  setGlobalEngineFlags,
+  getEngineFlagPresets,
+  setEngineFlagPresets,
+  getDisplayMode,
+  setDisplayMode
 } from '../services/config.service'
+import { resetDisplays } from '../services/display.service'
+import { stopPlaylist } from '../services/playlist-player.service'
 import { getDefaultWorkshopPath } from '../utils/paths'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
 import { restartWatcher } from '../services/watcher.service'
@@ -82,7 +90,10 @@ export function registerConfigHandlers(): void {
     disablePuppetAnimation: getDisablePuppetAnimation(),
     disableAnimations: getDisableAnimations(),
     steamIdentity: getSteamIdentity(),
-    ignoredCreators: getIgnoredCreators()
+    ignoredCreators: getIgnoredCreators(),
+    engineFlags: getGlobalEngineFlags(),
+    engineFlagPresets: [...BUILT_IN_PRESETS, ...getEngineFlagPresets()],
+    displayMode: getDisplayMode()
   }))
 
   ipcMain.handle(IpcChannels.CONFIG_GET_AUTOSTART_SUPPORTED, () => isAutostartSupported())
@@ -173,8 +184,22 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
-  ipcMain.handle(IpcChannels.CONFIG_SET_DISABLE_ANIMATIONS, (_e, disabled: boolean) => {
-    setDisableAnimations(disabled)
+  ipcMain.handle(IpcChannels.CONFIG_SET_ENGINE_FLAGS, (_e, flags: EngineFlags) => {
+    setGlobalEngineFlags(flags)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.CONFIG_SET_ENGINE_PRESETS, (_e, presets: EngineFlagPreset[]) => {
+    setEngineFlagPresets(presets)
+    return { ok: true }
+  })
+
+  // what was on which screen means nothing in the other mode, so everything starts over
+  ipcMain.handle(IpcChannels.CONFIG_SET_DISPLAY_MODE, async (_e, mode: DisplayMode) => {
+    if (mode === getDisplayMode()) return { ok: true }
+    stopPlaylist()
+    await resetDisplays()
+    setDisplayMode(mode)
     return { ok: true }
   })
 

+ 2 - 0
src/main/ipc/index.ts

@@ -10,6 +10,7 @@ import { registerLweHandlers } from './lwe.handlers'
 import { registerDesktopIconsHandlers } from './desktop-icons.handlers'
 import { registerBackupHandlers } from './backup.handlers'
 import { registerPlaylistHandlers } from './playlist.handlers'
+import { registerScheduleHandlers } from './schedule.handlers'
 
 export function registerAllHandlers(win: BrowserWindow): void {
   registerSteamHandlers(win)
@@ -23,4 +24,5 @@ export function registerAllHandlers(win: BrowserWindow): void {
   registerDesktopIconsHandlers()
   registerBackupHandlers(win)
   registerPlaylistHandlers()
+  registerScheduleHandlers()
 }

+ 11 - 30
src/main/ipc/lwe.handlers.ts

@@ -1,6 +1,9 @@
 import { ipcMain, type BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects, listLweEffects } from '../services/lwe.service'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects, listLweEffects, type HotswapOptions } from '../services/lwe.service'
+import { stopDisplay } from '../services/display.service'
+import { stopPlaylist } from '../services/playlist-player.service'
+import type { ScreenTarget } from '@shared/types'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 import { getConnectedScreens } from '../utils/platform'
 
@@ -30,13 +33,14 @@ export function registerLweHandlers(win: BrowserWindow): void {
     return result
   })
 
-  ipcMain.handle(IpcChannels.LWE_LAUNCH, async (_e, wallpaperPath: string, options?: { screenRoot?: string; fps?: number }) => {
+  ipcMain.handle(IpcChannels.LWE_LAUNCH, async (_e, wallpaperPath: string, options?: { screen?: ScreenTarget; fps?: number }) => {
     await launchLweAsync(wallpaperPath, options)
     return { ok: true, running: isLweRunning() }
   })
 
   ipcMain.handle(IpcChannels.LWE_STOP, async () => {
-    await stopLwe()
+    stopPlaylist()
+    await stopDisplay()
     return { ok: true }
   })
 
@@ -54,33 +58,10 @@ export function registerLweHandlers(win: BrowserWindow): void {
     }
   })
 
-  ipcMain.handle(
-    IpcChannels.LWE_HOTSWAP_SETTINGS,
-    (
-      _e,
-      options: {
-        disabledObjects?: string[]
-        enabledObjects?: string[]
-        volume?: number
-        xray?: boolean
-        scaling?: string
-        zoom?: number
-        offsetX?: number
-        offsetY?: number
-        disableParallax?: boolean
-        expandCanvas?: boolean
-        cornerColor?: string
-        speed?: number
-        propertyOverrides?: Record<string, string>
-        audioScreen?: string
-        ambientVolume?: number
-        audioSensitivity?: Record<string, number>
-        soundVolume?: Record<string, number>
-      }
-    ) => {
-      return { ok: hotswapLweSettings(options) }
-    }
-  )
+  // live edits go to whichever engines show the wallpaper being edited
+  ipcMain.handle(IpcChannels.LWE_HOTSWAP_SETTINGS, async (_e, options: HotswapOptions, wallpaperPath?: string) => {
+    return { ok: await hotswapLweSettings(options, { wallpaperPath }) }
+  })
 
   ipcMain.handle(IpcChannels.LWE_LIST_SCREENS, () => {
     return getConnectedScreens()

+ 15 - 11
src/main/ipc/playlist.handlers.ts

@@ -1,6 +1,6 @@
 import { ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import type { PlaylistSettings } from '@shared/types'
+import type { PlaylistSettings, ScreenTarget } from '@shared/types'
 import * as playlists from '../services/playlist.service'
 import * as player from '../services/playlist-player.service'
 
@@ -18,7 +18,9 @@ export function registerPlaylistHandlers(): void {
   )
 
   ipcMain.handle(IpcChannels.PLAYLIST_DELETE, (_e, id: string) => {
-    if (player.getPlaybackState().playlistId === id) player.stopPlaylist()
+    for (const state of player.getPlaybackStates()) {
+      if (state.playlistId === id) player.stopPlaylist(state.screen)
+    }
     playlists.deletePlaylist(id)
     return { ok: true }
   })
@@ -49,14 +51,16 @@ export function registerPlaylistHandlers(): void {
       playlists.updatePlaylistItem(id, wallpaperId, patch)
   )
 
-  ipcMain.handle(IpcChannels.PLAYLIST_START, (_e, id: string) => player.startPlaylist(id))
-  ipcMain.handle(IpcChannels.PLAYLIST_PLAY_ITEM, (_e, id: string, wallpaperId: string) =>
-    player.playItem(id, wallpaperId)
+  ipcMain.handle(IpcChannels.PLAYLIST_START, (_e, id: string, screen?: ScreenTarget) =>
+    player.startPlaylist(id, screen)
   )
-  ipcMain.handle(IpcChannels.PLAYLIST_STOP, () => player.stopPlaylist())
-  ipcMain.handle(IpcChannels.PLAYLIST_PAUSE, () => player.pausePlaylist())
-  ipcMain.handle(IpcChannels.PLAYLIST_RESUME, () => player.resumePlaylist())
-  ipcMain.handle(IpcChannels.PLAYLIST_NEXT, () => player.nextItem())
-  ipcMain.handle(IpcChannels.PLAYLIST_PREVIOUS, () => player.previousItem())
-  ipcMain.handle(IpcChannels.PLAYLIST_GET_STATE, () => player.getPlaybackState())
+  ipcMain.handle(IpcChannels.PLAYLIST_PLAY_ITEM, (_e, id: string, wallpaperId: string, screen?: ScreenTarget) =>
+    player.playItem(id, wallpaperId, screen)
+  )
+  ipcMain.handle(IpcChannels.PLAYLIST_STOP, (_e, screen?: ScreenTarget) => player.stopPlaylist(screen))
+  ipcMain.handle(IpcChannels.PLAYLIST_PAUSE, (_e, screen?: ScreenTarget) => player.pausePlaylist(screen))
+  ipcMain.handle(IpcChannels.PLAYLIST_RESUME, (_e, screen?: ScreenTarget) => player.resumePlaylist(screen))
+  ipcMain.handle(IpcChannels.PLAYLIST_NEXT, (_e, screen?: ScreenTarget) => player.nextItem(screen))
+  ipcMain.handle(IpcChannels.PLAYLIST_PREVIOUS, (_e, screen?: ScreenTarget) => player.previousItem(screen))
+  ipcMain.handle(IpcChannels.PLAYLIST_GET_STATE, () => player.getPlaybackStates())
 }

+ 24 - 0
src/main/ipc/schedule.handlers.ts

@@ -0,0 +1,24 @@
+import { ipcMain } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { ScheduleRule } from '@shared/types'
+import { deleteScheduleRule, getScheduleRules, runRule, saveScheduleRule } from '../services/schedule.service'
+
+export function registerScheduleHandlers(): void {
+  ipcMain.handle(IpcChannels.SCHEDULE_GET_ALL, () => getScheduleRules())
+
+  ipcMain.handle(IpcChannels.SCHEDULE_SAVE, (_e, rule: Omit<ScheduleRule, 'id'> & { id?: string }) =>
+    saveScheduleRule(rule)
+  )
+
+  ipcMain.handle(IpcChannels.SCHEDULE_DELETE, (_e, id: string) => {
+    deleteScheduleRule(id)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.SCHEDULE_RUN, async (_e, id: string) => {
+    const rule = getScheduleRules().find((r) => r.id === id)
+    if (!rule) throw new Error('Schedule rule not found')
+    await runRule(rule)
+    return { ok: true }
+  })
+}

+ 4 - 0
src/main/ipc/steam.handlers.ts

@@ -116,6 +116,10 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     return steam.getVotedUpItemIds()
   })
 
+  ipcMain.handle(IpcChannels.STEAM_CHECK_VOTE, (_e, itemId: string) => {
+    return steam.refreshItemVote(BigInt(itemId))
+  })
+
   ipcMain.handle(IpcChannels.STEAM_GET_SUBSCRIBED, () => {
     return steam.getSubscribedItems()
   })

+ 49 - 8
src/main/ipc/wallpaper.handlers.ts

@@ -1,10 +1,19 @@
-import { ipcMain } from 'electron'
+import { BrowserWindow, dialog, ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import { applyWallpaperMeta, detectEnvironment } from '../services/wallpaper.service'
+import { detectEnvironment } from '../services/wallpaper.service'
 import { getWallpaper, updateWallpaper } from '../services/library.service'
-import { setActiveWallpaperId, getActiveWallpaperId } from '../services/wallpaper-state.service'
-import { getPlaybackState, pausePlaylist } from '../services/playlist-player.service'
-import type { ApplyWallpaperOptions } from '@shared/types'
+import { getActiveWallpaperId } from '../services/wallpaper-state.service'
+import { pausePlaylist } from '../services/playlist-player.service'
+import {
+  expandScreen,
+  getDisplayTargets,
+  getScreenAssignments,
+  showWallpaper,
+  stopDisplay,
+  testFreshLaunch
+} from '../services/display.service'
+import type { ApplyWallpaperOptions, ScreenTarget } from '@shared/types'
+import { deleteThumbnail, generateThumbnail, IMAGE_EXTENSIONS, useThumbnailFile } from '../services/thumbnail.service'
 
 export function registerWallpaperHandlers(): void {
   ipcMain.handle(IpcChannels.WALLPAPER_DETECT_ENV, () => {
@@ -16,15 +25,19 @@ export function registerWallpaperHandlers(): void {
     return id ? getWallpaper(id) : null
   })
 
+  ipcMain.handle(IpcChannels.WALLPAPER_GET_ASSIGNMENTS, () => getScreenAssignments())
+
+  ipcMain.handle(IpcChannels.WALLPAPER_GET_TARGETS, () => getDisplayTargets())
+
   ipcMain.handle(IpcChannels.WALLPAPER_APPLY, async (_e, options: ApplyWallpaperOptions) => {
     const wallpaper = getWallpaper(options.wallpaperId)
     if (!wallpaper) throw new Error(`Wallpaper ${options.wallpaperId} not found in library`)
 
-    if (getPlaybackState().isPlaying) pausePlaylist()
+    // a playlist would replace it again on its next switch
+    for (const screen of expandScreen(options.screen)) pausePlaylist(screen)
 
-    const appliedPath = await applyWallpaperMeta(wallpaper, { backend: options.backend })
+    const appliedPath = await showWallpaper(wallpaper, options.screen, { backend: options.backend })
 
-    setActiveWallpaperId(options.wallpaperId)
     updateWallpaper(options.wallpaperId, {
       appliedCount: (wallpaper.appliedCount ?? 0) + 1,
       lastAppliedAt: Date.now()
@@ -32,4 +45,32 @@ export function registerWallpaperHandlers(): void {
 
     return { ok: true, appliedPath }
   })
+
+  ipcMain.handle(IpcChannels.WALLPAPER_STOP, async (_e, screen?: ScreenTarget) => {
+    for (const s of expandScreen(screen)) pausePlaylist(s)
+    await stopDisplay(screen)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.WALLPAPER_TEST_LAUNCH, async (_e, wallpaperId: string, screen?: ScreenTarget) => {
+    for (const s of expandScreen(screen).slice(0, 1)) pausePlaylist(s)
+    await testFreshLaunch(wallpaperId, screen)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.WALLPAPER_GENERATE_THUMBNAIL, (_e, wallpaperId: string) => generateThumbnail(wallpaperId))
+
+  ipcMain.handle(IpcChannels.WALLPAPER_PICK_THUMBNAIL, async (_e, wallpaperId: string) => {
+    const win = BrowserWindow.getFocusedWindow()
+    const options = {
+      properties: ['openFile' as const],
+      title: 'Choose a thumbnail',
+      filters: [{ name: 'Images', extensions: IMAGE_EXTENSIONS.map((e) => e.slice(1)) }]
+    }
+    const result = win ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options)
+    if (result.canceled || result.filePaths.length === 0) return null
+    return useThumbnailFile(wallpaperId, result.filePaths[0])
+  })
+
+  ipcMain.handle(IpcChannels.WALLPAPER_DELETE_THUMBNAIL, (_e, wallpaperId: string) => deleteThumbnail(wallpaperId))
 }

+ 34 - 4
src/main/services/config.service.ts

@@ -3,7 +3,7 @@ import * as fs from 'fs'
 import * as path from 'path'
 import { app } from 'electron'
 import { WE_APP_ID } from '@shared/constants'
-import type { SteamIdentity } from '@shared/types'
+import type { DisplayMode, EngineFlagPreset, EngineFlags, SteamIdentity } from '@shared/types'
 
 interface AppConfig {
   workshopPath: string | null
@@ -31,6 +31,10 @@ interface AppConfig {
   steamIdentity: SteamIdentity
   /** steamId64 of creators whose workshop items are hidden from browsing by default */
   ignoredCreators: string[]
+  /** Launch-only engine flags for every wallpaper; disableAnimations lives in its own key above */
+  engineFlags: EngineFlags
+  engineFlagPresets: EngineFlagPreset[]
+  displayMode: DisplayMode
 }
 
 const store = new Store<AppConfig>({
@@ -56,7 +60,10 @@ const store = new Store<AppConfig>({
     disablePuppetAnimation: false,
     disableAnimations: false,
     steamIdentity: 'wallpaper-engine',
-    ignoredCreators: []
+    ignoredCreators: [],
+    engineFlags: {},
+    engineFlagPresets: [],
+    displayMode: 'shared'
   }
 })
 
@@ -213,8 +220,31 @@ export function getDisableAnimations(): boolean {
   return store.get('disableAnimations')
 }
 
-export function setDisableAnimations(disabled: boolean): void {
-  store.set('disableAnimations', disabled)
+export function getGlobalEngineFlags(): EngineFlags {
+  return { ...store.get('engineFlags'), disableAnimations: store.get('disableAnimations') }
+}
+
+export function setGlobalEngineFlags(flags: EngineFlags): void {
+  const { disableAnimations, ...rest } = flags
+  store.set('engineFlags', rest)
+  store.set('disableAnimations', !!disableAnimations)
+}
+
+export function getEngineFlagPresets(): EngineFlagPreset[] {
+  return store.get('engineFlagPresets')
+}
+
+export function setEngineFlagPresets(presets: EngineFlagPreset[]): void {
+  store.set('engineFlagPresets', presets.filter((p) => !p.builtIn))
+}
+
+/** 'shared' runs one engine across every screen, 'per-screen' one engine (and playlist) per screen */
+export function getDisplayMode(): DisplayMode {
+  return store.get('displayMode')
+}
+
+export function setDisplayMode(mode: DisplayMode): void {
+  store.set('displayMode', mode)
 }
 
 /** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override. */

+ 206 - 0
src/main/services/display.service.ts

@@ -0,0 +1,206 @@
+import type { BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import type {
+  CrashEvent,
+  CrashPhase,
+  ScreenAssignment,
+  ScreenTarget,
+  WallpaperBackend,
+  WallpaperCompat,
+  WallpaperMeta
+} from '@shared/types'
+import { getAllWallpapers, getWallpaper, updateWallpaper } from './library.service'
+import { applyWallpaperMeta } from './wallpaper.service'
+import { ALL_SCREENS, getRunningInstances, onLweExit, stopLwe, type LweExitInfo } from './lwe.service'
+import { clearAssignment, getAssignments, setAssignment } from './wallpaper-state.service'
+import { getDisplayMode } from './config.service'
+import { getConnectedScreens } from '../utils/platform'
+
+// how long a fresh launch has to survive before it counts as working
+const LAUNCH_CHECK_MS = 20_000
+// automatic relaunches after a runtime crash, at most one per screen in this window
+const RELAUNCH_COOLDOWN_MS = 60_000
+
+let win: BrowserWindow | null = null
+const lastAutoRelaunch = new Map<ScreenTarget, number>()
+const launchChecks = new Map<ScreenTarget, ReturnType<typeof setTimeout>>()
+let playlistRecovery: ((screen: ScreenTarget, wallpaperId: string) => boolean) | null = null
+
+function send(channel: string, payload?: unknown): void {
+  if (win && !win.isDestroyed()) win.webContents.send(channel, payload)
+}
+
+export function getDisplayTargets(): ScreenTarget[] {
+  if (getDisplayMode() === 'shared') return [ALL_SCREENS]
+  const screens = getConnectedScreens()
+  return screens.length > 0 ? screens : [ALL_SCREENS]
+}
+
+/** '*' (or nothing) means every screen: one engine in shared mode, one per screen otherwise */
+export function expandScreen(screen?: ScreenTarget): ScreenTarget[] {
+  const targets = getDisplayTargets()
+  if (screen === undefined || screen === ALL_SCREENS) return targets
+  return targets.includes(screen) ? [screen] : []
+}
+
+export function getScreenAssignments(): ScreenAssignment[] {
+  return Object.entries(getAssignments()).map(([screen, wallpaperId]) => ({ screen, wallpaperId }))
+}
+
+/** The playlist player takes over after a crash on a screen it plays on, instead of a relaunch */
+export function setPlaylistRecovery(handler: (screen: ScreenTarget, wallpaperId: string) => boolean): void {
+  playlistRecovery = handler
+}
+
+function patchCompat(wallpaper: WallpaperMeta, patch: Partial<WallpaperCompat>): void {
+  updateWallpaper(wallpaper.id, { compat: { ...wallpaper.compat, ...patch } })
+  send(IpcChannels.EVENT_LIBRARY_CHANGED)
+}
+
+function watchLaunch(wallpaperId: string, screen: ScreenTarget, onSurvived?: () => void): void {
+  clearTimeout(launchChecks.get(screen))
+  launchChecks.set(
+    screen,
+    setTimeout(() => {
+      launchChecks.delete(screen)
+      const wallpaper = getWallpaper(wallpaperId)
+      const inst = getRunningInstances().find((i) => i.screen === screen)
+      if (!wallpaper || !inst || inst.wallpaperPath !== wallpaper.localPath || inst.swappedAt !== null) return
+      if (wallpaper.compat?.freshLaunch !== 'ok') patchCompat(wallpaper, { freshLaunch: 'ok' })
+      onSurvived?.()
+    }, LAUNCH_CHECK_MS)
+  )
+}
+
+export async function showWallpaper(
+  wallpaper: WallpaperMeta,
+  screen: ScreenTarget | undefined,
+  options: {
+    volume?: number
+    backend?: WallpaperBackend
+    dependencyPrompt?: 'always' | 'once'
+    forceFresh?: boolean
+  } = {}
+): Promise<string> {
+  const targets = expandScreen(screen)
+  if (targets.length === 0) throw new Error(`Screen ${screen} is not connected`)
+
+  let appliedPath = ''
+  for (const target of targets) {
+    appliedPath = await applyWallpaperMeta(wallpaper, { ...options, screen: target })
+    setAssignment(target, wallpaper.id)
+    // only a wallpaper that crashed on launch before needs its fresh launches watched
+    if (wallpaper.compat?.freshLaunch === 'crash') watchLaunch(wallpaper.id, target)
+  }
+  send(IpcChannels.EVENT_DISPLAY_CHANGED)
+  return appliedPath
+}
+
+export async function stopDisplay(screen?: ScreenTarget): Promise<void> {
+  if (screen === undefined || screen === ALL_SCREENS) {
+    await stopLwe()
+    clearAssignment()
+  } else {
+    await stopLwe(screen)
+    clearAssignment(screen)
+  }
+  send(IpcChannels.EVENT_DISPLAY_CHANGED)
+}
+
+function wallpaperForPath(wallpaperPath: string): WallpaperMeta | undefined {
+  if (!wallpaperPath) return undefined
+  return getAllWallpapers().find((w) => w.localPath === wallpaperPath)
+}
+
+function notifyCrash(wallpaper: WallpaperMeta, screen: ScreenTarget, phase: CrashPhase, outcome: string): void {
+  const event: CrashEvent = { wallpaperId: wallpaper.id, title: wallpaper.title, screen, phase, outcome }
+  send(IpcChannels.EVENT_WALLPAPER_CRASHED, event)
+}
+
+async function relaunchFresh(wallpaper: WallpaperMeta, screen: ScreenTarget): Promise<boolean> {
+  try {
+    await applyWallpaperMeta(getWallpaper(wallpaper.id) ?? wallpaper, { screen, forceFresh: true, dependencyPrompt: 'once' })
+    return true
+  } catch (err) {
+    console.error('[Display] Fresh relaunch failed:', (err as Error).message)
+    return false
+  }
+}
+
+async function handleExit(info: LweExitInfo): Promise<void> {
+  const wallpaper = wallpaperForPath(info.wallpaperPath)
+  if (!info.crashed || !wallpaper) {
+    if (!info.crashed) clearAssignment(info.screen === ALL_SCREENS ? undefined : info.screen)
+    send(IpcChannels.EVENT_DISPLAY_CHANGED)
+    return
+  }
+
+  console.warn(`[Display] ${wallpaper.title} crashed on ${info.screen} (${info.phase}, ${info.description})`)
+  const crash = { at: Date.now(), phase: info.phase, screen: info.screen, log: info.log.slice(-30) }
+  clearTimeout(launchChecks.get(info.screen))
+  launchChecks.delete(info.screen)
+
+  if (info.phase === 'hotswap') {
+    patchCompat(wallpaper, { hotswapCrash: true, lastCrash: crash })
+    // the user still wants this wallpaper, and a fresh launch tells whether only the hotswap is the problem
+    if (!(await relaunchFresh(wallpaper, info.screen))) return
+    notifyCrash(wallpaper, info.screen, 'hotswap', 'Crashed when switched to - testing a fresh launch')
+    watchLaunch(wallpaper.id, info.screen, () =>
+      notifyCrash(wallpaper, info.screen, 'hotswap', 'Works on a fresh launch, it will always be launched fresh from now on')
+    )
+    return
+  }
+
+  if (info.phase === 'launch') {
+    patchCompat(wallpaper, { freshLaunch: 'crash', lastCrash: crash })
+    clearAssignment(info.screen === ALL_SCREENS ? undefined : info.screen)
+    const skipped = playlistRecovery?.(info.screen, wallpaper.id) ?? false
+    notifyCrash(wallpaper, info.screen, 'launch', skipped ? 'Crashes on launch - skipped to the next wallpaper' : 'Crashes on launch')
+    send(IpcChannels.EVENT_DISPLAY_CHANGED)
+    return
+  }
+
+  patchCompat(wallpaper, { runtimeCrashes: (wallpaper.compat?.runtimeCrashes ?? 0) + 1, lastCrash: crash })
+  if (playlistRecovery?.(info.screen, wallpaper.id)) {
+    notifyCrash(wallpaper, info.screen, 'runtime', 'Crashed while running - skipped to the next wallpaper')
+    return
+  }
+  const last = lastAutoRelaunch.get(info.screen) ?? 0
+  if (Date.now() - last > RELAUNCH_COOLDOWN_MS) {
+    lastAutoRelaunch.set(info.screen, Date.now())
+    if (await relaunchFresh(wallpaper, info.screen)) {
+      notifyCrash(wallpaper, info.screen, 'runtime', 'Crashed while running - restarted it')
+      return
+    }
+  }
+  clearAssignment(info.screen === ALL_SCREENS ? undefined : info.screen)
+  notifyCrash(wallpaper, info.screen, 'runtime', 'Crashed while running')
+  send(IpcChannels.EVENT_DISPLAY_CHANGED)
+}
+
+export async function testFreshLaunch(wallpaperId: string, screen?: ScreenTarget): Promise<void> {
+  const wallpaper = getWallpaper(wallpaperId)
+  if (!wallpaper) throw new Error(`Wallpaper ${wallpaperId} not found in library`)
+  const target = expandScreen(screen)[0]
+  if (!target) throw new Error('No screen to test on')
+  await applyWallpaperMeta(wallpaper, { screen: target, forceFresh: true })
+  setAssignment(target, wallpaper.id)
+  send(IpcChannels.EVENT_DISPLAY_CHANGED)
+  watchLaunch(wallpaper.id, target, () =>
+    notifyCrash(getWallpaper(wallpaper.id) ?? wallpaper, target, 'launch', 'Fresh launch test passed')
+  )
+}
+
+/** Stops everything and forgets assignments, the screens mean something else after a mode switch */
+export async function resetDisplays(): Promise<void> {
+  for (const timer of launchChecks.values()) clearTimeout(timer)
+  launchChecks.clear()
+  await stopDisplay()
+}
+
+export function initDisplay(window: BrowserWindow): void {
+  win = window
+  onLweExit((info) => {
+    void handleExit(info).catch((err) => console.error('[Display] Crash handling failed:', err))
+  })
+}

+ 3 - 1
src/main/services/library.service.ts

@@ -1,6 +1,6 @@
 import Store from 'electron-store'
 import type { WallpaperMeta, WallpaperType, ContentRating, LibraryFilters, WallpaperFolder } from '@shared/types'
-import { getWorkshopPath } from '../utils/paths'
+import { getThumbnailsPath, getWorkshopPath } from '../utils/paths'
 import * as path from 'path'
 import * as fs from 'fs'
 import { randomUUID } from 'crypto'
@@ -123,6 +123,8 @@ export function updateWallpaper(id: string, patch: Partial<WallpaperMeta>): void
 
 export function deleteWallpaper(id: string): void {
   const wallpapers = store.get('wallpapers')
+  const thumbnail = wallpapers[id]?.customPreview
+  if (thumbnail && path.dirname(thumbnail) === getThumbnailsPath()) fs.rmSync(thumbnail, { force: true })
   delete wallpapers[id]
   store.set('wallpapers', wallpapers)
 }

+ 66 - 0
src/main/services/lwe.service.test.ts

@@ -0,0 +1,66 @@
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('electron', () => ({ app: { getPath: () => '/tmp' } }))
+vi.mock('./config.service', () => ({
+  getConfiguredWorkshopPath: () => null,
+  getLweRepoUrl: () => null,
+  getLweRepoBranch: () => null,
+  getLweCmakeArgs: () => null,
+  getAudioScreen: () => null,
+  getAmbientVolume: () => null,
+  getDefaultAudioSensitivity: () => 1,
+  getDisablePuppetAnimation: () => false
+}))
+
+const { buildHotswapLines, parseCustomArgs } = await import('./lwe.service')
+
+describe('parseCustomArgs', () => {
+  it('splits on whitespace and keeps quoted values together', () => {
+    expect(parseCustomArgs(`--render-debug skip-effect=726  --set-property foo="bar baz" --x 'a b'`)).toEqual([
+      '--render-debug',
+      'skip-effect=726',
+      '--set-property',
+      'foo=bar baz',
+      '--x',
+      'a b'
+    ])
+    expect(parseCustomArgs('   ')).toEqual([])
+    expect(parseCustomArgs(`--title ""`)).toEqual(['--title', ''])
+  })
+})
+
+describe('buildHotswapLines', () => {
+  it('writes only the fields that were given', () => {
+    expect(buildHotswapLines({ volume: 40, xray: false })).toEqual(['volume=40', 'xray=off'])
+    expect(buildHotswapLines({})).toEqual([])
+  })
+
+  it('replaces the layer lists when either one is present, even empty', () => {
+    expect(buildHotswapLines({ disabledObjects: [] })).toEqual(['layers=1'])
+    expect(buildHotswapLines({ disabledObjects: ['12'], enabledObjects: ['fog'] })).toEqual([
+      'layers=1',
+      'disable-object=12',
+      'enable-object=fog'
+    ])
+  })
+
+  it('writes a full wallpaper swap in the order the engine expects', () => {
+    expect(
+      buildHotswapLines({
+        path: '/w/1',
+        fps: 30,
+        offsetX: 0.5,
+        propertyOverrides: { color: '1 0 0' },
+        audioSensitivity: { '*': 2 },
+        soundVolume: { '7': 0.5 }
+      })
+    ).toEqual([
+      'path=/w/1',
+      'fps=30',
+      'offset=0.5,0',
+      'property=color=1 0 0',
+      'audio-sensitivity=*=2',
+      'sound-volume=7=0.5'
+    ])
+  })
+})

+ 512 - 204
src/main/services/lwe.service.ts

@@ -12,7 +12,10 @@ import type {
   LweSceneObject,
   LweProperty,
   LweAudioObject,
-  LweSceneEffect
+  LweSceneEffect,
+  CrashPhase,
+  EngineFlags,
+  ScreenTarget
 } from '@shared/types'
 import {
   isCommandAvailable,
@@ -30,10 +33,12 @@ import {
   getAudioScreen,
   getAmbientVolume,
   getDefaultAudioSensitivity,
-  getDisablePuppetAnimation,
-  getDisableAnimations
+  getDisablePuppetAnimation
 } from './config.service'
 import { DEFAULT_LWE_FPS, DEFAULT_LWE_REPO } from '@shared/constants'
+import { engineFlagArgs } from '@shared/engineFlags'
+
+export const ALL_SCREENS: ScreenTarget = '*'
 
 const execFileAsync = promisify(execFile)
 
@@ -68,8 +73,6 @@ const LWE_SEARCH_PATHS = [
   '/opt/linux-wallpaperengine/linux-wallpaperengine'
 ]
 
-let activeProcess: ChildProcess | null = null
-
 async function removeBuildDir(dir: string): Promise<void> {
   if (!fs.existsSync(dir)) return
   try {
@@ -423,6 +426,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     invalidateEffectFlagsSupport()
     invalidateDisableAnimationsSupport()
     invalidateExpandCanvasSupport()
+    invalidateHelpText()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -541,6 +545,7 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
     invalidateEffectFlagsSupport()
     invalidateDisableAnimationsSupport()
     invalidateExpandCanvasSupport()
+    invalidateHelpText()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -783,48 +788,98 @@ function supportsExpandCanvas(): boolean {
   return expandCanvasSupported
 }
 
+// shell-like: quotes group whitespace anywhere in a token (foo="bar baz") and are dropped
 export function parseCustomArgs(raw: string): string[] {
   const tokens: string[] = []
-  const re = /"([^"]*)"|'([^']*)'|(\S+)/g
-  let match: RegExpExecArray | null
-  while ((match = re.exec(raw)) !== null) {
-    tokens.push(match[1] ?? match[2] ?? match[3])
+  let current = ''
+  let inToken = false
+  let quote: string | null = null
+
+  for (const ch of raw) {
+    if (quote) {
+      if (ch === quote) quote = null
+      else current += ch
+    } else if (ch === '"' || ch === "'") {
+      quote = ch
+      inToken = true
+    } else if (/\s/.test(ch)) {
+      if (inToken) tokens.push(current)
+      current = ''
+      inToken = false
+    } else {
+      current += ch
+      inToken = true
+    }
   }
+  if (inToken) tokens.push(current)
   return tokens
 }
 
+let helpText: string | null = null
+
+function invalidateHelpText(): void {
+  helpText = null
+}
+
+// older builds ignore unknown flags, so a flag the binary doesn't list is left out instead of passed
+function supportsFlag(flag: string): boolean {
+  if (helpText === null) {
+    try {
+      helpText = execFileSync('env', [...buildLweEnvVars(), getLweBinaryPath(), '--help'], {
+        timeout: 5_000,
+        encoding: 'utf8',
+        maxBuffer: 2 * 1024 * 1024
+      })
+    } catch {
+      helpText = ''
+    }
+  }
+  return helpText.includes(flag)
+}
+
+export interface LweLaunchOptions {
+  /** '*' runs one engine for every connected screen */
+  screen?: ScreenTarget
+  fps?: number
+  volume?: number
+  disabledObjects?: string[]
+  enabledObjects?: string[]
+  disabledEffects?: string[]
+  enabledEffects?: string[]
+  propertyOverrides?: Record<string, string>
+  xrayFullReveal?: boolean
+  scalingMode?: string
+  zoom?: number
+  offsetX?: number
+  offsetY?: number
+  disableParallax?: boolean
+  expandCanvas?: boolean
+  cornerColor?: string
+  speed?: number
+  audioSensitivity?: Record<string, number>
+  soundVolume?: Record<string, number>
+  customArgs?: string
+  /** Already merged with the global flags */
+  engineFlags?: EngineFlags
+  /** Restart the engine even when it could have taken a hotswap */
+  forceFresh?: boolean
+}
+
 /** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
 function buildLweArgs(
   wallpaperPath: string,
-  options: {
-    screenRoot?: string
-    fps?: number
-    volume?: number
-    disabledObjects?: string[]
-    enabledObjects?: string[]
-    disabledEffects?: string[]
-    enabledEffects?: string[]
-    propertyOverrides?: Record<string, string>
-    scalingMode?: string
-    zoom?: number
-    offsetX?: number
-    offsetY?: number
-    disableParallax?: boolean
-    expandCanvas?: boolean
-    cornerColor?: string
-    speed?: number
-    audioSensitivity?: Record<string, number>
-    soundVolume?: Record<string, number>
-    customArgs?: string
-  }
+  options: LweLaunchOptions,
+  capture?: { window: string; screenshotPath: string; delayFrames: number }
 ): string[] {
   const args: string[] = []
   const assetsDir = findWeAssetsDir()
   if (assetsDir) args.push('--assets-dir', assetsDir)
 
   // --screen-root uses layer-shell (Wayland) or root-window overlay (X11)
-  if (options.screenRoot) {
-    args.push('--screen-root', options.screenRoot)
+  if (capture) {
+    args.push('--window', capture.window)
+  } else if (options.screen && options.screen !== ALL_SCREENS) {
+    args.push('--screen-root', options.screen)
   } else {
     const screens = getConnectedScreens()
     for (const s of screens) args.push('--screen-root', s)
@@ -838,7 +893,9 @@ function buildLweArgs(
   const ambientVolume = getAmbientVolume()
   if (ambientVolume !== null) args.push('--ambient-volume', String(ambientVolume))
   if (getDisablePuppetAnimation()) args.push('--render-debug', 'no-puppet-animation')
-  if (getDisableAnimations() && supportsDisableAnimations()) args.push('--disable-animations')
+  const flags = options.engineFlags ?? {}
+  if (flags.disableAnimations && supportsDisableAnimations()) args.push('--disable-animations')
+  args.push(...engineFlagArgs(flags).filter(supportsFlag))
   if (options.scalingMode) args.push('--scaling', options.scalingMode)
   if (options.zoom !== undefined) args.push('--zoom', String(options.zoom))
   if (options.offsetX !== undefined || options.offsetY !== undefined) {
@@ -877,6 +934,9 @@ function buildLweArgs(
   if (options.customArgs?.trim()) {
     args.push(...parseCustomArgs(options.customArgs))
   }
+  if (capture) {
+    args.push('--screenshot', capture.screenshotPath, '--screenshot-delay', String(capture.delayFrames))
+  }
   args.push(wallpaperPath)
   return args
 }
@@ -887,27 +947,69 @@ function getControlFilePath(): string {
 }
 
 /**
- * Tracks a still-running LWE process across app restarts. `activeProcess` is an in-memory handle
- * that dies with this Electron process, but the LWE child is spawned detached/unref'd so it
- * survives independently - after the app is closed and reopened, `activeProcess` comes back null
- * even though the OS process is still alive. resolveActivePid() below covers that gap.
+ * The engine processes are spawned detached and outlive this app, so what's running is also written
+ * to the runtime dir. After a restart the app adopts whatever is still alive from there.
  */
-function getPidFilePath(): string {
+function getInstancesFilePath(): string {
+  return path.join(getXdgRuntimeDir(), 'lwe-instances.json')
+}
+
+function getLegacyPidFilePath(): string {
   return path.join(getXdgRuntimeDir(), 'lwe-pid')
 }
 
-function writePidFile(pid: number): void {
-  try {
-    fs.writeFileSync(getPidFilePath(), String(pid))
-  } catch (err) {
-    console.error('[LWE] Failed to write pid file:', err)
-  }
+interface LweInstance {
+  screen: ScreenTarget
+  pid: number
+  /** null for a process adopted from a previous run of the app, only its pid is known then */
+  child: ChildProcess | null
+  wallpaperPath: string
+  launchKey: string
+  startedAt: number
+  swappedAt: number | null
+  stopping: boolean
+  log: string[]
 }
 
-function clearPidFile(): void {
+export interface LweExitInfo {
+  screen: ScreenTarget
+  wallpaperPath: string
+  crashed: boolean
+  phase: CrashPhase
+  description: string
+  log: string[]
+}
+
+const instances = new Map<ScreenTarget, LweInstance>()
+let instancesLoaded = false
+let adoptedPoll: ReturnType<typeof setInterval> | null = null
+const exitListeners = new Set<(info: LweExitInfo) => void>()
+
+const LOG_LINES_KEPT = 60
+// a crash this soon after a launch or hotswap is blamed on it
+const CRASH_WINDOW_MS = 20_000
+const CRASH_SIGNALS = new Set(['SIGSEGV', 'SIGABRT', 'SIGBUS', 'SIGFPE', 'SIGILL', 'SIGTRAP'])
+
+export function onLweExit(cb: (info: LweExitInfo) => void): () => void {
+  exitListeners.add(cb)
+  return () => exitListeners.delete(cb)
+}
+
+function persistInstances(): void {
+  const entries = [...instances.values()].map((i) => ({
+    screen: i.screen,
+    pid: i.pid,
+    wallpaperPath: i.wallpaperPath,
+    launchKey: i.launchKey,
+    startedAt: i.startedAt
+  }))
   try {
-    fs.rmSync(getPidFilePath(), { force: true })
-  } catch { /* best effort */ }
+    if (entries.length === 0) fs.rmSync(getInstancesFilePath(), { force: true })
+    else fs.writeFileSync(getInstancesFilePath(), JSON.stringify(entries))
+    fs.rmSync(getLegacyPidFilePath(), { force: true })
+  } catch (err) {
+    console.error('[LWE] Failed to write instances file:', err)
+  }
 }
 
 /** True if `pid` is alive and actually looks like a linux-wallpaperengine process (not a recycled PID). */
@@ -927,44 +1029,133 @@ function isLweProcess(pid: number): boolean {
   }
 }
 
-/**
- * Resolves the PID to signal for hotswap/stop, preferring the in-memory child handle (cheap,
- * exact) and falling back to the persisted pid file (recovers tracking after an app restart).
- */
-function resolveActivePid(): number | null {
-  if (activeProcess && activeProcess.exitCode === null && activeProcess.pid !== undefined) {
-    return activeProcess.pid
-  }
+function adopt(screen: ScreenTarget, pid: number, wallpaperPath: string, key: string, startedAt: number): void {
+  if (!Number.isInteger(pid) || !isLweProcess(pid)) return
+  instances.set(screen, {
+    screen,
+    pid,
+    child: null,
+    wallpaperPath,
+    launchKey: key,
+    startedAt,
+    swappedAt: null,
+    stopping: false,
+    log: []
+  })
+}
+
+function loadInstances(): void {
+  if (instancesLoaded) return
+  instancesLoaded = true
 
-  let stored: number | null = null
   try {
-    stored = parseInt(fs.readFileSync(getPidFilePath(), 'utf8').trim(), 10)
-  } catch {
-    return null
-  }
+    const entries = JSON.parse(fs.readFileSync(getInstancesFilePath(), 'utf8')) as Array<{
+      screen: string
+      pid: number
+      wallpaperPath: string
+      launchKey: string
+      startedAt: number
+    }>
+    for (const e of entries) adopt(e.screen, e.pid, e.wallpaperPath, e.launchKey, e.startedAt)
+  } catch { /* nothing running, or written by an older version */ }
+
+  try {
+    const legacyPid = parseInt(fs.readFileSync(getLegacyPidFilePath(), 'utf8').trim(), 10)
+    if (!instances.has(ALL_SCREENS)) adopt(ALL_SCREENS, legacyPid, '', '', Date.now())
+  } catch { /* no legacy pid file */ }
+
+  persistInstances()
+  watchAdoptedInstances()
+}
 
-  if (!Number.isInteger(stored) || !isLweProcess(stored)) {
-    clearPidFile()
-    return null
+// Node only reports exits of its own children, so a process adopted from an earlier run is polled
+function watchAdoptedInstances(): void {
+  if (adoptedPoll) return
+  adoptedPoll = setInterval(() => {
+    let adopted = 0
+    for (const inst of instances.values()) {
+      if (inst.child) continue
+      adopted++
+      if (isLweProcess(inst.pid)) continue
+      instances.delete(inst.screen)
+      persistInstances()
+      if (!inst.stopping) {
+        // no exit status for a process we didn't spawn, so a crash can't be told from a kill
+        emitExit(inst, false, 'exited while the app was not watching it')
+      }
+    }
+    if (adopted === 0 && adoptedPoll) {
+      clearInterval(adoptedPoll)
+      adoptedPoll = null
+    }
+  }, 3000)
+}
+
+function crashPhase(inst: LweInstance): CrashPhase {
+  const now = Date.now()
+  if (inst.swappedAt !== null && now - inst.swappedAt < CRASH_WINDOW_MS) return 'hotswap'
+  if (now - inst.startedAt < CRASH_WINDOW_MS) return 'launch'
+  return 'runtime'
+}
+
+function emitExit(inst: LweInstance, crashed: boolean, description: string): void {
+  const info: LweExitInfo = {
+    screen: inst.screen,
+    wallpaperPath: inst.wallpaperPath,
+    crashed,
+    phase: crashPhase(inst),
+    description,
+    log: [...inst.log]
   }
+  for (const cb of exitListeners) cb(info)
+}
 
-  return stored
+function liveInstance(screen: ScreenTarget): LweInstance | undefined {
+  loadInstances()
+  const inst = instances.get(screen)
+  if (!inst) return undefined
+  if (inst.child ? inst.child.exitCode === null && inst.child.signalCode === null : isLweProcess(inst.pid)) return inst
+  instances.delete(screen)
+  persistInstances()
+  return undefined
 }
 
-/**
- * Pushes a background swap and/or layer/volume/xray/scaling/zoom/offset/parallax changes to an already-
- * running instance via the extended key=value control file. All fields are optional and
- * independent - only the ones provided get written. All fields for one logical change must go
- * through a single call: the engine has only one pending-request slot, so two calls in quick
- * succession can race and silently drop whichever one wrote last. disabledObjects/enabledObjects
- * being present (even as empty arrays) is what tells the engine to replace its override lists -
- * omit both to leave layers untouched. xray is a live-only setting with no launch-time equivalent,
- * so it has to be pushed here even right after a fresh launch. cornerColor is a hex
- * "RRGGBB"/"RRGGBBAA" string, only visible where the engine's clamp mode is border (the default).
- * propertyOverrides sends every key in the map, not just changed ones, since the engine only
- * reapplies whatever's currently in its settings map on reload.
- */
-export function hotswapLweSettings(options: {
+function liveInstances(): LweInstance[] {
+  loadInstances()
+  return [...instances.keys()].map(liveInstance).filter((i): i is LweInstance => !!i)
+}
+
+export interface RunningInstance {
+  screen: ScreenTarget
+  wallpaperPath: string
+  pid: number
+  startedAt: number
+  swappedAt: number | null
+}
+
+export function getRunningInstances(): RunningInstance[] {
+  return liveInstances().map(({ screen, wallpaperPath, pid, startedAt, swappedAt }) => ({
+    screen,
+    wallpaperPath,
+    pid,
+    startedAt,
+    swappedAt
+  }))
+}
+
+// flags a hotswap can't change, a running engine launched with different ones has to be restarted
+function launchKey(options: LweLaunchOptions): string {
+  const flags = options.engineFlags ?? {}
+  return JSON.stringify({
+    customArgs: options.customArgs?.trim() ?? '',
+    disabledEffects: options.disabledEffects ?? [],
+    enabledEffects: options.enabledEffects ?? [],
+    disableAnimations: !!flags.disableAnimations,
+    flags: engineFlagArgs(flags)
+  })
+}
+
+export interface HotswapOptions {
   path?: string
   disabledObjects?: string[]
   enabledObjects?: string[]
@@ -987,10 +1178,15 @@ export function hotswapLweSettings(options: {
   audioSensitivity?: Record<string, number>
   /** Sound object id -> volume (0-1), applied live without a reload */
   soundVolume?: Record<string, number>
-}): boolean {
-  const pid = resolveActivePid()
-  if (pid === null) return false
+}
 
+/**
+ * Control file lines for a hotswap. disabledObjects/enabledObjects being present (even as empty
+ * arrays) is what tells the engine to replace its override lists - omit both to leave layers
+ * untouched. propertyOverrides sends every key in the map, not just changed ones, since the engine
+ * only reapplies whatever's currently in its settings map on reload.
+ */
+export function buildHotswapLines(options: HotswapOptions): string[] {
   const lines: string[] = []
   if (options.path !== undefined) lines.push(`path=${options.path}`)
   if (options.disabledObjects !== undefined || options.enabledObjects !== undefined) {
@@ -1021,86 +1217,127 @@ export function hotswapLweSettings(options: {
   for (const [id, volume] of Object.entries(options.soundVolume ?? {})) {
     lines.push(`sound-volume=${id}=${volume}`)
   }
+  return lines
+}
+
+// Every engine process reads the same control file once it gets SIGUSR1, so a request for another
+// process must not overwrite it before the previous one had a chance to read it
+const HOTSWAP_SETTLE_MS = 1000
+let lastSignal: { pid: number; at: number } | null = null
+let signalQueue: Promise<unknown> = Promise.resolve()
+
+function signalInstance(inst: LweInstance, lines: string[]): Promise<boolean> {
+  const send = async (): Promise<boolean> => {
+    if (lastSignal && lastSignal.pid !== inst.pid) {
+      const wait = lastSignal.at + HOTSWAP_SETTLE_MS - Date.now()
+      if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait))
+    }
+    try {
+      fs.writeFileSync(getControlFilePath(), lines.join('\n') + '\n')
+      process.kill(inst.pid, 'SIGUSR1')
+      lastSignal = { pid: inst.pid, at: Date.now() }
+      console.log(`[LWE] Hotswap sent to ${inst.screen}:`, lines.join(' '))
+      return true
+    } catch (err) {
+      console.error('[LWE] Hotswap failed:', err)
+      return false
+    }
+  }
+  const result = signalQueue.then(send, send)
+  signalQueue = result
+  return result
+}
+
+/**
+ * Pushes a background swap and/or live setting changes to running engines. All fields for one
+ * logical change must go through a single call: an engine has only one pending-request slot, so two
+ * calls in quick succession can race and drop whichever one wrote last. Goes to the given screens,
+ * else to every engine showing `wallpaperPath`, else to all of them. xray is a live-only setting
+ * with no launch-time equivalent, so it has to be pushed here even right after a fresh launch.
+ * cornerColor is a hex "RRGGBB"/"RRGGBBAA" string, only visible where the engine's clamp mode is
+ * border (the default).
+ */
+export async function hotswapLweSettings(
+  options: HotswapOptions,
+  target: { screens?: ScreenTarget[]; wallpaperPath?: string } = {}
+): Promise<boolean> {
+  const lines = buildHotswapLines(options)
   if (lines.length === 0) return false
 
-  try {
-    fs.writeFileSync(getControlFilePath(), lines.join('\n') + '\n')
-    process.kill(pid, 'SIGUSR1')
-    console.log('[LWE] Hotswap settings sent:', lines.join(' '))
-    return true
-  } catch (err) {
-    console.error('[LWE] Hotswap settings failed:', err)
-    return false
+  let targets = liveInstances()
+  if (target.screens) targets = targets.filter((i) => target.screens!.includes(i.screen))
+  else if (target.wallpaperPath) targets = targets.filter((i) => i.wallpaperPath === target.wallpaperPath)
+  if (targets.length === 0) return false
+
+  let sent = false
+  for (const inst of targets) {
+    if (!(await signalInstance(inst, lines))) continue
+    sent = true
+    if (options.path !== undefined) {
+      inst.wallpaperPath = options.path
+      inst.swappedAt = Date.now()
+    }
   }
+  if (sent && options.path !== undefined) persistInstances()
+  return sent
 }
 
-/** Launch LWE and resolve after 2s if still running; reject if it exits with error (so UI can show message). */
-export async function launchLweAsync(
-  wallpaperPath: string,
-  options: {
-    screenRoot?: string
-    fps?: number
-    volume?: number
-    disabledObjects?: string[]
-    enabledObjects?: string[]
-    disabledEffects?: string[]
-    enabledEffects?: string[]
-    propertyOverrides?: Record<string, string>
-    xrayFullReveal?: boolean
-    scalingMode?: string
-    zoom?: number
-    offsetX?: number
-    offsetY?: number
-    disableParallax?: boolean
-    expandCanvas?: boolean
-    cornerColor?: string
-    speed?: number
-    audioSensitivity?: Record<string, number>
-    soundVolume?: Record<string, number>
-    customArgs?: string
-  } = {}
-): Promise<void> {
-  // Hot-reload if already running. xray has no launch flag and the running instance keeps its own
-  // xray state across a background swap, so it needs an explicit push even when off. Scaling/zoom/
-  // parallax/cornerColor/speed/propertyOverrides do have launch flags, but hot-reload doesn't
-  // restart the process - the running instance keeps whatever the previous wallpaper set, so they
-  // need the same explicit push, defaulting to "no override" when the new wallpaper doesn't set them.
-  // customArgs and effect overrides have no hotswap control-file key, so they always force a restart
+function hotswapOptionsFor(wallpaperPath: string, options: LweLaunchOptions): HotswapOptions {
   const defaultSensitivity = getDefaultAudioSensitivity()
-  if (
-    isLweRunning() &&
+  return {
+    path: wallpaperPath,
+    // the engine drops the previous wallpaper's overrides on a path swap, the new one's go with it
+    disabledObjects: options.disabledObjects ?? [],
+    enabledObjects: options.enabledObjects ?? [],
+    // the running engine keeps the previous wallpaper's limit, so a wallpaper without one has to
+    // put it back to what a fresh launch without --fps gets
+    fps: options.fps ?? DEFAULT_LWE_FPS,
+    xray: options.xrayFullReveal,
+    scaling: options.scalingMode ?? 'default',
+    zoom: options.zoom ?? 1,
+    offsetX: options.offsetX ?? 0,
+    offsetY: options.offsetY ?? 0,
+    disableParallax: options.disableParallax ?? false,
+    expandCanvas: options.expandCanvas ?? false,
+    cornerColor: options.cornerColor ?? '000000',
+    speed: options.speed ?? 1,
+    propertyOverrides: options.propertyOverrides ?? {},
+    audioSensitivity: {
+      ...(defaultSensitivity !== 1 ? { '*': defaultSensitivity } : {}),
+      ...options.audioSensitivity
+    },
+    soundVolume: options.soundVolume ?? {}
+  }
+}
+
+/**
+ * Shows a wallpaper on `options.screen` ('*' = one engine for every screen). An engine already
+ * running there gets a hotswap when nothing launch-only differs, otherwise it is restarted.
+ * Resolves after 2s if the new engine is still running, rejects if it exits with an error first.
+ */
+export async function launchLweAsync(wallpaperPath: string, options: LweLaunchOptions = {}): Promise<void> {
+  const screen = options.screen ?? ALL_SCREENS
+  const key = launchKey(options)
+
+  // one engine for every screen and engines for single screens would draw over each other
+  for (const inst of liveInstances()) {
+    if ((screen === ALL_SCREENS) !== (inst.screen === ALL_SCREENS)) await stopLwe(inst.screen)
+  }
+
+  const existing = liveInstance(screen)
+  // with more than one engine a hotswap could be read by the wrong one, see signalInstance
+  const canHotswap =
+    existing &&
+    !options.forceFresh &&
     !options.customArgs?.trim() &&
-    !options.disabledEffects?.length &&
-    !options.enabledEffects?.length &&
-    hotswapLweSettings({
-      path: wallpaperPath,
-      // the engine drops the previous wallpaper's overrides on a path swap, the new one's go with it
-      disabledObjects: options.disabledObjects ?? [],
-      enabledObjects: options.enabledObjects ?? [],
-      // the running engine keeps the previous wallpaper's limit, so a wallpaper without one has to
-      // put it back to what a fresh launch without --fps gets
-      fps: options.fps ?? DEFAULT_LWE_FPS,
-      xray: options.xrayFullReveal,
-      scaling: options.scalingMode ?? 'default',
-      zoom: options.zoom ?? 1,
-      offsetX: options.offsetX ?? 0,
-      offsetY: options.offsetY ?? 0,
-      disableParallax: options.disableParallax ?? false,
-      expandCanvas: options.expandCanvas ?? false,
-      cornerColor: options.cornerColor ?? '000000',
-      speed: options.speed ?? 1,
-      propertyOverrides: options.propertyOverrides ?? {},
-      audioSensitivity: {
-        ...(defaultSensitivity !== 1 ? { '*': defaultSensitivity } : {}),
-        ...options.audioSensitivity
-      },
-      soundVolume: options.soundVolume ?? {}
-    })
-  ) {
+    existing.launchKey === key &&
+    liveInstances().length === 1
+
+  if (canHotswap && (await hotswapLweSettings(hotswapOptionsFor(wallpaperPath, options), { screens: [screen] }))) {
     return
   }
 
-  await stopLwe()
+  await stopLwe(screen)
 
   const binaryPath = getLweBinaryPath()
   const lweArgs = buildLweArgs(wallpaperPath, options)
@@ -1117,52 +1354,64 @@ export async function launchLweAsync(
       stdio: ['ignore', 'pipe', 'pipe'],
       detached: true
     })
-    activeProcess = child
-    if (child.pid !== undefined) writePidFile(child.pid)
-
-    const stderrChunks: Buffer[] = []
-    if (child.stdout) {
-      child.stdout.on('data', (chunk: Buffer) => {
-        const msg = chunk.toString('utf8').trim()
-        if (msg) console.log('[LWE stdout]', msg)
-      })
+
+    const inst: LweInstance = {
+      screen,
+      pid: child.pid ?? -1,
+      child,
+      wallpaperPath,
+      launchKey: key,
+      startedAt: Date.now(),
+      swappedAt: null,
+      stopping: false,
+      log: []
     }
-    if (child.stderr) {
-      child.stderr.on('data', (chunk: Buffer) => {
-        stderrChunks.push(chunk)
-        const msg = chunk.toString('utf8').trim()
-        if (msg) console.log('[LWE stderr]', msg)
-      })
+    if (child.pid !== undefined) {
+      instances.set(screen, inst)
+      persistInstances()
+    }
+
+    const collect = (stream: 'stdout' | 'stderr') => (chunk: Buffer) => {
+      const msg = chunk.toString('utf8').trim()
+      if (!msg) return
+      console.log(`[LWE ${stream}]`, msg)
+      inst.log.push(...msg.split('\n'))
+      if (inst.log.length > LOG_LINES_KEPT) inst.log.splice(0, inst.log.length - LOG_LINES_KEPT)
     }
+    child.stdout?.on('data', collect('stdout'))
+    child.stderr?.on('data', collect('stderr'))
 
     let settled = false
-    // A rapid stop+relaunch can leave the old process still shutting down (CEF/GL
-    // teardown isn't instant) when its exit event finally arrives - only clear
-    // activeProcess if it's still pointing at this child, not a newer one that
-    // superseded it, otherwise the newer launch's own "still running" resolve
-    // check below never passes and its promise hangs forever.
-    const cleanup = () => {
-      if (activeProcess === child) {
-        activeProcess = null
-        clearPidFile()
+
+    child.on('exit', (code, signal) => {
+      // a rapid stop+relaunch can deliver the old process's exit after its replacement registered
+      const current = instances.get(screen) === inst
+      if (current) {
+        instances.delete(screen)
+        persistInstances()
       }
-    }
 
-    child.on('exit', (code) => {
-      cleanup()
-      if (settled) return
-      settled = true
-      if (code !== 0 && code !== null) {
-        const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
-        const msg = stderr ? stderr.split('\n').slice(0, 3).join('\n') : `Exit code ${code}`
-        reject(new Error(`linux-wallpaperengine failed: ${msg}`))
-      } else {
-        resolve()
+      const crashed = (signal !== null && CRASH_SIGNALS.has(signal)) || (code !== null && code !== 0)
+      const description = signal ? `killed by ${signal}` : `exit code ${code}`
+
+      if (!settled) {
+        settled = true
+        if (crashed) {
+          const msg = inst.log.length ? inst.log.slice(-3).join('\n') : description
+          reject(new Error(`linux-wallpaperengine failed: ${msg}`))
+        } else {
+          resolve()
+        }
       }
+
+      if (current && !inst.stopping) emitExit(inst, crashed, description)
     })
 
     child.on('error', (err) => {
-      cleanup()
+      if (instances.get(screen) === inst) {
+        instances.delete(screen)
+        persistInstances()
+      }
       if (!settled) {
         settled = true
         reject(err)
@@ -1171,11 +1420,10 @@ export async function launchLweAsync(
 
     child.unref()
 
-    // If still running after 2s, consider it started successfully
     setTimeout(() => {
-      if (!settled && activeProcess === child && child.exitCode === null) {
+      if (!settled && instances.get(screen) === inst && child.exitCode === null) {
         settled = true
-        if (options.xrayFullReveal) hotswapLweSettings({ xray: true })
+        if (options.xrayFullReveal) void hotswapLweSettings({ xray: true }, { screens: [screen] })
         resolve()
       }
     }, 2000)
@@ -1425,26 +1673,34 @@ async function waitForPidExit(pid: number, timeoutMs = 2000): Promise<void> {
   await pollUntilGone(pid, 500)
 }
 
-export async function stopLwe(): Promise<void> {
-  const pid = resolveActivePid()
-  if (pid !== null) {
-    try {
-      process.kill(pid, 'SIGTERM')
-    } catch { /* already dead */ }
-    await waitForPidExit(pid)
-  }
-  activeProcess = null
-  clearPidFile()
+/** Stops the engine on one screen, or every engine this app started when no screen is given. */
+export async function stopLwe(screen?: ScreenTarget): Promise<void> {
+  loadInstances()
+  const targets = screen === undefined ? [...instances.values()] : [instances.get(screen)].filter((i) => !!i)
+
+  await Promise.all(
+    targets.map(async (inst) => {
+      inst.stopping = true
+      try {
+        process.kill(inst.pid, 'SIGTERM')
+      } catch { /* already dead */ }
+      await waitForPidExit(inst.pid)
+      if (instances.get(inst.screen) === inst) instances.delete(inst.screen)
+    })
+  )
+  persistInstances()
 }
 
-export function isLweRunning(): boolean {
-  return resolveActivePid() !== null
+export function isLweRunning(screen?: ScreenTarget): boolean {
+  return screen === undefined ? liveInstances().length > 0 : !!liveInstance(screen)
 }
 
 /** Force-kill every linux-wallpaperengine process on the system, including ones this app isn't tracking. */
 export async function killAllLweProcesses(): Promise<{ ok: boolean; message: string }> {
-  activeProcess = null
-  clearPidFile()
+  loadInstances()
+  for (const inst of instances.values()) inst.stopping = true
+  instances.clear()
+  persistInstances()
 
   try {
     const { stdout } = await execFileAsync('pkill', ['-9', '-f', '-c', LWE_BINARY])
@@ -1459,3 +1715,55 @@ export async function killAllLweProcesses(): Promise<{ ok: boolean; message: str
     return { ok: false, message: `Failed to kill processes: ${(err as Error).message}` }
   }
 }
+
+const SCREENSHOT_TIMEOUT_MS = 45_000
+
+/**
+ * Renders the wallpaper into a small window of its own (screen engines can't be asked for a frame)
+ * and saves one frame to `outPath` (.png/.jpg/.bmp) once it had time to settle.
+ */
+export async function captureLweScreenshot(
+  wallpaperPath: string,
+  options: LweLaunchOptions,
+  outPath: string,
+  size = { width: 1280, height: 720 }
+): Promise<void> {
+  if (!getLweStatus().installed) throw new Error('linux-wallpaperengine is not installed.')
+  fs.rmSync(outPath, { force: true })
+
+  const args = buildLweArgs(
+    wallpaperPath,
+    {
+      ...options,
+      fps: 60,
+      volume: 0,
+      engineFlags: { ...options.engineFlags, silent: true, fullscreenPause: 'off', disableMouse: true }
+    },
+    { window: `0x0x${size.width}x${size.height}`, screenshotPath: outPath, delayFrames: 120 }
+  )
+  const child = spawn('env', [...buildLweEnvVars(), getLweBinaryPath(), ...args], { stdio: ['ignore', 'ignore', 'pipe'] })
+  const stderr: string[] = []
+  child.stderr?.on('data', (chunk: Buffer) => {
+    stderr.push(chunk.toString('utf8'))
+  })
+
+  try {
+    const deadline = Date.now() + SCREENSHOT_TIMEOUT_MS
+    let lastSize = -1
+    while (Date.now() < deadline) {
+      await new Promise((resolve) => setTimeout(resolve, 250))
+      if (child.exitCode !== null || child.signalCode !== null) {
+        const msg = stderr.join('').trim().split('\n').slice(-3).join('\n')
+        throw new Error(`linux-wallpaperengine exited before taking the screenshot${msg ? `: ${msg}` : ''}`)
+      }
+      // the file is written in one go, but a size that held still for a poll is certainly done
+      const current = fs.existsSync(outPath) ? fs.statSync(outPath).size : -1
+      if (current > 0 && current === lastSize) return
+      lastSize = current
+    }
+    throw new Error('Timed out waiting for the screenshot')
+  } finally {
+    child.kill('SIGTERM')
+    if (child.pid !== undefined) await waitForPidExit(child.pid, 3000)
+  }
+}

+ 234 - 141
src/main/services/playlist-player.service.ts

@@ -1,29 +1,41 @@
 import Store from 'electron-store'
 import type { BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import type { Playlist, PlaylistItem, PlaylistPlaybackState } from '@shared/types'
+import type { Playlist, PlaylistItem, PlaylistPlaybackState, ScreenTarget } from '@shared/types'
+import { compatStatus } from '@shared/compat'
 import { getPlaylist } from './playlist.service'
 import { getWallpaper, updateWallpaper } from './library.service'
-import { applyWallpaperMeta } from './wallpaper.service'
-import { setActiveWallpaperId } from './wallpaper-state.service'
-import { getLweStatus } from './lwe.service'
+import { getLweStatus, ALL_SCREENS } from './lwe.service'
 import { getAutostartPlaylistId } from './config.service'
+import { expandScreen, getDisplayTargets, setPlaylistRecovery, showWallpaper } from './display.service'
 import { findWallpaperVideoFile, getVideoDurationSec } from '../utils/video'
 
+interface SavedPlayer {
+  playlistId: string
+  isPlaying: boolean
+}
+
 interface PlaybackStore {
+  /** Written by older versions, which only had the one player */
   activePlaylistId: string | null
   isPlaying: boolean
+  players: Record<ScreenTarget, SavedPlayer> | null
 }
 
 const store = new Store<PlaybackStore>({
   name: 'playlist-playback',
-  defaults: { activePlaylistId: null, isPlaying: false }
+  defaults: { activePlaylistId: null, isPlaying: false, players: null }
 })
 
+interface Player {
+  state: PlaylistPlaybackState
+  timer: NodeJS.Timeout | null
+  scheduleToken: number
+  applying: boolean
+}
+
 let win: BrowserWindow | null = null
-let timer: NodeJS.Timeout | null = null
-let applying = false
-let scheduleToken = 0
+const players = new Map<ScreenTarget, Player>()
 const listeners = new Set<() => void>()
 
 /** Subscribe to playback state changes (used by the tray to keep its menu labels accurate). */
@@ -32,12 +44,8 @@ export function onPlaybackStateChanged(cb: () => void): () => void {
   return () => listeners.delete(cb)
 }
 
-let state: PlaylistPlaybackState = {
-  playlistId: null,
-  currentItemId: null,
-  currentIndex: -1,
-  isPlaying: false,
-  order: []
+function idleState(screen: ScreenTarget): PlaylistPlaybackState {
+  return { screen, playlistId: null, currentItemId: null, currentIndex: -1, isPlaying: false, order: [] }
 }
 
 function shuffle<T>(arr: T[]): T[] {
@@ -76,17 +84,25 @@ function buildOrder(playlist: Playlist): string[] {
   return items.map((i) => i.wallpaperId)
 }
 
-function clearTimer(): void {
-  scheduleToken++
-  if (timer) {
-    clearTimeout(timer)
-    timer = null
+function persist(): void {
+  const saved: Record<ScreenTarget, SavedPlayer> = {}
+  for (const [screen, p] of players) {
+    if (p.state.playlistId) saved[screen] = { playlistId: p.state.playlistId, isPlaying: p.state.isPlaying }
+  }
+  store.set('players', saved)
+}
+
+function clearTimer(player: Player): void {
+  player.scheduleToken++
+  if (player.timer) {
+    clearTimeout(player.timer)
+    player.timer = null
   }
 }
 
 function emitState(): void {
   if (win && !win.isDestroyed()) {
-    win.webContents.send(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, state)
+    win.webContents.send(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, getPlaybackStates())
   }
   for (const cb of listeners) cb()
 }
@@ -95,29 +111,41 @@ function findItem(playlist: Playlist, wallpaperId: string): PlaylistItem | undef
   return playlist.items.find((i) => i.wallpaperId === wallpaperId)
 }
 
-async function applyCurrent(playlist: Playlist): Promise<void> {
-  const wallpaperId = state.order[state.currentIndex]
-  if (!wallpaperId) return
-
-  const wallpaper = getWallpaper(wallpaperId)
-  if (!wallpaper) return
-
-  const item = findItem(playlist, wallpaperId)
-  const volume = item?.volume ?? playlist.settings.defaultVolume
+/**
+ * Shows the current item, moving on to the next one when it can't be shown. Wallpapers known to
+ * crash on launch are passed over without trying, as long as something else is left to play.
+ */
+async function applyCurrent(player: Player, playlist: Playlist): Promise<void> {
+  const { state } = player
+  const count = state.order.length
+  const known = (id: string) => compatStatus(getWallpaper(id)?.compat) !== 'crashes'
+  const anyPlayable = state.order.some(known)
 
-  applying = true
+  player.applying = true
   try {
-    await applyWallpaperMeta(wallpaper, { volume, dependencyPrompt: 'once' })
-    setActiveWallpaperId(wallpaperId)
-    updateWallpaper(wallpaperId, {
-      appliedCount: (wallpaper.appliedCount ?? 0) + 1,
-      lastAppliedAt: Date.now()
-    })
-    state.currentItemId = wallpaperId
-  } catch (err) {
-    console.error('[PlaylistPlayer] Failed to apply wallpaper:', err)
+    for (let tries = 0; tries < count; tries++, state.currentIndex = (state.currentIndex + 1) % count) {
+      const wallpaper = getWallpaper(state.order[state.currentIndex])
+      if (!wallpaper || (anyPlayable && !known(wallpaper.id))) continue
+
+      const item = findItem(playlist, wallpaper.id)
+      try {
+        await showWallpaper(wallpaper, state.screen, {
+          volume: item?.volume ?? playlist.settings.defaultVolume,
+          dependencyPrompt: 'once'
+        })
+      } catch (err) {
+        console.error('[PlaylistPlayer] Failed to apply wallpaper:', err)
+        continue
+      }
+      updateWallpaper(wallpaper.id, {
+        appliedCount: (wallpaper.appliedCount ?? 0) + 1,
+        lastAppliedAt: Date.now()
+      })
+      state.currentItemId = wallpaper.id
+      return
+    }
   } finally {
-    applying = false
+    player.applying = false
   }
 }
 
@@ -131,18 +159,18 @@ async function getVideoLengthSec(wallpaperId: string): Promise<number | undefine
   return videoFile ? getVideoDurationSec(videoFile) : undefined
 }
 
-function scheduleNext(playlist: Playlist): void {
-  clearTimer()
-  if (!state.isPlaying) return
+function scheduleNext(player: Player, playlist: Playlist): void {
+  clearTimer(player)
+  if (!player.state.isPlaying) return
 
-  const wallpaperId = state.order[state.currentIndex]
+  const wallpaperId = player.state.order[player.state.currentIndex]
   const item = wallpaperId ? findItem(playlist, wallpaperId) : undefined
   const durationSec = item?.durationSec ?? playlist.settings.defaultDurationSec
-  const token = scheduleToken
+  const token = player.scheduleToken
 
   const arm = (sec: number): void => {
-    timer = setTimeout(() => {
-      void advance()
+    player.timer = setTimeout(() => {
+      void advance(player)
     }, sec * 1000)
   }
 
@@ -152,7 +180,7 @@ function scheduleNext(playlist: Playlist): void {
   }
 
   void getVideoLengthSec(wallpaperId).then((videoSec) => {
-    if (token !== scheduleToken) return
+    if (token !== player.scheduleToken) return
     if (videoSec !== undefined && videoSec > durationSec) {
       arm(videoSec + VIDEO_END_GRACE_SEC)
     } else {
@@ -161,155 +189,220 @@ function scheduleNext(playlist: Playlist): void {
   })
 }
 
-async function advance(): Promise<void> {
-  if (!state.playlistId) return
-  const playlist = getPlaylist(state.playlistId)
+async function advance(player: Player): Promise<void> {
+  if (!player.state.playlistId) return
+  const playlist = getPlaylist(player.state.playlistId)
   if (!playlist || playlist.items.length === 0) {
-    stopPlaylist()
+    stopPlaylist(player.state.screen)
     return
   }
 
-  state.currentIndex++
-  if (state.currentIndex >= state.order.length) {
+  player.state.currentIndex++
+  if (player.state.currentIndex >= player.state.order.length) {
     // Loop: rebuild order (reshuffles if randomize is on)
-    state.order = buildOrder(playlist)
-    state.currentIndex = 0
+    player.state.order = buildOrder(playlist)
+    player.state.currentIndex = 0
   }
 
-  await applyCurrent(playlist)
+  await applyCurrent(player, playlist)
   emitState()
-  scheduleNext(playlist)
+  scheduleNext(player, playlist)
 }
 
-export function startPlaylist(id: string): PlaylistPlaybackState {
-  const playlist = getPlaylist(id)
-  if (!playlist) throw new Error(`Playlist ${id} not found`)
-  if (playlist.items.length === 0) throw new Error('Playlist has no wallpapers')
+function playerFor(screen: ScreenTarget): Player {
+  let player = players.get(screen)
+  if (!player) {
+    player = { state: idleState(screen), timer: null, scheduleToken: 0, applying: false }
+    players.set(screen, player)
+  }
+  return player
+}
 
-  clearTimer()
-  state = {
+/** Players the given screen selection refers to; nothing given means every screen */
+function selectPlayers(screen?: ScreenTarget): Player[] {
+  if (screen === undefined || screen === ALL_SCREENS) return [...players.values()]
+  const player = players.get(screen)
+  return player ? [player] : []
+}
+
+function startOn(screen: ScreenTarget, playlist: Playlist): PlaylistPlaybackState {
+  const player = playerFor(screen)
+  clearTimer(player)
+  player.state = {
+    screen,
     playlistId: playlist.id,
     currentItemId: null,
     currentIndex: 0,
     isPlaying: true,
     order: buildOrder(playlist)
   }
-  store.set('activePlaylistId', playlist.id)
-  store.set('isPlaying', true)
 
-  void applyCurrent(playlist).then(() => {
+  void applyCurrent(player, playlist).then(() => {
     emitState()
-    scheduleNext(playlist)
+    scheduleNext(player, playlist)
   })
-  emitState()
+  return player.state
+}
 
-  return state
+export function startPlaylist(id: string, screen?: ScreenTarget): PlaylistPlaybackState[] {
+  const playlist = getPlaylist(id)
+  if (!playlist) throw new Error(`Playlist ${id} not found`)
+  if (playlist.items.length === 0) throw new Error('Playlist has no wallpapers')
+
+  const targets = expandScreen(screen)
+  if (targets.length === 0) throw new Error(`Screen ${screen} is not connected`)
+  // players on screens that no longer exist in this mode would fight over the same engine
+  for (const s of players.keys()) {
+    if (!getDisplayTargets().includes(s)) stopPlaylist(s)
+  }
+
+  const states = targets.map((s) => startOn(s, playlist))
+  persist()
+  emitState()
+  return states
 }
 
-export async function playItem(playlistId: string, wallpaperId: string): Promise<PlaylistPlaybackState> {
+export async function playItem(playlistId: string, wallpaperId: string, screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> {
   const playlist = getPlaylist(playlistId)
   if (!playlist) throw new Error(`Playlist ${playlistId} not found`)
   if (!findItem(playlist, wallpaperId)) throw new Error(`Wallpaper ${wallpaperId} is not in this playlist`)
 
-  clearTimer()
-  if (state.playlistId !== playlistId) {
-    state = {
-      playlistId: playlist.id,
-      currentItemId: null,
-      currentIndex: -1,
-      isPlaying: true,
-      order: buildOrder(playlist)
+  // without a screen, play it wherever this playlist is already playing, or on every screen
+  let targets = screen === undefined
+    ? [...players.values()].filter((p) => p.state.playlistId === playlistId).map((p) => p.state.screen)
+    : expandScreen(screen)
+  if (targets.length === 0) targets = expandScreen()
+
+  for (const target of targets) {
+    const player = playerFor(target)
+    clearTimer(player)
+    if (player.state.playlistId !== playlistId) {
+      player.state = { ...idleState(target), playlistId: playlist.id, isPlaying: true, order: buildOrder(playlist) }
     }
-  }
 
-  let index = state.order.indexOf(wallpaperId)
-  if (index === -1) {
-    state.order = buildOrder(playlist)
-    index = state.order.indexOf(wallpaperId)
-  }
-  state.currentIndex = index
-  state.isPlaying = true
-  store.set('activePlaylistId', playlist.id)
-  store.set('isPlaying', true)
+    let index = player.state.order.indexOf(wallpaperId)
+    if (index === -1) {
+      player.state.order = buildOrder(playlist)
+      index = player.state.order.indexOf(wallpaperId)
+    }
+    player.state.currentIndex = index
+    player.state.isPlaying = true
 
-  await applyCurrent(playlist)
+    await applyCurrent(player, playlist)
+    scheduleNext(player, playlist)
+  }
+  persist()
   emitState()
-  scheduleNext(playlist)
-  return state
+  return getPlaybackStates()
 }
 
-export function stopPlaylist(): PlaylistPlaybackState {
-  clearTimer()
-  state = { playlistId: null, currentItemId: null, currentIndex: -1, isPlaying: false, order: [] }
-  store.set('activePlaylistId', null)
-  store.set('isPlaying', false)
+export function stopPlaylist(screen?: ScreenTarget): PlaylistPlaybackState[] {
+  for (const player of selectPlayers(screen)) {
+    clearTimer(player)
+    players.delete(player.state.screen)
+  }
+  persist()
   emitState()
-  return state
+  return getPlaybackStates()
 }
 
-export function pausePlaylist(): PlaylistPlaybackState {
-  if (!state.playlistId) return state
-  clearTimer()
-  state.isPlaying = false
-  store.set('isPlaying', false)
+export function pausePlaylist(screen?: ScreenTarget): PlaylistPlaybackState[] {
+  for (const player of selectPlayers(screen)) {
+    if (!player.state.playlistId) continue
+    clearTimer(player)
+    player.state.isPlaying = false
+  }
+  persist()
   emitState()
-  return state
+  return getPlaybackStates()
 }
 
-export function resumePlaylist(): PlaylistPlaybackState {
-  if (!state.playlistId) return state
-  const playlist = getPlaylist(state.playlistId)
-  if (!playlist) return state
-  state.isPlaying = true
-  store.set('isPlaying', true)
-  scheduleNext(playlist)
+export function resumePlaylist(screen?: ScreenTarget): PlaylistPlaybackState[] {
+  for (const player of selectPlayers(screen)) {
+    const playlist = player.state.playlistId ? getPlaylist(player.state.playlistId) : null
+    if (!playlist) continue
+    player.state.isPlaying = true
+    scheduleNext(player, playlist)
+  }
+  persist()
   emitState()
-  return state
+  return getPlaybackStates()
 }
 
-export async function nextItem(): Promise<PlaylistPlaybackState> {
-  if (!state.playlistId || applying) return state
-  const playlist = getPlaylist(state.playlistId)
-  if (!playlist) return state
-  await advance()
-  return state
+export async function nextItem(screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> {
+  for (const player of selectPlayers(screen)) {
+    if (!player.state.playlistId || player.applying) continue
+    await advance(player)
+  }
+  return getPlaybackStates()
 }
 
-export async function previousItem(): Promise<PlaylistPlaybackState> {
-  if (!state.playlistId || applying) return state
-  const playlist = getPlaylist(state.playlistId)
-  if (!playlist || playlist.items.length === 0) return state
+export async function previousItem(screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> {
+  for (const player of selectPlayers(screen)) {
+    if (!player.state.playlistId || player.applying) continue
+    const playlist = getPlaylist(player.state.playlistId)
+    if (!playlist || playlist.items.length === 0) continue
 
-  state.currentIndex--
-  if (state.currentIndex < 0) state.currentIndex = state.order.length - 1
+    player.state.currentIndex--
+    if (player.state.currentIndex < 0) player.state.currentIndex = player.state.order.length - 1
 
-  await applyCurrent(playlist)
+    await applyCurrent(player, playlist)
+    scheduleNext(player, playlist)
+  }
   emitState()
-  scheduleNext(playlist)
-  return state
+  return getPlaybackStates()
+}
+
+export function getPlaybackStates(): PlaylistPlaybackState[] {
+  return [...players.values()].map((p) => p.state)
 }
 
+/** The one state that matters most, for places with room for only one (the tray) */
 export function getPlaybackState(): PlaylistPlaybackState {
-  return state
+  const states = getPlaybackStates()
+  return states.find((s) => s.isPlaying) ?? states[0] ?? idleState(ALL_SCREENS)
+}
+
+export function isPlayingOn(screen: ScreenTarget): boolean {
+  return selectPlayers(screen).some((p) => p.state.isPlaying)
+}
+
+function savedPlayers(): Record<ScreenTarget, SavedPlayer> {
+  const saved = store.get('players')
+  if (saved) return saved
+  const legacyId = store.get('activePlaylistId')
+  return legacyId ? { [ALL_SCREENS]: { playlistId: legacyId, isPlaying: store.get('isPlaying') } } : {}
 }
 
 /**
- * Call once on app startup. Resumes whichever playlist was active when the app last
- * closed. Returns true if a playlist was resumed, so the caller can skip the
- * "autostart playlist" config setting to avoid starting two playlists at once.
- * Does nothing if linux-wallpaperengine isn't installed.
+ * Call once on app startup. Resumes the playlists that were active when the app last closed.
+ * Returns true if any was resumed, so the caller can skip the "autostart playlist" config setting
+ * to avoid starting two playlists at once. Does nothing if linux-wallpaperengine isn't installed.
  */
 export function initPlaylistPlayer(window: BrowserWindow): boolean {
   win = window
-  const activeId = store.get('activePlaylistId')
-  if (!activeId) return false
-  const playlist = getPlaylist(activeId)
-  if (!playlist || playlist.items.length === 0) return false
+  setPlaylistRecovery((screen) => {
+    const player = players.get(screen)
+    if (!player?.state.isPlaying || player.applying) return false
+    void advance(player)
+    return true
+  })
+
   if (!getLweStatus().installed) return false
 
-  const wasPlaying = store.get('isPlaying')
-  const isConfiguredAutostart = activeId === getAutostartPlaylistId()
-  startPlaylist(activeId)
-  if (!wasPlaying && !isConfiguredAutostart) pausePlaylist()
-  return true
+  let resumed = false
+  const targets = getDisplayTargets()
+  for (const [screen, saved] of Object.entries(savedPlayers())) {
+    const playlist = getPlaylist(saved.playlistId)
+    if (!playlist || playlist.items.length === 0 || !targets.includes(screen)) continue
+
+    startOn(screen, playlist)
+    resumed = true
+    const isConfiguredAutostart = saved.playlistId === getAutostartPlaylistId()
+    if (!saved.isPlaying && !isConfiguredAutostart) pausePlaylist(screen)
+  }
+  store.set('activePlaylistId', null)
+  persist()
+  emitState()
+  return resumed
 }

+ 102 - 0
src/main/services/schedule.service.ts

@@ -0,0 +1,102 @@
+import Store from 'electron-store'
+import { nativeTheme, type BrowserWindow } from 'electron'
+import { randomUUID } from 'crypto'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { ScheduleRule } from '@shared/types'
+import { dueTimeRules } from '@shared/schedule'
+import { getWallpaper, updateWallpaper } from './library.service'
+import { getPlaylist } from './playlist.service'
+import { expandScreen, showWallpaper, stopDisplay } from './display.service'
+import { startPlaylist, stopPlaylist } from './playlist-player.service'
+
+interface ScheduleStore {
+  rules: ScheduleRule[]
+  /** When the schedule was last checked, so rules that came due while the app was closed still fire */
+  lastTick: number | null
+}
+
+const store = new Store<ScheduleStore>({
+  name: 'schedule',
+  defaults: { rules: [], lastTick: null }
+})
+
+const TICK_MS = 30_000
+// rules older than this are not caught up on after a long time closed
+const MAX_CATCH_UP_MS = 7 * 24 * 60 * 60 * 1000
+
+let win: BrowserWindow | null = null
+let timer: ReturnType<typeof setInterval> | null = null
+let lastDark: boolean | null = null
+
+export function getScheduleRules(): ScheduleRule[] {
+  return store.get('rules')
+}
+
+export function saveScheduleRule(rule: Omit<ScheduleRule, 'id'> & { id?: string }): ScheduleRule {
+  const saved: ScheduleRule = { ...rule, id: rule.id ?? randomUUID() }
+  const rules = store.get('rules')
+  const index = rules.findIndex((r) => r.id === saved.id)
+  if (index === -1) rules.push(saved)
+  else rules[index] = saved
+  store.set('rules', rules)
+  return saved
+}
+
+export function deleteScheduleRule(id: string): void {
+  store.set('rules', store.get('rules').filter((r) => r.id !== id))
+}
+
+export async function runRule(rule: ScheduleRule): Promise<void> {
+  const { action, screen } = rule
+  console.log(`[Schedule] Running "${rule.name}" on ${screen}`)
+
+  if (action.type === 'wallpaper') {
+    const wallpaper = getWallpaper(action.wallpaperId)
+    if (!wallpaper) throw new Error('The scheduled wallpaper is no longer in the library')
+    for (const s of expandScreen(screen)) stopPlaylist(s)
+    await showWallpaper(wallpaper, screen, { dependencyPrompt: 'once' })
+    updateWallpaper(wallpaper.id, { appliedCount: (wallpaper.appliedCount ?? 0) + 1, lastAppliedAt: Date.now() })
+  } else if (action.type === 'playlist') {
+    if (!getPlaylist(action.playlistId)) throw new Error('The scheduled playlist no longer exists')
+    startPlaylist(action.playlistId, screen)
+  } else {
+    stopPlaylist(screen)
+    await stopDisplay(screen)
+  }
+
+  if (win && !win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_SCHEDULE_FIRED, rule.name)
+}
+
+// one after another, a later rule for a single screen has to land after an earlier one for all of them
+async function runAll(rules: ScheduleRule[]): Promise<void> {
+  for (const rule of rules) {
+    await runRule(rule).catch((err) => console.error(`[Schedule] "${rule.name}" failed:`, err.message))
+  }
+}
+
+function tick(): void {
+  const now = Date.now()
+  const last = store.get('lastTick')
+  store.set('lastTick', now)
+  if (last === null || last >= now) return
+
+  const from = new Date(Math.max(last, now - MAX_CATCH_UP_MS))
+  void runAll(dueTimeRules(store.get('rules'), from, new Date(now)))
+}
+
+function onThemeChanged(): void {
+  const dark = nativeTheme.shouldUseDarkColors
+  if (dark === lastDark) return
+  lastDark = dark
+  const theme = dark ? 'dark' : 'light'
+  void runAll(store.get('rules').filter((r) => r.enabled && r.trigger.type === 'theme' && r.trigger.theme === theme))
+}
+
+/** Call after the playlist player resumed, so a rule that came due while closed wins over it */
+export function initSchedule(window: BrowserWindow): void {
+  win = window
+  lastDark = nativeTheme.shouldUseDarkColors
+  nativeTheme.on('updated', onThemeChanged)
+  tick()
+  timer ??= setInterval(tick, TICK_MS)
+}

+ 26 - 0
src/main/services/steam.service.ts

@@ -442,6 +442,32 @@ export async function getVotedUpItemIds(): Promise<string[]> {
   return [...votedUpCache]
 }
 
+// a vote this fresh may not have reached Steam's per-item lookup yet
+const VOTE_SETTLE_MS = 10_000
+
+export async function refreshItemVote(itemId: bigint): Promise<boolean | null> {
+  if (!isSteamRunning() || !isSteamOnline()) return null
+  const idStr = itemId.toString()
+  const result = await withTimeout(checkUserVote(itemId), PAGE_FETCH_TIMEOUT_MS, 'getUserVote').catch((err) => {
+    console.warn('[Steam] Per-item vote check failed:', err?.message ?? err)
+    return null
+  })
+  if (!result) return null
+
+  const pending = pendingVotes.get(idStr)
+  if (pending && Date.now() - pending.at < VOTE_SETTLE_MS) return pending.up
+  pendingVotes.delete(idStr)
+  missingLastCycle.delete(idStr)
+
+  if (votedUpCache.has(idStr) !== result.votedUp) {
+    if (result.votedUp) votedUpCache.add(idStr)
+    else votedUpCache.delete(idStr)
+    persistVoteCache()
+    notifyVotedIds()
+  }
+  return result.votedUp
+}
+
 let voteSyncTimer: ReturnType<typeof setInterval> | null = null
 
 export function startVotedItemsSync(onChange: (ids: string[]) => void): void {

+ 64 - 0
src/main/services/thumbnail.service.ts

@@ -0,0 +1,64 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import type { WallpaperMeta } from '@shared/types'
+import { getWallpaper, updateWallpaper } from './library.service'
+import { captureLweScreenshot } from './lwe.service'
+import { isAnimatedWallpaper, lweOptionsFor } from './wallpaper.service'
+import { getThumbnailsPath } from '../utils/paths'
+
+export const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']
+
+function thumbnailsDir(): string {
+  const dir = getThumbnailsPath()
+  fs.mkdirSync(dir, { recursive: true })
+  return dir
+}
+
+// a new name for every version, the renderer would otherwise keep showing the cached old image
+function newThumbnailPath(wallpaperId: string, extension: string): string {
+  return path.join(thumbnailsDir(), `${wallpaperId}-${Date.now()}${extension}`)
+}
+
+function removeOwnThumbnail(wallpaper: WallpaperMeta): void {
+  const current = wallpaper.customPreview
+  if (current && path.dirname(current) === thumbnailsDir()) fs.rmSync(current, { force: true })
+}
+
+function requireWallpaper(id: string): WallpaperMeta {
+  const wallpaper = getWallpaper(id)
+  if (!wallpaper) throw new Error(`Wallpaper ${id} not found in library`)
+  return wallpaper
+}
+
+function setThumbnail(wallpaper: WallpaperMeta, file: string): WallpaperMeta {
+  removeOwnThumbnail(wallpaper)
+  updateWallpaper(wallpaper.id, { customPreview: file, useOriginalPreview: false })
+  return getWallpaper(wallpaper.id)!
+}
+
+export async function generateThumbnail(id: string): Promise<WallpaperMeta> {
+  const wallpaper = requireWallpaper(id)
+  if (!wallpaper.localPath) throw new Error('The wallpaper is not downloaded')
+  if (!isAnimatedWallpaper(wallpaper)) throw new Error('Only scene, video and web wallpapers can be captured')
+
+  const out = newThumbnailPath(id, '.png')
+  await captureLweScreenshot(wallpaper.localPath, await lweOptionsFor(wallpaper), out)
+  return setThumbnail(wallpaper, out)
+}
+
+export function useThumbnailFile(id: string, source: string): WallpaperMeta {
+  const wallpaper = requireWallpaper(id)
+  const extension = path.extname(source).toLowerCase()
+  if (!IMAGE_EXTENSIONS.includes(extension)) throw new Error('Pick a png, jpg, webp, gif or bmp image')
+
+  const out = newThumbnailPath(id, extension)
+  fs.copyFileSync(source, out)
+  return setThumbnail(wallpaper, out)
+}
+
+export function deleteThumbnail(id: string): WallpaperMeta {
+  const wallpaper = requireWallpaper(id)
+  removeOwnThumbnail(wallpaper)
+  updateWallpaper(id, { customPreview: undefined, useOriginalPreview: false })
+  return getWallpaper(id)!
+}

+ 28 - 4
src/main/services/wallpaper-state.service.ts

@@ -1,15 +1,39 @@
 import Store from 'electron-store'
+import type { ScreenTarget } from '@shared/types'
 
 interface StoreSchema {
+  /** Written by older versions, read once as the '*' assignment */
   activeWallpaperId?: string
+  assignments?: Record<ScreenTarget, string>
 }
 
 const store = new Store<StoreSchema>()
 
-export function getActiveWallpaperId(): string | undefined {
-  return store.get('activeWallpaperId')
+export function getAssignments(): Record<ScreenTarget, string> {
+  const assignments = store.get('assignments')
+  if (assignments) return assignments
+  const legacy = store.get('activeWallpaperId')
+  return legacy ? { '*': legacy } : {}
+}
+
+export function setAssignment(screen: ScreenTarget, wallpaperId: string): void {
+  const next = screen === '*' ? {} : { ...getAssignments() }
+  if (screen !== '*') delete next['*']
+  next[screen] = wallpaperId
+  store.set('assignments', next)
 }
 
-export function setActiveWallpaperId(id: string): void {
-  store.set('activeWallpaperId', id)
+export function clearAssignment(screen?: ScreenTarget): void {
+  if (screen === undefined) {
+    store.set('assignments', {})
+    return
+  }
+  const next = { ...getAssignments() }
+  delete next[screen]
+  store.set('assignments', next)
+}
+
+/** The wallpaper on the first screen, for callers that only care about one */
+export function getActiveWallpaperId(): string | undefined {
+  return Object.values(getAssignments())[0]
 }

+ 62 - 40
src/main/services/wallpaper.service.ts

@@ -4,12 +4,19 @@ import * as os from 'os'
 import * as fs from 'fs'
 import * as path from 'path'
 import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
-import type { WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
-import { getLweStatus, launchLweAsync } from './lwe.service'
+import type { ScreenTarget, WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
+import { getLweStatus, launchLweAsync, type LweLaunchOptions } from './lwe.service'
 import { ensureDependencyInstalled } from './dependency.service'
-import { getDefaultFps, getRecommendedFpsEnabled, getRecommendedWebFpsEnabled } from './config.service'
+import {
+  getDefaultFps,
+  getGlobalEngineFlags,
+  getRecommendedFpsEnabled,
+  getRecommendedWebFpsEnabled
+} from './config.service'
 import { findWallpaperVideoFile, getVideoFps } from '../utils/video'
 import { RECOMMENDED_WEB_FPS } from '@shared/constants'
+import { resolveEngineFlags } from '@shared/engineFlags'
+import { needsFreshLaunch } from '@shared/compat'
 
 const execFileAsync = promisify(execFile)
 
@@ -196,6 +203,52 @@ function findWallpaperImage(dirPath: string): string {
   throw new Error(`No image found in ${dirPath}`)
 }
 
+export async function lweOptionsFor(
+  wallpaper: WallpaperMeta,
+  options: { fps?: number; volume?: number } = {}
+): Promise<LweLaunchOptions> {
+  let fps = options.fps ?? wallpaper.fpsOverride
+  if (fps === undefined && wallpaper.type === 'video' && wallpaper.localPath && getRecommendedFpsEnabled()) {
+    const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
+    if (videoFile) fps = await getVideoFps(videoFile)
+  }
+  if (fps === undefined && wallpaper.type === 'web' && getRecommendedWebFpsEnabled()) {
+    fps = RECOMMENDED_WEB_FPS
+  }
+  fps = fps ?? getDefaultFps() ?? undefined
+
+  return {
+    engineFlags: resolveEngineFlags(getGlobalEngineFlags(), wallpaper.engineFlags),
+    fps,
+    volume: options.volume ?? wallpaper.volumeOverride,
+    disabledObjects: wallpaper.disabledObjects,
+    enabledObjects: wallpaper.enabledObjects,
+    disabledEffects: wallpaper.disabledEffects,
+    enabledEffects: wallpaper.enabledEffects,
+    propertyOverrides: wallpaper.propertyOverrides,
+    // Explicit even when off: xray state lives on the running LWE process, not per-wallpaper,
+    // so a hot-reload into a wallpaper with xray off must actively clear a previous wallpaper's "on".
+    xrayFullReveal: wallpaper.xrayFullReveal ?? false,
+    scalingMode: wallpaper.scalingMode,
+    zoom: wallpaper.zoom,
+    offsetX: wallpaper.offsetX,
+    offsetY: wallpaper.offsetY,
+    // Explicit even when off, same reasoning as xrayFullReveal above.
+    disableParallax: wallpaper.disableParallax ?? false,
+    // Explicit even when off, same reasoning as xrayFullReveal above.
+    expandCanvas: wallpaper.expandCanvas ?? false,
+    cornerColor: wallpaper.cornerColor,
+    speed: wallpaper.playbackSpeed,
+    audioSensitivity: wallpaper.audioSensitivity,
+    soundVolume: wallpaper.soundVolume,
+    customArgs: wallpaper.customArgs
+  }
+}
+
+export function isAnimatedWallpaper(wallpaper: WallpaperMeta): boolean {
+  return wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
+}
+
 /**
  * Apply a library wallpaper: renders animated/scene/web/video wallpapers via
  * linux-wallpaperengine when available, otherwise falls back to a static image.
@@ -208,50 +261,19 @@ export async function applyWallpaperMeta(
     volume?: number
     backend?: WallpaperBackend
     dependencyPrompt?: 'always' | 'once'
+    screen?: ScreenTarget
+    forceFresh?: boolean
   } = {}
 ): Promise<string> {
   if (!wallpaper.localPath) throw new Error(`Wallpaper ${wallpaper.id} has no local path`)
 
   await ensureDependencyInstalled(wallpaper, options.dependencyPrompt)
 
-  const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
-  const lwe = getLweStatus()
-
-  if (isAnimated && os.platform() === 'linux' && lwe.installed) {
-    let fps = options.fps ?? wallpaper.fpsOverride
-    if (fps === undefined && wallpaper.type === 'video' && getRecommendedFpsEnabled()) {
-      const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
-      if (videoFile) fps = await getVideoFps(videoFile)
-    }
-    if (fps === undefined && wallpaper.type === 'web' && getRecommendedWebFpsEnabled()) {
-      fps = RECOMMENDED_WEB_FPS
-    }
-    fps = fps ?? getDefaultFps() ?? undefined
-    const volume = options.volume ?? wallpaper.volumeOverride
+  if (isAnimatedWallpaper(wallpaper) && os.platform() === 'linux' && getLweStatus().installed) {
     await launchLweAsync(wallpaper.localPath, {
-      fps,
-      volume,
-      disabledObjects: wallpaper.disabledObjects,
-      enabledObjects: wallpaper.enabledObjects,
-      disabledEffects: wallpaper.disabledEffects,
-      enabledEffects: wallpaper.enabledEffects,
-      propertyOverrides: wallpaper.propertyOverrides,
-      // Explicit even when off: xray state lives on the running LWE process, not per-wallpaper,
-      // so a hot-reload into a wallpaper with xray off must actively clear a previous wallpaper's "on".
-      xrayFullReveal: wallpaper.xrayFullReveal ?? false,
-      scalingMode: wallpaper.scalingMode,
-      zoom: wallpaper.zoom,
-      offsetX: wallpaper.offsetX,
-      offsetY: wallpaper.offsetY,
-      // Explicit even when off, same reasoning as xrayFullReveal above.
-      disableParallax: wallpaper.disableParallax ?? false,
-      // Explicit even when off, same reasoning as xrayFullReveal above.
-      expandCanvas: wallpaper.expandCanvas ?? false,
-      cornerColor: wallpaper.cornerColor,
-      speed: wallpaper.playbackSpeed,
-      audioSensitivity: wallpaper.audioSensitivity,
-      soundVolume: wallpaper.soundVolume,
-      customArgs: wallpaper.customArgs
+      ...(await lweOptionsFor(wallpaper, options)),
+      screen: options.screen,
+      forceFresh: options.forceFresh || needsFreshLaunch(wallpaper.compat)
     })
     return wallpaper.localPath
   }

+ 4 - 0
src/main/utils/paths.ts

@@ -52,3 +52,7 @@ export function getPreviewCachePath(): string {
 export function getLweManifestPath(): string {
   return path.join(getDataPath(), 'lwe-install-manifest.json')
 }
+
+export function getThumbnailsPath(): string {
+  return path.join(getDataPath(), 'thumbnails')
+}

+ 76 - 19
src/preload/index.ts

@@ -23,7 +23,14 @@ import type {
   Playlist,
   PlaylistSettings,
   PlaylistPlaybackState,
-  SteamIdentity
+  SteamIdentity,
+  EngineFlags,
+  EngineFlagPreset,
+  DisplayMode,
+  ScreenTarget,
+  ScreenAssignment,
+  ScheduleRule,
+  CrashEvent
 } from '../shared/types'
 
 const api = {
@@ -59,6 +66,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
     getVotedIds: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS),
+    checkVote: (itemId: string): Promise<boolean | null> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_CHECK_VOTE, itemId),
     getAuthorInfo: (steamId: string): Promise<WorkshopAuthorInfo | null> =>
       ipcRenderer.invoke(IpcChannels.STEAM_GET_AUTHOR_INFO, steamId)
   },
@@ -117,7 +126,16 @@ const api = {
       disableAnimations: boolean
       steamIdentity: SteamIdentity
       ignoredCreators: string[]
+      engineFlags: EngineFlags
+      engineFlagPresets: EngineFlagPreset[]
+      displayMode: DisplayMode
     }> => ipcRenderer.invoke(IpcChannels.CONFIG_GET),
+    setEngineFlags: (flags: EngineFlags): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_ENGINE_FLAGS, flags),
+    setEnginePresets: (presets: EngineFlagPreset[]): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_ENGINE_PRESETS, presets),
+    setDisplayMode: (mode: DisplayMode): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISPLAY_MODE, mode),
     setWorkshopPath: (p: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_WORKSHOP_PATH, p),
     setDefaultFps: (fps: number | null): Promise<{ ok: boolean }> =>
@@ -162,8 +180,6 @@ const api = {
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, multiplier),
     setDisablePuppetAnimation: (disabled: boolean): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISABLE_PUPPET_ANIMATION, disabled),
-    setDisableAnimations: (disabled: boolean): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISABLE_ANIMATIONS, disabled),
     setSteamIdentity: (identity: SteamIdentity): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_STEAM_IDENTITY, identity),
     ignoreCreator: (steamId: string): Promise<{ ok: boolean }> =>
@@ -196,17 +212,21 @@ const api = {
       patch: { volume?: number; durationSec?: number }
     ): Promise<Playlist | null> =>
       ipcRenderer.invoke(IpcChannels.PLAYLIST_UPDATE_ITEM, id, wallpaperId, patch),
-    start: (id: string): Promise<PlaylistPlaybackState> =>
-      ipcRenderer.invoke(IpcChannels.PLAYLIST_START, id),
-    playItem: (id: string, wallpaperId: string): Promise<PlaylistPlaybackState> =>
-      ipcRenderer.invoke(IpcChannels.PLAYLIST_PLAY_ITEM, id, wallpaperId),
-    stop: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_STOP),
-    pause: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_PAUSE),
-    resume: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_RESUME),
-    next: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_NEXT),
-    previous: (): Promise<PlaylistPlaybackState> =>
-      ipcRenderer.invoke(IpcChannels.PLAYLIST_PREVIOUS),
-    getState: (): Promise<PlaylistPlaybackState> =>
+    start: (id: string, screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_START, id, screen),
+    playItem: (id: string, wallpaperId: string, screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_PLAY_ITEM, id, wallpaperId, screen),
+    stop: (screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_STOP, screen),
+    pause: (screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_PAUSE, screen),
+    resume: (screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_RESUME, screen),
+    next: (screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_NEXT, screen),
+    previous: (screen?: ScreenTarget): Promise<PlaylistPlaybackState[]> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_PREVIOUS, screen),
+    getState: (): Promise<PlaylistPlaybackState[]> =>
       ipcRenderer.invoke(IpcChannels.PLAYLIST_GET_STATE)
   },
 
@@ -234,6 +254,20 @@ const api = {
       ipcRenderer.invoke(IpcChannels.WALLPAPER_APPLY, options),
     getActive: (): Promise<WallpaperMeta | null> =>
       ipcRenderer.invoke(IpcChannels.WALLPAPER_GET_ACTIVE),
+    getAssignments: (): Promise<ScreenAssignment[]> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_GET_ASSIGNMENTS),
+    getTargets: (): Promise<ScreenTarget[]> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_GET_TARGETS),
+    stop: (screen?: ScreenTarget): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_STOP, screen),
+    testLaunch: (wallpaperId: string, screen?: ScreenTarget): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_TEST_LAUNCH, wallpaperId, screen),
+    generateThumbnail: (wallpaperId: string): Promise<WallpaperMeta> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_GENERATE_THUMBNAIL, wallpaperId),
+    pickThumbnail: (wallpaperId: string): Promise<WallpaperMeta | null> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_PICK_THUMBNAIL, wallpaperId),
+    deleteThumbnail: (wallpaperId: string): Promise<WallpaperMeta> =>
+      ipcRenderer.invoke(IpcChannels.WALLPAPER_DELETE_THUMBNAIL, wallpaperId),
     detectEnvironment: (): Promise<WallpaperEnvironment> =>
       ipcRenderer.invoke(IpcChannels.WALLPAPER_DETECT_ENV)
   },
@@ -249,7 +283,7 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LWE_INSTALL),
     uninstall: (): Promise<{ ok: boolean; message: string }> =>
       ipcRenderer.invoke(IpcChannels.LWE_UNINSTALL),
-    launch: (wallpaperPath: string, options?: { screenRoot?: string; fps?: number }): Promise<{ ok: boolean; running: boolean }> =>
+    launch: (wallpaperPath: string, options?: { screen?: ScreenTarget; fps?: number }): Promise<{ ok: boolean; running: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_LAUNCH, wallpaperPath, options),
     stop: (): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_STOP),
@@ -275,8 +309,8 @@ const api = {
       ambientVolume?: number
       audioSensitivity?: Record<string, number>
       soundVolume?: Record<string, number>
-    }): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options),
+    }, wallpaperPath?: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options, wallpaperPath),
     listProperties: (wallpaperPath: string): Promise<LweProperty[]> =>
       ipcRenderer.invoke(IpcChannels.LWE_LIST_PROPERTIES, wallpaperPath),
     listScreens: (): Promise<string[]> =>
@@ -287,6 +321,14 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LWE_LIST_EFFECTS, wallpaperPath)
   },
 
+  schedule: {
+    getAll: (): Promise<ScheduleRule[]> => ipcRenderer.invoke(IpcChannels.SCHEDULE_GET_ALL),
+    save: (rule: Omit<ScheduleRule, 'id'> & { id?: string }): Promise<ScheduleRule> =>
+      ipcRenderer.invoke(IpcChannels.SCHEDULE_SAVE, rule),
+    delete: (id: string): Promise<{ ok: boolean }> => ipcRenderer.invoke(IpcChannels.SCHEDULE_DELETE, id),
+    run: (id: string): Promise<{ ok: boolean }> => ipcRenderer.invoke(IpcChannels.SCHEDULE_RUN, id)
+  },
+
   backup: {
     item: (itemId: string): Promise<{ ok: boolean; unsubscribed: boolean }> =>
       ipcRenderer.invoke(IpcChannels.BACKUP_ITEM, itemId),
@@ -342,8 +384,8 @@ const api = {
       ipcRenderer.on(IpcChannels.EVENT_BACKUP_PROGRESS, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_BACKUP_PROGRESS, listener)
     },
-    playlistStateChanged: (cb: (state: PlaylistPlaybackState) => void): (() => void) => {
-      const listener = (_: Electron.IpcRendererEvent, state: PlaylistPlaybackState) => cb(state)
+    playlistStateChanged: (cb: (states: PlaylistPlaybackState[]) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, states: PlaylistPlaybackState[]) => cb(states)
       ipcRenderer.on(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
     },
@@ -356,6 +398,21 @@ const api = {
       const listener = () => cb()
       ipcRenderer.on(IpcChannels.EVENT_LIBRARY_CHANGED, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_LIBRARY_CHANGED, listener)
+    },
+    displayChanged: (cb: () => void): (() => void) => {
+      const listener = () => cb()
+      ipcRenderer.on(IpcChannels.EVENT_DISPLAY_CHANGED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_DISPLAY_CHANGED, listener)
+    },
+    wallpaperCrashed: (cb: (event: CrashEvent) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, event: CrashEvent) => cb(event)
+      ipcRenderer.on(IpcChannels.EVENT_WALLPAPER_CRASHED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_WALLPAPER_CRASHED, listener)
+    },
+    scheduleFired: (cb: (ruleName: string) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, ruleName: string) => cb(ruleName)
+      ipcRenderer.on(IpcChannels.EVENT_SCHEDULE_FIRED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_SCHEDULE_FIRED, listener)
     }
   }
 }

+ 14 - 0
src/renderer/src/App.tsx

@@ -7,6 +7,8 @@ import PlaylistsView from './components/playlist/PlaylistsView'
 import SettingsView from './components/settings/SettingsView'
 import SetupScreen from './components/setup/SetupScreen'
 import StatusBar from './components/layout/StatusBar'
+import { useToast } from './components/common/Toast'
+import { screenLabel } from './utils/screens'
 
 type View = 'workshop' | 'library' | 'playlists' | 'settings'
 
@@ -36,6 +38,7 @@ export default function App() {
   const [defaultPath, setDefaultPath] = useState('')
   const [creatorFilter, setCreatorFilter] = useState<string | null>(null)
   const queryClient = useQueryClient()
+  const { showToast } = useToast()
 
   function browseCreator(steamId: string) {
     setCreatorFilter(steamId)
@@ -72,6 +75,17 @@ export default function App() {
     })
   }, [queryClient])
 
+  useEffect(() => {
+    return window.electronAPI.on.wallpaperCrashed((event) => {
+      const where = event.screen === '*' ? '' : ` on ${screenLabel(event.screen)}`
+      showToast(`${event.title}${where}: ${event.outcome}`, { warning: true, durationMs: 8000 })
+    })
+  }, [showToast])
+
+  useEffect(() => {
+    return window.electronAPI.on.scheduleFired((name) => showToast(`Schedule: ${name}`))
+  }, [showToast])
+
   if (setupDone === null) return null // loading
 
   if (!setupDone) {

+ 39 - 0
src/renderer/src/components/common/CompatBadge.tsx

@@ -0,0 +1,39 @@
+import { OctagonX, RefreshCwOff, StickyNote, TriangleAlert } from 'lucide-react'
+import clsx from 'clsx'
+import type { CompatStatus, WallpaperCompat } from '@shared/types'
+import { COMPAT_LABELS, compatStatus } from '@shared/compat'
+
+const STATUS_ICONS: Record<Exclude<CompatStatus, 'ok'>, { icon: typeof OctagonX; className: string }> = {
+  crashes: { icon: OctagonX, className: 'text-red-400' },
+  'hotswap-only': { icon: RefreshCwOff, className: 'text-amber-300' },
+  'hotswap-untested': { icon: TriangleAlert, className: 'text-amber-400' },
+  unstable: { icon: TriangleAlert, className: 'text-orange-400' }
+}
+
+export function CompatStatusIcon({ status, size = 12 }: { status: CompatStatus; size?: number }) {
+  if (status === 'ok') return null
+  const { icon: Icon, className } = STATUS_ICONS[status]
+  return <Icon size={size} className={className} />
+}
+
+/** Small icons for a card's corner: how the wallpaper behaved in the engine, and whether it has a note */
+export default function CompatBadge({ compat, className }: { compat?: WallpaperCompat; className?: string }) {
+  const status = compatStatus(compat)
+  const note = compat?.note?.trim()
+  if (status === 'ok' && !note) return null
+
+  return (
+    <span className={clsx('flex items-center gap-1 rounded bg-black/70 px-1 py-0.5', className)}>
+      {status !== 'ok' && (
+        <span title={COMPAT_LABELS[status]}>
+          <CompatStatusIcon status={status} />
+        </span>
+      )}
+      {note && (
+        <span title={note}>
+          <StickyNote size={12} className="text-sky-300" />
+        </span>
+      )}
+    </span>
+  )
+}

+ 74 - 17
src/renderer/src/components/common/DetailSidebar.tsx

@@ -43,13 +43,20 @@ import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/ste
 import { WE_TYPES, WE_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
 import { formatFileSize } from '../../utils/format'
 import { useToast } from './Toast'
+import ScreenSelect from './ScreenSelect'
+import { getPreviewSrc } from '../../utils/preview'
+import EngineFlagsSection from './sidebar/EngineFlagsSection'
+import CompatSection from './sidebar/CompatSection'
+import ThumbnailSection from './sidebar/ThumbnailSection'
+import { useDisplayTargets, useScreenAssignments } from '../../hooks/useDisplayTargets'
 import type {
   LweSceneObject,
   LweSceneEffect,
   LweProperty,
   LweAudioObject,
   WallpaperMeta,
-  ScalingMode
+  ScalingMode,
+  ScreenTarget
 } from '@shared/types'
 
 const SCALING_MODE_OPTIONS: { value: ScalingMode; label: string }[] = [
@@ -378,7 +385,7 @@ interface DetailSidebarProps {
   onSubscribe: () => void
   onUnsubscribe: () => void | Promise<void>
   onLiked: () => void
-  onPlay: () => void | Promise<void>
+  onPlay: (screen?: ScreenTarget) => void | Promise<void>
   onBrowseCreator?: (steamId: string) => void
 }
 
@@ -390,7 +397,7 @@ export default function DetailSidebar({
   fallbackAuthorSteamId,
   localFileSize,
   isSubscribed,
-  isLiked,
+  isLiked: cachedLiked,
   canPlay,
   lweInstalled,
   onClose,
@@ -427,8 +434,16 @@ export default function DetailSidebar({
     staleTime: 60_000
   })
 
+  const { data: steamVote } = useQuery({
+    queryKey: ['steam-vote', id],
+    queryFn: () => window.electronAPI.steam.checkVote(id),
+    enabled: workshopId,
+    staleTime: 60_000,
+    retry: false
+  })
+  const isLiked = typeof steamVote === 'boolean' ? steamVote : cachedLiked
+
   const title = item?.title ?? fallbackTitle
-  const previewUrl = item?.previewUrl ?? fallbackPreviewUrl
   const tags = item?.tags ?? fallbackTags
   const creatorSteamId = item?.creatorSteamId ?? fallbackAuthorSteamId
   const description = item?.description
@@ -445,6 +460,10 @@ export default function DetailSidebar({
     queryFn: () => window.electronAPI.library.getOne(id)
   })
   const localPath = libraryMeta?.localPath
+  const previewUrl =
+    libraryMeta?.customPreview && !libraryMeta.useOriginalPreview
+      ? getPreviewSrc(libraryMeta)
+      : (item?.previewUrl ?? fallbackPreviewUrl)
   const disabledObjects = libraryMeta?.disabledObjects ?? []
   const enabledObjects = libraryMeta?.enabledObjects ?? []
   // item resolves to exactly null (not undefined) once a workshop lookup confirms the id no
@@ -452,12 +471,12 @@ export default function DetailSidebar({
   // "unavailable" flag and grey out the vote buttons below.
   const unavailable = !!libraryMeta?.unavailable || item === null
 
-  const { data: activeWallpaper } = useQuery({
-    queryKey: ['active-wallpaper'],
-    queryFn: () => window.electronAPI.wallpaper.getActive(),
-    staleTime: 5_000
-  })
-  const isActive = activeWallpaper?.id === id
+  const { targets, perScreen } = useDisplayTargets()
+  const [playScreen, setPlayScreen] = useState<ScreenTarget>('*')
+  const activeScreens = useScreenAssignments()
+    .filter((a) => a.wallpaperId === id)
+    .map((a) => a.screen)
+  const isActive = activeScreens.length > 0
 
   const { data: objects = [], isLoading: objectsLoading } = useQuery({
     queryKey: ['lwe-objects', localPath],
@@ -523,7 +542,7 @@ export default function DetailSidebar({
     queryClient.invalidateQueries({ queryKey: ['library'] })
     if (isActive) {
       // customArgs and effect overrides have no hotswap control-file key, so they always force a restart
-      const { ok } = 'customArgs' in patch || 'disabledEffects' in patch || 'enabledEffects' in patch
+      const { ok } = 'customArgs' in patch || 'disabledEffects' in patch || 'enabledEffects' in patch || 'engineFlags' in patch
         ? { ok: false }
         : await window.electronAPI.lwe.hotswapSettings({
             disabledObjects: patch.disabledObjects,
@@ -541,15 +560,22 @@ export default function DetailSidebar({
             propertyOverrides: patch.propertyOverrides,
             audioSensitivity: patch.audioSensitivity,
             soundVolume: patch.soundVolume
-          })
+          }, localPath)
+      // a relaunch restarts the engine by itself when a launch-only setting changed
       if (!ok) {
-        await window.electronAPI.lwe.stop()
-        await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+        for (const screen of activeScreens) await window.electronAPI.wallpaper.apply({ wallpaperId: id, screen })
       }
-      queryClient.invalidateQueries({ queryKey: ['active-wallpaper'] })
+      queryClient.invalidateQueries({ queryKey: ['screen-assignments'] })
     }
   }
 
+  // for settings that don't touch the running engine
+  async function persistOnly(patch: Partial<WallpaperMeta>) {
+    const updated = await window.electronAPI.library.update(id, patch)
+    queryClient.setQueryData(['library-item', id], updated)
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+  }
+
   const livePreviewTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
 
   // Pushes a value live while a slider is being dragged, debounced so a fast drag doesn't flood
@@ -565,7 +591,7 @@ export default function DetailSidebar({
     if (!isActive) return
     if (livePreviewTimer.current) clearTimeout(livePreviewTimer.current)
     livePreviewTimer.current = setTimeout(() => {
-      window.electronAPI.lwe.hotswapSettings(patch)
+      window.electronAPI.lwe.hotswapSettings(patch, localPath)
     }, 80)
   }
 
@@ -833,6 +859,7 @@ export default function DetailSidebar({
     try {
       const { confirmed } = await window.electronAPI.steam.vote(id, true)
       if (confirmed) {
+        queryClient.setQueryData(['steam-vote', id], true)
         onLiked()
       } else {
         showToast('Could not confirm the like on Steam - try again')
@@ -849,6 +876,7 @@ export default function DetailSidebar({
     setIsDisliking(true)
     try {
       const { confirmed } = await window.electronAPI.steam.vote(id, false)
+      if (confirmed) queryClient.setQueryData(['steam-vote', id], false)
       if (!confirmed) showToast('Could not confirm the dislike on Steam - try again')
     } catch (err) {
       showToast((err as Error).message)
@@ -861,7 +889,7 @@ export default function DetailSidebar({
     if (!canPlay || isApplying) return
     setIsApplying(true)
     try {
-      await onPlay()
+      await onPlay(perScreen ? playScreen : undefined)
     } finally {
       setIsApplying(false)
     }
@@ -1488,6 +1516,32 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {libraryMeta && (
+          <>
+            {lweInstalled && localPath && (
+              <EngineFlagsSection
+                flags={libraryMeta.engineFlags}
+                onChange={persistAndMaybeRelaunch}
+              />
+            )}
+            <CompatSection
+              wallpaper={libraryMeta}
+              canTest={lweInstalled && !!localPath}
+              onChange={(compat) => persistOnly({ compat })}
+              onTest={() => window.electronAPI.wallpaper.testLaunch(id, perScreen ? playScreen : undefined)}
+            />
+            <ThumbnailSection
+              wallpaper={libraryMeta}
+              canGenerate={lweInstalled && !!localPath && libraryMeta.type !== 'application'}
+              onUpdated={(updated) => {
+                queryClient.setQueryData(['library-item', id], updated)
+                queryClient.invalidateQueries({ queryKey: ['library'] })
+                queryClient.invalidateQueries({ queryKey: ['library-all'] })
+              }}
+            />
+          </>
+        )}
+
         <div className="flex flex-col gap-1.5 border-t border-white/5 pt-3">
           <div className="flex gap-1.5">
             <button
@@ -1520,6 +1574,9 @@ export default function DetailSidebar({
             </button>
           </div>
 
+          {canPlay && perScreen && (
+            <ScreenSelect targets={targets} value={playScreen} onChange={setPlayScreen} title="Which screen to play on" />
+          )}
           {canPlay && (
             <button
               onClick={handlePlay}

+ 30 - 0
src/renderer/src/components/common/ScreenSelect.tsx

@@ -0,0 +1,30 @@
+import { Monitor } from 'lucide-react'
+import type { ScreenTarget } from '@shared/types'
+import { screenLabel } from '../../utils/screens'
+
+interface Props {
+  targets: ScreenTarget[]
+  value: ScreenTarget
+  onChange: (screen: ScreenTarget) => void
+  title?: string
+}
+
+export default function ScreenSelect({ targets, value, onChange, title = 'Screen' }: Props) {
+  return (
+    <label className="flex items-center gap-1.5 text-xs text-gray-400" title={title}>
+      <Monitor size={12} />
+      <select
+        value={value}
+        onChange={(e) => onChange(e.target.value)}
+        className="rounded bg-white/5 px-1.5 py-1 text-xs text-gray-200 outline-none"
+      >
+        <option value="*">All screens</option>
+        {targets.map((t) => (
+          <option key={t} value={t}>
+            {screenLabel(t)}
+          </option>
+        ))}
+      </select>
+    </label>
+  )
+}

+ 17 - 7
src/renderer/src/components/common/Toast.tsx

@@ -1,13 +1,20 @@
 import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
-import { CheckCircle2 } from 'lucide-react'
+import { CheckCircle2, TriangleAlert } from 'lucide-react'
+import clsx from 'clsx'
+
+interface ToastOptions {
+  warning?: boolean
+  durationMs?: number
+}
 
 interface ToastItem {
   id: number
   message: string
+  warning: boolean
 }
 
 interface ToastContextValue {
-  showToast: (message: string) => void
+  showToast: (message: string, options?: ToastOptions) => void
 }
 
 const ToastContext = createContext<ToastContextValue | null>(null)
@@ -22,12 +29,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
   const [toasts, setToasts] = useState<ToastItem[]>([])
   const nextId = useRef(0)
 
-  const showToast = useCallback((message: string) => {
+  const showToast = useCallback((message: string, options: ToastOptions = {}) => {
     const id = nextId.current++
-    setToasts((prev) => [...prev, { id, message }])
+    setToasts((prev) => [...prev, { id, message, warning: !!options.warning }])
     setTimeout(() => {
       setToasts((prev) => prev.filter((t) => t.id !== id))
-    }, 2000)
+    }, options.durationMs ?? 2000)
   }, [])
 
   return (
@@ -37,9 +44,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
         {toasts.map((t) => (
           <div
             key={t.id}
-            className="flex items-center gap-1.5 rounded-lg border border-green-500/30 bg-green-600/90 px-3 py-2 text-xs font-medium text-white shadow-2xl"
+            className={clsx(
+              'flex max-w-sm items-center gap-1.5 rounded-lg border px-3 py-2 text-xs font-medium text-white shadow-2xl',
+              t.warning ? 'border-amber-500/30 bg-amber-600/90' : 'border-green-500/30 bg-green-600/90'
+            )}
           >
-            <CheckCircle2 size={13} />
+            {t.warning ? <TriangleAlert size={13} className="shrink-0" /> : <CheckCircle2 size={13} className="shrink-0" />}
             {t.message}
           </div>
         ))}

+ 128 - 0
src/renderer/src/components/common/sidebar/CompatSection.tsx

@@ -0,0 +1,128 @@
+import { useState } from 'react'
+import { Bug, ChevronDown, ChevronRight, FlaskConical, Loader2, Undo2 } from 'lucide-react'
+import type { WallpaperCompat, WallpaperMeta } from '@shared/types'
+import { COMPAT_LABELS, compatStatus } from '@shared/compat'
+import { CompatStatusIcon } from '../CompatBadge'
+import { useToast } from '../Toast'
+import { screenLabel } from '../../../utils/screens'
+
+interface Props {
+  wallpaper: WallpaperMeta
+  canTest: boolean
+  onChange: (compat: WallpaperCompat | undefined) => Promise<void>
+  onTest: () => Promise<unknown>
+}
+
+const PHASE_LABELS = {
+  launch: 'on launch',
+  hotswap: 'when switched to',
+  runtime: 'while running'
+}
+
+export default function CompatSection({ wallpaper, canTest, onChange, onTest }: Props) {
+  const compat = wallpaper.compat
+  const status = compatStatus(compat)
+  const [open, setOpen] = useState(status !== 'ok')
+  const [noteDraft, setNoteDraft] = useState<string | null>(null)
+  const [showLog, setShowLog] = useState(false)
+  const [testing, setTesting] = useState(false)
+  const { showToast } = useToast()
+  const note = noteDraft ?? compat?.note ?? ''
+
+  async function commitNote(value: string) {
+    setNoteDraft(null)
+    if (value.trim() === (compat?.note ?? '')) return
+    const next = { ...compat, note: value.trim() || undefined }
+    await onChange(Object.values(next).some((v) => v !== undefined) ? next : undefined)
+  }
+
+  async function clearStatus() {
+    await onChange(compat?.note ? { note: compat.note } : undefined)
+  }
+
+  async function test() {
+    setTesting(true)
+    try {
+      await onTest()
+      showToast('Launched fresh, watching it for 20 seconds')
+    } catch (err) {
+      showToast((err as Error).message, { warning: true, durationMs: 6000 })
+    } finally {
+      setTesting(false)
+    }
+  }
+
+  return (
+    <div className="border-t border-white/5 pt-3">
+      <button
+        onClick={() => setOpen((v) => !v)}
+        className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
+      >
+        {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+        <Bug size={12} />
+        <span className="flex-1 text-left">Compatibility</span>
+        <CompatStatusIcon status={status} />
+      </button>
+
+      {open && (
+        <div className="mt-2 flex flex-col gap-2 text-xs">
+          <p className="flex items-center gap-1.5 text-gray-300">
+            <CompatStatusIcon status={status} />
+            {COMPAT_LABELS[status]}
+          </p>
+
+          {compat?.lastCrash && (
+            <div className="text-gray-500">
+              Last crash {PHASE_LABELS[compat.lastCrash.phase]}
+              {compat.lastCrash.screen !== '*' && ` on ${screenLabel(compat.lastCrash.screen)}`},{' '}
+              {new Date(compat.lastCrash.at).toLocaleString()}
+              {compat.runtimeCrashes ? ` (${compat.runtimeCrashes} while running so far)` : ''}
+              {compat.lastCrash.log.length > 0 && (
+                <button onClick={() => setShowLog((v) => !v)} className="ml-1 text-indigo-300 hover:text-indigo-200">
+                  {showLog ? 'hide log' : 'show log'}
+                </button>
+              )}
+              {showLog && (
+                <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap rounded bg-black/40 p-2 text-[10px] text-gray-400">
+                  {compat.lastCrash.log.join('\n')}
+                </pre>
+              )}
+            </div>
+          )}
+
+          <textarea
+            value={note}
+            onChange={(e) => setNoteDraft(e.target.value)}
+            onBlur={(e) => commitNote(e.target.value)}
+            rows={2}
+            placeholder="Notes, e.g. needs Show full image, glitches with effects on"
+            className="w-full resize-y rounded border border-white/10 bg-black/30 px-2 py-1 text-xs text-gray-200 placeholder:text-gray-600 focus:border-indigo-500/50 focus:outline-none"
+          />
+
+          <div className="flex gap-1.5">
+            {canTest && (
+              <button
+                onClick={test}
+                disabled={testing}
+                title="Launch it fresh and record whether it survives"
+                className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10 disabled:opacity-50"
+              >
+                {testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
+                Test fresh launch
+              </button>
+            )}
+            {status !== 'ok' && (
+              <button
+                onClick={clearStatus}
+                className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10"
+              >
+                <Undo2 size={11} />
+                Forget crashes
+              </button>
+            )}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 139 - 0
src/renderer/src/components/common/sidebar/EngineFlagsSection.tsx

@@ -0,0 +1,139 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { ChevronDown, ChevronRight, Gauge, Loader2 } from 'lucide-react'
+import type { EngineFlags, FullscreenPauseMode, WallpaperMeta } from '@shared/types'
+import { hasEngineFlags } from '@shared/engineFlags'
+import { BOOLEAN_FLAGS, FULLSCREEN_PAUSE_OPTIONS } from '../../../constants/engineFlags'
+
+interface Props {
+  flags?: EngineFlags
+  onChange: (patch: Pick<WallpaperMeta, 'engineFlags' | 'fpsOverride'>) => Promise<void>
+}
+
+const selectClass = 'rounded bg-white/5 px-1.5 py-1 text-xs text-gray-200 outline-none disabled:opacity-50'
+
+const onOff = (value: boolean | undefined) => (value ? 'on' : 'off')
+
+export default function EngineFlagsSection({ flags = {}, onChange }: Props) {
+  const [open, setOpen] = useState(false)
+  const [busy, setBusy] = useState(false)
+
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const global = config?.engineFlags ?? {}
+  const presets = config?.engineFlagPresets ?? []
+
+  async function commit(patch: Pick<WallpaperMeta, 'engineFlags' | 'fpsOverride'>) {
+    setBusy(true)
+    try {
+      await onChange(patch)
+    } finally {
+      setBusy(false)
+    }
+  }
+
+  function setFlag<K extends keyof EngineFlags>(key: K, value: EngineFlags[K] | undefined) {
+    const next = { ...flags, [key]: value }
+    if (value === undefined) delete next[key]
+    void commit({ engineFlags: hasEngineFlags(next) ? next : undefined })
+  }
+
+  function applyPreset(id: string) {
+    const preset = presets.find((p) => p.id === id)
+    if (!preset) return
+    const patch: Pick<WallpaperMeta, 'engineFlags' | 'fpsOverride'> = { engineFlags: { ...flags, ...preset.flags } }
+    if (preset.fps !== undefined) patch.fpsOverride = preset.fps ?? undefined
+    void commit(patch)
+  }
+
+  return (
+    <div className="border-t border-white/5 pt-3">
+      <button
+        onClick={() => setOpen((v) => !v)}
+        className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
+      >
+        {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+        <Gauge size={12} />
+        <span className="flex-1 text-left">Performance</span>
+        {hasEngineFlags(flags) && (
+          <span className="rounded-full bg-indigo-600/30 px-1.5 normal-case text-indigo-300">custom</span>
+        )}
+        {busy && <Loader2 size={11} className="animate-spin" />}
+      </button>
+
+      {open && (
+        <div className="mt-2 flex flex-col gap-2">
+          <p className="text-[11px] text-gray-600">
+            Overrides the global settings for this wallpaper. Changing one restarts it if it's playing.
+          </p>
+
+          <div className="flex items-center gap-2">
+            <select
+              value=""
+              disabled={busy}
+              onChange={(e) => applyPreset(e.target.value)}
+              className={`${selectClass} flex-1`}
+            >
+              <option value="" disabled>
+                Apply a preset...
+              </option>
+              {presets.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.name}
+                </option>
+              ))}
+            </select>
+            {hasEngineFlags(flags) && (
+              <button
+                onClick={() => commit({ engineFlags: undefined })}
+                disabled={busy}
+                className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400 hover:bg-white/10 disabled:opacity-50"
+              >
+                Reset
+              </button>
+            )}
+          </div>
+
+          {BOOLEAN_FLAGS.map(({ key, label, hint }) => (
+            <label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-300" title={hint}>
+              <span>{label}</span>
+              <select
+                value={flags[key] === undefined ? 'inherit' : onOff(flags[key])}
+                disabled={busy}
+                onChange={(e) => setFlag(key, e.target.value === 'inherit' ? undefined : e.target.value === 'on')}
+                className={selectClass}
+              >
+                <option value="inherit">Default ({onOff(global[key])})</option>
+                <option value="on">On</option>
+                <option value="off">Off</option>
+              </select>
+            </label>
+          ))}
+
+          <label className="flex flex-col gap-1 text-xs text-gray-300">
+            <span>Fullscreen apps</span>
+            <select
+              value={flags.fullscreenPause ?? 'inherit'}
+              disabled={busy}
+              onChange={(e) =>
+                setFlag('fullscreenPause', e.target.value === 'inherit' ? undefined : (e.target.value as FullscreenPauseMode))
+              }
+              className={selectClass}
+            >
+              <option value="inherit">
+                Default ({FULLSCREEN_PAUSE_OPTIONS.find((o) => o.value === (global.fullscreenPause ?? 'default'))?.label})
+              </option>
+              {FULLSCREEN_PAUSE_OPTIONS.map((o) => (
+                <option key={o.value} value={o.value}>
+                  {o.label}
+                </option>
+              ))}
+            </select>
+          </label>
+        </div>
+      )}
+    </div>
+  )
+}

+ 111 - 0
src/renderer/src/components/common/sidebar/ThumbnailSection.tsx

@@ -0,0 +1,111 @@
+import { useState } from 'react'
+import { Camera, ChevronDown, ChevronRight, ImageOff, ImagePlus, Loader2 } from 'lucide-react'
+import clsx from 'clsx'
+import type { WallpaperMeta } from '@shared/types'
+import { useToast } from '../Toast'
+
+interface Props {
+  wallpaper: WallpaperMeta
+  canGenerate: boolean
+  onUpdated: (wallpaper: WallpaperMeta) => void
+}
+
+export default function ThumbnailSection({ wallpaper, canGenerate, onUpdated }: Props) {
+  const [open, setOpen] = useState(false)
+  const [busy, setBusy] = useState<'generate' | 'pick' | 'delete' | 'toggle' | null>(null)
+  const { showToast } = useToast()
+  const hasCustom = !!wallpaper.customPreview
+  const usingCustom = hasCustom && !wallpaper.useOriginalPreview
+
+  async function run(kind: NonNullable<typeof busy>, action: () => Promise<WallpaperMeta | null>) {
+    setBusy(kind)
+    try {
+      const updated = await action()
+      if (updated) onUpdated(updated)
+    } catch (err) {
+      showToast((err as Error).message, { warning: true, durationMs: 6000 })
+    } finally {
+      setBusy(null)
+    }
+  }
+
+  const showOriginal = (value: boolean) =>
+    run('toggle', () => window.electronAPI.library.update(wallpaper.id, { useOriginalPreview: value }))
+
+  return (
+    <div className="border-t border-white/5 pt-3">
+      <button
+        onClick={() => setOpen((v) => !v)}
+        className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300"
+      >
+        {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+        <Camera size={12} />
+        <span className="flex-1 text-left">Thumbnail</span>
+        {usingCustom && <span className="rounded-full bg-indigo-600/30 px-1.5 normal-case text-indigo-300">custom</span>}
+        {busy && <Loader2 size={11} className="animate-spin" />}
+      </button>
+
+      {open && (
+        <div className="mt-2 flex flex-col gap-2 text-xs">
+          {hasCustom && (
+            <div className="flex overflow-hidden rounded bg-white/5">
+              {[
+                { label: 'Custom', original: false },
+                { label: 'Original', original: true }
+              ].map(({ label, original }) => (
+                <button
+                  key={label}
+                  onClick={() => showOriginal(original)}
+                  disabled={busy !== null || usingCustom === !original}
+                  className={clsx(
+                    'flex-1 py-1',
+                    usingCustom === !original ? 'bg-indigo-600 text-white' : 'text-gray-400 hover:bg-white/10'
+                  )}
+                >
+                  {label}
+                </button>
+              ))}
+            </div>
+          )}
+
+          <div className="flex flex-wrap gap-1.5">
+            {canGenerate && (
+              <button
+                onClick={() => run('generate', () => window.electronAPI.wallpaper.generateThumbnail(wallpaper.id))}
+                disabled={busy !== null}
+                title="Renders the wallpaper with your settings in a small window for a few seconds and saves a frame"
+                className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10 disabled:opacity-50"
+              >
+                <Camera size={11} />
+                {busy === 'generate' ? 'Capturing...' : 'Capture'}
+              </button>
+            )}
+            <button
+              onClick={() => run('pick', () => window.electronAPI.wallpaper.pickThumbnail(wallpaper.id))}
+              disabled={busy !== null}
+              className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10 disabled:opacity-50"
+            >
+              <ImagePlus size={11} />
+              Choose image
+            </button>
+            {hasCustom && (
+              <button
+                onClick={() => run('delete', () => window.electronAPI.wallpaper.deleteThumbnail(wallpaper.id))}
+                disabled={busy !== null}
+                className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-red-500/20 hover:text-red-400 disabled:opacity-50"
+              >
+                <ImageOff size={11} />
+                Remove custom
+              </button>
+            )}
+          </div>
+          {canGenerate && (
+            <p className="text-[11px] text-gray-600">
+              Capture opens a small window for a few seconds while the wallpaper renders.
+            </p>
+          )}
+        </div>
+      )}
+    </div>
+  )
+}

+ 27 - 29
src/renderer/src/components/layout/StatusBar.tsx

@@ -1,11 +1,12 @@
 import { useEffect, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
 import { Wifi, WifiOff, ListVideo, Play, Pause, SkipForward } from 'lucide-react'
 import type { PlaylistPlaybackState } from '@shared/types'
+import { screenLabel } from '../../utils/screens'
 
 export default function StatusBar() {
   const [steamRunning, setSteamRunning] = useState(false)
-  const [playback, setPlayback] = useState<PlaylistPlaybackState | null>(null)
-  const [playlistTitle, setPlaylistTitle] = useState<string | null>(null)
+  const [players, setPlayers] = useState<PlaylistPlaybackState[]>([])
 
   useEffect(() => {
     window.electronAPI.steam.isRunning().then(setSteamRunning).catch(() => setSteamRunning(false))
@@ -15,26 +16,16 @@ export default function StatusBar() {
   }, [])
 
   useEffect(() => {
-    window.electronAPI.playlist.getState().then(setPlayback)
-    return window.electronAPI.on.playlistStateChanged(setPlayback)
+    window.electronAPI.playlist.getState().then(setPlayers)
+    return window.electronAPI.on.playlistStateChanged(setPlayers)
   }, [])
 
-  useEffect(() => {
-    if (!playback?.playlistId) {
-      setPlaylistTitle(null)
-      return
-    }
-    window.electronAPI.playlist.getOne(playback.playlistId).then((p) => setPlaylistTitle(p?.title ?? null))
-  }, [playback?.playlistId])
-
-  async function handlePauseResume() {
-    if (playback?.isPlaying) await window.electronAPI.playlist.pause()
-    else await window.electronAPI.playlist.resume()
-  }
+  const { data: playlists } = useQuery({
+    queryKey: ['playlists'],
+    queryFn: () => window.electronAPI.playlist.getAll()
+  })
 
-  async function handleNext() {
-    await window.electronAPI.playlist.next()
-  }
+  const active = players.filter((p) => p.playlistId)
 
   return (
     <footer className="flex h-6 items-center gap-3 border-t border-white/5 bg-[#0a0a0a] px-3 text-xs text-gray-500">
@@ -52,25 +43,32 @@ export default function StatusBar() {
         )}
       </span>
 
-      {playback?.playlistId && (
-        <>
+      {active.map((playback) => (
+        <span key={playback.screen} className="flex items-center gap-1.5">
           <span className="h-3 w-px bg-white/10" />
-          <span className="flex items-center gap-1.5">
-            <ListVideo size={12} className="text-indigo-400" />
-            {playlistTitle ?? 'Playlist'}
-          </span>
+          <ListVideo size={12} className="text-indigo-400" />
+          {playlists?.find((p) => p.id === playback.playlistId)?.title ?? 'Playlist'}
+          {playback.screen !== '*' && <span className="text-gray-600">on {screenLabel(playback.screen)}</span>}
           <button
-            onClick={handlePauseResume}
+            onClick={() =>
+              playback.isPlaying
+                ? window.electronAPI.playlist.pause(playback.screen)
+                : window.electronAPI.playlist.resume(playback.screen)
+            }
             title={playback.isPlaying ? 'Pause' : 'Resume'}
             className="text-gray-400 hover:text-gray-200"
           >
             {playback.isPlaying ? <Pause size={11} /> : <Play size={11} />}
           </button>
-          <button onClick={handleNext} title="Next" className="text-gray-400 hover:text-gray-200">
+          <button
+            onClick={() => window.electronAPI.playlist.next(playback.screen)}
+            title="Next"
+            className="text-gray-400 hover:text-gray-200"
+          >
             <SkipForward size={11} />
           </button>
-        </>
-      )}
+        </span>
+      ))}
     </footer>
   )
 }

+ 7 - 5
src/renderer/src/components/library/LibraryView.tsx

@@ -31,7 +31,7 @@ import WallpaperCard from './WallpaperCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
 import DetailSidebar from '../common/DetailSidebar'
 import { useToast } from '../common/Toast'
-import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
+import type { LibraryFilters, WallpaperFolder, LweStatus, ScreenTarget } from '@shared/types'
 import clsx from 'clsx'
 import { WE_TYPES, WE_LIBRARY_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
@@ -613,7 +613,9 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     queryKey: ['playlist-playback-state'],
     queryFn: () => window.electronAPI.playlist.getState()
   })
-  const currentPlaylistId = playbackState?.playlistId ?? null
+  // the playlist "add to current" goes to, the first one playing when several screens have their own
+  const currentPlaylistId =
+    (playbackState?.find((s) => s.isPlaying) ?? playbackState?.find((s) => s.playlistId))?.playlistId ?? null
 
   useEffect(() => {
     return window.electronAPI.on.playlistStateChanged((state) => {
@@ -867,9 +869,9 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     await unsubscribeIds(ids)
   }
 
-  async function handleApplyDetail(id: string) {
+  async function handleApplyDetail(id: string, screen?: ScreenTarget) {
     try {
-      await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+      await window.electronAPI.wallpaper.apply({ wallpaperId: id, screen })
       queryClient.invalidateQueries({ queryKey: ['library'] })
     } catch (err) {
       showToast((err as Error).message)
@@ -1598,7 +1600,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                 old ? [...old, detailId] : [detailId]
               )
             }}
-            onPlay={() => handleApplyDetail(detailId)}
+            onPlay={(screen) => handleApplyDetail(detailId, screen)}
             onBrowseCreator={onBrowseCreator}
           />
         )}

+ 5 - 0
src/renderer/src/components/library/WallpaperCard.tsx

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
 import { Play, Loader2, Check, X, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw, Plus, FolderOpen, WifiOff } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
+import CompatBadge from '../common/CompatBadge'
 import { getPreviewSrc } from '../../utils/preview'
 import { openWorkshopPage } from '../../utils/steam'
 import { useToast } from '../common/Toast'
@@ -270,6 +271,10 @@ export default function WallpaperCard({
             <WifiOff size={12} />
           </div>
         )}
+        <CompatBadge
+          compat={wallpaper.compat}
+          className={clsx('absolute bottom-2', wallpaper.unavailable ? 'left-8' : 'left-2')}
+        />
       </div>
 
       <div className="p-3">

+ 2 - 0
src/renderer/src/components/playlist/PlaylistItemRow.tsx

@@ -1,6 +1,7 @@
 import { useState } from 'react'
 import { GripVertical, Trash2, Volume2, Clock, ChevronDown, ChevronUp, Play, Pause } from 'lucide-react'
 import clsx from 'clsx'
+import CompatBadge from '../common/CompatBadge'
 import type { PlaylistItem, WallpaperMeta } from '@shared/types'
 import { getPreviewSrc } from '../../utils/preview'
 
@@ -72,6 +73,7 @@ export default function PlaylistItemRow({
           {previewSrc && (
             <img src={previewSrc} alt="" className="h-full w-full object-cover" loading="lazy" />
           )}
+          <CompatBadge compat={wallpaper?.compat} className="absolute bottom-0.5 left-0.5 z-10" />
           <div
             className={clsx(
               'absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity',

+ 44 - 37
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -25,7 +25,7 @@ import {
   User
 } from 'lucide-react'
 import clsx from 'clsx'
-import type { Playlist, PlaylistPlaybackState, PlaylistSettings } from '@shared/types'
+import type { Playlist, PlaylistPlaybackState, PlaylistSettings, ScreenTarget } from '@shared/types'
 import PlaylistItemRow from './PlaylistItemRow'
 import WallpaperPickerModal from './WallpaperPickerModal'
 import DetailSidebar from '../common/DetailSidebar'
@@ -34,6 +34,9 @@ import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosit
 import { openWorkshopPage, isWorkshopId } from '../../utils/steam'
 import { useToast } from '../common/Toast'
 import { getPreviewSrc } from '../../utils/preview'
+import ScreenSelect from '../common/ScreenSelect'
+import { useDisplayTargets } from '../../hooks/useDisplayTargets'
+import { screenLabel } from '../../utils/screens'
 
 const SORT_OPTIONS: { value: PlaylistSettings['sortBy']; label: string }[] = [
   { value: 'manual', label: 'Manual order' },
@@ -100,10 +103,12 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
   })
   const isBackupConfigured = config?.isBackupConfigured ?? false
 
-  const { data: playbackState } = useQuery({
+  const { data: playbackStates = [] } = useQuery({
     queryKey: ['playlist-playback-state'],
     queryFn: () => window.electronAPI.playlist.getState()
   })
+  const { targets, perScreen } = useDisplayTargets()
+  const [screen, setScreen] = useState<ScreenTarget>('*')
 
   const { data: lweStatus } = useQuery({
     queryKey: ['lwe-status'],
@@ -118,8 +123,8 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
   const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
 
   useEffect(() => {
-    return window.electronAPI.on.playlistStateChanged((state: PlaylistPlaybackState) => {
-      queryClient.setQueryData(['playlist-playback-state'], state)
+    return window.electronAPI.on.playlistStateChanged((states: PlaylistPlaybackState[]) => {
+      queryClient.setQueryData(['playlist-playback-state'], states)
     })
   }, [queryClient])
 
@@ -215,9 +220,9 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
     refetchFolders()
   }
 
-  async function handleApplyDetail(wallpaperId: string) {
+  async function handleApplyDetail(wallpaperId: string, target?: ScreenTarget) {
     try {
-      await window.electronAPI.wallpaper.apply({ wallpaperId })
+      await window.electronAPI.wallpaper.apply({ wallpaperId, screen: target })
       queryClient.invalidateQueries({ queryKey: ['library-all'] })
     } catch (err) {
       showToast((err as Error).message)
@@ -330,7 +335,7 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
     if (!activePlaylist) return
     setError(null)
     try {
-      await window.electronAPI.playlist.start(activePlaylist.id)
+      await window.electronAPI.playlist.start(activePlaylist.id, screen)
       queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
     } catch (err) {
       setError((err as Error).message)
@@ -339,45 +344,39 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
 
   async function handlePlayItem(wallpaperId: string) {
     if (!activePlaylist) return
-    if (isThisPlaylistActive && playbackState?.isPlaying && playbackState?.currentItemId === wallpaperId) {
-      await window.electronAPI.playlist.pause()
+    const playingIt = thisStates.filter((s) => s.isPlaying && s.currentItemId === wallpaperId)
+    if (playingIt.length > 0) {
+      for (const s of playingIt) await window.electronAPI.playlist.pause(s.screen)
       queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
       return
     }
     setError(null)
     try {
-      await window.electronAPI.playlist.playItem(activePlaylist.id, wallpaperId)
+      await window.electronAPI.playlist.playItem(activePlaylist.id, wallpaperId, isThisPlaylistActive ? undefined : screen)
       queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
     } catch (err) {
       setError((err as Error).message)
     }
   }
 
-  async function handlePauseResume() {
-    if (isThisPlaylistActive && playbackState?.isPlaying) {
-      await window.electronAPI.playlist.pause()
-    } else {
-      await window.electronAPI.playlist.resume()
-    }
+  // controls act on every screen this playlist plays on, narrowed down by the screen picker
+  async function forEachScreen(action: (screen: ScreenTarget) => Promise<unknown>) {
+    for (const s of thisStates) await action(s.screen)
     queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
   }
 
-  async function handleStop() {
-    await window.electronAPI.playlist.stop()
-    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
-  }
-
-  async function handleNext() {
-    await window.electronAPI.playlist.next()
-    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
-  }
+  const handlePauseResume = () =>
+    forEachScreen((s) => (isPlaying ? window.electronAPI.playlist.pause(s) : window.electronAPI.playlist.resume(s)))
+  const handleStop = () => forEachScreen((s) => window.electronAPI.playlist.stop(s))
+  const handleNext = () => forEachScreen((s) => window.electronAPI.playlist.next(s))
+  const handlePrevious = () => forEachScreen((s) => window.electronAPI.playlist.previous(s))
 
-  async function handlePrevious() {
-    await window.electronAPI.playlist.previous()
-    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
-  }
-
-  const isThisPlaylistActive = playbackState?.playlistId === activePlaylist?.id
+  const thisStates = playbackStates.filter(
+    (s) => s.playlistId === activePlaylist?.id && (screen === '*' || s.screen === screen)
+  )
+  const isThisPlaylistActive = thisStates.length > 0
+  const isPlaying = thisStates.some((s) => s.isPlaying)
+  const currentItemIds = new Set(thisStates.map((s) => s.currentItemId))
   const existingIds = useMemo(
     () => new Set(activePlaylist?.items.map((i) => i.wallpaperId) ?? []),
     [activePlaylist]
@@ -441,7 +440,7 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
               size={14}
               className={clsx(
                 'shrink-0',
-                playbackState?.playlistId === p.id ? 'text-indigo-400' : 'text-gray-600'
+                playbackStates.some((s) => s.playlistId === p.id) ? 'text-indigo-400' : 'text-gray-600'
               )}
             />
             {renamingId === p.id ? (
@@ -496,6 +495,9 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
               <div className="flex items-center justify-between">
                 <h2 className="text-lg font-semibold text-gray-100">{activePlaylist.title}</h2>
                 <div className="flex items-center gap-2">
+                  {perScreen && (
+                    <ScreenSelect targets={targets} value={screen} onChange={setScreen} title="Which screen to play on" />
+                  )}
                   {isThisPlaylistActive ? (
                     <>
                       <button
@@ -507,10 +509,10 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
                       </button>
                       <button
                         onClick={handlePauseResume}
-                        title={playbackState?.isPlaying ? 'Pause' : 'Resume'}
+                        title={isPlaying ? 'Pause' : 'Resume'}
                         className="rounded-lg bg-indigo-600 p-2 text-white hover:bg-indigo-500"
                       >
-                        {playbackState?.isPlaying ? <Pause size={14} /> : <Play size={14} />}
+                        {isPlaying ? <Pause size={14} /> : <Play size={14} />}
                       </button>
                       <button
                         onClick={handleNext}
@@ -540,6 +542,11 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
                 </div>
               </div>
               {error && <p className="mt-2 text-xs text-red-400">{error}</p>}
+              {perScreen && isThisPlaylistActive && (
+                <p className="mt-1 text-xs text-gray-500">
+                  Playing on {thisStates.map((s) => screenLabel(s.screen)).join(', ')}
+                </p>
+              )}
 
               <div className="mt-3 flex flex-wrap items-center gap-4">
                 <label className="flex items-center gap-2 text-xs text-gray-400">
@@ -658,8 +665,8 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
                   item={item}
                   wallpaper={wallpaperById.get(item.wallpaperId)}
                   index={index}
-                  isPlaying={Boolean(isThisPlaylistActive && playbackState?.isPlaying)}
-                  isCurrent={isThisPlaylistActive && playbackState?.currentItemId === item.wallpaperId}
+                  isPlaying={isPlaying}
+                  isCurrent={currentItemIds.has(item.wallpaperId)}
                   onDragStart={handleDragStart}
                   onDragOver={handleDragOver}
                   onDrop={handleDrop}
@@ -707,7 +714,7 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
               old ? [...old, detailId] : [detailId]
             )
           }}
-          onPlay={() => handleApplyDetail(detailId)}
+          onPlay={(target) => handleApplyDetail(detailId, target)}
           onBrowseCreator={onBrowseCreator}
         />
       )}

+ 64 - 0
src/renderer/src/components/settings/DisplaySettings.tsx

@@ -0,0 +1,64 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Loader2, Monitor, MonitorSmartphone } from 'lucide-react'
+import type { DisplayMode } from '@shared/types'
+
+const MODES: { mode: DisplayMode; label: string; icon: typeof Monitor }[] = [
+  { mode: 'shared', label: 'Same on every screen', icon: Monitor },
+  { mode: 'per-screen', label: 'Each screen on its own', icon: MonitorSmartphone }
+]
+
+export default function DisplaySettings({ screens }: { screens: string[] }) {
+  const queryClient = useQueryClient()
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const [switching, setSwitching] = useState(false)
+  const current = config?.displayMode ?? 'shared'
+
+  async function switchTo(mode: DisplayMode) {
+    if (mode === current || switching) return
+    setSwitching(true)
+    try {
+      await window.electronAPI.config.setDisplayMode(mode)
+      for (const key of ['config', 'display-targets', 'screen-assignments', 'playlist-playback-state']) {
+        queryClient.invalidateQueries({ queryKey: [key] })
+      }
+    } finally {
+      setSwitching(false)
+    }
+  }
+
+  return (
+    <div>
+      <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">Screens</label>
+      <p className="mt-1 text-xs text-gray-600">
+        With each screen on its own, every screen gets its own wallpaper and playlist, and Play asks which screen
+        to use. Switching stops whatever is playing now.
+      </p>
+      <div className="mt-3 inline-flex overflow-hidden rounded-lg text-sm">
+        {MODES.map(({ mode, label, icon: Icon }) => (
+          <button
+            key={mode}
+            onClick={() => switchTo(mode)}
+            disabled={switching}
+            className={`flex items-center gap-2 px-3 py-2 transition-colors ${
+              current === mode ? 'bg-indigo-600 text-white' : 'bg-white/5 text-gray-400 hover:text-gray-200'
+            }`}
+          >
+            {switching && current !== mode ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
+            {label}
+          </button>
+        ))}
+      </div>
+      {current === 'per-screen' && (
+        <p className="mt-2 text-xs text-gray-500">
+          {screens.length > 0 ? `Detected: ${screens.join(', ')}` : 'No screens detected, everything goes to one engine.'}
+          {screens.length > 1 &&
+            ' Switching wallpapers restarts that screen\'s engine instead of hot-swapping, since the engines would otherwise share one control file.'}
+        </p>
+      )}
+    </div>
+  )
+}

+ 173 - 0
src/renderer/src/components/settings/PerformanceSettings.tsx

@@ -0,0 +1,173 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { Check, Plus, Save, Trash2 } from 'lucide-react'
+import type { EngineFlagPreset, EngineFlags, FullscreenPauseMode } from '@shared/types'
+import { BOOLEAN_FLAGS, FULLSCREEN_PAUSE_OPTIONS } from '../../constants/engineFlags'
+
+interface Props {
+  defaultFps: number | null
+  onDefaultFpsChanged: (fps: number | null) => void
+}
+
+function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
+  return (
+    <button
+      onClick={onClick}
+      className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+        on ? 'bg-indigo-600' : 'bg-white/10'
+      }`}
+    >
+      <span
+        className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+          on ? 'translate-x-4' : 'translate-x-0.5'
+        }`}
+      />
+    </button>
+  )
+}
+
+function describePreset(preset: EngineFlagPreset): string {
+  const parts = BOOLEAN_FLAGS.filter((f) => preset.flags[f.key]).map((f) => f.label.toLowerCase())
+  const pause = FULLSCREEN_PAUSE_OPTIONS.find((o) => o.value === preset.flags.fullscreenPause)
+  if (pause && pause.value !== 'default') parts.push(pause.label.toLowerCase())
+  if (preset.fps) parts.push(`${preset.fps} FPS`)
+  return parts.length ? parts.join(', ') : 'everything on'
+}
+
+export default function PerformanceSettings({ defaultFps, onDefaultFpsChanged }: Props) {
+  const queryClient = useQueryClient()
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const flags: EngineFlags = config?.engineFlags ?? {}
+  const presets = config?.engineFlagPresets ?? []
+  const [newName, setNewName] = useState('')
+  const [appliedId, setAppliedId] = useState<string | null>(null)
+
+  async function saveFlags(next: EngineFlags) {
+    queryClient.setQueryData(['config'], { ...config!, engineFlags: next })
+    await window.electronAPI.config.setEngineFlags(next)
+    queryClient.invalidateQueries({ queryKey: ['config'] })
+  }
+
+  async function savePresets(next: EngineFlagPreset[]) {
+    await window.electronAPI.config.setEnginePresets(next)
+    queryClient.invalidateQueries({ queryKey: ['config'] })
+  }
+
+  async function applyPreset(preset: EngineFlagPreset) {
+    await saveFlags({ ...flags, ...preset.flags })
+    if (preset.fps !== undefined) {
+      await window.electronAPI.config.setDefaultFps(preset.fps)
+      onDefaultFpsChanged(preset.fps)
+    }
+    setAppliedId(preset.id)
+    setTimeout(() => setAppliedId(null), 2000)
+  }
+
+  async function createPreset() {
+    const name = newName.trim()
+    if (!name) return
+    setNewName('')
+    await savePresets([...presets, { id: crypto.randomUUID(), name, flags: { ...flags }, fps: defaultFps }])
+  }
+
+  async function overwritePreset(preset: EngineFlagPreset) {
+    await savePresets(presets.map((p) => (p.id === preset.id ? { ...p, flags: { ...flags }, fps: defaultFps } : p)))
+  }
+
+  async function deletePreset(id: string) {
+    await savePresets(presets.filter((p) => p.id !== id))
+  }
+
+  return (
+    <div>
+      <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">Performance</label>
+      <p className="mt-1 text-xs text-gray-600">
+        Engine settings for every wallpaper. A wallpaper can override each of them in its Performance panel.
+        They only take effect when a wallpaper is (re)launched.
+      </p>
+
+      <div className="mt-3 space-y-2">
+        {BOOLEAN_FLAGS.map(({ key, label, hint }) => (
+          <label key={key} className="flex cursor-pointer items-center gap-3" title={hint}>
+            <Toggle on={!!flags[key]} onClick={() => saveFlags({ ...flags, [key]: !flags[key] })} />
+            <span className="text-sm text-gray-300">{label}</span>
+          </label>
+        ))}
+        <label className="flex items-center gap-3 text-sm text-gray-300">
+          <span>Fullscreen apps</span>
+          <select
+            value={flags.fullscreenPause ?? 'default'}
+            onChange={(e) => saveFlags({ ...flags, fullscreenPause: e.target.value as FullscreenPauseMode })}
+            className="rounded-lg bg-white/5 px-2 py-1.5 text-sm text-gray-200 outline-none"
+          >
+            {FULLSCREEN_PAUSE_OPTIONS.map((o) => (
+              <option key={o.value} value={o.value}>
+                {o.label}
+              </option>
+            ))}
+          </select>
+        </label>
+      </div>
+
+      <p className="mt-4 text-xs font-medium text-gray-400">Presets</p>
+      <p className="mt-1 text-xs text-gray-600">
+        Applying one copies its settings (and FPS limit, if it has one) into the settings above.
+      </p>
+      <div className="mt-2 space-y-1.5">
+        {presets.map((preset) => (
+          <div key={preset.id} className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2">
+            <div className="min-w-0 flex-1">
+              <p className="truncate text-sm text-gray-200">{preset.name}</p>
+              <p className="truncate text-xs text-gray-500">{describePreset(preset)}</p>
+            </div>
+            <button
+              onClick={() => applyPreset(preset)}
+              className="flex items-center gap-1 rounded bg-indigo-600 px-2 py-1 text-xs text-white hover:bg-indigo-500"
+            >
+              {appliedId === preset.id && <Check size={11} />}
+              Apply
+            </button>
+            {!preset.builtIn && (
+              <>
+                <button
+                  onClick={() => overwritePreset(preset)}
+                  title="Replace with the current settings"
+                  className="rounded p-1 text-gray-500 hover:text-gray-200"
+                >
+                  <Save size={13} />
+                </button>
+                <button
+                  onClick={() => deletePreset(preset.id)}
+                  title="Delete preset"
+                  className="rounded p-1 text-gray-500 hover:text-red-400"
+                >
+                  <Trash2 size={13} />
+                </button>
+              </>
+            )}
+          </div>
+        ))}
+      </div>
+      <div className="mt-2 flex gap-2">
+        <input
+          value={newName}
+          onChange={(e) => setNewName(e.target.value)}
+          onKeyDown={(e) => e.key === 'Enter' && createPreset()}
+          placeholder="New preset from the current settings"
+          className="flex-1 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+        />
+        <button
+          onClick={createPreset}
+          disabled={!newName.trim()}
+          className="flex items-center gap-1.5 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-50"
+        >
+          <Plus size={14} />
+          Save
+        </button>
+      </div>
+    </div>
+  )
+}

+ 303 - 0
src/renderer/src/components/settings/ScheduleSettings.tsx

@@ -0,0 +1,303 @@
+import { useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import { CalendarClock, Moon, Pencil, Play, Plus, Sun, Trash2 } from 'lucide-react'
+import clsx from 'clsx'
+import type { ScheduleAction, ScheduleRule, ScheduleTrigger } from '@shared/types'
+import { nextFireTime, parseTime } from '@shared/schedule'
+import ScreenSelect from '../common/ScreenSelect'
+import { useDisplayTargets } from '../../hooks/useDisplayTargets'
+import { screenLabel } from '../../utils/screens'
+import { useToast } from '../common/Toast'
+
+const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+
+type Draft = Omit<ScheduleRule, 'id'> & { id?: string }
+
+const EMPTY_DRAFT: Draft = {
+  name: '',
+  enabled: true,
+  trigger: { type: 'time', time: '08:00', days: [] },
+  action: { type: 'stop' },
+  screen: '*'
+}
+
+const inputClass = 'rounded-lg bg-white/5 px-2 py-1.5 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500'
+
+function describeTrigger(trigger: ScheduleTrigger): string {
+  if (trigger.type === 'theme') return trigger.theme === 'dark' ? 'When the system goes dark' : 'When the system goes light'
+  const days = trigger.days.length === 0 || trigger.days.length === 7
+    ? 'every day'
+    : [...trigger.days].sort().map((d) => DAY_LABELS[d]).join(', ')
+  return `${trigger.time}, ${days}`
+}
+
+export default function ScheduleSettings() {
+  const queryClient = useQueryClient()
+  const { showToast } = useToast()
+  const { targets, perScreen } = useDisplayTargets()
+  const [draft, setDraft] = useState<Draft | null>(null)
+  const [wallpaperFilter, setWallpaperFilter] = useState('')
+
+  const { data: rules = [] } = useQuery({
+    queryKey: ['schedule'],
+    queryFn: () => window.electronAPI.schedule.getAll()
+  })
+  const { data: wallpapers = [] } = useQuery({
+    queryKey: ['library-all'],
+    queryFn: () => window.electronAPI.library.getAll()
+  })
+  const { data: playlists = [] } = useQuery({
+    queryKey: ['playlists'],
+    queryFn: () => window.electronAPI.playlist.getAll()
+  })
+
+  const wallpaperTitle = (id: string) => wallpapers.find((w) => w.id === id)?.title ?? 'missing wallpaper'
+  const playlistTitle = (id: string) => playlists.find((p) => p.id === id)?.title ?? 'missing playlist'
+
+  function describeAction(action: ScheduleAction): string {
+    if (action.type === 'wallpaper') return `Show ${wallpaperTitle(action.wallpaperId)}`
+    if (action.type === 'playlist') return `Play ${playlistTitle(action.playlistId)}`
+    return 'Stop the wallpaper'
+  }
+
+  const selectedWallpaperId = draft?.action.type === 'wallpaper' ? draft.action.wallpaperId : ''
+  const wallpaperOptions = useMemo(() => {
+    const needle = wallpaperFilter.trim().toLowerCase()
+    const matches = wallpapers
+      .filter((w) => w.localPath && (!needle || w.title.toLowerCase().includes(needle)))
+      .slice(0, 100)
+    const selected = wallpapers.find((w) => w.id === selectedWallpaperId)
+    return selected && !matches.includes(selected) ? [selected, ...matches] : matches
+  }, [wallpapers, wallpaperFilter, selectedWallpaperId])
+
+  function refresh() {
+    queryClient.invalidateQueries({ queryKey: ['schedule'] })
+  }
+
+  async function save() {
+    if (!draft) return
+    if (draft.trigger.type === 'time' && !parseTime(draft.trigger.time)) {
+      showToast('Enter the time as HH:MM', { warning: true })
+      return
+    }
+    if (draft.action.type === 'wallpaper' && !draft.action.wallpaperId) {
+      showToast('Pick a wallpaper', { warning: true })
+      return
+    }
+    if (draft.action.type === 'playlist' && !draft.action.playlistId) {
+      showToast('Pick a playlist', { warning: true })
+      return
+    }
+    await window.electronAPI.schedule.save({ ...draft, name: draft.name.trim() || describeAction(draft.action) })
+    setDraft(null)
+    refresh()
+  }
+
+  async function toggle(rule: ScheduleRule) {
+    await window.electronAPI.schedule.save({ ...rule, enabled: !rule.enabled })
+    refresh()
+  }
+
+  async function remove(id: string) {
+    await window.electronAPI.schedule.delete(id)
+    refresh()
+  }
+
+  async function runNow(rule: ScheduleRule) {
+    try {
+      await window.electronAPI.schedule.run(rule.id)
+    } catch (err) {
+      showToast((err as Error).message, { warning: true, durationMs: 6000 })
+    }
+  }
+
+  function setTrigger(trigger: ScheduleTrigger) {
+    setDraft((d) => (d ? { ...d, trigger } : d))
+  }
+
+  function setAction(action: ScheduleAction) {
+    setDraft((d) => (d ? { ...d, action } : d))
+  }
+
+  return (
+    <div>
+      <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">Schedule</label>
+      <p className="mt-1 text-xs text-gray-600">
+        Switch wallpapers or playlists at set times, or when the system switches between dark and light. A time
+        that passed while WE Manager was closed still runs when it starts, only the latest one per screen.
+      </p>
+
+      <div className="mt-3 space-y-1.5">
+        {rules.map((rule) => {
+          const next = rule.enabled && rule.trigger.type === 'time' ? nextFireTime(rule, new Date()) : null
+          return (
+            <div
+              key={rule.id}
+              className={clsx('flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2', !rule.enabled && 'opacity-50')}
+            >
+              <input type="checkbox" checked={rule.enabled} onChange={() => toggle(rule)} className="accent-indigo-500" />
+              <div className="min-w-0 flex-1">
+                <p className="truncate text-sm text-gray-200">{rule.name}</p>
+                <p className="truncate text-xs text-gray-500">
+                  {describeTrigger(rule.trigger)} · {describeAction(rule.action)}
+                  {perScreen && ` · ${screenLabel(rule.screen)}`}
+                  {next && ` · next ${next.toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })}`}
+                </p>
+              </div>
+              <button onClick={() => runNow(rule)} title="Run now" className="rounded p-1 text-gray-500 hover:text-gray-200">
+                <Play size={13} />
+              </button>
+              <button onClick={() => setDraft(rule)} title="Edit" className="rounded p-1 text-gray-500 hover:text-gray-200">
+                <Pencil size={13} />
+              </button>
+              <button onClick={() => remove(rule.id)} title="Delete" className="rounded p-1 text-gray-500 hover:text-red-400">
+                <Trash2 size={13} />
+              </button>
+            </div>
+          )
+        })}
+      </div>
+
+      {!draft ? (
+        <button
+          onClick={() => setDraft(EMPTY_DRAFT)}
+          className="mt-2 flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 hover:bg-white/10"
+        >
+          <Plus size={14} />
+          Add schedule
+        </button>
+      ) : (
+        <div className="mt-2 space-y-3 rounded-lg border border-white/10 p-3">
+          <input
+            value={draft.name}
+            onChange={(e) => setDraft({ ...draft, name: e.target.value })}
+            placeholder="Name (optional)"
+            className={`${inputClass} w-full`}
+          />
+
+          <div className="flex flex-wrap items-center gap-2 text-sm text-gray-300">
+            <CalendarClock size={14} />
+            <select
+              value={draft.trigger.type === 'time' ? 'time' : draft.trigger.theme}
+              onChange={(e) =>
+                setTrigger(
+                  e.target.value === 'time'
+                    ? { type: 'time', time: '08:00', days: [] }
+                    : { type: 'theme', theme: e.target.value as 'dark' | 'light' }
+                )
+              }
+              className={inputClass}
+            >
+              <option value="time">At a time</option>
+              <option value="dark">When the system goes dark</option>
+              <option value="light">When the system goes light</option>
+            </select>
+            {draft.trigger.type === 'time' && (
+              <input
+                type="time"
+                value={draft.trigger.time}
+                onChange={(e) => draft.trigger.type === 'time' && setTrigger({ ...draft.trigger, time: e.target.value })}
+                className={inputClass}
+              />
+            )}
+            {draft.trigger.type === 'theme' && (draft.trigger.theme === 'dark' ? <Moon size={14} /> : <Sun size={14} />)}
+          </div>
+
+          {draft.trigger.type === 'time' && (
+            <div className="flex flex-wrap gap-1">
+              {DAY_LABELS.map((label, day) => {
+                const trigger = draft.trigger as Extract<ScheduleTrigger, { type: 'time' }>
+                const on = trigger.days.includes(day)
+                return (
+                  <button
+                    key={label}
+                    onClick={() =>
+                      setTrigger({ ...trigger, days: on ? trigger.days.filter((d) => d !== day) : [...trigger.days, day] })
+                    }
+                    className={clsx(
+                      'rounded px-2 py-1 text-xs',
+                      on ? 'bg-indigo-600 text-white' : 'bg-white/5 text-gray-400 hover:text-gray-200'
+                    )}
+                  >
+                    {label}
+                  </button>
+                )
+              })}
+              <span className="self-center text-xs text-gray-600">none picked = every day</span>
+            </div>
+          )}
+
+          <div className="flex flex-wrap items-center gap-2">
+            <select
+              value={draft.action.type}
+              onChange={(e) => {
+                const type = e.target.value as ScheduleAction['type']
+                setAction(
+                  type === 'wallpaper'
+                    ? { type, wallpaperId: '' }
+                    : type === 'playlist'
+                      ? { type, playlistId: playlists[0]?.id ?? '' }
+                      : { type }
+                )
+              }}
+              className={inputClass}
+            >
+              <option value="wallpaper">Show a wallpaper</option>
+              <option value="playlist">Play a playlist</option>
+              <option value="stop">Stop the wallpaper</option>
+            </select>
+            {perScreen && (
+              <ScreenSelect targets={targets} value={draft.screen} onChange={(screen) => setDraft({ ...draft, screen })} />
+            )}
+          </div>
+
+          {draft.action.type === 'playlist' && (
+            <select
+              value={draft.action.playlistId}
+              onChange={(e) => setAction({ type: 'playlist', playlistId: e.target.value })}
+              className={`${inputClass} w-full`}
+            >
+              {playlists.map((p) => (
+                <option key={p.id} value={p.id}>
+                  {p.title}
+                </option>
+              ))}
+            </select>
+          )}
+
+          {draft.action.type === 'wallpaper' && (
+            <div className="space-y-1.5">
+              <input
+                value={wallpaperFilter}
+                onChange={(e) => setWallpaperFilter(e.target.value)}
+                placeholder="Search the library"
+                className={`${inputClass} w-full`}
+              />
+              <select
+                value={draft.action.wallpaperId}
+                onChange={(e) => setAction({ type: 'wallpaper', wallpaperId: e.target.value })}
+                size={6}
+                className={`${inputClass} w-full`}
+              >
+                {wallpaperOptions.map((w) => (
+                  <option key={w.id} value={w.id}>
+                    {w.title}
+                  </option>
+                ))}
+              </select>
+            </div>
+          )}
+
+          <div className="flex gap-2">
+            <button onClick={save} className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
+              Save
+            </button>
+            <button onClick={() => setDraft(null)} className="rounded-lg bg-white/5 px-4 py-2 text-sm text-gray-300 hover:bg-white/10">
+              Cancel
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 12 - 39
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,7 +1,10 @@
 import { useState, useEffect } from 'react'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film, Globe, Shield, Bug, Pause } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film, Globe, Shield, Bug } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro, SteamIdentity } from '../../../../shared/types'
+import PerformanceSettings from './PerformanceSettings'
+import DisplaySettings from './DisplaySettings'
+import ScheduleSettings from './ScheduleSettings'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
   fedora: 'Fedora / Nobara / RHEL',
@@ -69,7 +72,6 @@ export default function SettingsView() {
   const [defaultAudioSensitivity, setDefaultAudioSensitivity] = useState<string>('100')
   const [defaultAudioSensitivitySaved, setDefaultAudioSensitivitySaved] = useState(false)
   const [disablePuppetAnimation, setDisablePuppetAnimation] = useState(false)
-  const [disableAnimations, setDisableAnimations] = useState(false)
 
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
@@ -94,7 +96,6 @@ export default function SettingsView() {
       setAmbientVolume(cfg.ambientVolume != null ? String(cfg.ambientVolume) : '')
       setDefaultAudioSensitivity(String(Math.round(cfg.defaultAudioSensitivity * 100)))
       setDisablePuppetAnimation(cfg.disablePuppetAnimation)
-      setDisableAnimations(cfg.disableAnimations)
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
@@ -281,12 +282,6 @@ export default function SettingsView() {
     await window.electronAPI.config.setDisablePuppetAnimation(newVal)
   }
 
-  async function handleDisableAnimationsToggle() {
-    const newVal = !disableAnimations
-    setDisableAnimations(newVal)
-    await window.electronAPI.config.setDisableAnimations(newVal)
-  }
-
   async function handleSteamIdentityChange(identity: SteamIdentity) {
     setSteamIdentityState(identity)
     await window.electronAPI.config.setSteamIdentity(identity)
@@ -569,6 +564,10 @@ export default function SettingsView() {
           </div>
         </div>
 
+        <DisplaySettings screens={screens} />
+
+        <ScheduleSettings />
+
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Audio
@@ -903,36 +902,10 @@ export default function SettingsView() {
           </div>
         </div>
 
-        <div>
-          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
-            Effects &amp; Animation
-          </label>
-          <p className="mt-1 text-xs text-gray-600">
-            Freezes every wallpaper's scene time - scripts, particles, effects and puppet meshes all stop
-            advancing and stay on whatever frame they were on. Only applies to wallpapers played after enabling
-            this; a wallpaper already running needs to be replayed to pick it up. To disable a specific effect
-            (bloom, fog, glow, etc) on one wallpaper instead of freezing everything everywhere, use the Effects
-            panel on that wallpaper's detail page.
-          </p>
-          <label className="mt-3 flex items-center gap-3 cursor-pointer">
-            <button
-              onClick={handleDisableAnimationsToggle}
-              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
-                disableAnimations ? 'bg-indigo-600' : 'bg-white/10'
-              }`}
-            >
-              <span
-                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
-                  disableAnimations ? 'translate-x-4' : 'translate-x-0.5'
-                }`}
-              />
-            </button>
-            <span className="flex items-center gap-2 text-sm text-gray-300">
-              <Pause size={16} />
-              Disable wallpaper animation
-            </span>
-          </label>
-        </div>
+        <PerformanceSettings
+          defaultFps={defaultFps ? Number(defaultFps) : null}
+          onDefaultFpsChanged={(fps) => setDefaultFps(fps != null ? String(fps) : '')}
+        />
 
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">

+ 4 - 4
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -21,7 +21,7 @@ import WorkshopCard from './WorkshopCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
 import DetailSidebar from '../common/DetailSidebar'
 import { useToast } from '../common/Toast'
-import type { WorkshopQueryType } from '@shared/types'
+import type { ScreenTarget, WorkshopQueryType } from '@shared/types'
 import clsx from 'clsx'
 import {
   WE_SHOW_ONLY,
@@ -425,9 +425,9 @@ export default function WorkshopBrowser({
   })
   const ignoredCreatorSet = useMemo(() => new Set(ignoredCreators), [ignoredCreators])
 
-  async function handlePlay(id: string) {
+  async function handlePlay(id: string, screen?: ScreenTarget) {
     try {
-      await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+      await window.electronAPI.wallpaper.apply({ wallpaperId: id, screen })
     } catch (err) {
       showToast((err as Error).message)
     }
@@ -1038,7 +1038,7 @@ export default function WorkshopBrowser({
                 old ? [...old, detailId] : [detailId]
               )
             }}
-            onPlay={() => handlePlay(detailId)}
+            onPlay={(screen) => handlePlay(detailId, screen)}
             onBrowseCreator={onBrowseCreator}
           />
         )}

+ 18 - 0
src/renderer/src/constants/engineFlags.ts

@@ -0,0 +1,18 @@
+import type { EngineFlags, FullscreenPauseMode } from '@shared/types'
+
+type BooleanFlag = Exclude<keyof EngineFlags, 'fullscreenPause'>
+
+export const BOOLEAN_FLAGS: { key: BooleanFlag; label: string; hint: string }[] = [
+  { key: 'disableParticles', label: 'Disable particles', hint: 'Skips particle systems (fog, sparks, rain), usually the biggest GPU cost' },
+  { key: 'disableAnimations', label: 'Freeze animations', hint: 'Stops scene time: scripts, particles, effects and puppets' },
+  { key: 'disableMouse', label: 'Ignore the mouse', hint: 'No parallax or cursor effects' },
+  { key: 'noAudioProcessing', label: 'No audio visualizer', hint: 'Stops capturing system audio for audio-reactive wallpapers' },
+  { key: 'automute', label: 'Mute when other apps play sound', hint: '' },
+  { key: 'silent', label: 'Always muted', hint: '' }
+]
+
+export const FULLSCREEN_PAUSE_OPTIONS: { value: FullscreenPauseMode; label: string }[] = [
+  { value: 'default', label: 'Pause behind fullscreen apps' },
+  { value: 'active-only', label: 'Pause only for the focused fullscreen app' },
+  { value: 'off', label: 'Never pause' }
+]

+ 38 - 0
src/renderer/src/hooks/useDisplayTargets.ts

@@ -0,0 +1,38 @@
+import { useEffect } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import type { ScreenTarget } from '@shared/types'
+
+/** The screens things can be put on; a single '*' in shared mode, where there is nothing to choose */
+export function useDisplayTargets(): { targets: ScreenTarget[]; perScreen: boolean } {
+  const queryClient = useQueryClient()
+  const { data: targets = ['*'] } = useQuery({
+    queryKey: ['display-targets'],
+    queryFn: () => window.electronAPI.wallpaper.getTargets(),
+    staleTime: 30_000
+  })
+
+  useEffect(() => {
+    return window.electronAPI.on.displayChanged(() => {
+      queryClient.invalidateQueries({ queryKey: ['display-targets'] })
+    })
+  }, [queryClient])
+
+  return { targets, perScreen: targets.length > 1 || targets[0] !== '*' }
+}
+
+export function useScreenAssignments() {
+  const queryClient = useQueryClient()
+  const query = useQuery({
+    queryKey: ['screen-assignments'],
+    queryFn: () => window.electronAPI.wallpaper.getAssignments(),
+    staleTime: 5_000
+  })
+
+  useEffect(() => {
+    return window.electronAPI.on.displayChanged(() => {
+      queryClient.invalidateQueries({ queryKey: ['screen-assignments'] })
+    })
+  }, [queryClient])
+
+  return query.data ?? []
+}

+ 3 - 0
src/renderer/src/utils/preview.ts

@@ -1,9 +1,12 @@
 interface Previewable {
   previewLocal?: string
   previewUrl?: string
+  customPreview?: string
+  useOriginalPreview?: boolean
 }
 
 export function getPreviewSrc(wallpaper: Previewable | undefined): string | undefined {
   if (!wallpaper) return undefined
+  if (wallpaper.customPreview && !wallpaper.useOriginalPreview) return `wallpaper://${wallpaper.customPreview}`
   return wallpaper.previewLocal ? `wallpaper://${wallpaper.previewLocal}` : wallpaper.previewUrl
 }

+ 5 - 0
src/renderer/src/utils/screens.ts

@@ -0,0 +1,5 @@
+import type { ScreenTarget } from '@shared/types'
+
+export function screenLabel(screen: ScreenTarget): string {
+  return screen === '*' ? 'All screens' : screen
+}

+ 26 - 0
src/shared/compat.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest'
+import { compatStatus, needsFreshLaunch } from './compat'
+
+describe('compatStatus', () => {
+  it('ranks a launch crash above everything else', () => {
+    expect(compatStatus({ freshLaunch: 'crash', hotswapCrash: true, runtimeCrashes: 3 })).toBe('crashes')
+  })
+
+  it('tells a hotswap-only problem from one still being tested', () => {
+    expect(compatStatus({ hotswapCrash: true, freshLaunch: 'ok' })).toBe('hotswap-only')
+    expect(compatStatus({ hotswapCrash: true })).toBe('hotswap-untested')
+  })
+
+  it('reports runtime crashes and is fine otherwise', () => {
+    expect(compatStatus({ runtimeCrashes: 1 })).toBe('unstable')
+    expect(compatStatus({ note: 'looks odd' })).toBe('ok')
+    expect(compatStatus(undefined)).toBe('ok')
+  })
+})
+
+describe('needsFreshLaunch', () => {
+  it('is only needed after a hotswap crash', () => {
+    expect(needsFreshLaunch({ hotswapCrash: true, freshLaunch: 'ok' })).toBe(true)
+    expect(needsFreshLaunch({ runtimeCrashes: 2 })).toBe(false)
+  })
+})

+ 22 - 0
src/shared/compat.ts

@@ -0,0 +1,22 @@
+import type { CompatStatus, WallpaperCompat } from './types'
+
+export function compatStatus(compat: WallpaperCompat | undefined): CompatStatus {
+  if (!compat) return 'ok'
+  if (compat.freshLaunch === 'crash') return 'crashes'
+  if (compat.hotswapCrash) return compat.freshLaunch === 'ok' ? 'hotswap-only' : 'hotswap-untested'
+  if (compat.runtimeCrashes) return 'unstable'
+  return 'ok'
+}
+
+/** Hot-swapping into it crashed the engine but a fresh launch was fine, so always launch it fresh */
+export function needsFreshLaunch(compat: WallpaperCompat | undefined): boolean {
+  return !!compat?.hotswapCrash
+}
+
+export const COMPAT_LABELS: Record<CompatStatus, string> = {
+  ok: 'No known problems',
+  crashes: 'Crashes on launch',
+  'hotswap-only': 'Crashes when switched to, works on a fresh launch',
+  'hotswap-untested': 'Crashed when switched to',
+  unstable: 'Crashed while running'
+}

+ 34 - 0
src/shared/engineFlags.test.ts

@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { engineFlagArgs, hasEngineFlags, resolveEngineFlags } from './engineFlags'
+
+describe('resolveEngineFlags', () => {
+  it('lets a wallpaper override the global flags, unset ones fall through', () => {
+    const resolved = resolveEngineFlags(
+      { disableParticles: true, fullscreenPause: 'off' },
+      { disableParticles: false, disableMouse: true, fullscreenPause: undefined }
+    )
+    expect(resolved).toEqual({ disableParticles: false, disableMouse: true, fullscreenPause: 'off' })
+  })
+})
+
+describe('engineFlagArgs', () => {
+  it('maps flags to linux-wallpaperengine arguments', () => {
+    expect(
+      engineFlagArgs({ disableParticles: true, disableMouse: false, fullscreenPause: 'active-only', silent: true })
+    ).toEqual(['--disable-particles', '--fullscreen-pause-only-active', '--silent'])
+    expect(engineFlagArgs({ fullscreenPause: 'off' })).toEqual(['--no-fullscreen-pause'])
+    expect(engineFlagArgs({ fullscreenPause: 'default' })).toEqual([])
+  })
+
+  it('leaves disableAnimations to the caller, it needs its own capability probe', () => {
+    expect(engineFlagArgs({ disableAnimations: true })).toEqual([])
+  })
+})
+
+describe('hasEngineFlags', () => {
+  it('is false for nothing or only unset values', () => {
+    expect(hasEngineFlags(undefined)).toBe(false)
+    expect(hasEngineFlags({ disableMouse: undefined })).toBe(false)
+    expect(hasEngineFlags({ disableMouse: false })).toBe(true)
+  })
+})

+ 62 - 0
src/shared/engineFlags.ts

@@ -0,0 +1,62 @@
+import type { EngineFlagPreset, EngineFlags } from './types'
+
+export const BUILT_IN_PRESETS: EngineFlagPreset[] = [
+  {
+    id: 'builtin-battery',
+    name: 'Battery saver',
+    builtIn: true,
+    fps: 30,
+    flags: {
+      disableParticles: true,
+      disableMouse: true,
+      noAudioProcessing: true,
+      fullscreenPause: 'default',
+      automute: true
+    }
+  },
+  {
+    id: 'builtin-quality',
+    name: 'Full quality',
+    builtIn: true,
+    fps: null,
+    flags: {
+      disableParticles: false,
+      disableMouse: false,
+      disableAnimations: false,
+      noAudioProcessing: false,
+      fullscreenPause: 'default',
+      automute: false,
+      silent: false
+    }
+  },
+  {
+    id: 'builtin-always-on',
+    name: 'Never pause',
+    builtIn: true,
+    flags: { fullscreenPause: 'off' }
+  }
+]
+
+export function resolveEngineFlags(global: EngineFlags, wallpaper?: EngineFlags): EngineFlags {
+  const resolved: EngineFlags = { ...global }
+  for (const [key, value] of Object.entries(wallpaper ?? {})) {
+    if (value !== undefined) (resolved as Record<string, unknown>)[key] = value
+  }
+  return resolved
+}
+
+export function engineFlagArgs(flags: EngineFlags): string[] {
+  const args: string[] = []
+  if (flags.disableParticles) args.push('--disable-particles')
+  if (flags.disableMouse) args.push('--disable-mouse')
+  if (flags.noAudioProcessing) args.push('--no-audio-processing')
+  if (flags.fullscreenPause === 'off') args.push('--no-fullscreen-pause')
+  if (flags.fullscreenPause === 'active-only') args.push('--fullscreen-pause-only-active')
+  if (flags.automute) args.push('--automute')
+  if (flags.silent) args.push('--silent')
+  return args
+}
+
+export function hasEngineFlags(flags: EngineFlags | undefined): boolean {
+  return !!flags && Object.values(flags).some((v) => v !== undefined)
+}

+ 21 - 2
src/shared/ipc-channels.ts

@@ -15,6 +15,7 @@ export const IpcChannels = {
   STEAM_VOTE: 'steam:vote',
   STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
   STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
+  STEAM_CHECK_VOTE: 'steam:check-vote',
   STEAM_REDOWNLOAD: 'steam:redownload',
   STEAM_GET_AUTHOR_INFO: 'steam:get-author-info',
 
@@ -46,6 +47,19 @@ export const IpcChannels = {
   WALLPAPER_APPLY: 'wallpaper:apply',
   WALLPAPER_GET_ACTIVE: 'wallpaper:get-active',
   WALLPAPER_DETECT_ENV: 'wallpaper:detect-env',
+  WALLPAPER_GET_ASSIGNMENTS: 'wallpaper:get-assignments',
+  WALLPAPER_GET_TARGETS: 'wallpaper:get-targets',
+  WALLPAPER_STOP: 'wallpaper:stop',
+  WALLPAPER_TEST_LAUNCH: 'wallpaper:test-launch',
+  WALLPAPER_GENERATE_THUMBNAIL: 'wallpaper:generate-thumbnail',
+  WALLPAPER_PICK_THUMBNAIL: 'wallpaper:pick-thumbnail',
+  WALLPAPER_DELETE_THUMBNAIL: 'wallpaper:delete-thumbnail',
+
+  // Scheduling
+  SCHEDULE_GET_ALL: 'schedule:get-all',
+  SCHEDULE_SAVE: 'schedule:save',
+  SCHEDULE_DELETE: 'schedule:delete',
+  SCHEDULE_RUN: 'schedule:run',
 
   // Configuration
   CONFIG_GET: 'config:get',
@@ -61,7 +75,9 @@ export const IpcChannels = {
   CONFIG_SET_AMBIENT_VOLUME: 'config:set-ambient-volume',
   CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY: 'config:set-default-audio-sensitivity',
   CONFIG_SET_DISABLE_PUPPET_ANIMATION: 'config:set-disable-puppet-animation',
-  CONFIG_SET_DISABLE_ANIMATIONS: 'config:set-disable-animations',
+  CONFIG_SET_ENGINE_FLAGS: 'config:set-engine-flags',
+  CONFIG_SET_ENGINE_PRESETS: 'config:set-engine-presets',
+  CONFIG_SET_DISPLAY_MODE: 'config:set-display-mode',
   CONFIG_SET_STEAM_IDENTITY: 'config:set-steam-identity',
   CONFIG_IGNORE_CREATOR: 'config:ignore-creator',
   CONFIG_UNIGNORE_CREATOR: 'config:unignore-creator',
@@ -136,7 +152,10 @@ export const IpcChannels = {
   EVENT_BACKUP_PROGRESS: 'event:backup-progress',
   EVENT_PLAYLIST_STATE_CHANGED: 'event:playlist-state-changed',
   EVENT_VOTED_IDS_CHANGED: 'event:voted-ids-changed',
-  EVENT_LIBRARY_CHANGED: 'event:library-changed'
+  EVENT_LIBRARY_CHANGED: 'event:library-changed',
+  EVENT_DISPLAY_CHANGED: 'event:display-changed',
+  EVENT_WALLPAPER_CRASHED: 'event:wallpaper-crashed',
+  EVENT_SCHEDULE_FIRED: 'event:schedule-fired'
 } as const
 
 export type IpcChannel = (typeof IpcChannels)[keyof typeof IpcChannels]

+ 73 - 0
src/shared/schedule.test.ts

@@ -0,0 +1,73 @@
+import { describe, expect, it } from 'vitest'
+import type { ScheduleRule } from './types'
+import { dueTimeRules, fireTimesBetween, nextFireTime, parseTime } from './schedule'
+
+function rule(id: string, time: string, extra: Partial<ScheduleRule> = {}): ScheduleRule {
+  return {
+    id,
+    name: id,
+    enabled: true,
+    trigger: { type: 'time', time, days: [] },
+    action: { type: 'stop' },
+    screen: '*',
+    ...extra
+  }
+}
+
+// 2026-09-21 is a Monday
+const at = (day: number, hours: number, minutes = 0) => new Date(2026, 8, day, hours, minutes)
+
+describe('parseTime', () => {
+  it('reads HH:MM and rejects anything else', () => {
+    expect(parseTime('08:30')).toEqual({ hours: 8, minutes: 30 })
+    expect(parseTime('8:05')).toEqual({ hours: 8, minutes: 5 })
+    expect(parseTime('24:00')).toBeNull()
+    expect(parseTime('12:60')).toBeNull()
+    expect(parseTime('noon')).toBeNull()
+  })
+})
+
+describe('fireTimesBetween', () => {
+  it('excludes the start and includes the end of the window', () => {
+    const r = rule('a', '08:00')
+    expect(fireTimesBetween(r, at(21, 8), at(22, 8))).toEqual([at(22, 8)])
+  })
+
+  it('only fires on the picked days', () => {
+    const weekdays = rule('a', '09:00', { trigger: { type: 'time', time: '09:00', days: [1, 2, 3, 4, 5] } })
+    // Saturday the 26th and Sunday the 27th are skipped
+    expect(fireTimesBetween(weekdays, at(25, 10), at(28, 10))).toEqual([at(28, 9)])
+  })
+
+  it('ignores theme rules', () => {
+    const theme = rule('a', '', { trigger: { type: 'theme', theme: 'dark' } })
+    expect(fireTimesBetween(theme, at(21, 0), at(28, 0))).toEqual([])
+  })
+})
+
+describe('dueTimeRules', () => {
+  it('keeps only the latest rule per screen after a long gap', () => {
+    const morning = rule('morning', '08:00')
+    const evening = rule('evening', '20:00')
+    expect(dueTimeRules([morning, evening], at(21, 19), at(22, 9)).map((r) => r.id)).toEqual(['morning'])
+  })
+
+  it('treats screens separately, but an all-screens rule replaces older per-screen ones', () => {
+    const left = rule('left', '07:00', { screen: 'DP-1' })
+    const right = rule('right', '07:30', { screen: 'DP-2' })
+    const all = rule('all', '07:15')
+    expect(dueTimeRules([left, right], at(21, 6), at(21, 8)).map((r) => r.id).sort()).toEqual(['left', 'right'])
+    expect(dueTimeRules([left, all, right], at(21, 6), at(21, 8)).map((r) => r.id).sort()).toEqual(['all', 'right'])
+  })
+
+  it('skips disabled rules', () => {
+    expect(dueTimeRules([rule('a', '08:00', { enabled: false })], at(21, 7), at(21, 9))).toEqual([])
+  })
+})
+
+describe('nextFireTime', () => {
+  it('finds the next matching day', () => {
+    const sundays = rule('a', '10:00', { trigger: { type: 'time', time: '10:00', days: [0] } })
+    expect(nextFireTime(sundays, at(21, 12))).toEqual(at(27, 10))
+  })
+})

+ 56 - 0
src/shared/schedule.ts

@@ -0,0 +1,56 @@
+import type { ScheduleRule } from './types'
+
+export function parseTime(time: string): { hours: number; minutes: number } | null {
+  const m = /^(\d{1,2}):(\d{2})$/.exec(time.trim())
+  if (!m) return null
+  const hours = Number(m[1])
+  const minutes = Number(m[2])
+  if (hours > 23 || minutes > 59) return null
+  return { hours, minutes }
+}
+
+/** Every moment in (from, to] a time rule was meant to fire at, oldest first */
+export function fireTimesBetween(rule: ScheduleRule, from: Date, to: Date): Date[] {
+  if (rule.trigger.type !== 'time') return []
+  const time = parseTime(rule.trigger.time)
+  if (!time) return []
+
+  const days = rule.trigger.days.length > 0 ? rule.trigger.days : [0, 1, 2, 3, 4, 5, 6]
+  const result: Date[] = []
+  const day = new Date(from.getFullYear(), from.getMonth(), from.getDate())
+  while (day.getTime() <= to.getTime()) {
+    const at = new Date(day.getFullYear(), day.getMonth(), day.getDate(), time.hours, time.minutes)
+    if (days.includes(at.getDay()) && at > from && at <= to) result.push(at)
+    day.setDate(day.getDate() + 1)
+  }
+  return result
+}
+
+/**
+ * The time rules that came due in (from, to], oldest first and at most one per screen: when several
+ * did (the app was closed or the machine asleep across more than one), only the most recent still
+ * matters. A '*' rule stands for every screen, so it replaces all older ones, and a later rule for
+ * one screen then takes over just that screen.
+ */
+export function dueTimeRules(rules: ScheduleRule[], from: Date, to: Date): ScheduleRule[] {
+  const fired: { rule: ScheduleRule; at: number }[] = []
+  for (const rule of rules) {
+    if (!rule.enabled) continue
+    const times = fireTimesBetween(rule, from, to)
+    if (times.length > 0) fired.push({ rule, at: times[times.length - 1].getTime() })
+  }
+  fired.sort((a, b) => a.at - b.at)
+
+  const latest = new Map<string, ScheduleRule>()
+  for (const { rule } of fired) {
+    if (rule.screen === '*') latest.clear()
+    else latest.delete(rule.screen)
+    latest.set(rule.screen, rule)
+  }
+  return [...latest.values()]
+}
+
+export function nextFireTime(rule: ScheduleRule, now: Date): Date | null {
+  const inAWeek = new Date(now.getTime() + 8 * 24 * 60 * 60 * 1000)
+  return fireTimesBetween(rule, now, inAWeek)[0] ?? null
+}

+ 91 - 1
src/shared/types.ts

@@ -111,6 +111,94 @@ export interface WallpaperMeta {
    * Launch-time only, so changing this always restarts the process.
    */
   customArgs?: string
+  /** Launch-only engine flags, each one unset here falls back to the global setting */
+  engineFlags?: EngineFlags
+  compat?: WallpaperCompat
+  /** Thumbnail made by the app (a screenshot of the running wallpaper, or an image the user picked) */
+  customPreview?: string
+  /** Show the Workshop/project preview even though a custom one exists */
+  useOriginalPreview?: boolean
+}
+
+export type FullscreenPauseMode = 'default' | 'off' | 'active-only'
+
+/** linux-wallpaperengine flags that only take effect on a fresh launch */
+export interface EngineFlags {
+  disableParticles?: boolean
+  disableMouse?: boolean
+  disableAnimations?: boolean
+  noAudioProcessing?: boolean
+  fullscreenPause?: FullscreenPauseMode
+  automute?: boolean
+  silent?: boolean
+}
+
+export interface EngineFlagPreset {
+  id: string
+  name: string
+  flags: EngineFlags
+  /** Applied as the default FPS (globally) or the FPS override (per wallpaper); undefined leaves it alone */
+  fps?: number | null
+  builtIn?: boolean
+}
+
+export type CrashPhase = 'launch' | 'hotswap' | 'runtime'
+
+export interface WallpaperCrash {
+  at: number
+  phase: CrashPhase
+  screen: string
+  log: string[]
+}
+
+export interface WallpaperCompat {
+  /** The engine died right after this wallpaper was hot-swapped in */
+  hotswapCrash?: boolean
+  /** Outcome of the last fresh launch that was watched for a crash */
+  freshLaunch?: 'ok' | 'crash'
+  runtimeCrashes?: number
+  lastCrash?: WallpaperCrash
+  note?: string
+}
+
+export type CompatStatus = 'ok' | 'crashes' | 'hotswap-only' | 'hotswap-untested' | 'unstable'
+
+export interface CrashEvent {
+  wallpaperId: string
+  title: string
+  screen: string
+  phase: CrashPhase
+  /** What the app did about it, for the toast */
+  outcome: string
+}
+
+/** '*' is the one engine process covering every screen */
+export type ScreenTarget = string
+
+export type DisplayMode = 'shared' | 'per-screen'
+
+export interface ScreenAssignment {
+  screen: ScreenTarget
+  wallpaperId: string
+}
+
+export type ScheduleAction =
+  | { type: 'wallpaper'; wallpaperId: string }
+  | { type: 'playlist'; playlistId: string }
+  | { type: 'stop' }
+
+export type ScheduleTrigger =
+  | { type: 'time'; time: string; days: number[] }
+  | { type: 'theme'; theme: 'dark' | 'light' }
+
+export interface ScheduleRule {
+  id: string
+  name: string
+  enabled: boolean
+  trigger: ScheduleTrigger
+  action: ScheduleAction
+  /** Screen name in per-screen mode, '*' for every screen */
+  screen: ScreenTarget
 }
 
 export interface LweSceneObject {
@@ -201,7 +289,8 @@ export interface CreatorWorkshopQueryParams {
 
 export interface ApplyWallpaperOptions {
   wallpaperId: string
-  displayIndex?: number
+  /** Per-screen mode only: a screen name, or '*' for every screen */
+  screen?: ScreenTarget
   backend?: WallpaperBackend
 }
 
@@ -291,6 +380,7 @@ export interface Playlist {
 }
 
 export interface PlaylistPlaybackState {
+  screen: ScreenTarget
   playlistId: string | null
   currentItemId: string | null
   currentIndex: number

+ 15 - 0
vitest.config.ts

@@ -0,0 +1,15 @@
+import { resolve } from 'path'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      '@shared': resolve('src/shared'),
+      '@renderer': resolve('src/renderer/src')
+    }
+  },
+  test: {
+    include: ['src/**/*.test.ts'],
+    environment: 'node'
+  }
+})