فهرست منبع

plenty of new features, standalone/steam setting, like cache, ignore artist, etc

UwU 1 ماه پیش
والد
کامیت
d4fb1bbbc3

+ 1 - 1
package.json

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

+ 29 - 2
src/main/index.ts

@@ -1,8 +1,9 @@
 import { app, BrowserWindow, net, protocol, shell } from 'electron'
 import path from 'path'
 import { pathToFileURL } from 'url'
-import { initSteam } from './services/steam.service'
-import { initLibrary } from './services/library.service'
+import { IpcChannels } from '@shared/ipc-channels'
+import { initSteam, isSteamRunning, startVotedItemsSync } from './services/steam.service'
+import { initLibrary, checkUnavailableWallpapers } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
 import { initDesktopIcons, cleanupDesktopIcons } from './services/desktop-icons.service'
@@ -15,6 +16,31 @@ const startMinimized = process.argv.includes('--minimized')
 let isQuitting = false
 let hasKilledLweOnQuit = false
 
+const BACKGROUND_SYNC_INTERVAL_MS = 3 * 60 * 1000
+
+// Keeps "liked" state and library availability fresh without the user having to restart the
+// app or click "Check unavailable" - votes cast on Steam directly (client, community website)
+// and items taken down from the Workshop both eventually show up on their own.
+function startBackgroundSync(win: BrowserWindow): void {
+  startVotedItemsSync((ids) => {
+    if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_VOTED_IDS_CHANGED, ids)
+  })
+
+  setInterval(async () => {
+    if (win.isDestroyed()) return
+    const running = isSteamRunning()
+    win.webContents.send(IpcChannels.EVENT_STEAM_STATUS, running)
+    if (!running) return
+
+    try {
+      const { changed } = await checkUnavailableWallpapers()
+      if (changed && !win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_LIBRARY_CHANGED)
+    } catch {
+      // transient Steam hiccup - try again next tick
+    }
+  }, BACKGROUND_SYNC_INTERVAL_MS)
+}
+
 function createWindow(): BrowserWindow {
   const win = new BrowserWindow({
     width: 1280,
@@ -77,6 +103,7 @@ app.whenReady().then(() => {
   const win = createWindow()
   registerAllHandlers(win)
   startWatcher(win)
+  startBackgroundSync(win)
   initDesktopIcons()
   const resumed = initPlaylistPlayer(win)
 

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

@@ -1,5 +1,6 @@
 import { ipcMain, dialog, BrowserWindow } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
+import type { SteamIdentity } from '@shared/types'
 import {
   getConfiguredWorkshopPath,
   setConfiguredWorkshopPath,
@@ -36,7 +37,14 @@ import {
   getAmbientVolume,
   setAmbientVolume,
   getDefaultAudioSensitivity,
-  setDefaultAudioSensitivity
+  setDefaultAudioSensitivity,
+  getDisablePuppetAnimation,
+  setDisablePuppetAnimation,
+  getSteamIdentity,
+  setSteamIdentity,
+  getIgnoredCreators,
+  ignoreCreator,
+  unignoreCreator
 } from '../services/config.service'
 import { getDefaultWorkshopPath } from '../utils/paths'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
@@ -68,7 +76,10 @@ export function registerConfigHandlers(): void {
     killLweOnQuit: getKillLweOnQuit(),
     audioScreen: getAudioScreen(),
     ambientVolume: getAmbientVolume(),
-    defaultAudioSensitivity: getDefaultAudioSensitivity()
+    defaultAudioSensitivity: getDefaultAudioSensitivity(),
+    disablePuppetAnimation: getDisablePuppetAnimation(),
+    steamIdentity: getSteamIdentity(),
+    ignoredCreators: getIgnoredCreators()
   }))
 
   ipcMain.handle(IpcChannels.CONFIG_GET_AUTOSTART_SUPPORTED, () => isAutostartSupported())
@@ -99,6 +110,21 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_STEAM_IDENTITY, (_e, identity: SteamIdentity) => {
+    setSteamIdentity(identity)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.CONFIG_IGNORE_CREATOR, (_e, steamId: string) => {
+    ignoreCreator(steamId)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.CONFIG_UNIGNORE_CREATOR, (_e, steamId: string) => {
+    unignoreCreator(steamId)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_BACKUP_PATH, (_e, newPath: string) => {
     setConfiguredBackupPath(newPath)
     return { ok: true }
@@ -139,6 +165,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_DISABLE_PUPPET_ANIMATION, (_e, disabled: boolean) => {
+    setDisablePuppetAnimation(disabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_LWE_REPO, (_e, url: string | null, branch: string | null) => {
     setLweRepo(url, branch)
     return { ok: true }

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

@@ -72,6 +72,7 @@ export function registerLweHandlers(win: BrowserWindow): void {
         audioScreen?: string
         ambientVolume?: number
         audioSensitivity?: Record<string, number>
+        soundVolume?: Record<string, number>
       }
     ) => {
       return { ok: hotswapLweSettings(options) }

+ 44 - 3
src/main/services/config.service.ts

@@ -3,6 +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'
 
 interface AppConfig {
   workshopPath: string | null
@@ -23,6 +24,11 @@ interface AppConfig {
   ambientVolume: number | null
   /** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override */
   defaultAudioSensitivity: number
+  /** Passes --render-debug no-puppet-animation to LWE - freezes puppet (.mdl) meshes at their bind pose, for troubleshooting */
+  disablePuppetAnimation: boolean
+  steamIdentity: SteamIdentity
+  /** steamId64 of creators whose workshop items are hidden from browsing by default */
+  ignoredCreators: string[]
 }
 
 const store = new Store<AppConfig>({
@@ -44,7 +50,10 @@ const store = new Store<AppConfig>({
     killLweOnQuit: false,
     audioScreen: null,
     ambientVolume: null,
-    defaultAudioSensitivity: 1
+    defaultAudioSensitivity: 1,
+    disablePuppetAnimation: false,
+    steamIdentity: 'wallpaper-engine',
+    ignoredCreators: []
   }
 })
 
@@ -117,6 +126,31 @@ export function setKillLweOnQuit(enabled: boolean): void {
   store.set('killLweOnQuit', enabled)
 }
 
+/** How this process identifies itself to Steam - 'wallpaper-engine' registers under WE's own
+ *  app id (full workshop compatibility, but Steam treats this process as WE and closes it on
+ *  logout); 'standalone' registers under a neutral test app id so Steam leaves it running.
+ *  Only read once at startup (steam.service.ts initSteam()) - changing it takes effect on relaunch. */
+export function getSteamIdentity(): SteamIdentity {
+  return store.get('steamIdentity')
+}
+
+export function setSteamIdentity(identity: SteamIdentity): void {
+  store.set('steamIdentity', identity)
+}
+
+export function getIgnoredCreators(): string[] {
+  return store.get('ignoredCreators')
+}
+
+export function ignoreCreator(steamId: string): void {
+  const current = store.get('ignoredCreators')
+  if (!current.includes(steamId)) store.set('ignoredCreators', [...current, steamId])
+}
+
+export function unignoreCreator(steamId: string): void {
+  store.set('ignoredCreators', store.get('ignoredCreators').filter((id) => id !== steamId))
+}
+
 export function getDefaultFps(): number | null {
   return store.get('defaultFps')
 }
@@ -163,6 +197,15 @@ export function setAmbientVolume(volume: number | null): void {
   store.set('ambientVolume', volume)
 }
 
+/** Freezes puppet (.mdl) meshes at their bind pose (--render-debug no-puppet-animation) - troubleshooting only. */
+export function getDisablePuppetAnimation(): boolean {
+  return store.get('disablePuppetAnimation')
+}
+
+export function setDisablePuppetAnimation(disabled: boolean): void {
+  store.set('disablePuppetAnimation', disabled)
+}
+
 /** Default --audio-sensitivity multiplier for audio-reactive objects with no per-wallpaper override. */
 export function getDefaultAudioSensitivity(): number {
   return store.get('defaultAudioSensitivity')
@@ -196,7 +239,6 @@ export function setLweCmakeArgs(args: string | null): void {
   store.set('lweCmakeArgs', args?.trim() || null)
 }
 
-/** Path where we store our copy of config.json */
 export function getWeConfigPath(): string {
   return path.join(app.getPath('userData'), 'we-config.json')
 }
@@ -233,7 +275,6 @@ export function importWEConfigFile(sourcePath: string): string {
   return destPath
 }
 
-/** Create a fresh empty WE-style config.json */
 export function createFreshConfig(): string {
   const destPath = getWeConfigPath()
   const skeleton = {

+ 0 - 1
src/main/services/desktop-icons.service.ts

@@ -73,7 +73,6 @@ export function startDesktopIconsOverlay(): void {
   const envVars = buildOverlayEnvVars()
   const screens = getConnectedScreens()
 
-  // Spawn one overlay per screen
   const targets = screens.length > 0 ? screens : [undefined]
   for (const screen of targets) {
     const args = ['python3', scriptPath]

+ 9 - 5
src/main/services/library.service.ts

@@ -549,14 +549,18 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
 // author deleted it, Valve removed it, etc). Those items can never be
 // re-downloaded once lost locally, which is what "backup all unavailable"
 // is for - only workshop-sourced items with local content are worth checking.
-export async function checkUnavailableWallpapers(): Promise<{ checked: number; unavailable: number }> {
-  if (!isSteamRunning()) return { checked: 0, unavailable: 0 }
+export async function checkUnavailableWallpapers(): Promise<{
+  checked: number
+  unavailable: number
+  changed: boolean
+}> {
+  if (!isSteamRunning()) return { checked: 0, unavailable: 0, changed: false }
 
   const wallpapers = store.get('wallpapers')
   const candidates = Object.values(wallpapers).filter(
     (w) => w.source === 'workshop' && !!w.localPath && !w.downloading
   )
-  if (candidates.length === 0) return { checked: 0, unavailable: 0 }
+  if (candidates.length === 0) return { checked: 0, unavailable: 0, changed: false }
 
   let unavailableIds: Set<string>
   try {
@@ -564,7 +568,7 @@ export async function checkUnavailableWallpapers(): Promise<{ checked: number; u
   } catch {
     // Steam request failed outright - leave existing unavailable flags alone
     // rather than reporting everything as available again.
-    return { checked: 0, unavailable: 0 }
+    return { checked: 0, unavailable: 0, changed: false }
   }
 
   let changed = false
@@ -577,7 +581,7 @@ export async function checkUnavailableWallpapers(): Promise<{ checked: number; u
   }
   if (changed) store.set('wallpapers', wallpapers)
 
-  return { checked: candidates.length, unavailable: unavailableIds.size }
+  return { checked: candidates.length, unavailable: unavailableIds.size, changed }
 }
 
 export function getAllFolders(): WallpaperFolder[] {

+ 44 - 2
src/main/services/lwe.service.ts

@@ -28,7 +28,8 @@ import {
   getLweCmakeArgs,
   getAudioScreen,
   getAmbientVolume,
-  getDefaultAudioSensitivity
+  getDefaultAudioSensitivity,
+  getDisablePuppetAnimation
 } from './config.service'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
 
@@ -416,6 +417,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     invalidateCommandCache(LWE_BINARY)
     invalidateObjectFlagsSupport()
     invalidateAudioSensitivitySupport()
+    invalidateSoundVolumeSupport()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -530,6 +532,7 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
     invalidateCommandCache(LWE_BINARY)
     invalidateObjectFlagsSupport()
     invalidateAudioSensitivitySupport()
+    invalidateSoundVolumeSupport()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -678,6 +681,31 @@ function supportsAudioSensitivity(): boolean {
   return audioSensitivitySupported
 }
 
+// --sound-volume is newer still - its own probe rather than assuming a build with
+// --list-audio-objects also has it.
+let soundVolumeSupported: boolean | null = null
+
+function invalidateSoundVolumeSupport(): void {
+  soundVolumeSupported = null
+}
+
+function supportsSoundVolume(): boolean {
+  if (soundVolumeSupported !== null) return soundVolumeSupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    soundVolumeSupported = stdout.includes('--sound-volume')
+  } catch {
+    soundVolumeSupported = false
+  }
+  return soundVolumeSupported
+}
+
 /** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
 function buildLweArgs(
   wallpaperPath: string,
@@ -694,6 +722,7 @@ function buildLweArgs(
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
+    soundVolume?: Record<string, number>
   }
 ): string[] {
   const args: string[] = []
@@ -715,6 +744,7 @@ function buildLweArgs(
   if (audioScreen) args.push('--audio-screen', audioScreen)
   const ambientVolume = getAmbientVolume()
   if (ambientVolume !== null) args.push('--ambient-volume', String(ambientVolume))
+  if (getDisablePuppetAnimation()) args.push('--render-debug', 'no-puppet-animation')
   if (options.scalingMode) args.push('--scaling', options.scalingMode)
   if (options.zoom !== undefined) args.push('--zoom', String(options.zoom))
   if (options.disableParallax) args.push('--disable-parallax')
@@ -737,6 +767,11 @@ function buildLweArgs(
       args.push('--audio-sensitivity', `${id}=${multiplier}`)
     }
   }
+  if (supportsSoundVolume()) {
+    for (const [id, volume] of Object.entries(options.soundVolume ?? {})) {
+      args.push('--sound-volume', `${id}=${volume}`)
+    }
+  }
   args.push(wallpaperPath)
   return args
 }
@@ -841,6 +876,8 @@ export function hotswapLweSettings(options: {
   ambientVolume?: number
   /** Object id (or "*" for every audio-reactive object with no more specific entry) -> multiplier */
   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
@@ -867,6 +904,9 @@ export function hotswapLweSettings(options: {
   for (const [id, multiplier] of Object.entries(options.audioSensitivity ?? {})) {
     lines.push(`audio-sensitivity=${id}=${multiplier}`)
   }
+  for (const [id, volume] of Object.entries(options.soundVolume ?? {})) {
+    lines.push(`sound-volume=${id}=${volume}`)
+  }
   if (lines.length === 0) return false
 
   try {
@@ -897,6 +937,7 @@ export async function launchLweAsync(
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
+    soundVolume?: Record<string, number>
   } = {}
 ): Promise<void> {
   // Hot-reload if already running. xray has no launch flag and the running instance keeps its own
@@ -919,7 +960,8 @@ export async function launchLweAsync(
       audioSensitivity: {
         ...(defaultSensitivity !== 1 ? { '*': defaultSensitivity } : {}),
         ...options.audioSensitivity
-      }
+      },
+      soundVolume: options.soundVolume ?? {}
     })
   ) {
     return

+ 70 - 12
src/main/services/steam.service.ts

@@ -1,30 +1,53 @@
 import * as path from 'path'
-import { WE_APP_ID } from '@shared/constants'
+import { WE_APP_ID, STANDALONE_APP_ID } from '@shared/constants'
 import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
+import { getSteamIdentity } from './config.service'
 
 let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
 
+// Steam not running is a normal, common state (this app works as a local library manager
+// without it). Every isSteamRunning()/getClient() call used to retry the native init and log a
+// warning, so anything that polls (react-query refetches, library scans, ...) turned "Steam is
+// closed" into a continuous stream of console warnings. Only actually retry - and only warn -
+// once per cooldown window, and only log the first failure of a run.
+const RETRY_COOLDOWN_MS = 15000
+let lastInitAttempt = 0
+let hasWarnedThisOutage = false
+
 export function initSteam(): boolean {
+  lastInitAttempt = Date.now()
   try {
     // steamworks.js is a native module and must be required at runtime
     // eslint-disable-next-line @typescript-eslint/no-require-imports
     const steamworks: typeof import('steamworks.js') = require('steamworks.js')
-    client = steamworks.init(WE_APP_ID)
-    console.log('[Steam] Initialized')
+    const appId = getSteamIdentity() === 'standalone' ? STANDALONE_APP_ID : WE_APP_ID
+    client = steamworks.init(appId)
+    hasWarnedThisOutage = false
+    console.log(`[Steam] Initialized (app id ${appId})`)
     return true
   } catch (err) {
-    console.warn('[Steam] Init failed (Steam may not be running):', err)
+    client = null
+    if (!hasWarnedThisOutage) {
+      hasWarnedThisOutage = true
+      console.warn('[Steam] Init failed (Steam may not be running):', err)
+    }
     return false
   }
 }
 
+function ensureClient(): void {
+  if (client) return
+  if (Date.now() - lastInitAttempt < RETRY_COOLDOWN_MS) return
+  initSteam()
+}
+
 export function isSteamRunning(): boolean {
-  if (!client) initSteam()
+  ensureClient()
   return client !== null
 }
 
 export function getClient() {
-  if (!client) initSteam()
+  ensureClient()
   if (!client) throw new Error('Steam not initialized')
   return client
 }
@@ -95,12 +118,12 @@ export function openWorkshopItemOverlay(itemId: bigint): void {
   )
 }
 
-// Fetched via getUserItems(VotedUp) and cached for the process lifetime;
-// invalidateVoteCache() is called after each vote.
+// Cached for the process lifetime; invalidateVoteCache() is called after each vote, and
+// startVotedItemsSync() below periodically replaces it wholesale in the background so votes
+// cast outside this app (Steam client, community website) show up without a restart.
 let votedUpCache: Set<string> | null = null
 
-export async function getVotedUpItemIds(): Promise<string[]> {
-  if (votedUpCache) return [...votedUpCache]
+async function fetchVotedUpItems(): Promise<Set<string>> {
   const c = getClient()
   const accountId = c.localplayer.getSteamId().accountId
   const ids = new Set<string>()
@@ -118,14 +141,49 @@ export async function getVotedUpItemIds(): Promise<string[]> {
     if (r.returnedResults === 0 || ids.size >= r.totalResults) break
     page++
   }
-  votedUpCache = ids
-  return [...ids]
+  return ids
+}
+
+export async function getVotedUpItemIds(): Promise<string[]> {
+  if (votedUpCache) return [...votedUpCache]
+  votedUpCache = await fetchVotedUpItems()
+  return [...votedUpCache]
 }
 
 export function invalidateVoteCache(): void {
   votedUpCache = null
 }
 
+function sameIds(a: Set<string>, b: Set<string>): boolean {
+  if (a.size !== b.size) return false
+  for (const id of a) if (!b.has(id)) return false
+  return true
+}
+
+const VOTE_SYNC_INTERVAL_MS = 3 * 60 * 1000
+
+// Background reconciliation for the vote cache: an in-app vote already updates the renderer
+// optimistically (see WorkshopCard/WallpaperCard onLiked handlers), so this isn't on the
+// critical path for your own votes - it's what catches up on votes cast elsewhere (Steam
+// client, community website) and settles the rare case where SetUserItemVote's async call
+// didn't actually land before the optimistic update fired.
+let voteSyncTimer: ReturnType<typeof setInterval> | null = null
+
+export function startVotedItemsSync(onChange: (ids: string[]) => void): void {
+  if (voteSyncTimer) return
+  voteSyncTimer = setInterval(async () => {
+    if (!isSteamRunning()) return
+    try {
+      const fresh = await fetchVotedUpItems()
+      const changed = !votedUpCache || !sameIds(votedUpCache, fresh)
+      votedUpCache = fresh
+      if (changed) onChange([...fresh])
+    } catch {
+      // transient Steam hiccup - just try again next tick
+    }
+  }, VOTE_SYNC_INTERVAL_MS)
+}
+
 export function getSubscribedItems(): string[] {
   return getClient()
     .workshop.getSubscribedItems()

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

@@ -235,7 +235,8 @@ export async function applyWallpaperMeta(
       disableParallax: wallpaper.disableParallax ?? false,
       cornerColor: wallpaper.cornerColor,
       speed: wallpaper.playbackSpeed,
-      audioSensitivity: wallpaper.audioSensitivity
+      audioSensitivity: wallpaper.audioSensitivity,
+      soundVolume: wallpaper.soundVolume
     })
     return wallpaper.localPath
   }

+ 65 - 6
src/main/services/workshop.service.ts

@@ -53,8 +53,11 @@ export async function queryWorkshop(params: WorkshopQueryParams): Promise<Worksh
     client.workshop.getSubscribedItems().map((id) => id.toString())
   )
 
+  const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
+  await attachFileSizes(items)
+
   return {
-    items: result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet)),
+    items,
     page,
     totalResults: result.totalResults
   }
@@ -62,15 +65,25 @@ export async function queryWorkshop(params: WorkshopQueryParams): Promise<Worksh
 
 export async function getWorkshopItem(publishedFileId: string): Promise<WorkshopItem | null> {
   const client = getClient()
-  const item = await client.workshop.getItem(BigInt(publishedFileId), {
-    includeLongDescription: true
-  })
+  let item: Awaited<ReturnType<typeof client.workshop.getItem>>
+  try {
+    item = await client.workshop.getItem(BigInt(publishedFileId), {
+      includeLongDescription: true
+    })
+  } catch {
+    // Steam's UGC details lookup throws a generic failure for an id it can no longer resolve
+    // (taken down, banned) rather than returning null the way a batch getItems() call does -
+    // treat it the same way: "we can't tell you about this item" rather than a hard error.
+    return null
+  }
   if (!item) return null
 
   const subscribedSet = new Set(
     client.workshop.getSubscribedItems().map((id) => id.toString())
   )
-  return transformItem(item, subscribedSet)
+  const transformed = transformItem(item, subscribedSet)
+  await attachFileSizes([transformed])
+  return transformed
 }
 
 // UserUGCList lets us query another Steam user's published items directly,
@@ -103,8 +116,11 @@ export async function queryWorkshopByCreator(
     client.workshop.getSubscribedItems().map((id) => id.toString())
   )
 
+  const items = result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet))
+  await attachFileSizes(items)
+
   return {
-    items: result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet)),
+    items,
     page,
     totalResults: result.totalResults
   }
@@ -170,6 +186,49 @@ export async function getUnavailableWorkshopItems(publishedFileIds: string[]): P
   return unavailable
 }
 
+// steamworks.js's UGC query bindings don't surface a file's size (the underlying Steamworks SDK
+// struct has it, the napi wrapper just doesn't map that field). Steam's own public web API does,
+// unauthenticated, for any published file regardless of subscription state - this is the exact
+// "File Size" figure shown on the item's Steam Workshop page. Cached per id for the process
+// lifetime since it barely ever changes.
+const fileSizeCache = new Map<string, number>()
+const FILE_SIZE_BATCH = 100
+
+async function fetchFileSizesBatch(ids: string[]): Promise<void> {
+  if (ids.length === 0) return
+  const body = new URLSearchParams({ itemcount: String(ids.length) })
+  ids.forEach((id, i) => body.set(`publishedfileids[${i}]`, id))
+
+  const res = await fetch('https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/', {
+    method: 'POST',
+    body
+  })
+  const data = await res.json()
+  const details = data?.response?.publishedfiledetails ?? []
+  for (const d of details) {
+    if (d?.publishedfileid && d.file_size != null) {
+      fileSizeCache.set(d.publishedfileid, Number(d.file_size))
+    }
+  }
+}
+
+async function attachFileSizes(items: WorkshopItem[]): Promise<void> {
+  const uncached = [...new Set(items.map((i) => i.publishedFileId).filter((id) => !fileSizeCache.has(id)))]
+  if (uncached.length > 0) {
+    try {
+      for (let i = 0; i < uncached.length; i += FILE_SIZE_BATCH) {
+        await fetchFileSizesBatch(uncached.slice(i, i + FILE_SIZE_BATCH))
+      }
+    } catch (err) {
+      console.warn('[Workshop] Failed to fetch file sizes:', err)
+    }
+  }
+  for (const item of items) {
+    const size = fileSizeCache.get(item.publishedFileId)
+    if (size != null) item.fileSize = size
+  }
+}
+
 function transformItem(
   item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
   subscribedSet: Set<string>

+ 26 - 3
src/preload/index.ts

@@ -21,7 +21,8 @@ import type {
   BackupProgressEvent,
   Playlist,
   PlaylistSettings,
-  PlaylistPlaybackState
+  PlaylistPlaybackState,
+  SteamIdentity
 } from '../shared/types'
 
 const api = {
@@ -83,7 +84,7 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LIBRARY_DISTINCT_TAGS),
     resetFpsOverrides: (): Promise<{ count: number }> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_RESET_FPS_OVERRIDES),
-    checkUnavailable: (): Promise<{ checked: number; unavailable: number }> =>
+    checkUnavailable: (): Promise<{ checked: number; unavailable: number; changed: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_CHECK_UNAVAILABLE)
   },
 
@@ -111,6 +112,9 @@ const api = {
       audioScreen: string | null
       ambientVolume: number | null
       defaultAudioSensitivity: number
+      disablePuppetAnimation: boolean
+      steamIdentity: SteamIdentity
+      ignoredCreators: string[]
     }> => ipcRenderer.invoke(IpcChannels.CONFIG_GET),
     setWorkshopPath: (p: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_WORKSHOP_PATH, p),
@@ -153,7 +157,15 @@ const api = {
     setAmbientVolume: (volume: number | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_AMBIENT_VOLUME, volume),
     setDefaultAudioSensitivity: (multiplier: number): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, multiplier)
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_AUDIO_SENSITIVITY, multiplier),
+    setDisablePuppetAnimation: (disabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_DISABLE_PUPPET_ANIMATION, disabled),
+    setSteamIdentity: (identity: SteamIdentity): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_STEAM_IDENTITY, identity),
+    ignoreCreator: (steamId: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_IGNORE_CREATOR, steamId),
+    unignoreCreator: (steamId: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_UNIGNORE_CREATOR, steamId)
   },
 
   playlist: {
@@ -255,6 +267,7 @@ const api = {
       audioScreen?: string
       ambientVolume?: number
       audioSensitivity?: Record<string, number>
+      soundVolume?: Record<string, number>
     }): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options),
     listProperties: (wallpaperPath: string): Promise<LweProperty[]> =>
@@ -322,6 +335,16 @@ const api = {
       const listener = (_: Electron.IpcRendererEvent, state: PlaylistPlaybackState) => cb(state)
       ipcRenderer.on(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
+    },
+    votedIdsChanged: (cb: (ids: string[]) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, ids: string[]) => cb(ids)
+      ipcRenderer.on(IpcChannels.EVENT_VOTED_IDS_CHANGED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_VOTED_IDS_CHANGED, listener)
+    },
+    libraryChanged: (cb: () => void): (() => void) => {
+      const listener = () => cb()
+      ipcRenderer.on(IpcChannels.EVENT_LIBRARY_CHANGED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_LIBRARY_CHANGED, listener)
     }
   }
 }

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

@@ -57,6 +57,21 @@ export default function App() {
     })
   }, [queryClient])
 
+  // Background sync (main/index.ts) periodically re-checks votes and library availability
+  // against Steam and pushes updates here, so likes cast elsewhere and items pulled from the
+  // Workshop show up without a restart or a manual refresh.
+  useEffect(() => {
+    return window.electronAPI.on.votedIdsChanged((ids) => {
+      queryClient.setQueryData(['steam-voted-ids'], ids)
+    })
+  }, [queryClient])
+
+  useEffect(() => {
+    return window.electronAPI.on.libraryChanged(() => {
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    })
+  }, [queryClient])
+
   if (setupDone === null) return null // loading
 
   if (!setupDone) {

+ 117 - 18
src/renderer/src/components/common/DetailSidebar.tsx

@@ -36,6 +36,7 @@ import {
 import clsx from 'clsx'
 import { openWorkshopPage, openProfilePage, isWorkshopId } from '../../utils/steam'
 import { WE_TYPES, WE_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
+import { formatFileSize } from '../../utils/format'
 import type { LweSceneObject, LweProperty, LweAudioObject, WallpaperMeta, ScalingMode } from '@shared/types'
 
 const SCALING_MODE_OPTIONS: { value: ScalingMode; label: string }[] = [
@@ -50,13 +51,6 @@ const TYPE_TAGS = new Set(WE_TYPES.map((i) => i.tag))
 const AGE_TAGS = new Set(WE_AGE_RATINGS.map((i) => i.tag))
 const RESOLUTION_TAGS = new Set(WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag)))
 
-function formatFileSize(bytes?: number): string | null {
-  if (!bytes) return null
-  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
-  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
-  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
-}
-
 function StarRating({ upvotes, downvotes }: { upvotes: number; downvotes: number }) {
   const total = upvotes + downvotes
   if (total === 0) return <p className="text-xs text-gray-600">No ratings yet</p>
@@ -270,6 +264,48 @@ function AudioObjectRow({
   )
 }
 
+// One row per Sound object (e.g. an alternate music track) - volume is independent per track, so
+// muting all but one (or blending several) is just a matter of where each slider sits, applied
+// live without a wallpaper reload.
+function SoundTrackRow({
+  trackName,
+  volume,
+  busy,
+  onCommit
+}: {
+  trackName: string
+  volume: number
+  busy: boolean
+  onCommit: (volume: number) => void
+}) {
+  const [draft, setDraft] = useState<number | null>(null)
+  const value = draft ?? volume
+
+  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={trackName}>
+          {trackName}
+        </span>
+        <span className="text-gray-500">{value === 0 ? 'muted' : `${Math.round(value * 100)}%`}</span>
+      </div>
+      <input
+        type="range"
+        min={0}
+        max={1}
+        step={0.1}
+        value={value}
+        disabled={busy}
+        onChange={(e) => setDraft(Number(e.target.value))}
+        onMouseUp={(e) => { onCommit(Number(e.currentTarget.value)); setDraft(null) }}
+        onTouchEnd={(e) => { onCommit(Number(e.currentTarget.value)); setDraft(null) }}
+        onKeyUp={(e) => { onCommit(Number(e.currentTarget.value)); setDraft(null) }}
+        className="w-full accent-indigo-500 disabled:opacity-50"
+      />
+    </div>
+  )
+}
+
 interface DetailSidebarProps {
   id: string
   fallbackTitle: string
@@ -320,6 +356,8 @@ export default function DetailSidebar({
   const [committingProperty, setCommittingProperty] = useState<string | null>(null)
   const [audioOpen, setAudioOpen] = useState(false)
   const [committingAudioObjectId, setCommittingAudioObjectId] = useState<string | null>(null)
+  const [soundTracksOpen, setSoundTracksOpen] = useState(false)
+  const [committingSoundObjectId, setCommittingSoundObjectId] = useState<string | null>(null)
 
   const { data: item } = useQuery({
     queryKey: ['workshop-detail', id],
@@ -348,6 +386,10 @@ export default function DetailSidebar({
   const localPath = libraryMeta?.localPath
   const disabledObjects = libraryMeta?.disabledObjects ?? []
   const enabledObjects = libraryMeta?.enabledObjects ?? []
+  // item resolves to exactly null (not undefined) once a workshop lookup confirms the id no
+  // longer exists on Steam - voting on it would just fail, so treat it like the library's own
+  // "unavailable" flag and grey out the vote buttons below.
+  const unavailable = !!libraryMeta?.unavailable || item === null
 
   const { data: activeWallpaper } = useQuery({
     queryKey: ['active-wallpaper'],
@@ -394,6 +436,11 @@ export default function DetailSidebar({
   }
   const audioSensitivity = libraryMeta?.audioSensitivity ?? {}
 
+  // Sound objects (e.g. alternate music tracks) are already in the objects list fetched for the
+  // Layers section above - no separate query needed, just filter by type.
+  const soundObjects = objects.filter((o) => o.type === 'sound')
+  const soundVolume = libraryMeta?.soundVolume ?? {}
+
   // 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.
@@ -413,7 +460,8 @@ export default function DetailSidebar({
         cornerColor: patch.cornerColor,
         speed: patch.playbackSpeed,
         propertyOverrides: patch.propertyOverrides,
-        audioSensitivity: patch.audioSensitivity
+        audioSensitivity: patch.audioSensitivity,
+        soundVolume: patch.soundVolume
       })
       if (!ok) {
         await window.electronAPI.lwe.stop()
@@ -476,6 +524,16 @@ export default function DetailSidebar({
     }
   }
 
+  async function commitSoundVolume(objectId: string, volume: number) {
+    if (committingSoundObjectId) return
+    setCommittingSoundObjectId(objectId)
+    try {
+      await persistAndMaybeRelaunch({ soundVolume: { ...soundVolume, [objectId]: volume } })
+    } finally {
+      setCommittingSoundObjectId(null)
+    }
+  }
+
   const [volumeDraft, setVolumeDraft] = useState<number | null>(null)
   const [isCommittingVolume, setIsCommittingVolume] = useState(false)
   const volume = volumeDraft ?? libraryMeta?.volumeOverride ?? 15
@@ -592,10 +650,12 @@ export default function DetailSidebar({
   const genreTags = tags.filter(
     (t) => t !== typeTag && t !== ageTag && !resolutionTags.includes(t)
   )
-  const fileSizeLabel = formatFileSize(localFileSize)
+  // Prefer the real on-disk size for an already-downloaded item over Steam's reported upload
+  // size (item.fileSize) - they can differ once extracted, and the local number is exact.
+  const fileSizeLabel = formatFileSize(libraryMeta?.fileSize ?? item?.fileSize ?? localFileSize)
 
   async function handleLike() {
-    if (isLiked || isLiking) return
+    if (isLiked || isLiking || unavailable) return
     setIsLiking(true)
     try {
       await window.electronAPI.steam.vote(id, true)
@@ -606,7 +666,7 @@ export default function DetailSidebar({
   }
 
   async function handleDislike() {
-    if (isDisliking) return
+    if (isDisliking || unavailable) return
     setIsDisliking(true)
     try {
       await window.electronAPI.steam.vote(id, false)
@@ -1061,16 +1121,51 @@ export default function DetailSidebar({
           </div>
         )}
 
+        {localPath && lweInstalled && (objectsLoading || soundObjects.length > 0) && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={() => setSoundTracksOpen((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"
+            >
+              {soundTracksOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              <Volume2 size={12} />
+              <span className="flex-1 text-left">Sound Tracks</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {objectsLoading && <Loader2 size={11} className="animate-spin" />}
+            </button>
+            {soundTracksOpen && (
+              <div className="mt-1.5 space-y-0.5">
+                {soundObjects.map((obj) => (
+                  <SoundTrackRow
+                    key={obj.id}
+                    trackName={obj.name}
+                    volume={soundVolume[obj.id] ?? 0}
+                    busy={committingSoundObjectId === obj.id}
+                    onCommit={(volume) => commitSoundVolume(obj.id, volume)}
+                  />
+                ))}
+              </div>
+            )}
+          </div>
+        )}
+
         <div className="flex flex-col gap-1.5 border-t border-white/5 pt-3">
           <div className="flex gap-1.5">
             <button
               onClick={handleLike}
-              disabled={isLiked || isLiking}
+              disabled={isLiked || isLiking || unavailable}
+              title={unavailable ? 'Removed from the Steam Workshop - voting is no longer possible' : undefined}
               className={clsx(
-                'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-xs transition-colors disabled:cursor-default',
-                isLiked
-                  ? 'bg-green-600/30 text-green-300'
-                  : 'bg-white/5 text-gray-300 hover:bg-white/10'
+                'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-xs transition-colors disabled:cursor-not-allowed',
+                unavailable
+                  ? 'bg-white/5 text-gray-600 opacity-50'
+                  : isLiked
+                    ? 'bg-green-600/30 text-green-300'
+                    : 'bg-white/5 text-gray-300 hover:bg-white/10'
               )}
             >
               {isLiking ? <Loader2 size={12} className="animate-spin" /> : <ThumbsUp size={12} />}
@@ -1078,8 +1173,12 @@ export default function DetailSidebar({
             </button>
             <button
               onClick={handleDislike}
-              disabled={isDisliking}
-              className="flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-white/5 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10"
+              disabled={isDisliking || unavailable}
+              title={unavailable ? 'Removed from the Steam Workshop - voting is no longer possible' : undefined}
+              className={clsx(
+                'flex flex-1 items-center justify-center gap-1.5 rounded-lg py-1.5 text-xs transition-colors disabled:cursor-not-allowed',
+                unavailable ? 'bg-white/5 text-gray-600 opacity-50' : 'bg-white/5 text-gray-300 hover:bg-white/10'
+              )}
             >
               {isDisliking ? <Loader2 size={12} className="animate-spin" /> : <ThumbsDown size={12} />}
               Dislike

+ 0 - 3
src/renderer/src/components/library/LibraryView.tsx

@@ -658,7 +658,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
 
   const handleDrop = useCallback(
     async (folderId: string, wallpaperId: string) => {
-      // If the dragged item is in the selection, add all selected items
       const ids = selectedIds.has(wallpaperId)
         ? Array.from(selectedIds)
         : [wallpaperId]
@@ -1446,7 +1445,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
                   queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
                     old ? [...old, wallpaper.id] : [wallpaper.id]
                   )
-                  queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
                 }}
                 onUnsubscribed={() => {
                   queryClient.invalidateQueries({ queryKey: ['library'] })
@@ -1523,7 +1521,6 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
               queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
                 old ? [...old, detailId] : [detailId]
               )
-              queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
             }}
             onPlay={() => handleApplyDetail(detailId)}
             onBrowseCreator={onBrowseCreator}

+ 15 - 7
src/renderer/src/components/library/WallpaperCard.tsx

@@ -97,7 +97,7 @@ export default function WallpaperCard({
 
   async function handleLike(e: React.MouseEvent) {
     e.stopPropagation()
-    if (isLiked || isLiking) return
+    if (isLiked || isLiking || wallpaper.unavailable) return
     setIsLiking(true)
     try {
       await window.electronAPI.steam.vote(wallpaper.id, true)
@@ -283,14 +283,22 @@ export default function WallpaperCard({
         <div className={clsx('mt-2 flex items-center', compact ? 'gap-1' : 'gap-2')}>
           <button
             onClick={handleLike}
-            disabled={isLiked || isLiking}
-            title={isLiked ? 'Already liked on Steam' : 'Like on Steam'}
+            disabled={isLiked || isLiking || wallpaper.unavailable}
+            title={
+              wallpaper.unavailable
+                ? 'Removed from the Steam Workshop - voting is no longer possible'
+                : isLiked
+                  ? 'Already liked on Steam'
+                  : 'Like on Steam'
+            }
             className={clsx(
-              'flex items-center gap-1 rounded text-xs transition-colors disabled:cursor-default',
+              'flex items-center gap-1 rounded text-xs transition-colors disabled:cursor-not-allowed',
               compact ? 'p-1.5' : 'px-2 py-1',
-              isLiked
-                ? 'bg-green-600/30 text-green-300'
-                : 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-200'
+              wallpaper.unavailable
+                ? 'bg-white/5 text-gray-600 opacity-50'
+                : isLiked
+                  ? 'bg-green-600/30 text-green-300'
+                  : 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-200'
             )}
           >
             {isLiking ? <Loader2 size={11} className="animate-spin" /> : <ThumbsUp size={11} />}

+ 7 - 2
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -195,8 +195,13 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
 
   async function ctxVote(up: boolean) {
     if (!ctxMenu) return
-    await window.electronAPI.steam.vote(ctxMenu.wallpaperId, up)
-    queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+    const wallpaperId = ctxMenu.wallpaperId
+    await window.electronAPI.steam.vote(wallpaperId, up)
+    if (up) {
+      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+        old ? [...new Set([...old, wallpaperId])] : [wallpaperId]
+      )
+    }
     closeCtxMenu()
   }
 

+ 137 - 14
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,7 +1,7 @@
 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 } from 'lucide-react'
-import type { LweStatus, LweInstallProgress, LinuxDistro } from '../../../../shared/types'
+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'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
   fedora: 'Fedora / Nobara / RHEL',
@@ -53,6 +53,8 @@ export default function SettingsView() {
 
   const [trayEnabled, setTrayEnabled] = useState(false)
   const [killLweOnQuit, setKillLweOnQuit] = useState(false)
+  const [steamIdentity, setSteamIdentityState] = useState<SteamIdentity>('wallpaper-engine')
+  const [steamIdentitySaved, setSteamIdentitySaved] = useState(false)
   const [autostartSupported, setAutostartSupported] = useState(false)
   const [autostartEnabled, setAutostartEnabledState] = useState(false)
   const [autostartMinimized, setAutostartMinimized] = useState(false)
@@ -60,11 +62,13 @@ export default function SettingsView() {
   const [playlists, setPlaylists] = useState<{ id: string; title: string }[]>([])
 
   const [screens, setScreens] = useState<string[]>([])
+  const [screensRefreshing, setScreensRefreshing] = useState(false)
   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)
+  const [disablePuppetAnimation, setDisablePuppetAnimation] = useState(false)
 
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
@@ -80,6 +84,7 @@ export default function SettingsView() {
       setAutoUnsubscribeAfterBackup(cfg.autoUnsubscribeAfterBackup)
       setTrayEnabled(cfg.trayEnabled)
       setKillLweOnQuit(cfg.killLweOnQuit)
+      setSteamIdentityState(cfg.steamIdentity)
       setAutostartSupported(cfg.autostartSupported)
       setAutostartEnabledState(cfg.autostartEnabled)
       setAutostartMinimized(cfg.autostartMinimized)
@@ -87,6 +92,7 @@ export default function SettingsView() {
       setAudioScreen(cfg.audioScreen)
       setAmbientVolume(cfg.ambientVolume != null ? String(cfg.ambientVolume) : '')
       setDefaultAudioSensitivity(String(Math.round(cfg.defaultAudioSensitivity * 100)))
+      setDisablePuppetAnimation(cfg.disablePuppetAnimation)
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
@@ -267,6 +273,19 @@ export default function SettingsView() {
     await window.electronAPI.config.setKillLweOnQuit(newVal)
   }
 
+  async function handleDisablePuppetAnimationToggle() {
+    const newVal = !disablePuppetAnimation
+    setDisablePuppetAnimation(newVal)
+    await window.electronAPI.config.setDisablePuppetAnimation(newVal)
+  }
+
+  async function handleSteamIdentityChange(identity: SteamIdentity) {
+    setSteamIdentityState(identity)
+    await window.electronAPI.config.setSteamIdentity(identity)
+    setSteamIdentitySaved(true)
+    setTimeout(() => setSteamIdentitySaved(false), 3000)
+  }
+
   async function handleAudioScreenChange(screen: string | null) {
     setAudioScreen(screen)
     await window.electronAPI.config.setAudioScreen(screen)
@@ -274,6 +293,15 @@ export default function SettingsView() {
     await window.electronAPI.lwe.hotswapSettings({ audioScreen: screen ?? '' })
   }
 
+  async function handleRefreshScreens() {
+    setScreensRefreshing(true)
+    try {
+      setScreens(await window.electronAPI.lwe.listScreens())
+    } finally {
+      setScreensRefreshing(false)
+    }
+  }
+
   async function handleSaveAmbientVolume() {
     const parsed = ambientVolume.trim() === '' ? null : parseInt(ambientVolume, 10)
     if (parsed !== null && (isNaN(parsed) || parsed < 0 || parsed > 128)) return
@@ -546,18 +574,29 @@ export default function SettingsView() {
                 Restricts sound to a single monitor so wallpapers on other screens stay silent,
                 even if they have their own sound.
               </p>
-              <select
-                value={audioScreen ?? ''}
-                onChange={(e) => handleAudioScreenChange(e.target.value || null)}
-                className="w-56 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
-              >
-                <option value="">All screens (default)</option>
-                {screens.map((s) => (
-                  <option key={s} value={s}>
-                    {s}
-                  </option>
-                ))}
-              </select>
+              <div className="flex items-center gap-2">
+                <select
+                  value={audioScreen ?? ''}
+                  onChange={(e) => handleAudioScreenChange(e.target.value || null)}
+                  className="w-56 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
+                >
+                  <option value="">All screens (default)</option>
+                  {screens.map((s) => (
+                    <option key={s} value={s}>
+                      {s}
+                    </option>
+                  ))}
+                </select>
+                <button
+                  type="button"
+                  onClick={handleRefreshScreens}
+                  disabled={screensRefreshing}
+                  className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 outline-none hover:bg-white/10 disabled:opacity-50"
+                  title="Redetect connected screens"
+                >
+                  {screensRefreshing ? '...' : 'Redetect'}
+                </button>
+              </div>
             </div>
             <div>
               <label className="block text-xs text-gray-400 mb-1">Ambient wallpaper volume</label>
@@ -855,6 +894,35 @@ export default function SettingsView() {
           </div>
         </div>
 
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            Troubleshooting
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Freezes puppet (.mdl) meshes - rigged 2D character parts like eyes, hair, or limbs - at their bind
+            pose instead of animating them. Only applies to wallpapers played after enabling this; a wallpaper
+            already running needs to be replayed to pick it up.
+          </p>
+          <label className="mt-3 flex items-center gap-3 cursor-pointer">
+            <button
+              onClick={handleDisablePuppetAnimationToggle}
+              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                disablePuppetAnimation ? 'bg-indigo-600' : 'bg-white/10'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  disablePuppetAnimation ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+            <span className="flex items-center gap-2 text-sm text-gray-300">
+              <Bug size={16} />
+              Disable puppet mesh animation
+            </span>
+          </label>
+        </div>
+
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Desktop Icons
@@ -975,6 +1043,61 @@ export default function SettingsView() {
             </div>
           </div>
         </div>
+
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            Steam Integration
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            How WE Manager identifies itself to Steam. Takes effect after restarting WE Manager.
+          </p>
+
+          <div className="mt-3 flex rounded-lg overflow-hidden text-sm w-fit">
+            <button
+              onClick={() => handleSteamIdentityChange('wallpaper-engine')}
+              className={`flex items-center gap-2 px-3 py-2 transition-colors ${
+                steamIdentity === 'wallpaper-engine'
+                  ? 'bg-indigo-600 text-white'
+                  : 'bg-white/5 text-gray-400 hover:text-gray-200'
+              }`}
+            >
+              <Shield size={14} />
+              Steam app (Wallpaper Engine)
+            </button>
+            <button
+              onClick={() => handleSteamIdentityChange('standalone')}
+              className={`flex items-center gap-2 px-3 py-2 transition-colors ${
+                steamIdentity === 'standalone'
+                  ? 'bg-indigo-600 text-white'
+                  : 'bg-white/5 text-gray-400 hover:text-gray-200'
+              }`}
+            >
+              <Shield size={14} />
+              Own app (standalone)
+            </button>
+          </div>
+          {steamIdentitySaved && (
+            <p className="mt-2 text-xs text-green-400">Saved - restart WE Manager for this to take effect.</p>
+          )}
+          <p className="mt-2 text-xs text-gray-600">
+            {steamIdentity === 'wallpaper-engine' ? (
+              <>
+                Registers this process with Steam under Wallpaper Engine's own app id. Steam
+                considers WE Manager to be Wallpaper Engine itself, so it will offer to close it
+                when you log out or shut down Steam - the same as the official Windows app would
+                if you ran it standalone.
+              </>
+            ) : (
+              <>
+                Registers this process under a neutral, unrelated Steam app id instead, so Steam
+                doesn't associate WE Manager with Wallpaper Engine and won't try to close it on
+                logout. Workshop browsing, subscribing and voting still target Wallpaper Engine's
+                own workshop either way. This mode is less tested than the default - if
+                subscribing or voting misbehaves, switch back.
+              </>
+            )}
+          </p>
+        </div>
       </div>
     </div>
   )

+ 0 - 1
src/renderer/src/components/setup/SetupScreen.tsx

@@ -71,7 +71,6 @@ export default function SetupScreen({ defaultPath, onComplete }: SetupScreenProp
       <div className="w-full max-w-lg rounded-xl border border-white/10 bg-[#1a1a1a] p-8">
         <h1 className="text-xl font-semibold text-gray-100">Welcome to WE Manager</h1>
 
-        {/* Step indicator */}
         <div className="mt-4 flex items-center gap-2 text-xs text-gray-600">
           <span className={step === 1 ? 'text-indigo-400' : 'text-gray-500'}>
             1. Workshop folder

+ 98 - 11
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -10,7 +10,9 @@ import {
   User,
   ExternalLink,
   Play,
-  Download
+  Download,
+  EyeOff,
+  Eye
 } from 'lucide-react'
 import WorkshopCard from './WorkshopCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
@@ -186,6 +188,7 @@ export default function WorkshopBrowser({
   const [pageSize, setPageSize] = useState(50)
   const [previewSize, setPreviewSize] = usePreviewSize()
   const [detailId, setDetailId] = useState<string | null>(null)
+  const [showIgnored, setShowIgnored] = useState(false)
 
   useClickOutside(ctxRef, () => setCtxMenu(null), !!ctxMenu)
   const ctxPos = useClampedPosition(ctxRef, ctxMenu)
@@ -282,8 +285,13 @@ export default function WorkshopBrowser({
 
   async function ctxVote(up: boolean) {
     if (!ctxMenu) return
-    await forEachIgnoringErrors(ctxMenu.ids, (id) => window.electronAPI.steam.vote(id, up))
-    if (up) queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+    const ids = ctxMenu.ids
+    await forEachIgnoringErrors(ids, (id) => window.electronAPI.steam.vote(id, up))
+    if (up) {
+      queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) => [
+        ...new Set([...(old ?? []), ...ids])
+      ])
+    }
     closeCtxMenu()
   }
 
@@ -354,6 +362,12 @@ export default function WorkshopBrowser({
     queryFn: () => window.electronAPI.lwe.status()
   })
 
+  const { data: ignoredCreators = [] } = useQuery({
+    queryKey: ['ignored-creators'],
+    queryFn: () => window.electronAPI.config.get().then((cfg) => cfg.ignoredCreators)
+  })
+  const ignoredCreatorSet = useMemo(() => new Set(ignoredCreators), [ignoredCreators])
+
   async function handlePlay(id: string) {
     try {
       await window.electronAPI.wallpaper.apply({ wallpaperId: id })
@@ -370,8 +384,21 @@ export default function WorkshopBrowser({
     closeCtxMenu()
   }
 
+  async function ctxToggleIgnoreCreator() {
+    if (!ctxMenu || ctxMenu.ids.length !== 1) return
+    const item = allItems.find((i) => i.publishedFileId === ctxMenu.ids[0])
+    closeCtxMenu()
+    if (!item) return
+    if (ignoredCreatorSet.has(item.creatorSteamId)) {
+      await window.electronAPI.config.unignoreCreator(item.creatorSteamId)
+    } else {
+      await window.electronAPI.config.ignoreCreator(item.creatorSteamId)
+    }
+    queryClient.invalidateQueries({ queryKey: ['ignored-creators'] })
+  }
+
   // In OR mode: client-side OR filtering for multi-selected categories
-  const items = useMemo(() => {
+  const tagFiltered = useMemo(() => {
     if (filters.filterMode !== 'or') return allItems
     return allItems.filter((item) => {
       if (effectiveTypes.length > 1 && !effectiveTypes.some((t) => item.tags.includes(t)))
@@ -386,17 +413,33 @@ export default function WorkshopBrowser({
     })
   }, [allItems, filters.filterMode, effectiveTypes, effectiveAgeRatings, effectiveResolutions, effectiveGenres])
 
-  const totalFiltered = items.length
+  // Page boundaries are fixed against the full tag-filtered list (ignored creators' items
+  // included) so a page's contents don't shift around as the ignore list changes. Within a
+  // page, ignored items are pulled out of the normal grid and - only once "Show ignored" is
+  // on - appended after that same page's own shown items, never mixed into another page.
+  const totalFiltered = tagFiltered.length
   const totalPages = Math.max(1, Math.ceil(totalFiltered / pageSize))
   const safePage = Math.min(page, totalPages)
+  const rawPageItems = useMemo(
+    () => tagFiltered.slice((safePage - 1) * pageSize, safePage * pageSize),
+    [tagFiltered, safePage, pageSize]
+  )
+  const pageShownItems = useMemo(
+    () => rawPageItems.filter((item) => !ignoredCreatorSet.has(item.creatorSteamId)),
+    [rawPageItems, ignoredCreatorSet]
+  )
+  const pageIgnoredItems = useMemo(
+    () => rawPageItems.filter((item) => ignoredCreatorSet.has(item.creatorSteamId)),
+    [rawPageItems, ignoredCreatorSet]
+  )
   const paginatedItems = useMemo(
-    () => items.slice((safePage - 1) * pageSize, safePage * pageSize),
-    [items, safePage, pageSize]
+    () => (showIgnored ? [...pageShownItems, ...pageIgnoredItems] : pageShownItems),
+    [showIgnored, pageShownItems, pageIgnoredItems]
   )
 
   // Auto-fetch more Steam API pages if needed to fill the current view
   const totalResults = data?.pages[0]?.totalResults ?? 0
-  const needMore = safePage * pageSize > allItems.length && allItems.length < totalResults
+  const needMore = page * pageSize > allItems.length && allItems.length < totalResults
   useEffect(() => {
     if (needMore && hasNextPage && !isFetchingNextPage) {
       fetchNextPage()
@@ -819,7 +862,7 @@ export default function WorkshopBrowser({
               )}
             </div>
           )}
-          {!isLoading && items.length === 0 && !error && (
+          {!isLoading && tagFiltered.length === 0 && !error && (
             <div className="flex h-40 items-center justify-center text-gray-500">
               No results found
             </div>
@@ -830,6 +873,7 @@ export default function WorkshopBrowser({
                 key={item.publishedFileId}
                 item={item}
                 selected={selection.has(item.publishedFileId)}
+                ignored={ignoredCreatorSet.has(item.creatorSteamId)}
                 isLiked={votedSet.has(item.publishedFileId)}
                 canPlay={playableSet.has(item.publishedFileId)}
                 lweInstalled={lweStatus?.installed ?? false}
@@ -837,7 +881,12 @@ export default function WorkshopBrowser({
                 downloadPercentage={subscribeEntries.get(item.publishedFileId)?.percentage}
                 onSelect={(e) => handleCardSelect(item.publishedFileId, e)}
                 onContextMenu={(e) => openCtxMenu(e, item.publishedFileId)}
-                onLiked={() => queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })}
+                onLiked={() => {
+                  const votedId = item.publishedFileId
+                  queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+                    old ? [...old, votedId] : [votedId]
+                  )
+                }}
                 onPlay={() => handlePlay(item.publishedFileId)}
                 onSubscribe={() => enqueueSubscribe([item.publishedFileId])}
                 onOpenDetail={() => setDetailId(item.publishedFileId)}
@@ -860,6 +909,20 @@ export default function WorkshopBrowser({
               <Loader2 size={20} className="animate-spin" />
             </div>
           )}
+          {pageIgnoredItems.length > 0 && (
+            <div className="mt-4 flex items-center justify-center gap-2 border-t border-white/5 pt-3 text-xs text-gray-500">
+              <span>
+                {pageIgnoredItems.length} wallpaper{pageIgnoredItems.length === 1 ? '' : 's'} ignored on
+                this page
+              </span>
+              <button
+                onClick={() => setShowIgnored((v) => !v)}
+                className="rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10"
+              >
+                {showIgnored ? 'Hide ignored' : 'Show ignored'}
+              </button>
+            </div>
+          )}
           {totalFiltered > 0 && (
             <div className="mt-4 flex items-center justify-between border-t border-white/5 pt-3">
               <div className="flex items-center gap-2 text-xs text-gray-400">
@@ -911,7 +974,11 @@ export default function WorkshopBrowser({
             onClose={() => setDetailId(null)}
             onSubscribe={() => enqueueSubscribe([detailId])}
             onUnsubscribe={() => unsubscribeIds([detailId])}
-            onLiked={() => queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })}
+            onLiked={() => {
+              queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+                old ? [...old, detailId] : [detailId]
+              )
+            }}
             onPlay={() => handlePlay(detailId)}
             onBrowseCreator={onBrowseCreator}
           />
@@ -980,6 +1047,26 @@ export default function WorkshopBrowser({
               <User size={12} /> Browse wallpapers from this creator
             </button>
           )}
+          {ctxMenu.ids.length === 1 && (() => {
+            const ctxItem = allItems.find((i) => i.publishedFileId === ctxMenu.ids[0])
+            const ctxCreatorIgnored = ctxItem ? ignoredCreatorSet.has(ctxItem.creatorSteamId) : false
+            return (
+              <button
+                onClick={ctxToggleIgnoreCreator}
+                className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+              >
+                {ctxCreatorIgnored ? (
+                  <>
+                    <Eye size={12} /> Unignore this creator
+                  </>
+                ) : (
+                  <>
+                    <EyeOff size={12} /> Ignore this creator
+                  </>
+                )}
+              </button>
+            )
+          })()}
         </div>
       )}
     </div>

+ 18 - 2
src/renderer/src/components/workshop/WorkshopCard.tsx

@@ -4,10 +4,12 @@ import type { WorkshopItem } from '@shared/types'
 import type { SubscribeState } from '../../hooks/useSubscriptionQueue'
 import clsx from 'clsx'
 import { openWorkshopPage } from '../../utils/steam'
+import { formatFileSize } from '../../utils/format'
 
 interface WorkshopCardProps {
   item: WorkshopItem
   selected?: boolean
+  ignored?: boolean
   isLiked?: boolean
   canPlay?: boolean
   lweInstalled?: boolean
@@ -24,6 +26,7 @@ interface WorkshopCardProps {
 export default function WorkshopCard({
   item,
   selected = false,
+  ignored = false,
   isLiked = false,
   canPlay = false,
   lweInstalled = false,
@@ -39,6 +42,8 @@ export default function WorkshopCard({
   const [isLiking, setIsLiking] = useState(false)
   const [isApplying, setIsApplying] = useState(false)
 
+  const fileSizeLabel = formatFileSize(item.fileSize)
+
   const subscribed =
     item.isSubscribed ||
     subscribeState === 'download-queued' ||
@@ -115,12 +120,20 @@ export default function WorkshopCard({
           <img
             src={item.previewUrl}
             alt={item.title}
-            className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
+            className={clsx(
+              'h-full w-full object-cover transition-transform duration-300 group-hover:scale-105',
+              ignored && 'brightness-50'
+            )}
             loading="lazy"
           />
         ) : (
           <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
         )}
+        {ignored && (
+          <div className="absolute bottom-2 left-2 rounded bg-black/70 px-1.5 py-0.5 text-[10px] text-gray-300">
+            Ignored
+          </div>
+        )}
         {subscribeState === 'downloading' && downloadPercentage != null && (
           <div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
             <div
@@ -160,7 +173,10 @@ export default function WorkshopCard({
         <h3 className="truncate text-sm font-medium text-gray-200" title={item.title}>
           {item.title}
         </h3>
-        <p className="mt-1 text-xs text-gray-500">{item.subscriptions.toLocaleString()} subscribers</p>
+        <p className="mt-1 text-xs text-gray-500">
+          {item.subscriptions.toLocaleString()} subscribers
+          {fileSizeLabel ? ` · ${fileSizeLabel}` : ''}
+        </p>
         <div className="mt-2 flex items-center gap-1.5">
           {onLiked && (
             <button

+ 6 - 0
src/renderer/src/utils/format.ts

@@ -0,0 +1,6 @@
+export function formatFileSize(bytes?: number): string | null {
+  if (!bytes) return null
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
+  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`
+  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
+}

+ 5 - 0
src/shared/constants.ts

@@ -1,5 +1,10 @@
 export const WE_APP_ID = 431960
 
+// Valve's public test app ("Spacewar") - every Steam account owns it and it's never actually
+// "installed", so the Steam client doesn't treat a process init'd under it as a running game
+// that needs to be closed on logout, unlike WE_APP_ID above.
+export const STANDALONE_APP_ID = 480
+
 export const DEFAULT_LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
 
 // CEF's own windowless_frame_rate is set to max(60, maximumFPS) regardless of --fps (see

+ 7 - 1
src/shared/ipc-channels.ts

@@ -60,6 +60,10 @@ export const IpcChannels = {
   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_SET_DISABLE_PUPPET_ANIMATION: 'config:set-disable-puppet-animation',
+  CONFIG_SET_STEAM_IDENTITY: 'config:set-steam-identity',
+  CONFIG_IGNORE_CREATOR: 'config:ignore-creator',
+  CONFIG_UNIGNORE_CREATOR: 'config:unignore-creator',
   CONFIG_PICK_FOLDER: 'config:pick-folder',
   CONFIG_PICK_FILE: 'config:pick-file',
   CONFIG_IMPORT_WE: 'config:import-we',
@@ -127,7 +131,9 @@ export const IpcChannels = {
   EVENT_STEAM_STATUS: 'event:steam-status',
   EVENT_LWE_INSTALL_PROGRESS: 'event:lwe-install-progress',
   EVENT_BACKUP_PROGRESS: 'event:backup-progress',
-  EVENT_PLAYLIST_STATE_CHANGED: 'event:playlist-state-changed'
+  EVENT_PLAYLIST_STATE_CHANGED: 'event:playlist-state-changed',
+  EVENT_VOTED_IDS_CHANGED: 'event:voted-ids-changed',
+  EVENT_LIBRARY_CHANGED: 'event:library-changed'
 } as const
 
 export type IpcChannel = (typeof IpcChannels)[keyof typeof IpcChannels]

+ 11 - 0
src/shared/types.ts

@@ -24,6 +24,9 @@ export type WorkshopQueryType =
 
 export type ContentRating = 'everyone' | 'questionable' | 'mature' | 'uncategorized'
 
+/** How this process identifies itself to Steam - see config.service.ts getSteamIdentity(). */
+export type SteamIdentity = 'wallpaper-engine' | 'standalone'
+
 export interface WallpaperMeta {
   id: string
   title: string
@@ -83,6 +86,12 @@ export interface WallpaperMeta {
    * defaultAudioSensitivity from the global config instead.
    */
   audioSensitivity?: Record<string, number>
+  /**
+   * Sound object id -> volume (0-1), applied live via linux-wallpaperengine's sound-volume
+   * hotswap. Independent of the global volume - lets a wallpaper with several alternate music
+   * tracks (each its own Sound object) play only one.
+   */
+  soundVolume?: Record<string, number>
 }
 
 export interface LweSceneObject {
@@ -133,6 +142,8 @@ export interface WorkshopItem {
   upvotes: number
   downvotes: number
   isSubscribed: boolean
+  /** Size in bytes as reported by the Steam Workshop (same figure shown on the item's Steam page); undefined if unavailable. */
+  fileSize?: number
 }
 
 export interface WorkshopQueryParams {