Forráskód Böngészése

implemented support for audio vizualized wallpapers

UwU 1 hónapja
szülő
commit
fadeded2d8

+ 10 - 2
src/main/ipc/config.handlers.ts

@@ -34,7 +34,9 @@ import {
   getAudioScreen,
   setAudioScreen,
   getAmbientVolume,
-  setAmbientVolume
+  setAmbientVolume,
+  getDefaultAudioSensitivity,
+  setDefaultAudioSensitivity
 } from '../services/config.service'
 import { getDefaultWorkshopPath } from '../utils/paths'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
@@ -65,7 +67,8 @@ export function registerConfigHandlers(): void {
     autostartPlaylistId: getAutostartPlaylistId(),
     killLweOnQuit: getKillLweOnQuit(),
     audioScreen: getAudioScreen(),
-    ambientVolume: getAmbientVolume()
+    ambientVolume: getAmbientVolume(),
+    defaultAudioSensitivity: getDefaultAudioSensitivity()
   }))
 
   ipcMain.handle(IpcChannels.CONFIG_GET_AUTOSTART_SUPPORTED, () => isAutostartSupported())
@@ -131,6 +134,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, (_e, multiplier: number) => {
+    setDefaultAudioSensitivity(multiplier)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_LWE_REPO, (_e, url: string | null, branch: string | null) => {
     setLweRepo(url, branch)
     return { ok: true }

+ 13 - 1
src/main/ipc/lwe.handlers.ts

@@ -1,6 +1,6 @@
 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 } from '../services/lwe.service'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects } from '../services/lwe.service'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 import { getConnectedScreens } from '../utils/platform'
 
@@ -71,6 +71,7 @@ export function registerLweHandlers(win: BrowserWindow): void {
         propertyOverrides?: Record<string, string>
         audioScreen?: string
         ambientVolume?: number
+        audioSensitivity?: Record<string, number>
       }
     ) => {
       return { ok: hotswapLweSettings(options) }
@@ -91,4 +92,15 @@ export function registerLweHandlers(win: BrowserWindow): void {
       return []
     }
   })
+
+  ipcMain.handle(IpcChannels.LWE_LIST_AUDIO_OBJECTS, async (_e, wallpaperPath: string) => {
+    // Same reasoning as list-objects/list-properties: treat a parse failure as "nothing detected"
+    // rather than a hard error.
+    try {
+      return await listLweAudioObjects(wallpaperPath)
+    } catch (err) {
+      console.warn('[LWE] list-audio-objects failed for', wallpaperPath, ':', (err as Error).message)
+      return []
+    }
+  })
 }

+ 13 - 1
src/main/services/config.service.ts

