Procházet zdrojové kódy

added support for wallpaper slow up / speed down

UwU před 1 měsícem
rodič
revize
727f6ad691

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "we-manager",
-  "version": "0.3.1",
+  "version": "0.3.2",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
   "author": "UwU <260361707+WallpaperEngineLover@users.noreply.github.com>",

+ 2 - 0
src/main/ipc/lwe.handlers.ts

@@ -66,6 +66,8 @@ export function registerLweHandlers(win: BrowserWindow): void {
         zoom?: number
         disableParallax?: boolean
         cornerColor?: string
+        speed?: number
+        propertyOverrides?: Record<string, string>
       }
     ) => {
       return { ok: hotswapLweSettings(options) }

+ 105 - 15
src/main/services/lwe.service.ts

@@ -623,6 +623,7 @@ function buildLweArgs(
     zoom?: number
     disableParallax?: boolean
     cornerColor?: string
+    speed?: number
   }
 ): string[] {
   const args: string[] = []
@@ -643,6 +644,7 @@ function buildLweArgs(
   if (options.zoom !== undefined) args.push('--zoom', String(options.zoom))
   if (options.disableParallax) args.push('--disable-parallax')
   if (options.cornerColor) args.push('--corner-color', options.cornerColor)
+  if (options.speed !== undefined) args.push('--speed', String(options.speed))
   if ((options.disabledObjects?.length || options.enabledObjects?.length) && supportsObjectFlags()) {
     for (const id of options.disabledObjects ?? []) args.push('--disable-object', id)
     for (const id of options.enabledObjects ?? []) args.push('--enable-object', id)
@@ -659,13 +661,79 @@ function getControlFilePath(): string {
   return path.join(getXdgRuntimeDir(), 'lwe-control')
 }
 
+/**
+ * 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.
+ */
+function getPidFilePath(): 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)
+  }
+}
+
+function clearPidFile(): void {
+  try {
+    fs.rmSync(getPidFilePath(), { force: true })
+  } catch { /* best effort */ }
+}
+
+/** True if `pid` is alive and actually looks like a linux-wallpaperengine process (not a recycled PID). */
+function isLweProcess(pid: number): boolean {
+  try {
+    process.kill(pid, 0)
+  } catch {
+    return false
+  }
+
+  try {
+    const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8')
+    return cmdline.includes(LWE_BINARY)
+  } catch {
+    // /proc unreadable (e.g. owned by another user) - we already know it's alive, best effort
+    return true
+  }
+}
+
+/**
+ * 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
+  }
+
+  let stored: number | null = null
+  try {
+    stored = parseInt(fs.readFileSync(getPidFilePath(), 'utf8').trim(), 10)
+  } catch {
+    return null
+  }
+
+  if (!Number.isInteger(stored) || !isLweProcess(stored)) {
+    clearPidFile()
+    return null
+  }
+
+  return stored
+}
+
 /** Hot-reload: write new wallpaper path to control file and send SIGUSR1. */
 function hotReloadLwe(wallpaperPath: string): boolean {
-  if (!activeProcess || activeProcess.exitCode !== null) return false
+  const pid = resolveActivePid()
+  if (pid === null) return false
 
   try {
     fs.writeFileSync(getControlFilePath(), wallpaperPath + '\n')
-    activeProcess.kill('SIGUSR1')
+    process.kill(pid, 'SIGUSR1')
     console.log('[LWE] Hot-reload signal sent for:', wallpaperPath)
     return true
   } catch (err) {
@@ -687,7 +755,10 @@ function hotReloadLwe(wallpaperPath: string): boolean {
  * how volume/xray are global rather than per-screen here. disableParallax is a straight passthrough
  * to the engine's --disable-parallax setting, which every parallax-capable scene object reads live.
  * cornerColor is a hex "RRGGBB"/"RRGGBBAA" string, only visible where the engine's clamp mode is
- * border (the default).
+ * border (the default). speed is a positive multiplier, live like scaling/zoom/parallax/
+ * cornerColor. propertyOverrides (WE customize sliders/checkboxes/combos) trigger the same
+ * lightweight reload as layers instead of a live setter, since they're baked into the scene
+ * graph at parse time - every key in the map is sent, not just changed ones.
  */
 export function hotswapLweSettings(options: {
   disabledObjects?: string[]
@@ -698,8 +769,11 @@ export function hotswapLweSettings(options: {
   zoom?: number
   disableParallax?: boolean
   cornerColor?: string
+  speed?: number
+  propertyOverrides?: Record<string, string>
 }): boolean {
-  if (!activeProcess || activeProcess.exitCode !== null) return false
+  const pid = resolveActivePid()
+  if (pid === null) return false
 
   const lines: string[] = []
   if (options.disabledObjects !== undefined || options.enabledObjects !== undefined) {
@@ -713,11 +787,15 @@ export function hotswapLweSettings(options: {
   if (options.zoom !== undefined) lines.push(`zoom=${options.zoom}`)
   if (options.disableParallax !== undefined) lines.push(`disable-parallax=${options.disableParallax ? 'on' : 'off'}`)
   if (options.cornerColor !== undefined) lines.push(`corner-color=${options.cornerColor}`)
+  if (options.speed !== undefined) lines.push(`speed=${options.speed}`)
+  for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
+    lines.push(`property=${name}=${value}`)
+  }
   if (lines.length === 0) return false
 
   try {
     fs.writeFileSync(getControlFilePath(), lines.join('\n') + '\n')
-    activeProcess.kill('SIGUSR1')
+    process.kill(pid, 'SIGUSR1')
     console.log('[LWE] Hotswap settings sent:', lines.join(' '))
     return true
   } catch (err) {
@@ -741,21 +819,26 @@ export function launchLweAsync(
     zoom?: number
     disableParallax?: boolean
     cornerColor?: string
+    speed?: number
   } = {}
 ): 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 the new wallpaper wants it off. Scaling/zoom/parallax/cornerColor do have launch
-  // flags, but a hot-reload doesn't restart the process (so launch args never get re-read) -
-  // 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.
+  // even when the new wallpaper wants it off. Scaling/zoom/parallax/cornerColor/speed do have
+  // launch flags, but a hot-reload doesn't restart the process (so launch args never get
+  // re-read) - 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.
+  // propertyOverrides is pushed the same way - the engine only reapplies whatever's currently in
+  // its settings map on reload, which otherwise would still be the previous wallpaper's values.
   if (isLweRunning() && hotReloadLwe(wallpaperPath)) {
     if (options.xrayFullReveal !== undefined) hotswapLweSettings({ xray: options.xrayFullReveal })
     hotswapLweSettings({
       scaling: options.scalingMode ?? 'default',
       zoom: options.zoom ?? 1,
       disableParallax: options.disableParallax ?? false,
-      cornerColor: options.cornerColor ?? '000000'
+      cornerColor: options.cornerColor ?? '000000',
+      speed: options.speed ?? 1,
+      propertyOverrides: options.propertyOverrides ?? {}
     })
     return Promise.resolve()
   }
@@ -778,6 +861,7 @@ export function launchLweAsync(
       detached: true
     })
     activeProcess = child
+    if (child.pid !== undefined) writePidFile(child.pid)
 
     const stderrChunks: Buffer[] = []
     if (child.stdout) {
@@ -801,7 +885,10 @@ export function launchLweAsync(
     // 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
+      if (activeProcess === child) {
+        activeProcess = null
+        clearPidFile()
+      }
     }
 
     child.on('exit', (code) => {
@@ -960,21 +1047,24 @@ export async function listLweProperties(wallpaperPath: string): Promise<LwePrope
 }
 
 export function stopLwe(): void {
-  if (activeProcess) {
+  const pid = resolveActivePid()
+  if (pid !== null) {
     try {
-      activeProcess.kill('SIGTERM')
+      process.kill(pid, 'SIGTERM')
     } catch { /* already dead */ }
-    activeProcess = null
   }
+  activeProcess = null
+  clearPidFile()
 }
 
 export function isLweRunning(): boolean {
-  return activeProcess !== null && activeProcess.exitCode === null
+  return resolveActivePid() !== null
 }
 
 /** 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()
 
   try {
     const { stdout } = await execFileAsync('pkill', ['-9', '-f', '-c', LWE_BINARY])

+ 2 - 1
src/main/services/wallpaper.service.ts

@@ -229,7 +229,8 @@ export async function applyWallpaperMeta(
       zoom: wallpaper.zoom,
       // Explicit even when off, same reasoning as xrayFullReveal above.
       disableParallax: wallpaper.disableParallax ?? false,
-      cornerColor: wallpaper.cornerColor
+      cornerColor: wallpaper.cornerColor,
+      speed: wallpaper.playbackSpeed
     })
     return wallpaper.localPath
   }

+ 2 - 0
src/preload/index.ts

@@ -237,6 +237,8 @@ const api = {
       zoom?: number
       disableParallax?: boolean
       cornerColor?: string
+      speed?: number
+      propertyOverrides?: Record<string, string>
     }): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options),
     listProperties: (wallpaperPath: string): Promise<LweProperty[]> =>

+ 52 - 8
src/renderer/src/components/common/DetailSidebar.tsx

@@ -28,7 +28,8 @@ import {
   ZoomIn,
   ZoomOut,
   Move3d,
-  Palette
+  Palette,
+  Gauge
 } from 'lucide-react'
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
@@ -327,10 +328,9 @@ export default function DetailSidebar({
   )
   const propertyOverrides = libraryMeta?.propertyOverrides ?? {}
 
-  // Persists a settings patch and, if this wallpaper is currently playing, pushes it live.
-  // Layers/volume go through the lightweight control-file hotswap (no restart); if that
-  // fails (e.g. an older linux-wallpaperengine build without the extended protocol), fall
-  // back to a full stop+relaunch so the change still takes effect one way or another.
+  // Persists a settings patch and, if this wallpaper is currently playing, pushes it live via
+  // the control-file hotswap (no process restart). Falls back to a full stop+relaunch if the
+  // push fails, e.g. an older linux-wallpaperengine build without the extended protocol.
   async function persistAndMaybeRelaunch(patch: Partial<WallpaperMeta>) {
     const updated = await window.electronAPI.library.update(id, patch)
     queryClient.setQueryData(['library-item', id], updated)
@@ -344,7 +344,9 @@ export default function DetailSidebar({
         scaling: patch.scalingMode,
         zoom: patch.zoom,
         disableParallax: patch.disableParallax,
-        cornerColor: patch.cornerColor
+        cornerColor: patch.cornerColor,
+        speed: patch.playbackSpeed,
+        propertyOverrides: patch.propertyOverrides
       })
       if (!ok) {
         await window.electronAPI.lwe.stop()
@@ -374,8 +376,6 @@ export default function DetailSidebar({
     }
   }
 
-  // --set-property is launch-time only (no hotswap support for it yet), so this always
-  // falls through to persistAndMaybeRelaunch's full stop+relaunch fallback when active.
   async function commitProperty(name: string, value: string) {
     if (committingProperty) return
     setCommittingProperty(name)
@@ -440,6 +440,20 @@ export default function DetailSidebar({
     }
   }
 
+  const [speedDraft, setSpeedDraft] = useState<number | null>(null)
+  const [isCommittingSpeed, setIsCommittingSpeed] = useState(false)
+  const speedPercent = speedDraft ?? Math.round((libraryMeta?.playbackSpeed ?? 1) * 100)
+
+  async function commitSpeed(percent: number) {
+    setIsCommittingSpeed(true)
+    try {
+      await persistAndMaybeRelaunch({ playbackSpeed: percent / 100 })
+    } finally {
+      setSpeedDraft(null)
+      setIsCommittingSpeed(false)
+    }
+  }
+
   const [isCommittingCornerColor, setIsCommittingCornerColor] = useState(false)
   const cornerColor = libraryMeta?.cornerColor ?? '#000000'
 
@@ -677,6 +691,36 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
+          <div className="border-t border-white/5 pt-3">
+            <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
+              <Gauge size={12} />
+              <span className="flex-1 text-left">Playback speed</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {isCommittingSpeed && <Loader2 size={11} className="animate-spin" />}
+              <span className="normal-case text-gray-400">{speedPercent}%</span>
+            </div>
+            <input
+              type="range"
+              min={10}
+              max={200}
+              step={5}
+              value={speedPercent}
+              disabled={isCommittingSpeed}
+              onChange={(e) => setSpeedDraft(Number(e.target.value))}
+              onMouseUp={(e) => commitSpeed(Number(e.currentTarget.value))}
+              onTouchEnd={(e) => commitSpeed(Number(e.currentTarget.value))}
+              onKeyUp={(e) => commitSpeed(Number(e.currentTarget.value))}
+              title="Slows down or speeds up particles, animated effects and scripts (100% = normal speed)"
+              className="w-full accent-indigo-500 disabled:opacity-50"
+            />
+          </div>
+        )}
+
         {localPath && lweInstalled && libraryMeta?.type && libraryMeta.type !== 'application' && (
           <div className="border-t border-white/5 pt-3">
             <label className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">

+ 5 - 0
src/shared/types.ts

@@ -62,6 +62,11 @@ export interface WallpaperMeta {
    * "RRGGBB"/"RRGGBBAA" hex string, applied via linux-wallpaperengine --corner-color. Default: black.
    */
   cornerColor?: string
+  /**
+   * Global playback speed multiplier for animations/particles/effects, applied via
+   * linux-wallpaperengine --speed (1 = normal speed, less than 1 = slower)
+   */
+  playbackSpeed?: number
   backedUp?: boolean
   /** Set by a "check unavailable" scan: true if the item has been removed from the Steam Workshop */
   unavailable?: boolean