@@ -21,6 +21,8 @@ interface AppConfig {
   recommendedWebFpsEnabled: boolean
   audioScreen: string | null
   ambientVolume: number | null
+  /** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override */
+  defaultAudioSensitivity: number
 }
 
 const store = new Store<AppConfig>({
@@ -41,7 +43,8 @@ const store = new Store<AppConfig>({
     autostartPlaylistId: null,
     killLweOnQuit: false,
     audioScreen: null,
-    ambientVolume: null
+    ambientVolume: null,
+    defaultAudioSensitivity: 1
   }
 })
 
@@ -160,6 +163,15 @@ export function setAmbientVolume(volume: number | null): void {
   store.set('ambientVolume', volume)
 }
 
+/** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override. */
+export function getDefaultAudioSensitivity(): number {
+  return store.get('defaultAudioSensitivity')
+}
+
+export function setDefaultAudioSensitivity(multiplier: number): void {
+  store.set('defaultAudioSensitivity', multiplier)
+}
+
 /** Custom linux-wallpaperengine repo (git URL or local path); null = official repo. */
 export function getLweRepoUrl(): string | null {
   return store.get('lweRepoUrl')

+ 119 - 3
src/main/services/lwe.service.ts

@@ -5,7 +5,14 @@ import * as path from 'path'
 import * as os from 'os'
 import type { BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import type { LweStatus, LweInstallProgress, LinuxDistro, LweSceneObject, LweProperty } from '@shared/types'
+import type {
+  LweStatus,
+  LweInstallProgress,
+  LinuxDistro,
+  LweSceneObject,
+  LweProperty,
+  LweAudioObject
+} from '@shared/types'
 import {
   isCommandAvailable,
   invalidateCommandCache,
@@ -15,7 +22,14 @@ import {
   getXdgRuntimeDir
 } from '../utils/platform'
 import { getWorkshopPath, getLweManifestPath } from '../utils/paths'
-import { getLweRepoUrl, getLweRepoBranch, getLweCmakeArgs, getAudioScreen, getAmbientVolume } from './config.service'
+import {
+  getLweRepoUrl,
+  getLweRepoBranch,
+  getLweCmakeArgs,
+  getAudioScreen,
+  getAmbientVolume,
+  getDefaultAudioSensitivity
+} from './config.service'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
 
 const execFileAsync = promisify(execFile)
@@ -396,6 +410,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     // Invalidate cache so next status check re-detects
     invalidateCommandCache(LWE_BINARY)
     invalidateObjectFlagsSupport()
+    invalidateAudioSensitivitySupport()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -509,6 +524,7 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
 
     invalidateCommandCache(LWE_BINARY)
     invalidateObjectFlagsSupport()
+    invalidateAudioSensitivitySupport()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -632,6 +648,31 @@ function supportsObjectFlags(): boolean {
   return objectFlagsSupported
 }
 
+// --list-audio-objects/--audio-sensitivity are even more recent than --list-objects, so they get
+// their own cached probe rather than assuming a build new enough for one is new enough for both.
+let audioSensitivitySupported: boolean | null = null
+
+function invalidateAudioSensitivitySupport(): void {
+  audioSensitivitySupported = null
+}
+
+function supportsAudioSensitivity(): boolean {
+  if (audioSensitivitySupported !== null) return audioSensitivitySupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    audioSensitivitySupported = stdout.includes('--list-audio-objects')
+  } catch {
+    audioSensitivitySupported = false
+  }
+  return audioSensitivitySupported
+}
+
 /** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
 function buildLweArgs(
   wallpaperPath: string,
@@ -647,6 +688,7 @@ function buildLweArgs(
     disableParallax?: boolean
     cornerColor?: string
     speed?: number
+    audioSensitivity?: Record<string, number>
   }
 ): string[] {
   const args: string[] = []
@@ -680,6 +722,16 @@ function buildLweArgs(
   for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
     args.push('--set-property', `${name}=${value}`)
   }
+  if (supportsAudioSensitivity()) {
+    // Global default (config.service) applies to every audio-reactive object via the "*"
+    // wildcard; per-wallpaper overrides in options.audioSensitivity are matched first by the
+    // engine (specific id/name always wins over "*"), so both can be sent unconditionally.
+    const defaultSensitivity = getDefaultAudioSensitivity()
+    if (defaultSensitivity !== 1) args.push('--audio-sensitivity', `*=${defaultSensitivity}`)
+    for (const [id, multiplier] of Object.entries(options.audioSensitivity ?? {})) {
+      args.push('--audio-sensitivity', `${id}=${multiplier}`)
+    }
+  }
   args.push(wallpaperPath)
   return args
 }
@@ -782,6 +834,8 @@ export function hotswapLweSettings(options: {
   /** Empty string clears the restriction back to "every screen can produce audio" */
   audioScreen?: string
   ambientVolume?: number
+  /** Object id (or "*" for every audio-reactive object with no more specific entry) -> multiplier */
+  audioSensitivity?: Record<string, number>
 }): boolean {
   const pid = resolveActivePid()
   if (pid === null) return false
@@ -805,6 +859,9 @@ export function hotswapLweSettings(options: {
   for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
     lines.push(`property=${name}=${value}`)
   }
+  for (const [id, multiplier] of Object.entries(options.audioSensitivity ?? {})) {
+    lines.push(`audio-sensitivity=${id}=${multiplier}`)
+  }
   if (lines.length === 0) return false
 
   try {
@@ -834,6 +891,7 @@ export function launchLweAsync(
     disableParallax?: boolean
     cornerColor?: string
     speed?: number
+    audioSensitivity?: Record<string, number>
   } = {}
 ): Promise<void> {
   // Hot-reload if already running. xray has no launch flag and the running instance keeps its own
@@ -841,6 +899,7 @@ export function launchLweAsync(
   // 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.
+  const defaultSensitivity = getDefaultAudioSensitivity()
   if (
     isLweRunning() &&
     hotswapLweSettings({
@@ -851,7 +910,11 @@ export function launchLweAsync(
       disableParallax: options.disableParallax ?? false,
       cornerColor: options.cornerColor ?? '000000',
       speed: options.speed ?? 1,
-      propertyOverrides: options.propertyOverrides ?? {}
+      propertyOverrides: options.propertyOverrides ?? {},
+      audioSensitivity: {
+        ...(defaultSensitivity !== 1 ? { '*': defaultSensitivity } : {}),
+        ...options.audioSensitivity
+      }
     })
   ) {
     return Promise.resolve()
@@ -980,6 +1043,59 @@ export async function listLweObjects(wallpaperPath: string): Promise<LweSceneObj
   }
 }
 
+// Matches lines like "  24 - transbig (scale): minvalue=0.9 maxvalue=1.1 frequency=0 smoothing=10"
+// printed by --list-audio-objects
+const AUDIO_OBJECT_LINE_RE =
+  /^\s*(\S+)\s-\s(.*)\s\((.+)\):\sminvalue=(\S+)\smaxvalue=(\S+)\sfrequency=(\S+)\ssmoothing=(\S+)\s*$/
+
+function parseLweAudioObjectList(output: string): LweAudioObject[] {
+  const objects: LweAudioObject[] = []
+  for (const line of output.split('\n')) {
+    const m = line.match(AUDIO_OBJECT_LINE_RE)
+    if (m) {
+      objects.push({
+        objectId: m[1],
+        objectName: m[2],
+        property: m[3],
+        minvalue: parseFloat(m[4]),
+        maxvalue: parseFloat(m[5]),
+        frequency: parseFloat(m[6]),
+        smoothing: parseFloat(m[7])
+      })
+    }
+  }
+  return objects
+}
+
+// Returns [] for wallpapers with no audio-reactive scripted properties (most wallpapers)
+export async function listLweAudioObjects(wallpaperPath: string): Promise<LweAudioObject[]> {
+  const status = getLweStatus()
+  if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
+  // Same reasoning as supportsObjectFlags(): older builds silently ignore unknown flags and fall
+  // through to actually rendering the wallpaper instead of exiting.
+  if (!supportsAudioSensitivity()) return []
+
+  const binaryPath = getLweBinaryPath()
+  const args = ['--list-audio-objects']
+  const assetsDir = findWeAssetsDir()
+  if (assetsDir) args.push('--assets-dir', assetsDir)
+  args.push(wallpaperPath)
+
+  const envVars = buildLweEnvVars()
+  try {
+    const { stdout } = await execFileAsync('env', [...envVars, binaryPath, ...args], {
+      timeout: 15_000,
+      maxBuffer: 5 * 1024 * 1024,
+      encoding: 'utf8'
+    })
+    return parseLweAudioObjectList(stdout)
+  } catch (err) {
+    const e = err as Error & { stdout?: string; stderr?: string }
+    const msg = (e.stderr || e.stdout || e.message || String(err)).trim().split('\n').slice(0, 3).join('\n')
+    throw new Error(`Failed to list audio-reactive objects: ${msg}`)
+  }
+}
+
 // Parses --list-properties output, e.g.:
 //   background_edit_brightness - slider
 //   	Text: Brightness

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

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

+ 9 - 2
src/preload/index.ts

@@ -17,6 +17,7 @@ import type {
   LinuxDistro,
   LweSceneObject,
   LweProperty,
+  LweAudioObject,
   BackupProgressEvent,
   Playlist,
   PlaylistSettings,
@@ -109,6 +110,7 @@ const api = {
       killLweOnQuit: boolean
       audioScreen: string | null
       ambientVolume: number | null
+      defaultAudioSensitivity: number
     }> => ipcRenderer.invoke(IpcChannels.CONFIG_GET),
     setWorkshopPath: (p: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_WORKSHOP_PATH, p),
@@ -149,7 +151,9 @@ const api = {
     setAudioScreen: (screen: string | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_AUDIO_SCREEN, screen),
     setAmbientVolume: (volume: number | null): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.CONFIG_SET_AMBIENT_VOLUME, volume)
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_AMBIENT_VOLUME, volume),
+    setDefaultAudioSensitivity: (multiplier: number): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, multiplier)
   },
 
   playlist: {
@@ -250,12 +254,15 @@ const api = {
       propertyOverrides?: Record<string, string>
       audioScreen?: string
       ambientVolume?: number
+      audioSensitivity?: Record<string, number>
     }): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options),
     listProperties: (wallpaperPath: string): Promise<LweProperty[]> =>
       ipcRenderer.invoke(IpcChannels.LWE_LIST_PROPERTIES, wallpaperPath),
     listScreens: (): Promise<string[]> =>
-      ipcRenderer.invoke(IpcChannels.LWE_LIST_SCREENS)
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_SCREENS),
+    listAudioObjects: (wallpaperPath: string): Promise<LweAudioObject[]> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_AUDIO_OBJECTS, wallpaperPath)
   },
 
   backup: {

+ 112 - 3
src/renderer/src/components/common/DetailSidebar.tsx

@@ -30,12 +30,13 @@ import {
   Move3d,
   Palette,
   Gauge,
-  Zap
+  Zap,
+  Music2
 } from 'lucide-react'
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
 import { WE_TYPES, WE_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
-import type { LweSceneObject, LweProperty, WallpaperMeta, ScalingMode } from '@shared/types'
+import type { LweSceneObject, LweProperty, LweAudioObject, WallpaperMeta, ScalingMode } from '@shared/types'
 
 const SCALING_MODE_OPTIONS: { value: ScalingMode; label: string }[] = [
   { value: 'default', label: 'Default' },
@@ -224,6 +225,51 @@ function PropertyRow({
   return null
 }
 
+// One row per unique object id detected as audio-reactive (an object can drive more than one
+// property, e.g. both scale and alpha, from the same sensitivity control - properties is just
+// shown as a hint). 0% locks the object (no pulse), 100% is the wallpaper's authored behavior,
+// up to 200% exaggerates it.
+function AudioObjectRow({
+  objectName,
+  properties,
+  multiplier,
+  busy,
+  onCommit
+}: {
+  objectName: string
+  properties: string[]
+  multiplier: number
+  busy: boolean
+  onCommit: (multiplier: number) => void
+}) {
+  const [draft, setDraft] = useState<number | null>(null)
+  const percent = draft ?? Math.round(multiplier * 100)
+
+  return (
+    <div className="px-1.5 py-1">
+      <div className="mb-0.5 flex items-center justify-between text-xs text-gray-400">
+        <span className="truncate" title={properties.join(', ')}>
+          {objectName}
+        </span>
+        <span className="text-gray-500">{percent === 0 ? 'locked' : `${percent}%`}</span>
+      </div>
+      <input
+        type="range"
+        min={0}
+        max={200}
+        step={5}
+        value={percent}
+        disabled={busy}
+        onChange={(e) => setDraft(Number(e.target.value))}
+        onMouseUp={(e) => { onCommit(Number(e.currentTarget.value) / 100); setDraft(null) }}
+        onTouchEnd={(e) => { onCommit(Number(e.currentTarget.value) / 100); setDraft(null) }}
+        onKeyUp={(e) => { onCommit(Number(e.currentTarget.value) / 100); setDraft(null) }}
+        className="w-full accent-indigo-500 disabled:opacity-50"
+      />
+    </div>
+  )
+}
+
 interface DetailSidebarProps {
   id: string
   fallbackTitle: string
@@ -272,6 +318,8 @@ export default function DetailSidebar({
   const [togglingObjectId, setTogglingObjectId] = useState<string | null>(null)
   const [propertiesOpen, setPropertiesOpen] = useState(false)
   const [committingProperty, setCommittingProperty] = useState<string | null>(null)
+  const [audioOpen, setAudioOpen] = useState(false)
+  const [committingAudioObjectId, setCommittingAudioObjectId] = useState<string | null>(null)
 
   const { data: item } = useQuery({
     queryKey: ['workshop-detail', id],
@@ -329,6 +377,23 @@ export default function DetailSidebar({
   )
   const propertyOverrides = libraryMeta?.propertyOverrides ?? {}
 
+  const { data: audioObjectProperties = [], isLoading: audioObjectsLoading } = useQuery({
+    queryKey: ['lwe-audio-objects', localPath],
+    queryFn: () => window.electronAPI.lwe.listAudioObjects(localPath!),
+    enabled: !!localPath && lweInstalled,
+    staleTime: Infinity,
+    retry: false
+  })
+  // An object can drive more than one audio-reactive property (e.g. scale and alpha) - the
+  // sensitivity control is per-object, so group by objectId and show one row per object.
+  const audioObjects: { objectId: string; objectName: string; properties: string[] }[] = []
+  for (const p of audioObjectProperties as LweAudioObject[]) {
+    const existing = audioObjects.find((o) => o.objectId === p.objectId)
+    if (existing) existing.properties.push(p.property)
+    else audioObjects.push({ objectId: p.objectId, objectName: p.objectName, properties: [p.property] })
+  }
+  const audioSensitivity = libraryMeta?.audioSensitivity ?? {}
+
   // 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.
@@ -347,7 +412,8 @@ export default function DetailSidebar({
         disableParallax: patch.disableParallax,
         cornerColor: patch.cornerColor,
         speed: patch.playbackSpeed,
-        propertyOverrides: patch.propertyOverrides
+        propertyOverrides: patch.propertyOverrides,
+        audioSensitivity: patch.audioSensitivity
       })
       if (!ok) {
         await window.electronAPI.lwe.stop()
@@ -400,6 +466,16 @@ export default function DetailSidebar({
     }
   }
 
+  async function commitAudioSensitivity(objectId: string, multiplier: number) {
+    if (committingAudioObjectId) return
+    setCommittingAudioObjectId(objectId)
+    try {
+      await persistAndMaybeRelaunch({ audioSensitivity: { ...audioSensitivity, [objectId]: multiplier } })
+    } finally {
+      setCommittingAudioObjectId(null)
+    }
+  }
+
   const [volumeDraft, setVolumeDraft] = useState<number | null>(null)
   const [isCommittingVolume, setIsCommittingVolume] = useState(false)
   const volume = volumeDraft ?? libraryMeta?.volumeOverride ?? 15
@@ -952,6 +1028,39 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && (audioObjectsLoading || audioObjects.length > 0) && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={() => setAudioOpen((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"
+            >
+              {audioOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              <Music2 size={12} />
+              <span className="flex-1 text-left">Audio Reactivity</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {audioObjectsLoading && <Loader2 size={11} className="animate-spin" />}
+            </button>
+            {audioOpen && (
+              <div className="mt-1.5 space-y-0.5">
+                {audioObjects.map((obj) => (
+                  <AudioObjectRow
+                    key={obj.objectId}
+                    objectName={obj.objectName}
+                    properties={obj.properties}
+                    multiplier={audioSensitivity[obj.objectId] ?? 1}
+                    busy={committingAudioObjectId === obj.objectId}
+                    onCommit={(multiplier) => commitAudioSensitivity(obj.objectId, multiplier)}
+                  />
+                ))}
+              </div>
+            )}
+          </div>
+        )}
+
         <div className="flex flex-col gap-1.5 border-t border-white/5 pt-3">
           <div className="flex gap-1.5">
             <button

+ 43 - 0
src/renderer/src/components/settings/SettingsView.tsx

@@ -63,6 +63,8 @@ export default function SettingsView() {
   const [audioScreen, setAudioScreen] = useState<string | null>(null)
   const [ambientVolume, setAmbientVolume] = useState<string>('')
   const [ambientVolumeSaved, setAmbientVolumeSaved] = useState(false)
+  const [defaultAudioSensitivity, setDefaultAudioSensitivity] = useState<string>('100')
+  const [defaultAudioSensitivitySaved, setDefaultAudioSensitivitySaved] = useState(false)
 
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
@@ -84,6 +86,7 @@ export default function SettingsView() {
       setAutostartPlaylistId(cfg.autostartPlaylistId)
       setAudioScreen(cfg.audioScreen)
       setAmbientVolume(cfg.ambientVolume != null ? String(cfg.ambientVolume) : '')
+      setDefaultAudioSensitivity(String(Math.round(cfg.defaultAudioSensitivity * 100)))
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
@@ -282,6 +285,18 @@ export default function SettingsView() {
     setTimeout(() => setAmbientVolumeSaved(false), 2000)
   }
 
+  async function handleSaveDefaultAudioSensitivity() {
+    const percent = parseInt(defaultAudioSensitivity, 10)
+    if (isNaN(percent) || percent < 0 || percent > 200) return
+    const multiplier = percent / 100
+    await window.electronAPI.config.setDefaultAudioSensitivity(multiplier)
+    // Push live so a currently running instance updates without a relaunch - "*" is the engine's
+    // wildcard default, per-wallpaper overrides still win over it.
+    await window.electronAPI.lwe.hotswapSettings({ audioSensitivity: { '*': multiplier } })
+    setDefaultAudioSensitivitySaved(true)
+    setTimeout(() => setDefaultAudioSensitivitySaved(false), 2000)
+  }
+
   async function saveAutostart(patch: {
     enabled?: boolean
     minimized?: boolean
@@ -572,6 +587,34 @@ export default function SettingsView() {
                 </button>
               </div>
             </div>
+            <div>
+              <label className="block text-xs text-gray-400 mb-1">Default audio reactivity sensitivity</label>
+              <p className="text-xs text-gray-600 mb-2">
+                Default strength (0-200%) for wallpaper elements that pulse to music, applied to
+                every audio-reactive object that doesn't have its own override set in a
+                wallpaper's detail panel. 0% locks all of them (no pulse), 100% is each
+                wallpaper's original authored behavior.
+              </p>
+              <div className="flex gap-2 items-center">
+                <input
+                  type="number"
+                  min={0}
+                  max={200}
+                  value={defaultAudioSensitivity}
+                  onChange={(e) => { setDefaultAudioSensitivity(e.target.value); setDefaultAudioSensitivitySaved(false) }}
+                  onKeyDown={(e) => e.key === 'Enter' && handleSaveDefaultAudioSensitivity()}
+                  className="w-44 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+                />
+                <span className="text-sm text-gray-500">%</span>
+                <button
+                  onClick={handleSaveDefaultAudioSensitivity}
+                  className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500"
+                >
+                  <Save size={14} />
+                  {defaultAudioSensitivitySaved ? 'Saved!' : 'Save'}
+                </button>
+              </div>
+            </div>
           </div>
         </div>
 

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

@@ -59,6 +59,7 @@ export const IpcChannels = {
   CONFIG_SET_AUTO_UNSUBSCRIBE: 'config:set-auto-unsubscribe',
   CONFIG_SET_AUDIO_SCREEN: 'config:set-audio-screen',
   CONFIG_SET_AMBIENT_VOLUME: 'config:set-ambient-volume',
+  CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY: 'config:set-default-audio-sensitivity',
   CONFIG_PICK_FOLDER: 'config:pick-folder',
   CONFIG_PICK_FILE: 'config:pick-file',
   CONFIG_IMPORT_WE: 'config:import-we',
@@ -114,6 +115,7 @@ export const IpcChannels = {
   LWE_HOTSWAP_SETTINGS: 'lwe:hotswap-settings',
   LWE_LIST_PROPERTIES: 'lwe:list-properties',
   LWE_LIST_SCREENS: 'lwe:list-screens',
+  LWE_LIST_AUDIO_OBJECTS: 'lwe:list-audio-objects',
 
   // Desktop icons overlay
   DESKTOP_ICONS_SET_ENABLED: 'desktop-icons:set-enabled',

+ 19 - 0
src/shared/types.ts

@@ -76,6 +76,13 @@ export interface WallpaperMeta {
   enabledObjects?: string[]
   /** Property name -> override value, applied via linux-wallpaperengine --set-property name=value */
   propertyOverrides?: Record<string, string>
+  /**
+   * Object id -> audio-reactive pulse sensitivity multiplier, applied via linux-wallpaperengine
+   * --audio-sensitivity id=multiplier. 0 locks the object (no pulse), 1 is the wallpaper's
+   * original authored behavior, >1 exaggerates it. Objects with no entry here use
+   * defaultAudioSensitivity from the global config instead.
+   */
+  audioSensitivity?: Record<string, number>
 }
 
 export interface LweSceneObject {
@@ -84,6 +91,18 @@ export interface LweSceneObject {
   type: 'image' | 'particle' | 'text' | 'sound' | 'unknown'
 }
 
+/** One audio-reactive script property on a scene object, as reported by --list-audio-objects */
+export interface LweAudioObject {
+  objectId: string
+  objectName: string
+  /** The scripted property driving the pulse, e.g. "scale" or "alpha" */
+  property: string
+  minvalue: number
+  maxvalue: number
+  frequency: number
+  smoothing: number
+}
+
 export interface LweProperty {
   name: string
   type: 'slider' | 'boolean' | 'color' | 'combo' | 'text' | 'scene-texture' | 'file' | 'textinput' | 'unknown'