Bladeren bron

added plenty of new features, browsing by author, support for layers and xray

UwU 2 maanden geleden
bovenliggende
commit
84d2966f49

+ 1 - 1
package.json

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

+ 8 - 3
src/main/index.ts

@@ -56,10 +56,15 @@ function createWindow(): BrowserWindow {
 }
 
 app.whenReady().then(() => {
-  // Serve local wallpaper files via custom protocol
-  protocol.handle('wallpaper', (request) => {
+  protocol.handle('wallpaper', async (request) => {
     const filePath = decodeURIComponent(request.url.replace('wallpaper://', ''))
-    return net.fetch(pathToFileURL(filePath).toString())
+    try {
+      return await net.fetch(pathToFileURL(filePath).toString())
+    } catch {
+      // File not on disk yet (e.g. still downloading) - a rejected fetch here
+      // would otherwise surface as an uncaught net::ERR_FILE_NOT_FOUND
+      return new Response(null, { status: 404 })
+    }
   })
 
   const steamOk = initSteam()

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

@@ -6,6 +6,8 @@ import {
   isWorkshopPathConfigured,
   getDefaultFps,
   setDefaultFps,
+  getRecommendedFpsEnabled,
+  setRecommendedFpsEnabled,
   getLweRepoUrl,
   getLweRepoBranch,
   setLweRepo,
@@ -41,6 +43,7 @@ export function registerConfigHandlers(): void {
     defaultWorkshopPath: getDefaultWorkshopPath(),
     isConfigured: isWorkshopPathConfigured(),
     defaultFps: getDefaultFps(),
+    recommendedFpsEnabled: getRecommendedFpsEnabled(),
     lweRepoUrl: getLweRepoUrl(),
     lweRepoBranch: getLweRepoBranch(),
     lweCmakeArgs: getLweCmakeArgs(),
@@ -99,6 +102,11 @@ export function registerConfigHandlers(): void {
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.CONFIG_SET_RECOMMENDED_FPS, (_e, enabled: boolean) => {
+    setRecommendedFpsEnabled(enabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_LWE_REPO, (_e, url: string | null, branch: string | null) => {
     setLweRepo(url, branch)
     return { ok: true }
@@ -137,9 +145,7 @@ export function registerConfigHandlers(): void {
   })
 
   ipcMain.handle(IpcChannels.CONFIG_IMPORT_WE, (_e, sourcePath: string) => {
-    // 1. Copy + fix paths
     const destPath = importWEConfigFile(sourcePath)
-    // 2. Import folders/playlists into our library store
     const result = importWEConfig(destPath)
     return { ...result, configPath: destPath }
   })

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

@@ -51,4 +51,8 @@ export function registerLibraryHandlers(): void {
   ipcMain.handle(IpcChannels.LIBRARY_RESET_FPS_OVERRIDES, () => {
     return library.resetAllFpsOverrides()
   })
+
+  ipcMain.handle(IpcChannels.LIBRARY_CHECK_UNAVAILABLE, () => {
+    return library.checkUnavailableWallpapers()
+  })
 }

+ 29 - 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 } from '../services/lwe.service'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, stopLwe, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties } from '../services/lwe.service'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 
 export function registerLweHandlers(win: BrowserWindow): void {
@@ -42,4 +42,32 @@ export function registerLweHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.LWE_KILL_ALL, async () => {
     return await killAllLweProcesses()
   })
+
+  ipcMain.handle(IpcChannels.LWE_LIST_OBJECTS, async (_e, wallpaperPath: string) => {
+    // Some wallpapers have project data linux-wallpaperengine itself can't parse; treat that as "no objects" rather than a hard error
+    try {
+      return await listLweObjects(wallpaperPath)
+    } catch (err) {
+      console.warn('[LWE] list-objects failed for', wallpaperPath, ':', (err as Error).message)
+      return []
+    }
+  })
+
+  ipcMain.handle(
+    IpcChannels.LWE_HOTSWAP_SETTINGS,
+    (_e, options: { disabledObjects?: string[]; enabledObjects?: string[]; volume?: number; xray?: boolean }) => {
+      return { ok: hotswapLweSettings(options) }
+    }
+  )
+
+  ipcMain.handle(IpcChannels.LWE_LIST_PROPERTIES, async (_e, wallpaperPath: string) => {
+    // Same reasoning as list-objects: some wallpapers have properties linux-wallpaperengine
+    // itself can't parse, treat that as "no properties" rather than a hard error.
+    try {
+      return await listLweProperties(wallpaperPath)
+    } catch (err) {
+      console.warn('[LWE] list-properties failed for', wallpaperPath, ':', (err as Error).message)
+      return []
+    }
+  })
 }

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

@@ -106,4 +106,8 @@ export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_INSTALL_INFO, (_e, itemId: string) => {
     return steam.getInstallInfo(BigInt(itemId))
   })
+
+  ipcMain.handle(IpcChannels.STEAM_GET_AUTHOR_INFO, (_e, steamId: string) => {
+    return steam.getAuthorInfo(steamId)
+  })
 }

+ 9 - 2
src/main/ipc/workshop.handlers.ts

@@ -1,7 +1,7 @@
 import { ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import { queryWorkshop, getWorkshopItem } from '../services/workshop.service'
-import type { WorkshopQueryParams } from '@shared/types'
+import { queryWorkshop, getWorkshopItem, queryWorkshopByCreator } from '../services/workshop.service'
+import type { WorkshopQueryParams, CreatorWorkshopQueryParams } from '@shared/types'
 
 export function registerWorkshopHandlers(): void {
   ipcMain.handle(IpcChannels.WORKSHOP_QUERY, async (_e, params: WorkshopQueryParams) => {
@@ -11,4 +11,11 @@ export function registerWorkshopHandlers(): void {
   ipcMain.handle(IpcChannels.WORKSHOP_GET_ITEM, async (_e, publishedFileId: string) => {
     return getWorkshopItem(publishedFileId)
   })
+
+  ipcMain.handle(
+    IpcChannels.WORKSHOP_QUERY_BY_CREATOR,
+    async (_e, creatorSteamId: string, params?: CreatorWorkshopQueryParams) => {
+      return queryWorkshopByCreator(creatorSteamId, params)
+    }
+  )
 }

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

@@ -17,6 +17,7 @@ interface AppConfig {
   autostartMinimized: boolean
   autostartPlaylistId: string | null
   killLweOnQuit: boolean
+  recommendedFpsEnabled: boolean
 }
 
 const store = new Store<AppConfig>({
@@ -24,6 +25,7 @@ const store = new Store<AppConfig>({
   defaults: {
     workshopPath: null,
     defaultFps: null,
+    recommendedFpsEnabled: false,
     lweRepoUrl: null,
     lweRepoBranch: null,
     lweCmakeArgs: null,
@@ -114,6 +116,15 @@ export function setDefaultFps(fps: number | null): void {
   store.set('defaultFps', fps)
 }
 
+/** When enabled, video wallpapers without a manual FPS override are launched at their own native frame rate. */
+export function getRecommendedFpsEnabled(): boolean {
+  return store.get('recommendedFpsEnabled')
+}
+
+export function setRecommendedFpsEnabled(enabled: boolean): void {
+  store.set('recommendedFpsEnabled', enabled)
+}
+
 /** Custom linux-wallpaperengine repo (git URL or local path); null = official repo. */
 export function getLweRepoUrl(): string | null {
   return store.get('lweRepoUrl')
@@ -159,12 +170,9 @@ export function importWEConfigFile(sourcePath: string): string {
   //   D:/Games/SteamLibrary/steamapps/workshop/content/431960/...
   //   C:/Program Files (x86)/Steam/steamapps/workshop/content/431960/...
   // We need to replace everything up to and including "431960/" with the configured workshop path + "/"
-  // Match any path prefix ending in /431960/ (Windows or Linux style)
   const appId = String(WE_APP_ID)
   const pattern = new RegExp(
-    // Matches: optional drive letter + any path chars + /431960/
     `[A-Za-z]:[^"]*?/steamapps/workshop/content/${appId}/|` +
-    // Also match Linux-style paths
     `/[^"]*?/steamapps/workshop/content/${appId}/`,
     'g'
   )

+ 93 - 18
src/main/services/library.service.ts

@@ -5,7 +5,12 @@ import * as path from 'path'
 import * as fs from 'fs'
 import { randomUUID } from 'crypto'
 import { cleanupPlaylists } from './playlist.service'
-import { getWorkshopTimesUpdated, getWorkshopTags } from './workshop.service'
+import {
+  getWorkshopTimesUpdated,
+  getWorkshopTags,
+  getWorkshopAuthors,
+  getUnavailableWorkshopItems
+} from './workshop.service'
 import { findAndMutate } from '../utils/collections'
 import { isSteamRunning, getItemDownloadStatus, getSubscribedItems } from './steam.service'
 
@@ -52,7 +57,6 @@ export function getAllWallpapers(filters?: LibraryFilters): WallpaperMeta[] {
     results = results.filter((w) => filters.categories!.some((cat) => w.categories.includes(cat)))
   }
 
-  // Sort
   const sortBy = filters?.sortBy ?? 'updatedAt'
   const dir = filters?.sortDir === 'asc' ? 1 : -1
 
@@ -208,6 +212,14 @@ async function safeGetWorkshopTags(ids: string[]): Promise<Map<string, string[]>
   }
 }
 
+async function safeGetWorkshopAuthors(ids: string[]): Promise<Map<string, string>> {
+  try {
+    return await getWorkshopAuthors(ids)
+  } catch {
+    return new Map()
+  }
+}
+
 export function getDirSize(dirPath: string): number {
   let total = 0
   try {
@@ -236,10 +248,18 @@ function buildMeta(
   localPath: string,
   pj: ProjectJson | null,
   existing?: WallpaperMeta,
-  workshopTimeUpdated?: number
+  workshopTimeUpdated?: number,
+  authorSteamId?: string
 ): WallpaperMeta {
   const now = Date.now()
   const { downloading: _d, downloadFailed: _df, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
+  // project.json can land on disk before the preview image it references has
+  // finished downloading (the watcher imports shortly after the folder shows
+  // up) - pointing previewLocal at a file that doesn't exist yet makes the
+  // wallpaper:// protocol handler fail with net::ERR_FILE_NOT_FOUND. Leaving
+  // it unset here falls back to the "No preview" placeholder, and the next
+  // library scan picks up the real file once it exists.
+  const previewPath = pj?.preview ? path.join(localPath, pj.preview) : undefined
   return {
     ...existingRest,
     id: workshopId,
@@ -247,7 +267,7 @@ function buildMeta(
     type: normalizeType(pj?.type),
     contentRating: normalizeRating(pj?.contentrating),
     description: pj?.description,
-    previewLocal: pj?.preview ? path.join(localPath, pj.preview) : undefined,
+    previewLocal: previewPath && fs.existsSync(previewPath) ? previewPath : undefined,
     previewUrl: undefined,
     localPath,
     file: pj?.file,
@@ -256,7 +276,8 @@ function buildMeta(
     updatedAt: workshopTimeUpdated ? workshopTimeUpdated * 1000 : (existing?.updatedAt ?? now),
     subscribed: true,
     source: 'workshop',
-    tags: pj?.tags ?? existing?.tags ?? []
+    tags: pj?.tags ?? existing?.tags ?? [],
+    authorSteamId: authorSteamId ?? existing?.authorSteamId
   }
 }
 
@@ -264,7 +285,8 @@ function buildIncompleteMeta(
   workshopId: string,
   localPath: string,
   existing?: WallpaperMeta,
-  workshopTags?: string[]
+  workshopTags?: string[],
+  authorSteamId?: string
 ): WallpaperMeta {
   const now = Date.now()
   const { downloading, failed } = isSteamRunning()
@@ -288,7 +310,8 @@ function buildIncompleteMeta(
     tags,
     categories: existing?.categories ?? [],
     downloading,
-    downloadFailed: failed
+    downloadFailed: failed,
+    authorSteamId: authorSteamId ?? existing?.authorSteamId
   }
 }
 
@@ -297,16 +320,18 @@ export async function importWallpaperById(workshopId: string): Promise<Wallpaper
   if (!fs.existsSync(localPath)) return null
 
   const existing = getWallpaper(workshopId) ?? undefined
+  const authorSteamId =
+    existing?.authorSteamId ?? (await safeGetWorkshopAuthors([workshopId])).get(workshopId)
   const pj = readProjectJson(localPath)
   if (!pj) {
     const tags = await safeGetWorkshopTags([workshopId])
-    const meta = buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId))
+    const meta = buildIncompleteMeta(workshopId, localPath, existing, tags.get(workshopId), authorSteamId)
     upsertWallpaper(meta)
     return meta
   }
 
   const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
-  const meta = buildMeta(workshopId, localPath, pj, existing, timesUpdated.get(workshopId))
+  const meta = buildMeta(workshopId, localPath, pj, existing, timesUpdated.get(workshopId), authorSteamId)
   upsertWallpaper(meta)
   return meta
 }
@@ -366,10 +391,18 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     incomplete.push({ id, localPath: path.join(workshopPath, id) })
   }
 
+  // Author never changes once known, so only fetch it for items that don't have it yet
+  const missingAuthorIds = [
+    ...incomplete.map((i) => i.id),
+    ...toRebuild.map(({ entry }) => entry.name),
+    ...cacheHits
+  ].filter((id) => !wallpapers[id]?.authorSteamId)
+  const authors = await safeGetWorkshopAuthors(missingAuthorIds)
+
   const incompleteTags = await safeGetWorkshopTags(incomplete.map((i) => i.id))
   for (const { id, localPath } of incomplete) {
     const existing = wallpapers[id]
-    const meta = buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id))
+    const meta = buildIncompleteMeta(id, localPath, existing, incompleteTags.get(id), authors.get(id))
     if (!existing) {
       wallpapers[id] = meta
       imported++
@@ -377,7 +410,8 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
       existing.downloading !== meta.downloading ||
       existing.downloadFailed !== meta.downloadFailed ||
       existing.type !== meta.type ||
-      existing.contentRating !== meta.contentRating
+      existing.contentRating !== meta.contentRating ||
+      existing.authorSteamId !== meta.authorSteamId
     ) {
       wallpapers[id] = {
         ...existing,
@@ -385,7 +419,8 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
         downloadFailed: meta.downloadFailed,
         type: meta.type,
         contentRating: meta.contentRating,
-        tags: meta.tags
+        tags: meta.tags,
+        authorSteamId: meta.authorSteamId
       }
       skipped++
     } else {
@@ -404,7 +439,8 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
       localPath,
       pj,
       wallpapers[entry.name] ?? undefined,
-      timesUpdated.get(entry.name)
+      timesUpdated.get(entry.name),
+      authors.get(entry.name)
     )
     imported++
   }
@@ -412,10 +448,16 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   let resynced = false
   for (const id of cacheHits) {
     const seconds = timesUpdated.get(id)
-    if (seconds === undefined) continue
-    const newUpdatedAt = seconds * 1000
-    if (wallpapers[id].updatedAt !== newUpdatedAt) {
-      wallpapers[id] = { ...wallpapers[id], updatedAt: newUpdatedAt }
+    const authorId = authors.get(id)
+    const patch: Partial<WallpaperMeta> = {}
+    if (seconds !== undefined && wallpapers[id].updatedAt !== seconds * 1000) {
+      patch.updatedAt = seconds * 1000
+    }
+    if (authorId && wallpapers[id].authorSteamId !== authorId) {
+      patch.authorSteamId = authorId
+    }
+    if (Object.keys(patch).length > 0) {
+      wallpapers[id] = { ...wallpapers[id], ...patch }
       resynced = true
     }
   }
@@ -433,7 +475,40 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   return { imported, skipped, removed }
 }
 
-// Folders
+// Flags library items whose Steam Workshop listing has been taken down (the
+// 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 }
+
+  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 }
+
+  let unavailableIds: Set<string>
+  try {
+    unavailableIds = await getUnavailableWorkshopItems(candidates.map((w) => w.id))
+  } catch {
+    // Steam request failed outright - leave existing unavailable flags alone
+    // rather than reporting everything as available again.
+    return { checked: 0, unavailable: 0 }
+  }
+
+  let changed = false
+  for (const w of candidates) {
+    const flag = unavailableIds.has(w.id)
+    if (!!w.unavailable !== flag) {
+      wallpapers[w.id] = { ...w, unavailable: flag }
+      changed = true
+    }
+  }
+  if (changed) store.set('wallpapers', wallpapers)
+
+  return { checked: candidates.length, unavailable: unavailableIds.size }
+}
 
 export function getAllFolders(): WallpaperFolder[] {
   return store.get('folders')

+ 226 - 12
src/main/services/lwe.service.ts

@@ -1,11 +1,11 @@
-import { execFile, spawn, type ChildProcess } from 'child_process'
+import { execFile, execFileSync, spawn, type ChildProcess } from 'child_process'
 import { promisify } from 'util'
 import * as fs from 'fs'
 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 } from '@shared/types'
+import type { LweStatus, LweInstallProgress, LinuxDistro, LweSceneObject, LweProperty } from '@shared/types'
 import {
   isCommandAvailable,
   invalidateCommandCache,
@@ -315,7 +315,6 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
       encoding: 'utf8'
     })
 
-    // Build
     send({ stage: 'building', message: 'Compiling (this may take a few minutes)...', percentage: 40 })
     const cores = Math.max(1, os.cpus().length - 1)
     await execFileAsync('make', ['-j', String(cores)], {
@@ -373,6 +372,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
 
     // Invalidate cache so next status check re-detects
     invalidateCommandCache(LWE_BINARY)
+    invalidateObjectFlagsSupport()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -435,7 +435,6 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
     return { ok: false, message: 'linux-wallpaperengine is not installed.' }
   }
 
-  // Stop any running instance first
   stopLwe()
 
   const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
@@ -485,8 +484,8 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
 
     try { fs.rmSync(getLweManifestPath(), { force: true }) } catch { /* ignore */ }
 
-    // Invalidate caches
     invalidateCommandCache(LWE_BINARY)
+    invalidateObjectFlagsSupport()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -538,7 +537,6 @@ function getLweLdLibraryPath(): string {
     '/usr/local/lib64',
     '/usr/local/lib'
   ]
-  // Only include paths that actually exist
   const paths = candidates.filter(p => {
     try { return fs.statSync(p).isDirectory() } catch { return false }
   })
@@ -578,7 +576,6 @@ function buildLweEnvVars(): string[] {
   const sessionType = process.env.XDG_SESSION_TYPE
   if (sessionType) vars.push(`XDG_SESSION_TYPE=${sessionType}`)
 
-  // DISPLAY for X11/XWayland
   if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
 
   // XDG_RUNTIME_DIR is needed for the Wayland socket
@@ -587,10 +584,42 @@ function buildLweEnvVars(): string[] {
   return vars
 }
 
-/** Build the common LWE args (assets dir, screen roots, fps, volume). */
+// Not every linux-wallpaperengine build has --list-objects/--disable-object/--enable-object
+// (they're recent additions), so probe --help once and cache the result rather than assuming.
+let objectFlagsSupported: boolean | null = null
+
+function invalidateObjectFlagsSupport(): void {
+  objectFlagsSupported = null
+}
+
+function supportsObjectFlags(): boolean {
+  if (objectFlagsSupported !== null) return objectFlagsSupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    objectFlagsSupported = stdout.includes('--list-objects')
+  } catch {
+    objectFlagsSupported = false
+  }
+  return objectFlagsSupported
+}
+
+/** Build the common LWE args (assets dir, screen roots, fps, volume, object overrides). */
 function buildLweArgs(
   wallpaperPath: string,
-  options: { screenRoot?: string; fps?: number; volume?: number }
+  options: {
+    screenRoot?: string
+    fps?: number
+    volume?: number
+    disabledObjects?: string[]
+    enabledObjects?: string[]
+    propertyOverrides?: Record<string, string>
+  }
 ): string[] {
   const args: string[] = []
   const assetsDir = findWeAssetsDir()
@@ -606,6 +635,13 @@ function buildLweArgs(
 
   if (options.fps) args.push('--fps', String(options.fps))
   if (options.volume !== undefined) args.push('--volume', String(options.volume))
+  if ((options.disabledObjects?.length || options.enabledObjects?.length) && supportsObjectFlags()) {
+    for (const id of options.disabledObjects ?? []) args.push('--disable-object', id)
+    for (const id of options.enabledObjects ?? []) args.push('--enable-object', id)
+  }
+  for (const [name, value] of Object.entries(options.propertyOverrides ?? {})) {
+    args.push('--set-property', `${name}=${value}`)
+  }
   args.push(wallpaperPath)
   return args
 }
@@ -630,13 +666,64 @@ function hotReloadLwe(wallpaperPath: string): boolean {
   }
 }
 
+/**
+ * Pushes layer/volume/xray changes to an already-running instance via the extended
+ * key=value control file, without touching the background path. Layers, volume and
+ * xray are independent in the protocol: a volume-only request never reloads the
+ * project, and a layers-only request doesn't need to resend the path. disabledObjects/
+ * enabledObjects being present (even as empty arrays) is what tells the engine to
+ * replace its override lists - omit both to leave layers untouched. xray toggles the
+ * "xray" scene effect's reveal spot between following the mouse (off) and covering the
+ * whole masked area (on) - it's a live-only setting with no launch-time equivalent, so
+ * it has to be pushed here even right after a fresh launch.
+ */
+export function hotswapLweSettings(options: {
+  disabledObjects?: string[]
+  enabledObjects?: string[]
+  volume?: number
+  xray?: boolean
+}): boolean {
+  if (!activeProcess || activeProcess.exitCode !== null) return false
+
+  const lines: string[] = []
+  if (options.disabledObjects !== undefined || options.enabledObjects !== undefined) {
+    lines.push('layers=1')
+    for (const id of options.disabledObjects ?? []) lines.push(`disable-object=${id}`)
+    for (const id of options.enabledObjects ?? []) lines.push(`enable-object=${id}`)
+  }
+  if (options.volume !== undefined) lines.push(`volume=${options.volume}`)
+  if (options.xray !== undefined) lines.push(`xray=${options.xray ? 'on' : 'off'}`)
+  if (lines.length === 0) return false
+
+  try {
+    fs.writeFileSync(getControlFilePath(), lines.join('\n') + '\n')
+    activeProcess.kill('SIGUSR1')
+    console.log('[LWE] Hotswap settings sent:', lines.join(' '))
+    return true
+  } catch (err) {
+    console.error('[LWE] Hotswap settings failed:', err)
+    return false
+  }
+}
+
 /** Launch LWE and resolve after 2s if still running; reject if it exits with error (so UI can show message). */
 export function launchLweAsync(
   wallpaperPath: string,
-  options: { screenRoot?: string; fps?: number; volume?: number } = {}
+  options: {
+    screenRoot?: string
+    fps?: number
+    volume?: number
+    disabledObjects?: string[]
+    enabledObjects?: string[]
+    propertyOverrides?: Record<string, string>
+    xrayFullReveal?: boolean
+  } = {}
 ): Promise<void> {
-  // Hot-reload if already running
+  // Hot-reload if already running. xray has no launch flag and the running instance
+  // keeps its own xray state across a background swap, so it needs an explicit push
+  // even when the new wallpaper wants it off.
   if (isLweRunning() && hotReloadLwe(wallpaperPath)) {
+    if (options.xrayFullReveal !== undefined) hotswapLweSettings({ xray: options.xrayFullReveal })
     return Promise.resolve()
   }
 
@@ -675,8 +762,13 @@ export function launchLweAsync(
     }
 
     let settled = false
+    // A rapid stop+relaunch can leave the old process still shutting down (CEF/GL
+    // teardown isn't instant) when its exit event finally arrives - only clear
+    // activeProcess if it's still pointing at this child, not a newer one that
+    // superseded it, otherwise the newer launch's own "still running" resolve
+    // check below never passes and its promise hangs forever.
     const cleanup = () => {
-      activeProcess = null
+      if (activeProcess === child) activeProcess = null
     }
 
     child.on('exit', (code) => {
@@ -706,12 +798,134 @@ export function launchLweAsync(
     setTimeout(() => {
       if (!settled && activeProcess === child && child.exitCode === null) {
         settled = true
+        if (options.xrayFullReveal) hotswapLweSettings({ xray: true })
         resolve()
       }
     }, 2000)
   })
 }
 
+// Matches lines like "  123 - Layer Name (image)" printed by --list-objects
+const OBJECT_LINE_RE = /^\s*(\S+)\s-\s(.*)\s\((image|particle|text|sound|unknown)\)\s*$/
+
+function parseLweObjectList(output: string): LweSceneObject[] {
+  const objects: LweSceneObject[] = []
+  for (const line of output.split('\n')) {
+    const m = line.match(OBJECT_LINE_RE)
+    if (m) objects.push({ id: m[1], name: m[2], type: m[3] as LweSceneObject['type'] })
+  }
+  return objects
+}
+
+// Returns [] for non-scene wallpapers (video/web print no objects)
+export async function listLweObjects(wallpaperPath: string): Promise<LweSceneObject[]> {
+  const status = getLweStatus()
+  if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
+  // Older builds silently ignore unknown flags and fall through to actually rendering the
+  // wallpaper instead of exiting, so this must never be sent to a binary that doesn't support it.
+  if (!supportsObjectFlags()) return []
+
+  const binaryPath = getLweBinaryPath()
+  const args = ['--list-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 parseLweObjectList(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 wallpaper objects: ${msg}`)
+  }
+}
+
+// Parses --list-properties output, e.g.:
+//   background_edit_brightness - slider
+//   	Text: Brightness
+//   	Min: 0
+//   	Max: 200
+//   	Step: 0
+//   	Value: 100.000000
+//
+//   clock_font - combo
+//   	Text: ui_clock_font
+//   	Value: impact
+//   Values:
+//   		segoe ui = ui_clock_font_4
+// Combo option values (before " = ") can contain spaces (e.g. font names), so that
+// line can't be split on whitespace - only on the literal " = " separator.
+const PROPERTY_HEADER_RE =
+  /^(\S+) - (slider|boolean|color|combo|text|scene texture|file|textinput)$/
+const PROPERTY_DETAIL_RE = /^\t(Text|Value|Min|Max|Step): (.*)$/
+const PROPERTY_COMBO_OPTION_RE = /^\t\t(.+?) = (.*)$/
+
+function parseLweProperties(output: string): LweProperty[] {
+  const properties: LweProperty[] = []
+  let current: LweProperty | null = null
+
+  for (const line of output.split('\n')) {
+    const header = line.match(PROPERTY_HEADER_RE)
+    if (header) {
+      if (current) properties.push(current)
+      const type = header[2] === 'scene texture' ? 'scene-texture' : (header[2] as LweProperty['type'])
+      current = { name: header[1], type, value: '' }
+      continue
+    }
+    if (!current) continue
+
+    const comboOption = line.match(PROPERTY_COMBO_OPTION_RE)
+    if (comboOption) {
+      current.options ??= []
+      current.options.push({ value: comboOption[1], label: comboOption[2] })
+      continue
+    }
+
+    const detail = line.match(PROPERTY_DETAIL_RE)
+    if (detail) {
+      const [, key, value] = detail
+      if (key === 'Text') current.text = value
+      else if (key === 'Value') current.value = value
+      else if (key === 'Min') current.min = parseFloat(value)
+      else if (key === 'Max') current.max = parseFloat(value)
+      else if (key === 'Step') current.step = parseFloat(value)
+    }
+  }
+  if (current) properties.push(current)
+  return properties
+}
+
+export async function listLweProperties(wallpaperPath: string): Promise<LweProperty[]> {
+  const status = getLweStatus()
+  if (!status.installed) throw new Error('linux-wallpaperengine is not installed.')
+
+  const binaryPath = getLweBinaryPath()
+  const args = ['--list-properties']
+  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 parseLweProperties(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 wallpaper properties: ${msg}`)
+  }
+}
+
 export function stopLwe(): void {
   if (activeProcess) {
     try {

+ 22 - 2
src/main/services/steam.service.ts

@@ -1,6 +1,6 @@
 import * as path from 'path'
 import { WE_APP_ID } from '@shared/constants'
-import type { DownloadProgressEvent } from '@shared/types'
+import type { DownloadProgressEvent, WorkshopAuthorInfo } from '@shared/types'
 
 let client: ReturnType<typeof import('steamworks.js')['init']> | null = null
 
@@ -97,7 +97,7 @@ export async function getVotedUpItemIds(): Promise<string[]> {
   while (true) {
     const r = await c.workshop.getUserItems(
       page, accountId,
-      2 /* VotedUp */, 0 /* Items */,
+      2 /* VotedUp */, 13 /* UGCType.All, since WE items span multiple subtypes */,
       1 /* CreationOrderDesc */,
       { consumer: WE_APP_ID }
     )
@@ -149,6 +149,26 @@ export function getInstallInfo(itemId: bigint): { folder: string; sizeOnDisk: nu
   }
 }
 
+// steamworks.js only exposes persona info for the local player/friends, so this
+// pulls it from the public community profile XML instead. Cached for the process lifetime.
+const authorInfoCache = new Map<string, WorkshopAuthorInfo | null>()
+
+export async function getAuthorInfo(steamId: string): Promise<WorkshopAuthorInfo | null> {
+  if (authorInfoCache.has(steamId)) return authorInfoCache.get(steamId) ?? null
+  try {
+    const res = await fetch(`https://steamcommunity.com/profiles/${steamId}?xml=1`)
+    const xml = await res.text()
+    const name = xml.match(/<steamID><!\[CDATA\[([\s\S]*?)\]\]><\/steamID>/)?.[1]
+    const avatarUrl = xml.match(/<avatarFull><!\[CDATA\[([\s\S]*?)\]\]><\/avatarFull>/)?.[1]
+    const info = name && avatarUrl ? { steamId, name, avatarUrl } : null
+    authorInfoCache.set(steamId, info)
+    return info
+  } catch {
+    authorInfoCache.set(steamId, null)
+    return null
+  }
+}
+
 export function getItemState(itemId: bigint): number {
   try {
     return getClient().workshop.state(itemId) as number

+ 19 - 3
src/main/services/wallpaper.service.ts

@@ -6,7 +6,8 @@ import * as path from 'path'
 import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
 import type { WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
 import { getLweStatus, launchLweAsync } from './lwe.service'
-import { getDefaultFps } from './config.service'
+import { getDefaultFps, getRecommendedFpsEnabled } from './config.service'
+import { findWallpaperVideoFile, getVideoFps } from '../utils/video'
 
 const execFileAsync = promisify(execFile)
 
@@ -208,8 +209,23 @@ export async function applyWallpaperMeta(
   const lwe = getLweStatus()
 
   if (isAnimated && os.platform() === 'linux' && lwe.installed) {
-    const fps = options.fps ?? wallpaper.fpsOverride ?? getDefaultFps() ?? undefined
-    await launchLweAsync(wallpaper.localPath, { fps, volume: options.volume })
+    let fps = options.fps ?? wallpaper.fpsOverride
+    if (fps === undefined && wallpaper.type === 'video' && getRecommendedFpsEnabled()) {
+      const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
+      if (videoFile) fps = await getVideoFps(videoFile)
+    }
+    fps = fps ?? getDefaultFps() ?? undefined
+    const volume = options.volume ?? wallpaper.volumeOverride
+    await launchLweAsync(wallpaper.localPath, {
+      fps,
+      volume,
+      disabledObjects: wallpaper.disabledObjects,
+      enabledObjects: wallpaper.enabledObjects,
+      propertyOverrides: wallpaper.propertyOverrides,
+      // Explicit even when off: xray state lives on the running LWE process, not per-wallpaper,
+      // so a hot-reload into a wallpaper with xray off must actively clear a previous wallpaper's "on".
+      xrayFullReveal: wallpaper.xrayFullReveal ?? false
+    })
     return wallpaper.localPath
   }
 

+ 86 - 1
src/main/services/workshop.service.ts

@@ -1,5 +1,10 @@
 import { WE_APP_ID } from '@shared/constants'
-import type { WorkshopQueryParams, WorkshopQueryResult, WorkshopItem } from '@shared/types'
+import type {
+  WorkshopQueryParams,
+  WorkshopQueryResult,
+  WorkshopItem,
+  CreatorWorkshopQueryParams
+} from '@shared/types'
 import { getClient } from './steam.service'
 
 // Maps our string names to steamworks.js UGCQueryType enum values
@@ -12,6 +17,18 @@ const QUERY_TYPE_MAP: Record<string, number> = {
   RankedByLastUpdatedDate: 19
 }
 
+// Maps our string names to steamworks.js UserListOrder enum values, used for
+// UserUGCList queries (querying a specific creator) which don't support the
+// UGCQueryType ranking options above.
+const USER_LIST_ORDER_MAP: Record<string, number> = {
+  RankedByVote: 5, // VoteScoreDesc
+  RankedByPublicationDate: 1, // CreationOrderDesc
+  RankedByTrend: 1, // no trend equivalent, falls back to newest
+  RankedByTotalUniqueSubscriptions: 4, // SubscriptionDateDesc, closest analog
+  RankedByTextSearch: 1,
+  RankedByLastUpdatedDate: 3 // LastUpdatedDesc
+}
+
 export async function queryWorkshop(params: WorkshopQueryParams): Promise<WorkshopQueryResult> {
   const client = getClient()
   const appId = params.appId ?? WE_APP_ID
@@ -56,6 +73,43 @@ export async function getWorkshopItem(publishedFileId: string): Promise<Workshop
   return transformItem(item, subscribedSet)
 }
 
+// UserUGCList lets us query another Steam user's published items directly,
+// unlike getAllItems which only ranks/filters across the whole workshop.
+export async function queryWorkshopByCreator(
+  creatorSteamId: string,
+  params: CreatorWorkshopQueryParams = {}
+): Promise<WorkshopQueryResult> {
+  const client = getClient()
+  const accountId = Number(BigInt(creatorSteamId) & 0xffffffffn)
+  const page = params.page ?? 1
+  const sortOrder = USER_LIST_ORDER_MAP[params.queryType ?? 'RankedByPublicationDate'] ?? 1
+
+  const result = await client.workshop.getUserItems(
+    page,
+    accountId,
+    0, // UserListType.Published
+    13, // UGCType.All
+    sortOrder,
+    { consumer: WE_APP_ID },
+    {
+      searchText: params.searchText,
+      requiredTags: params.tags,
+      excludedTags: params.excludedTags,
+      includeLongDescription: true
+    }
+  )
+
+  const subscribedSet = new Set(
+    client.workshop.getSubscribedItems().map((id) => id.toString())
+  )
+
+  return {
+    items: result.items.filter(Boolean).map((item) => transformItem(item!, subscribedSet)),
+    page,
+    totalResults: result.totalResults
+  }
+}
+
 export async function getWorkshopTimesUpdated(
   publishedFileIds: string[]
 ): Promise<Map<string, number>> {
@@ -85,6 +139,37 @@ export async function getWorkshopTags(publishedFileIds: string[]): Promise<Map<s
   return map
 }
 
+// Used to backfill authorSteamId on library items imported before this field existed
+// (and on items that never finished downloading, since they have no project.json).
+export async function getWorkshopAuthors(publishedFileIds: string[]): Promise<Map<string, string>> {
+  const map = new Map<string, string>()
+  if (publishedFileIds.length === 0) return map
+
+  const client = getClient()
+  const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
+  for (const item of result.items) {
+    if (item) map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
+  }
+  return map
+}
+
+// Steam returns a null entry for any id that no longer resolves to a workshop
+// item (removed by its author or taken down), so anything missing from the
+// result is treated as unavailable. Lets a request error (network hiccup)
+// propagate rather than swallowing it here - the caller decides what "we
+// couldn't tell" should mean, instead of this silently reporting "all clear".
+export async function getUnavailableWorkshopItems(publishedFileIds: string[]): Promise<Set<string>> {
+  const unavailable = new Set(publishedFileIds)
+  if (publishedFileIds.length === 0) return unavailable
+
+  const client = getClient()
+  const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
+  for (const item of result.items) {
+    if (item) unavailable.delete(item.publishedFileId.toString())
+  }
+  return unavailable
+}
+
 function transformItem(
   item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
   subscribedSet: Set<string>

+ 60 - 0
src/main/utils/video.ts

@@ -0,0 +1,60 @@
+import { execFile } from 'child_process'
+import { promisify } from 'util'
+import * as fs from 'fs'
+import * as path from 'path'
+import { isCommandAvailable } from './platform'
+
+const execFileAsync = promisify(execFile)
+
+const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.avi', '.mkv', '.mov', '.m4v']
+
+/** Locate a wallpaper's video file: the project.json `file` entry, or the first video in the folder. */
+export function findWallpaperVideoFile(localPath: string, projectFile?: string): string | undefined {
+  if (projectFile) {
+    const direct = path.join(localPath, projectFile)
+    if (fs.existsSync(direct)) return direct
+  }
+  try {
+    const files = fs.readdirSync(localPath)
+    const video = files.find((f) => VIDEO_EXTENSIONS.includes(path.extname(f).toLowerCase()))
+    if (video) return path.join(localPath, video)
+  } catch { /* unreadable dir */ }
+  return undefined
+}
+
+function parseFrameRate(raw?: string): number | undefined {
+  if (!raw) return undefined
+  const [num, den] = raw.split('/').map(Number)
+  if (!num || !den) return undefined
+  const fps = num / den
+  if (!isFinite(fps) || fps <= 0) return undefined
+  return Math.round(fps)
+}
+
+// Probing spawns ffprobe, so cache per file for the lifetime of the process (playlists
+// reapply the same wallpapers repeatedly).
+const fpsCache = new Map<string, number | undefined>()
+
+/** Read a video's native frame rate via ffprobe. Returns undefined if ffprobe is missing or fails. */
+export async function getVideoFps(filePath: string): Promise<number | undefined> {
+  if (fpsCache.has(filePath)) return fpsCache.get(filePath)
+  if (!isCommandAvailable('ffprobe')) return undefined
+
+  try {
+    const { stdout } = await execFileAsync('ffprobe', [
+      '-v', 'error',
+      '-select_streams', 'v:0',
+      '-show_entries', 'stream=r_frame_rate,avg_frame_rate',
+      '-of', 'csv=p=0',
+      filePath
+    ], { timeout: 10_000, encoding: 'utf8' })
+
+    const [rRate, avgRate] = stdout.trim().split('\n')[0]?.split(',') ?? []
+    const fps = parseFrameRate(rRate) ?? parseFrameRate(avgRate)
+    fpsCache.set(filePath, fps)
+    return fps
+  } catch {
+    fpsCache.set(filePath, undefined)
+    return undefined
+  }
+}

+ 31 - 4
src/preload/index.ts

@@ -4,15 +4,19 @@ import type {
   WorkshopQueryParams,
   WorkshopQueryResult,
   WorkshopItem,
+  CreatorWorkshopQueryParams,
   WallpaperMeta,
   WallpaperFolder,
   LibraryFilters,
+  WorkshopAuthorInfo,
   ApplyWallpaperOptions,
   DownloadProgressEvent,
   WallpaperEnvironment,
   LweStatus,
   LweInstallProgress,
   LinuxDistro,
+  LweSceneObject,
+  LweProperty,
   BackupProgressEvent,
   Playlist,
   PlaylistSettings,
@@ -24,7 +28,12 @@ const api = {
     query: (params: WorkshopQueryParams): Promise<WorkshopQueryResult> =>
       ipcRenderer.invoke(IpcChannels.WORKSHOP_QUERY, params),
     getItem: (publishedFileId: string): Promise<WorkshopItem | null> =>
-      ipcRenderer.invoke(IpcChannels.WORKSHOP_GET_ITEM, publishedFileId)
+      ipcRenderer.invoke(IpcChannels.WORKSHOP_GET_ITEM, publishedFileId),
+    queryByCreator: (
+      creatorSteamId: string,
+      params?: CreatorWorkshopQueryParams
+    ): Promise<WorkshopQueryResult> =>
+      ipcRenderer.invoke(IpcChannels.WORKSHOP_QUERY_BY_CREATOR, creatorSteamId, params)
   },
 
   steam: {
@@ -46,7 +55,9 @@ const api = {
     openWorkshopItem: (itemId: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
     getVotedIds: (): Promise<string[]> =>
-      ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS)
+      ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS),
+    getAuthorInfo: (steamId: string): Promise<WorkshopAuthorInfo | null> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_GET_AUTHOR_INFO, steamId)
   },
 
   library: {
@@ -70,7 +81,9 @@ const api = {
     distinctTags: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_DISTINCT_TAGS),
     resetFpsOverrides: (): Promise<{ count: number }> =>
-      ipcRenderer.invoke(IpcChannels.LIBRARY_RESET_FPS_OVERRIDES)
+      ipcRenderer.invoke(IpcChannels.LIBRARY_RESET_FPS_OVERRIDES),
+    checkUnavailable: (): Promise<{ checked: number; unavailable: number }> =>
+      ipcRenderer.invoke(IpcChannels.LIBRARY_CHECK_UNAVAILABLE)
   },
 
   config: {
@@ -79,6 +92,7 @@ const api = {
       defaultWorkshopPath: string
       isConfigured: boolean
       defaultFps: number | null
+      recommendedFpsEnabled: boolean
       lweRepoUrl: string | null
       lweRepoBranch: string | null
       lweCmakeArgs: string | null
@@ -97,6 +111,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_WORKSHOP_PATH, p),
     setDefaultFps: (fps: number | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_FPS, fps),
+    setRecommendedFpsEnabled: (enabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_RECOMMENDED_FPS, enabled),
     setLweRepo: (url: string | null, branch: string | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_LWE_REPO, url, branch),
     setLweCmakeArgs: (args: string | null): Promise<{ ok: boolean }> =>
@@ -209,7 +225,18 @@ const api = {
     stop: (): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.LWE_STOP),
     killAll: (): Promise<{ ok: boolean; message: string }> =>
-      ipcRenderer.invoke(IpcChannels.LWE_KILL_ALL)
+      ipcRenderer.invoke(IpcChannels.LWE_KILL_ALL),
+    listObjects: (wallpaperPath: string): Promise<LweSceneObject[]> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_OBJECTS, wallpaperPath),
+    hotswapSettings: (options: {
+      disabledObjects?: string[]
+      enabledObjects?: string[]
+      volume?: number
+      xray?: boolean
+    }): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.LWE_HOTSWAP_SETTINGS, options),
+    listProperties: (wallpaperPath: string): Promise<LweProperty[]> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_PROPERTIES, wallpaperPath)
   },
 
   backup: {

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

@@ -33,6 +33,12 @@ export default function App() {
   const [activeView, setActiveView] = useState<View>('workshop')
   const [setupDone, setSetupDone] = useState<boolean | null>(null)
   const [defaultPath, setDefaultPath] = useState('')
+  const [creatorFilter, setCreatorFilter] = useState<string | null>(null)
+
+  function browseCreator(steamId: string) {
+    setCreatorFilter(steamId)
+    setActiveView('workshop')
+  }
 
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
@@ -57,9 +63,15 @@ export default function App() {
         <div className="flex flex-1 overflow-hidden">
           <Sidebar activeView={activeView} onNavigate={setActiveView} />
           <main className="flex-1 overflow-hidden">
-            {activeView === 'workshop' && <WorkshopBrowser />}
-            {activeView === 'library' && <LibraryView />}
-            {activeView === 'playlists' && <PlaylistsView />}
+            {activeView === 'workshop' && (
+              <WorkshopBrowser
+                creatorFilter={creatorFilter}
+                onClearCreatorFilter={() => setCreatorFilter(null)}
+                onBrowseCreator={browseCreator}
+              />
+            )}
+            {activeView === 'library' && <LibraryView onBrowseCreator={browseCreator} />}
+            {activeView === 'playlists' && <PlaylistsView onBrowseCreator={browseCreator} />}
             {activeView === 'settings' && <SettingsView />}
           </main>
         </div>

+ 737 - 0
src/renderer/src/components/common/DetailSidebar.tsx

@@ -0,0 +1,737 @@
+import { useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+  X,
+  Star,
+  ThumbsUp,
+  ThumbsDown,
+  Download,
+  Play,
+  Loader2,
+  ExternalLink,
+  MessageCircle,
+  User,
+  Trash2,
+  Eye,
+  EyeOff,
+  Layers,
+  ChevronDown,
+  ChevronRight,
+  Pin,
+  Volume2,
+  VolumeX,
+  ToggleLeft,
+  ToggleRight,
+  SlidersHorizontal,
+  ScanEye
+} 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 } from '@shared/types'
+
+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>
+  const fraction = upvotes / total
+  return (
+    <div className="flex items-center gap-2">
+      <div className="relative inline-flex">
+        <div className="flex gap-0.5 text-gray-700">
+          {Array.from({ length: 5 }).map((_, i) => (
+            <Star key={i} size={14} fill="currentColor" />
+          ))}
+        </div>
+        <div
+          className="absolute inset-0 flex gap-0.5 overflow-hidden text-yellow-400"
+          style={{ width: `${fraction * 100}%` }}
+        >
+          {Array.from({ length: 5 }).map((_, i) => (
+            <Star key={i} size={14} fill="currentColor" />
+          ))}
+        </div>
+      </div>
+      <span className="text-xs text-gray-500">
+        {Math.round(fraction * 100)}% ({total.toLocaleString()})
+      </span>
+    </div>
+  )
+}
+
+type ObjectState = 'default' | 'hidden' | 'forced'
+
+function objectState(objId: string, disabled: string[], enabled: string[]): ObjectState {
+  if (disabled.includes(objId)) return 'hidden'
+  if (enabled.includes(objId)) return 'forced'
+  return 'default'
+}
+
+function LayerRow({
+  object,
+  state,
+  busy,
+  onToggle
+}: {
+  object: LweSceneObject
+  state: ObjectState
+  busy: boolean
+  onToggle: () => void
+}) {
+  return (
+    <button
+      onClick={onToggle}
+      disabled={busy}
+      title={
+        state === 'default'
+          ? 'Visible (default) - click to hide'
+          : state === 'hidden'
+            ? 'Hidden - click to force visible'
+            : 'Forced visible - click to reset to default'
+      }
+      className={clsx(
+        'flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-xs transition-colors hover:bg-white/5 disabled:opacity-40',
+        state === 'hidden' ? 'text-gray-600' : 'text-gray-300'
+      )}
+    >
+      {state === 'hidden' ? (
+        <EyeOff size={12} className="shrink-0 text-gray-600" />
+      ) : state === 'forced' ? (
+        <Pin size={12} className="shrink-0 text-indigo-400" />
+      ) : (
+        <Eye size={12} className="shrink-0 text-gray-500" />
+      )}
+      <span className={clsx('flex-1 truncate', state === 'hidden' && 'line-through')}>
+        {object.name}
+      </span>
+      <span className="shrink-0 text-gray-600">{object.type}</span>
+    </button>
+  )
+}
+
+// Only booleans/sliders/combos are user-facing toggles in the WE customize UI - color/
+// text/file/scene-texture/textinput properties are left out of the sidebar entirely.
+function PropertyRow({
+  property,
+  value,
+  busy,
+  onCommit
+}: {
+  property: LweProperty
+  value: string
+  busy: boolean
+  onCommit: (value: string) => void
+}) {
+  const [draft, setDraft] = useState<number | null>(null)
+  const label = property.text || property.name
+
+  if (property.type === 'boolean') {
+    const on = value === '1' || value === 'true'
+    return (
+      <button
+        onClick={() => onCommit(on ? '0' : '1')}
+        disabled={busy}
+        title={property.name}
+        className={clsx(
+          'flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-xs transition-colors hover:bg-white/5 disabled:opacity-40',
+          on ? 'text-gray-200' : 'text-gray-500'
+        )}
+      >
+        {on ? (
+          <ToggleRight size={14} className="shrink-0 text-indigo-400" />
+        ) : (
+          <ToggleLeft size={14} className="shrink-0 text-gray-600" />
+        )}
+        <span className="flex-1 truncate">{label}</span>
+      </button>
+    )
+  }
+
+  if (property.type === 'slider') {
+    const num = draft ?? (Number(value) || 0)
+    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={property.name}>
+            {label}
+          </span>
+          <span className="text-gray-500">{num}</span>
+        </div>
+        <input
+          type="range"
+          min={property.min ?? 0}
+          max={property.max ?? 100}
+          step={property.step || 1}
+          value={num}
+          disabled={busy}
+          onChange={(e) => setDraft(Number(e.target.value))}
+          onMouseUp={(e) => { onCommit(e.currentTarget.value); setDraft(null) }}
+          onTouchEnd={(e) => { onCommit(e.currentTarget.value); setDraft(null) }}
+          onKeyUp={(e) => { onCommit(e.currentTarget.value); setDraft(null) }}
+          className="w-full accent-indigo-500 disabled:opacity-50"
+        />
+      </div>
+    )
+  }
+
+  if (property.type === 'combo') {
+    return (
+      <label className="flex items-center gap-2 px-1.5 py-1 text-xs text-gray-300">
+        <span className="flex-1 truncate" title={property.name}>
+          {label}
+        </span>
+        <select
+          value={value}
+          disabled={busy}
+          onChange={(e) => onCommit(e.target.value)}
+          className="rounded bg-white/5 px-1.5 py-0.5 text-xs text-gray-300 outline-none disabled:opacity-50 [&>option]:bg-[#1a1a1a]"
+        >
+          {property.options?.map((opt) => (
+            <option key={opt.value} value={opt.value}>
+              {opt.label}
+            </option>
+          ))}
+        </select>
+      </label>
+    )
+  }
+
+  return null
+}
+
+interface DetailSidebarProps {
+  id: string
+  fallbackTitle: string
+  fallbackPreviewUrl?: string
+  fallbackTags: string[]
+  fallbackAuthorSteamId?: string
+  localFileSize?: number
+  isSubscribed: boolean
+  isLiked: boolean
+  canPlay: boolean
+  lweInstalled: boolean
+  onClose: () => void
+  onSubscribe: () => void
+  onUnsubscribe: () => void | Promise<void>
+  onLiked: () => void
+  onPlay: () => void | Promise<void>
+  onBrowseCreator?: (steamId: string) => void
+}
+
+export default function DetailSidebar({
+  id,
+  fallbackTitle,
+  fallbackPreviewUrl,
+  fallbackTags,
+  fallbackAuthorSteamId,
+  localFileSize,
+  isSubscribed,
+  isLiked,
+  canPlay,
+  lweInstalled,
+  onClose,
+  onSubscribe,
+  onUnsubscribe,
+  onLiked,
+  onPlay,
+  onBrowseCreator
+}: DetailSidebarProps) {
+  const workshopId = isWorkshopId(id)
+  const queryClient = useQueryClient()
+  const [isLiking, setIsLiking] = useState(false)
+  const [isDisliking, setIsDisliking] = useState(false)
+  const [isApplying, setIsApplying] = useState(false)
+  const [unsubConfirm, setUnsubConfirm] = useState(false)
+  const [isUnsubscribing, setIsUnsubscribing] = useState(false)
+  const [layersOpen, setLayersOpen] = useState(false)
+  const [togglingObjectId, setTogglingObjectId] = useState<string | null>(null)
+  const [propertiesOpen, setPropertiesOpen] = useState(false)
+  const [committingProperty, setCommittingProperty] = useState<string | null>(null)
+
+  const { data: item } = useQuery({
+    queryKey: ['workshop-detail', id],
+    queryFn: () => window.electronAPI.workshop.getItem(id),
+    enabled: workshopId,
+    staleTime: 60_000
+  })
+
+  const title = item?.title ?? fallbackTitle
+  const previewUrl = item?.previewUrl ?? fallbackPreviewUrl
+  const tags = item?.tags ?? fallbackTags
+  const creatorSteamId = item?.creatorSteamId ?? fallbackAuthorSteamId
+  const description = item?.description
+
+  const { data: author } = useQuery({
+    queryKey: ['author-info', creatorSteamId],
+    queryFn: () => window.electronAPI.steam.getAuthorInfo(creatorSteamId!),
+    enabled: !!creatorSteamId,
+    staleTime: Infinity
+  })
+
+  const { data: libraryMeta } = useQuery({
+    queryKey: ['library-item', id],
+    queryFn: () => window.electronAPI.library.getOne(id)
+  })
+  const localPath = libraryMeta?.localPath
+  const disabledObjects = libraryMeta?.disabledObjects ?? []
+  const enabledObjects = libraryMeta?.enabledObjects ?? []
+
+  const { data: activeWallpaper } = useQuery({
+    queryKey: ['active-wallpaper'],
+    queryFn: () => window.electronAPI.wallpaper.getActive(),
+    staleTime: 5_000
+  })
+  const isActive = activeWallpaper?.id === id
+
+  const { data: objects = [], isLoading: objectsLoading } = useQuery({
+    queryKey: ['lwe-objects', localPath],
+    queryFn: () => window.electronAPI.lwe.listObjects(localPath!),
+    enabled: !!localPath && lweInstalled,
+    staleTime: Infinity,
+    retry: false
+  })
+
+  const { data: allProperties = [], isLoading: propertiesLoading } = useQuery({
+    queryKey: ['lwe-properties', localPath],
+    queryFn: () => window.electronAPI.lwe.listProperties(localPath!),
+    enabled: !!localPath && lweInstalled,
+    staleTime: Infinity,
+    retry: false
+  })
+  // Only these types have an actual editable control in the WE customize UI
+  const properties = allProperties.filter(
+    (p) => p.type === 'boolean' || p.type === 'slider' || p.type === 'combo'
+  )
+  const propertyOverrides = libraryMeta?.propertyOverrides ?? {}
+
+  // Persists a settings patch and, if this wallpaper is currently playing, pushes it live.
+  // Layers/volume go through the lightweight control-file hotswap (no restart); if that
+  // fails (e.g. an older linux-wallpaperengine build without the extended protocol), fall
+  // back to a full stop+relaunch so the change still takes effect one way or another.
+  async function persistAndMaybeRelaunch(patch: Partial<WallpaperMeta>) {
+    const updated = await window.electronAPI.library.update(id, patch)
+    queryClient.setQueryData(['library-item', id], updated)
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+    if (isActive) {
+      const { ok } = await window.electronAPI.lwe.hotswapSettings({
+        disabledObjects: patch.disabledObjects,
+        enabledObjects: patch.enabledObjects,
+        volume: patch.volumeOverride,
+        xray: patch.xrayFullReveal
+      })
+      if (!ok) {
+        await window.electronAPI.lwe.stop()
+        await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+      }
+      queryClient.invalidateQueries({ queryKey: ['active-wallpaper'] })
+    }
+  }
+
+  async function toggleObject(objId: string) {
+    if (togglingObjectId) return
+    const disabled = new Set(disabledObjects)
+    const enabled = new Set(enabledObjects)
+    if (disabled.has(objId)) {
+      disabled.delete(objId)
+      enabled.add(objId)
+    } else if (enabled.has(objId)) {
+      enabled.delete(objId)
+    } else {
+      disabled.add(objId)
+    }
+    setTogglingObjectId(objId)
+    try {
+      await persistAndMaybeRelaunch({ disabledObjects: [...disabled], enabledObjects: [...enabled] })
+    } finally {
+      setTogglingObjectId(null)
+    }
+  }
+
+  // --set-property is launch-time only (no hotswap support for it yet), so this always
+  // falls through to persistAndMaybeRelaunch's full stop+relaunch fallback when active.
+  async function commitProperty(name: string, value: string) {
+    if (committingProperty) return
+    setCommittingProperty(name)
+    try {
+      await persistAndMaybeRelaunch({ propertyOverrides: { ...propertyOverrides, [name]: value } })
+    } finally {
+      setCommittingProperty(null)
+    }
+  }
+
+  const [volumeDraft, setVolumeDraft] = useState<number | null>(null)
+  const [isCommittingVolume, setIsCommittingVolume] = useState(false)
+  const volume = volumeDraft ?? libraryMeta?.volumeOverride ?? 15
+
+  async function commitVolume(v: number) {
+    setIsCommittingVolume(true)
+    try {
+      await persistAndMaybeRelaunch({ volumeOverride: v })
+    } finally {
+      setVolumeDraft(null)
+      setIsCommittingVolume(false)
+    }
+  }
+
+  const [isCommittingXray, setIsCommittingXray] = useState(false)
+  const xrayFullReveal = libraryMeta?.xrayFullReveal ?? false
+
+  async function toggleXray() {
+    if (isCommittingXray) return
+    setIsCommittingXray(true)
+    try {
+      await persistAndMaybeRelaunch({ xrayFullReveal: !xrayFullReveal })
+    } finally {
+      setIsCommittingXray(false)
+    }
+  }
+
+  const typeTag = tags.find((t) => TYPE_TAGS.has(t))
+  const ageTag = tags.find((t) => AGE_TAGS.has(t))
+  const resolutionTags = tags.filter((t) => RESOLUTION_TAGS.has(t))
+  const genreTags = tags.filter(
+    (t) => t !== typeTag && t !== ageTag && !resolutionTags.includes(t)
+  )
+  const fileSizeLabel = formatFileSize(localFileSize)
+
+  async function handleLike() {
+    if (isLiked || isLiking) return
+    setIsLiking(true)
+    try {
+      await window.electronAPI.steam.vote(id, true)
+      onLiked()
+    } finally {
+      setIsLiking(false)
+    }
+  }
+
+  async function handleDislike() {
+    if (isDisliking) return
+    setIsDisliking(true)
+    try {
+      await window.electronAPI.steam.vote(id, false)
+    } finally {
+      setIsDisliking(false)
+    }
+  }
+
+  async function handlePlay() {
+    if (!canPlay || isApplying) return
+    setIsApplying(true)
+    try {
+      await onPlay()
+    } finally {
+      setIsApplying(false)
+    }
+  }
+
+  async function handleUnsubscribe() {
+    if (!unsubConfirm) {
+      setUnsubConfirm(true)
+      return
+    }
+    setIsUnsubscribing(true)
+    try {
+      await onUnsubscribe()
+    } finally {
+      setIsUnsubscribing(false)
+      setUnsubConfirm(false)
+    }
+  }
+
+  return (
+    <div className="flex w-80 flex-shrink-0 flex-col overflow-y-auto border-l border-white/5 bg-[#0d0d0d]">
+      <div className="relative aspect-video shrink-0 overflow-hidden bg-[#111]">
+        {previewUrl ? (
+          <img src={previewUrl} alt={title} className="h-full w-full object-cover" />
+        ) : (
+          <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
+        )}
+        <button
+          onClick={onClose}
+          title="Close"
+          className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
+        >
+          <X size={14} />
+        </button>
+      </div>
+
+      <div className="flex flex-col gap-3 p-4">
+        <h2 className="text-sm font-semibold text-gray-100">{title}</h2>
+
+        {creatorSteamId && (
+          <button
+            onClick={() => {
+              onBrowseCreator?.(creatorSteamId)
+              onClose()
+            }}
+            disabled={!onBrowseCreator}
+            title={onBrowseCreator ? "Browse this creator's wallpapers" : undefined}
+            className="flex items-center gap-2 text-left disabled:cursor-default"
+          >
+            {author?.avatarUrl ? (
+              <img src={author.avatarUrl} alt="" className="h-8 w-8 rounded" />
+            ) : (
+              <div className="flex h-8 w-8 items-center justify-center rounded bg-white/5 text-gray-500">
+                <User size={14} />
+              </div>
+            )}
+            <span className="text-sm text-indigo-300 hover:text-indigo-200">
+              {author?.name ?? '...'}
+            </span>
+          </button>
+        )}
+
+        {item && <StarRating upvotes={item.upvotes} downvotes={item.downvotes} />}
+
+        <div className="flex flex-wrap gap-1.5">
+          {typeTag && (
+            <span className="rounded bg-white/5 px-2 py-1 text-xs text-gray-300">
+              {typeTag}
+              {fileSizeLabel ? ` · ${fileSizeLabel}` : ''}
+            </span>
+          )}
+          {genreTags.map((tag) => (
+            <span key={tag} className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400">
+              {tag}
+            </span>
+          ))}
+          {resolutionTags.map((tag) => (
+            <span key={tag} className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400">
+              {tag}
+            </span>
+          ))}
+          {ageTag && (
+            <span className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400">{ageTag}</span>
+          )}
+        </div>
+
+        {description && (
+          <p className="whitespace-pre-line text-xs text-gray-500">{description}</p>
+        )}
+
+        {localPath && lweInstalled && (objectsLoading || objects.length > 0) && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={() => setLayersOpen((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"
+            >
+              {layersOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              <Layers size={12} />
+              <span className="flex-1 text-left">Layers</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>
+            {layersOpen && (
+              <div className="mt-1.5 space-y-0.5">
+                {objects.map((obj) => (
+                  <LayerRow
+                    key={obj.id}
+                    object={obj}
+                    state={objectState(obj.id, disabledObjects, enabledObjects)}
+                    busy={togglingObjectId === obj.id}
+                    onToggle={() => toggleObject(obj.id)}
+                  />
+                ))}
+              </div>
+            )}
+          </div>
+        )}
+
+        {localPath && lweInstalled && libraryMeta?.type === 'video' && (
+          <div className="border-t border-white/5 pt-3">
+            <div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500">
+              {volume === 0 ? <VolumeX size={12} /> : <Volume2 size={12} />}
+              <span className="flex-1 text-left">Volume</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              <span className="normal-case text-gray-400">{Math.round((volume / 128) * 100)}%</span>
+            </div>
+            <input
+              type="range"
+              min={0}
+              max={128}
+              value={volume}
+              disabled={isCommittingVolume}
+              onChange={(e) => setVolumeDraft(Number(e.target.value))}
+              onMouseUp={(e) => commitVolume(Number(e.currentTarget.value))}
+              onTouchEnd={(e) => commitVolume(Number(e.currentTarget.value))}
+              onKeyUp={(e) => commitVolume(Number(e.currentTarget.value))}
+              className="w-full accent-indigo-500 disabled:opacity-50"
+            />
+          </div>
+        )}
+
+        {localPath && lweInstalled && libraryMeta?.type === 'scene' && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={toggleXray}
+              disabled={isCommittingXray}
+              title="Force the xray effect's reveal spot to cover the whole scene instead of following the mouse"
+              className="flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300 disabled:opacity-40"
+            >
+              <ScanEye size={12} />
+              <span className="flex-1 text-left">Full x-ray</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {isCommittingXray ? (
+                <Loader2 size={14} className="animate-spin text-gray-500" />
+              ) : xrayFullReveal ? (
+                <ToggleRight size={14} className="text-indigo-400" />
+              ) : (
+                <ToggleLeft size={14} className="text-gray-600" />
+              )}
+            </button>
+          </div>
+        )}
+
+        {localPath && lweInstalled && (propertiesLoading || properties.length > 0) && (
+          <div className="border-t border-white/5 pt-3">
+            <button
+              onClick={() => setPropertiesOpen((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"
+            >
+              {propertiesOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
+              <SlidersHorizontal size={12} />
+              <span className="flex-1 text-left">Properties</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {propertiesLoading && <Loader2 size={11} className="animate-spin" />}
+            </button>
+            {propertiesOpen && (
+              <div className="mt-1.5 space-y-0.5">
+                {properties.map((prop) => (
+                  <PropertyRow
+                    key={prop.name}
+                    property={prop}
+                    value={propertyOverrides[prop.name] ?? prop.value}
+                    busy={committingProperty === prop.name}
+                    onCommit={(value) => commitProperty(prop.name, value)}
+                  />
+                ))}
+              </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}
+              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'
+              )}
+            >
+              {isLiking ? <Loader2 size={12} className="animate-spin" /> : <ThumbsUp size={12} />}
+              {isLiked ? 'Liked' : 'Like'}
+            </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"
+            >
+              {isDisliking ? <Loader2 size={12} className="animate-spin" /> : <ThumbsDown size={12} />}
+              Dislike
+            </button>
+          </div>
+
+          {canPlay && (
+            <button
+              onClick={handlePlay}
+              disabled={isApplying || !lweInstalled}
+              title={lweInstalled ? undefined : 'Install linux-wallpaperengine in Settings first'}
+              className="flex items-center justify-center gap-1.5 rounded-lg bg-indigo-600 py-1.5 text-xs text-white transition-colors hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {isApplying ? <Loader2 size={12} className="animate-spin" /> : <Play size={12} />}
+              Play wallpaper
+            </button>
+          )}
+
+          {isSubscribed ? (
+            <button
+              onClick={handleUnsubscribe}
+              disabled={isUnsubscribing}
+              className={clsx(
+                'flex items-center justify-center gap-1.5 rounded-lg py-1.5 text-xs transition-colors',
+                unsubConfirm
+                  ? 'bg-red-600 text-white hover:bg-red-500'
+                  : 'bg-white/5 text-gray-300 hover:bg-red-500/20 hover:text-red-400'
+              )}
+            >
+              {isUnsubscribing ? (
+                <Loader2 size={12} className="animate-spin" />
+              ) : (
+                <Trash2 size={12} />
+              )}
+              {unsubConfirm ? 'Click again to confirm' : 'Unsubscribe'}
+            </button>
+          ) : (
+            <button
+              onClick={onSubscribe}
+              className="flex 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"
+            >
+              <Download size={12} />
+              Subscribe
+            </button>
+          )}
+
+          {workshopId && (
+            <>
+              <button
+                onClick={() => window.electronAPI.steam.openWorkshopItem(id)}
+                className="flex 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"
+              >
+                <MessageCircle size={12} />
+                Comment
+              </button>
+              <button
+                onClick={() => openWorkshopPage(id)}
+                className="flex 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"
+              >
+                <ExternalLink size={12} />
+                Open in Steam Workshop
+              </button>
+            </>
+          )}
+
+          {creatorSteamId && (
+            <button
+              onClick={() => openProfilePage(creatorSteamId)}
+              className="flex 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"
+            >
+              <User size={12} />
+              View creator's Steam profile
+            </button>
+          )}
+        </div>
+      </div>
+    </div>
+  )
+}

+ 319 - 77
src/renderer/src/components/library/LibraryView.tsx

@@ -21,19 +21,26 @@ import {
   ThumbsDown,
   Eraser,
   Archive,
-  AlertTriangle
+  AlertTriangle,
+  Heart,
+  User,
+  WifiOff,
+  ShieldAlert
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
+import DetailSidebar from '../common/DetailSidebar'
 import { useToast } from '../common/Toast'
 import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
 import { WE_TYPES, WE_LIBRARY_AGE_RATINGS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useClickOutside } from '../../hooks/useClickOutside'
+import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
 import { toggle } from '../../utils/array'
 import { forEachIgnoringErrors } from '../../utils/async'
-import { openWorkshopPage } from '../../utils/steam'
+import { openWorkshopPage, isWorkshopId } from '../../utils/steam'
+import { getPreviewSrc } from '../../utils/preview'
 
 const STORAGE_KEY = 'we-library-filters'
 
@@ -43,6 +50,8 @@ interface LibraryFilterState {
   genres: string[]
   sources: string[]
   failedOnly: boolean
+  likedOnly: boolean
+  unavailableOnly: boolean
 }
 
 const DEFAULT_STATE: LibraryFilterState = {
@@ -50,7 +59,9 @@ const DEFAULT_STATE: LibraryFilterState = {
   ageRatings: [],
   genres: [],
   sources: [],
-  failedOnly: false
+  failedOnly: false,
+  likedOnly: false,
+  unavailableOnly: false
 }
 
 const TYPE_MAP: Record<string, string> = {
@@ -79,6 +90,24 @@ function loadFilters(): LibraryFilterState {
   }
 }
 
+const SORT_STORAGE_KEY = 'we-library-sort'
+
+interface SortState {
+  sortBy: LibraryFilters['sortBy']
+  sortDir: 'asc' | 'desc'
+}
+
+const DEFAULT_SORT: SortState = { sortBy: 'updatedAt', sortDir: 'desc' }
+
+function loadSort(): SortState {
+  try {
+    const raw = localStorage.getItem(SORT_STORAGE_KEY)
+    return raw ? { ...DEFAULT_SORT, ...JSON.parse(raw) } : DEFAULT_SORT
+  } catch {
+    return DEFAULT_SORT
+  }
+}
+
 function Chip({
   label,
   active,
@@ -197,12 +226,13 @@ function FolderMenu({
   const ref = useRef<HTMLDivElement>(null)
 
   useClickOutside(ref, onClose)
+  const pos = useClampedPosition(ref, { x, y })
 
   return (
     <div
       ref={ref}
       className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-      style={{ left: x, top: y }}
+      style={{ left: pos?.x ?? x, top: pos?.y ?? y }}
     >
       <button
         onClick={onRename}
@@ -227,12 +257,18 @@ interface Marquee {
   endY: number
 }
 
-export default function LibraryView() {
+interface LibraryViewProps {
+  onBrowseCreator?: (creatorSteamId: string) => void
+}
+
+export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
   const queryClient = useQueryClient()
   const [search, setSearch] = useState('')
-  const [sortBy, setSortBy] = useState<LibraryFilters['sortBy']>('updatedAt')
-  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
+  const [sortBy, setSortBy] = useState<LibraryFilters['sortBy']>(() => loadSort().sortBy)
+  const [sortDir, setSortDir] = useState<'asc' | 'desc'>(() => loadSort().sortDir)
   const [filterState, setFilterState] = useState<LibraryFilterState>(loadFilters)
+  const [showFilters, setShowFilters] = useState(true)
+  const [detailId, setDetailId] = useState<string | null>(null)
   const [scanning, setScanning] = useState(false)
   const [scanResult, setScanResult] = useState<{
     imported: number
@@ -240,12 +276,10 @@ export default function LibraryView() {
     removed: number
   } | null>(null)
 
-  // Pagination
   const [page, setPage] = useState(1)
   const [pageSize, setPageSize] = useState(50)
   const [previewSize, setPreviewSize] = usePreviewSize()
 
-  // Folder state
   const [activeFolder, setActiveFolder] = useState<string | null>(null) // null = "All"
   const [folderMenu, setFolderMenu] = useState<{
     folder: WallpaperFolder
@@ -255,14 +289,12 @@ export default function LibraryView() {
   const [renamingId, setRenamingId] = useState<string | null>(null)
   const [renameValue, setRenameValue] = useState('')
 
-  // Selection state
   const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
   const [lastClickedId, setLastClickedId] = useState<string | null>(null)
   const [marquee, setMarquee] = useState<Marquee | null>(null)
   const marqueeActive = useRef(false)
   const gridRef = useRef<HTMLDivElement>(null)
 
-  // Wallpaper context menu state
   const [ctxMenu, setCtxMenu] = useState<{
     x: number
     y: number
@@ -270,13 +302,20 @@ export default function LibraryView() {
     showFolderSub: boolean
   } | null>(null)
   const ctxMenuRef = useRef<HTMLDivElement>(null)
+  const folderSubRef = useRef<HTMLDivElement>(null)
 
   useClickOutside(ctxMenuRef, () => setCtxMenu(null), !!ctxMenu)
+  const ctxPos = useClampedPosition(ctxMenuRef, ctxMenu)
+  const folderSubSide = useFlipSide(folderSubRef, !!ctxMenu?.showFolderSub)
 
   useEffect(() => {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(filterState))
   }, [filterState])
 
+  useEffect(() => {
+    localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ sortBy, sortDir }))
+  }, [sortBy, sortDir])
+
   function updateFilter<K extends 'types' | 'ageRatings' | 'genres' | 'sources'>(key: K, tag: string) {
     setFilterState((prev) => ({ ...prev, [key]: toggle(prev[key], tag) }))
   }
@@ -285,12 +324,22 @@ export default function LibraryView() {
     setFilterState((prev) => ({ ...prev, failedOnly: !prev.failedOnly }))
   }
 
+  function toggleLikedOnly() {
+    setFilterState((prev) => ({ ...prev, likedOnly: !prev.likedOnly }))
+  }
+
+  function toggleUnavailableOnly() {
+    setFilterState((prev) => ({ ...prev, unavailableOnly: !prev.unavailableOnly }))
+  }
+
   const activeCount =
     filterState.types.length +
     filterState.ageRatings.length +
     filterState.genres.length +
     filterState.sources.length +
-    (filterState.failedOnly ? 1 : 0)
+    (filterState.failedOnly ? 1 : 0) +
+    (filterState.likedOnly ? 1 : 0) +
+    (filterState.unavailableOnly ? 1 : 0)
 
   // Refresh the library once a background download (subscribe or redownload) settles,
   // so the downloading/downloadFailed badges stay in sync without a manual Scan.
@@ -326,6 +375,13 @@ export default function LibraryView() {
     queryFn: () => window.electronAPI.folders.getAll()
   })
 
+  const { data: votedIds } = useQuery({
+    queryKey: ['steam-voted-ids'],
+    queryFn: () => window.electronAPI.steam.getVotedIds(),
+    staleTime: Infinity
+  })
+  const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+
   // Client-side OR filtering for multiple types / ratings / genres
   const filtered = useMemo(() => {
     return allWallpapers.filter((w) => {
@@ -347,11 +403,12 @@ export default function LibraryView() {
         if (!sources.includes(w.source)) return false
       }
       if (filterState.failedOnly && !w.downloadFailed) return false
+      if (filterState.likedOnly && !votedSet.has(w.id)) return false
+      if (filterState.unavailableOnly && !w.unavailable) return false
       return true
     })
-  }, [allWallpapers, filterState])
+  }, [allWallpapers, filterState, votedSet])
 
-  // Set of all existing library wallpaper IDs (for cross-referencing)
   const existingIds = useMemo(
     () => new Set(allWallpapers.map((w) => w.id)),
     [allWallpapers]
@@ -362,6 +419,17 @@ export default function LibraryView() {
     [allWallpapers]
   )
 
+  const unavailableCount = useMemo(
+    () => allWallpapers.filter((w) => w.unavailable).length,
+    [allWallpapers]
+  )
+
+  // Removed-from-workshop items with local content that hasn't been backed up yet
+  const unavailableNotBackedUp = useMemo(
+    () => allWallpapers.filter((w) => w.unavailable && !w.backedUp),
+    [allWallpapers]
+  )
+
   // Set of all IDs that belong to at least one folder AND exist in the library
   const allFolderItemIds = useMemo(() => {
     const set = new Set<string>()
@@ -404,7 +472,6 @@ export default function LibraryView() {
     return filtered.filter((w) => set.has(w.id))
   }, [filtered, activeFolder, folders, allFolderItemIds])
 
-  // Pagination
   const totalItems = allWallpapersForView.length
   const totalPages = Math.max(1, Math.ceil(totalItems / pageSize))
   const safePage = Math.min(page, totalPages)
@@ -413,7 +480,6 @@ export default function LibraryView() {
     [allWallpapersForView, safePage, pageSize]
   )
 
-  // Reset page when filters/folder/search/sort change
   useEffect(() => { setPage(1) }, [filterState, activeFolder, search, sortBy, sortDir, pageSize])
 
   // Count for "All" sidebar button: unsorted items only
@@ -432,13 +498,6 @@ export default function LibraryView() {
     queryFn: () => window.electronAPI.lwe.status()
   })
 
-  const { data: votedIds } = useQuery({
-    queryKey: ['steam-voted-ids'],
-    queryFn: () => window.electronAPI.steam.getVotedIds(),
-    staleTime: Infinity
-  })
-  const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
-
   const { data: config } = useQuery({
     queryKey: ['config'],
     queryFn: () => window.electronAPI.config.get()
@@ -497,6 +556,40 @@ export default function LibraryView() {
     }
   }
 
+  const [checkingUnavailable, setCheckingUnavailable] = useState(false)
+  const [backingUpUnavailable, setBackingUpUnavailable] = useState(false)
+
+  async function handleCheckUnavailable() {
+    setCheckingUnavailable(true)
+    setBackupStatus(null)
+    try {
+      const result = await window.electronAPI.library.checkUnavailable()
+      setBackupStatus(
+        `Checked ${result.checked} workshop item(s): ${result.unavailable} no longer available on the workshop.`
+      )
+    } finally {
+      setCheckingUnavailable(false)
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    }
+  }
+
+  async function handleBackupAllUnavailable() {
+    if (unavailableNotBackedUp.length === 0) return
+    setBackingUpUnavailable(true)
+    setBackupStatus(null)
+    try {
+      const result = await window.electronAPI.backup.selection(
+        unavailableNotBackedUp.map((w) => w.id)
+      )
+      setBackupStatus(
+        `Backed up ${result.backedUp}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
+      )
+    } finally {
+      setBackingUpUnavailable(false)
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    }
+  }
+
   async function handleScan() {
     setScanning(true)
     setScanResult(null)
@@ -563,7 +656,6 @@ export default function LibraryView() {
     refetchFolders()
   }
 
-  // Drop handler: add wallpaper(s) to folder via drag
   const handleDrop = useCallback(
     async (folderId: string, wallpaperId: string) => {
       // If the dragged item is in the selection, add all selected items
@@ -576,7 +668,6 @@ export default function LibraryView() {
     [refetchFolders, selectedIds]
   )
 
-  // Card click handler: normal = select single, ctrl = toggle, shift = range
   const handleCardSelect = useCallback(
     (wallpaperId: string, e: React.MouseEvent) => {
       if (e.ctrlKey || e.metaKey) {
@@ -612,7 +703,6 @@ export default function LibraryView() {
     [lastClickedId, wallpapers]
   )
 
-  // Marquee selection handlers
   const handleMarqueeStart = useCallback(
     (e: React.MouseEvent) => {
       // Only start marquee when clicking on the grid background, not on a card
@@ -629,7 +719,6 @@ export default function LibraryView() {
       marqueeActive.current = true
       setMarquee({ startX: x, startY: y, endX: x, endY: y })
 
-      // Clear selection unless ctrl is held
       if (!e.ctrlKey && !e.metaKey) {
         setSelectedIds(new Set())
       }
@@ -660,7 +749,6 @@ export default function LibraryView() {
     const container = gridRef.current
     if (!container) return
 
-    // Calculate marquee rectangle in container-relative coordinates
     const mx1 = Math.min(marquee.startX, marquee.endX)
     const my1 = Math.min(marquee.startY, marquee.endY)
     const mx2 = Math.max(marquee.startX, marquee.endX)
@@ -678,13 +766,11 @@ export default function LibraryView() {
 
     cards.forEach((card) => {
       const cardRect = card.getBoundingClientRect()
-      // Convert card rect to container-relative coordinates
       const cx1 = cardRect.left - containerRect.left + container.scrollLeft
       const cy1 = cardRect.top - containerRect.top + container.scrollTop
       const cx2 = cx1 + cardRect.width
       const cy2 = cy1 + cardRect.height
 
-      // Check intersection
       if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
         const id = card.getAttribute('data-wallpaper-id')
         if (id) hits.add(id)
@@ -692,7 +778,6 @@ export default function LibraryView() {
     })
 
     setSelectedIds((prev) => {
-      // If ctrl was held during start, merge with existing
       const next = new Set(prev)
       hits.forEach((id) => next.add(id))
       return next
@@ -701,13 +786,11 @@ export default function LibraryView() {
     setMarquee(null)
   }, [marquee])
 
-  // Clear selection when folder changes
   useEffect(() => {
     setSelectedIds(new Set())
     setLastClickedId(null)
   }, [activeFolder])
 
-  // Keyboard: Escape clears selection, Ctrl+A selects all
   useEffect(() => {
     function onKeyDown(e: KeyboardEvent) {
       if (e.key === 'Escape') {
@@ -730,7 +813,6 @@ export default function LibraryView() {
     return () => window.removeEventListener('keydown', onKeyDown)
   }, [wallpapers, selectedIds, currentPlaylistId])
 
-  // Compute marquee rect for rendering (in container-relative px)
   const marqueeRect = marquee
     ? {
         left: Math.min(marquee.startX, marquee.endX),
@@ -757,19 +839,32 @@ export default function LibraryView() {
     setCtxMenu(null)
   }
 
-  // Get WallpaperMeta objects for context menu items
   const ctxWallpapers = useMemo(() => {
     if (!ctxMenu) return []
     const idSet = new Set(ctxMenu.ids)
     return wallpapers.filter((w) => idSet.has(w.id))
   }, [ctxMenu, wallpapers])
 
+  async function unsubscribeIds(ids: string[]) {
+    await forEachIgnoringErrors(ids, (id) => window.electronAPI.steam.unsubscribe(id))
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+    refetchFolders()
+  }
+
   async function ctxUnsubscribe() {
     if (!ctxMenu) return
-    await forEachIgnoringErrors(ctxMenu.ids, (id) => window.electronAPI.steam.unsubscribe(id))
+    const ids = ctxMenu.ids
     closeCtxMenu()
-    queryClient.invalidateQueries({ queryKey: ['library'] })
-    refetchFolders()
+    await unsubscribeIds(ids)
+  }
+
+  async function handleApplyDetail(id: string) {
+    try {
+      await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    } catch (err) {
+      showToast((err as Error).message)
+    }
   }
 
   async function ctxVote(up: boolean) {
@@ -785,6 +880,26 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
+  async function ctxBrowseCreator() {
+    const wallpaper = ctxWallpapers[0]
+    if (!wallpaper) return
+    closeCtxMenu()
+    let creatorSteamId = wallpaper.authorSteamId
+    if (!creatorSteamId && isWorkshopId(wallpaper.id)) {
+      try {
+        const item = await window.electronAPI.workshop.getItem(wallpaper.id)
+        creatorSteamId = item?.creatorSteamId
+      } catch {
+        creatorSteamId = undefined
+      }
+    }
+    if (creatorSteamId) {
+      onBrowseCreator?.(creatorSteamId)
+    } else {
+      showToast('Could not find creator info for this wallpaper')
+    }
+  }
+
   async function ctxBackup() {
     if (!ctxMenu) return
     closeCtxMenu()
@@ -836,11 +951,13 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
-  // Check if any of the context menu items are video type
   const ctxHasVideo = ctxWallpapers.some((w) => w.type === 'video' && w.file)
   const ctxHasBackupable = ctxWallpapers.some((w) => w.source === 'workshop')
+  const ctxCanBrowseCreator =
+    ctxWallpapers.length === 1 &&
+    ctxWallpapers[0]?.source !== 'local' &&
+    (!!ctxWallpapers[0]?.authorSteamId || isWorkshopId(ctxWallpapers[0]?.id ?? ''))
 
-  // Folder membership of the selected wallpapers, for the context menu
   const ctxCurrentFolderIds = ctxMenu
     ? new Set(ctxMenu.ids.map((id) => folderOfItem.get(id)).filter((id): id is string => !!id))
     : new Set<string>()
@@ -850,10 +967,26 @@ export default function LibraryView() {
     (f) => !(ctxCurrentFolderIds.size === 1 && ctxCurrentFolderIds.has(f.id))
   )
 
+  const detailWallpaper = detailId ? wallpapers.find((w) => w.id === detailId) : undefined
+
   return (
     <div className="flex h-full flex-col">
-      {/* Header bar */}
       <div className="flex items-center gap-3 border-b border-white/5 px-4 py-3">
+        <button
+          onClick={() => setShowFilters((v) => !v)}
+          className={clsx(
+            'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
+            showFilters || activeCount > 0
+              ? 'bg-indigo-600 text-white'
+              : 'bg-white/5 text-gray-300 hover:bg-white/10'
+          )}
+        >
+          <SlidersHorizontal size={14} />
+          Filters
+          {activeCount > 0 && (
+            <span className="rounded-full bg-white/20 px-1.5 text-xs">{activeCount}</span>
+          )}
+        </button>
         <div className="relative max-w-md flex-1">
           <Search
             size={16}
@@ -922,12 +1055,23 @@ export default function LibraryView() {
           )}
           Scan backups
         </button>
+        <button
+          onClick={handleCheckUnavailable}
+          disabled={checkingUnavailable}
+          title="Check the workshop for wallpapers that have been removed"
+          className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-50"
+        >
+          {checkingUnavailable ? (
+            <Loader2 size={14} className="animate-spin" />
+          ) : (
+            <WifiOff size={14} />
+          )}
+          Check unavailable
+        </button>
       </div>
 
-      {/* Filter row */}
+      {showFilters && (
       <div className="flex flex-wrap items-center gap-2 border-b border-white/5 px-4 py-2">
-        <SlidersHorizontal size={12} className="text-gray-600" />
-
         <div className="flex gap-0.5">
           {WE_TYPES.filter((t) =>
             ['Scene', 'Video', 'Web', 'Application'].includes(t.tag)
@@ -988,6 +1132,43 @@ export default function LibraryView() {
           )}
         </button>
 
+        <button
+          onClick={toggleLikedOnly}
+          title="Show only wallpapers you've liked on Steam"
+          className={clsx(
+            'flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
+            filterState.likedOnly
+              ? 'bg-pink-600 text-white'
+              : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
+          )}
+        >
+          <Heart size={12} />
+          Liked
+        </button>
+
+        <button
+          onClick={toggleUnavailableOnly}
+          title={
+            unavailableNotBackedUp.length > 0
+              ? `Show only wallpapers removed from the Steam Workshop (${unavailableNotBackedUp.length} of these have no backup yet)`
+              : 'Show only wallpapers removed from the Steam Workshop'
+          }
+          className={clsx(
+            'flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
+            filterState.unavailableOnly
+              ? 'bg-amber-600 text-white'
+              : unavailableCount > 0
+                ? 'text-amber-400 hover:bg-white/5'
+                : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
+          )}
+        >
+          <WifiOff size={12} />
+          Unavailable
+          {unavailableCount > 0 && (
+            <span className="rounded-full bg-black/30 px-1.5">{unavailableCount}</span>
+          )}
+        </button>
+
         <div className="h-3 w-px bg-white/10" />
 
         <TagDropdown
@@ -1005,28 +1186,51 @@ export default function LibraryView() {
           </button>
         )}
 
-        {selectedIds.size > 0 && (
-          <button
-            onClick={handleBackupSelection}
-            disabled={backingUpSelection || !isBackupConfigured}
-            title={isBackupConfigured ? 'Back up selected wallpapers' : 'Configure a backup folder in Settings first'}
-            className="ml-auto flex items-center gap-1.5 rounded-lg bg-white/5 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/10 disabled:opacity-50"
-          >
-            {backingUpSelection ? (
-              <Loader2 size={12} className="animate-spin" />
-            ) : (
-              <Archive size={12} />
-            )}
-            Backup selection
-          </button>
-        )}
+        <div className="ml-auto flex items-center gap-2">
+          {unavailableNotBackedUp.length > 0 && (
+            <button
+              onClick={handleBackupAllUnavailable}
+              disabled={backingUpUnavailable || !isBackupConfigured}
+              title={
+                isBackupConfigured
+                  ? `Back up the ${unavailableNotBackedUp.length} removed-from-workshop wallpaper(s) that don't have a backup yet (${unavailableCount - unavailableNotBackedUp.length} already do)`
+                  : 'Configure a backup folder in Settings first'
+              }
+              className="flex items-center gap-1.5 rounded-lg bg-amber-600/80 px-3 py-1.5 text-xs text-white hover:bg-amber-600 disabled:opacity-50"
+            >
+              {backingUpUnavailable ? (
+                <Loader2 size={12} className="animate-spin" />
+              ) : (
+                <ShieldAlert size={12} />
+              )}
+              Backup all unavailable ({unavailableNotBackedUp.length})
+            </button>
+          )}
+
+          {selectedIds.size > 0 && (
+            <button
+              onClick={handleBackupSelection}
+              disabled={backingUpSelection || !isBackupConfigured}
+              title={isBackupConfigured ? 'Back up selected wallpapers' : 'Configure a backup folder in Settings first'}
+              className="flex items-center gap-1.5 rounded-lg bg-white/5 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/10 disabled:opacity-50"
+            >
+              {backingUpSelection ? (
+                <Loader2 size={12} className="animate-spin" />
+              ) : (
+                <Archive size={12} />
+              )}
+              Backup selection
+            </button>
+          )}
 
-        <span className={clsx('text-xs text-gray-600', selectedIds.size === 0 && 'ml-auto')}>
-          {selectedIds.size > 0
-            ? `${selectedIds.size} selected / ${wallpapers.length} wallpapers`
-            : `${wallpapers.length} wallpapers`}
-        </span>
+          <span className="text-xs text-gray-600">
+            {selectedIds.size > 0
+              ? `${selectedIds.size} selected / ${wallpapers.length} wallpapers`
+              : `${wallpapers.length} wallpapers`}
+          </span>
+        </div>
       </div>
+      )}
 
       {scanResult && (
         <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
@@ -1040,9 +1244,7 @@ export default function LibraryView() {
         </div>
       )}
 
-      {/* Main content: folder sidebar + grid */}
       <div className="flex flex-1 overflow-hidden">
-        {/* Folder sidebar */}
         <div className="flex w-48 flex-col border-r border-white/5 overflow-y-auto">
           <div className="flex items-center justify-between px-3 pt-3 pb-1">
             <span className="text-[10px] font-semibold uppercase tracking-wide text-gray-600">
@@ -1079,7 +1281,6 @@ export default function LibraryView() {
             </div>
           )}
 
-          {/* Default */}
           <button
             onClick={() => setActiveFolder(null)}
             className={clsx(
@@ -1094,7 +1295,6 @@ export default function LibraryView() {
             <span className="text-gray-600">{unsortedCount}</span>
           </button>
 
-          {/* Inline new folder input */}
           {creatingFolder && (
             <div className="flex items-center gap-1 px-3 py-1.5">
               <FolderPlus size={14} className="shrink-0 text-gray-500" />
@@ -1113,7 +1313,6 @@ export default function LibraryView() {
             </div>
           )}
 
-          {/* Folder list */}
           {folders.map((folder) => (
             <button
               key={folder.id}
@@ -1160,7 +1359,6 @@ export default function LibraryView() {
           ))}
         </div>
 
-        {/* Card grid */}
         <div
           ref={gridRef}
           className="relative flex-1 overflow-y-auto p-4 select-none"
@@ -1190,7 +1388,6 @@ export default function LibraryView() {
               )}
             </div>
           )}
-          {/* Folder cards in "All" view */}
           {!activeFolder && folders.length > 0 && (
             <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4 mb-4">
               {folders.map((folder) => (
@@ -1260,11 +1457,11 @@ export default function LibraryView() {
                 }
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
+                onOpenDetail={() => setDetailId(wallpaper.id)}
               />
             ))}
           </div>
 
-          {/* Pagination */}
           {totalPages > 1 && (
             <div className="mt-6 flex items-center justify-center gap-2">
               <button
@@ -1287,7 +1484,6 @@ export default function LibraryView() {
             </div>
           )}
 
-          {/* Marquee selection rectangle */}
           {marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3 && (
             <div
               className="pointer-events-none absolute z-20 border border-indigo-500 bg-indigo-500/15"
@@ -1300,9 +1496,41 @@ export default function LibraryView() {
             />
           )}
         </div>
+
+        {detailId && detailWallpaper && (
+          <DetailSidebar
+            id={detailId}
+            fallbackTitle={detailWallpaper.title}
+            fallbackPreviewUrl={getPreviewSrc(detailWallpaper)}
+            fallbackTags={detailWallpaper.tags}
+            fallbackAuthorSteamId={detailWallpaper.authorSteamId}
+            localFileSize={detailWallpaper.fileSize}
+            isSubscribed={detailWallpaper.subscribed}
+            isLiked={votedSet.has(detailId)}
+            canPlay={
+              !!detailWallpaper.localPath &&
+              !detailWallpaper.downloading &&
+              !detailWallpaper.downloadFailed
+            }
+            lweInstalled={lweStatus?.installed ?? false}
+            onClose={() => setDetailId(null)}
+            onSubscribe={async () => {
+              await window.electronAPI.steam.subscribe(detailId)
+              queryClient.invalidateQueries({ queryKey: ['library'] })
+            }}
+            onUnsubscribe={() => unsubscribeIds([detailId])}
+            onLiked={() => {
+              queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+                old ? [...old, detailId] : [detailId]
+              )
+              queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+            }}
+            onPlay={() => handleApplyDetail(detailId)}
+            onBrowseCreator={onBrowseCreator}
+          />
+        )}
       </div>
 
-      {/* Folder context menu */}
       {folderMenu && (
         <FolderMenu
           x={folderMenu.x}
@@ -1317,12 +1545,11 @@ export default function LibraryView() {
         />
       )}
 
-      {/* Wallpaper context menu */}
       {ctxMenu && (
         <div
           ref={ctxMenuRef}
           className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+          style={{ left: ctxPos?.x ?? ctxMenu.x, top: ctxPos?.y ?? ctxMenu.y }}
         >
           {ctxMenu.ids.length > 1 && (
             <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
@@ -1385,6 +1612,15 @@ export default function LibraryView() {
             </button>
           )}
 
+          {ctxCanBrowseCreator && (
+            <button
+              onClick={ctxBrowseCreator}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <User size={12} /> Browse wallpapers from this creator
+            </button>
+          )}
+
           <div className="my-1 border-t border-white/5" />
 
           <div className="relative">
@@ -1396,7 +1632,13 @@ export default function LibraryView() {
               <ChevronRight size={12} className="ml-auto" />
             </button>
             {ctxMenu.showFolderSub && (
-              <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+              <div
+                ref={folderSubRef}
+                className={clsx(
+                  'absolute top-0 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl',
+                  folderSubSide === 'left' ? 'right-full mr-1' : 'left-full ml-1'
+                )}
+              >
                 {ctxMoveTargets.length === 0 && (
                   <div className="px-3 py-1.5 text-gray-500">No other folders</div>
                 )}

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

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { Play, Loader2, Check, X, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw, Plus, FolderOpen } from 'lucide-react'
+import { Play, Loader2, Check, X, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw, Plus, FolderOpen, WifiOff } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 import { getPreviewSrc } from '../../utils/preview'
@@ -21,6 +21,7 @@ interface WallpaperCardProps {
   onRedownloaded?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
+  onOpenDetail?: () => void
 }
 
 export default function WallpaperCard({
@@ -36,7 +37,8 @@ export default function WallpaperCard({
   onAddedToPlaylist,
   onRedownloaded,
   onSelect,
-  onContextMenu
+  onContextMenu,
+  onOpenDetail
 }: WallpaperCardProps) {
   const { showToast } = useToast()
   const [isApplying, setIsApplying] = useState(false)
@@ -173,7 +175,6 @@ export default function WallpaperCard({
           : 'hover:ring-1 hover:ring-indigo-500/50'
       )}
     >
-      {/* Selection checkbox */}
       <div
         className={clsx(
           'absolute left-2 top-2 z-10 flex h-5 w-5 items-center justify-center rounded border transition-all',
@@ -185,7 +186,13 @@ export default function WallpaperCard({
         <Check size={12} />
       </div>
 
-      <div className="aspect-video overflow-hidden bg-[#111] relative">
+      <div
+        className="aspect-video overflow-hidden bg-[#111] relative cursor-pointer"
+        onClick={(e) => {
+          e.stopPropagation()
+          onOpenDetail?.()
+        }}
+      >
         {previewSrc ? (
           <img
             src={previewSrc}
@@ -235,6 +242,14 @@ export default function WallpaperCard({
             <Archive size={12} />
           </div>
         )}
+        {wallpaper.unavailable && (
+          <div
+            className="absolute bottom-2 left-2 flex h-5 w-5 items-center justify-center rounded bg-amber-600/80 text-white"
+            title="Removed from the Steam Workshop - back it up before it's lost"
+          >
+            <WifiOff size={12} />
+          </div>
+        )}
       </div>
 
       <div className="p-3">
@@ -354,7 +369,6 @@ export default function WallpaperCard({
         </div>
       </div>
 
-      {/* Apply button */}
       <div className="absolute right-2 top-2">
         <button
           onClick={handleApply}

+ 53 - 5
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -20,14 +20,17 @@ import {
   Eye,
   ThumbsUp,
   ThumbsDown,
-  Archive
+  Archive,
+  User
 } from 'lucide-react'
 import clsx from 'clsx'
 import type { Playlist, PlaylistPlaybackState, PlaylistSettings } from '@shared/types'
 import PlaylistItemRow from './PlaylistItemRow'
 import WallpaperPickerModal from './WallpaperPickerModal'
 import { useClickOutside } from '../../hooks/useClickOutside'
-import { openWorkshopPage } from '../../utils/steam'
+import { useClampedPosition, useFlipSide } from '../../hooks/useContextMenuPosition'
+import { openWorkshopPage, isWorkshopId } from '../../utils/steam'
+import { useToast } from '../common/Toast'
 
 const SORT_OPTIONS: { value: PlaylistSettings['sortBy']; label: string }[] = [
   { value: 'manual', label: 'Manual order' },
@@ -35,8 +38,13 @@ const SORT_OPTIONS: { value: PlaylistSettings['sortBy']; label: string }[] = [
   { value: 'createdAt', label: 'Date added' }
 ]
 
-export default function PlaylistsView() {
+interface PlaylistsViewProps {
+  onBrowseCreator?: (creatorSteamId: string) => void
+}
+
+export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
   const queryClient = useQueryClient()
+  const { showToast } = useToast()
   const [activePlaylistId, setActivePlaylistId] = useState<string | null>(null)
   const [creating, setCreating] = useState(false)
   const [newTitle, setNewTitle] = useState('')
@@ -54,7 +62,10 @@ export default function PlaylistsView() {
     showFolderSub: boolean
   } | null>(null)
   const ctxMenuRef = useRef<HTMLDivElement>(null)
+  const folderSubRef = useRef<HTMLDivElement>(null)
   useClickOutside(ctxMenuRef, () => setCtxMenu(null), !!ctxMenu)
+  const ctxPos = useClampedPosition(ctxMenuRef, ctxMenu)
+  const folderSubSide = useFlipSide(folderSubRef, !!ctxMenu?.showFolderSub)
 
   const { data: playlists = [] } = useQuery({
     queryKey: ['playlists'],
@@ -195,6 +206,26 @@ export default function PlaylistsView() {
     closeCtxMenu()
   }
 
+  async function ctxBrowseCreator() {
+    if (!ctxMenu) return
+    const wallpaperId = ctxMenu.wallpaperId
+    closeCtxMenu()
+    let creatorSteamId = ctxWallpaper?.authorSteamId
+    if (!creatorSteamId && isWorkshopId(wallpaperId)) {
+      try {
+        const item = await window.electronAPI.workshop.getItem(wallpaperId)
+        creatorSteamId = item?.creatorSteamId
+      } catch {
+        creatorSteamId = undefined
+      }
+    }
+    if (creatorSteamId) {
+      onBrowseCreator?.(creatorSteamId)
+    } else {
+      showToast('Could not find creator info for this wallpaper')
+    }
+  }
+
   async function ctxBackup() {
     if (!ctxMenu) return
     closeCtxMenu()
@@ -590,7 +621,7 @@ export default function PlaylistsView() {
         <div
           ref={ctxMenuRef}
           className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+          style={{ left: ctxPos?.x ?? ctxMenu.x, top: ctxPos?.y ?? ctxMenu.y }}
         >
           {ctxHasBackupable && isBackupConfigured && (
             <button
@@ -647,6 +678,17 @@ export default function PlaylistsView() {
             </button>
           )}
 
+          {ctxWallpaper &&
+            ctxWallpaper.source !== 'local' &&
+            (ctxWallpaper.authorSteamId || isWorkshopId(ctxWallpaper.id)) && (
+            <button
+              onClick={ctxBrowseCreator}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <User size={12} /> Browse wallpapers from this creator
+            </button>
+          )}
+
           <div className="my-1 border-t border-white/5" />
 
           <div className="relative">
@@ -658,7 +700,13 @@ export default function PlaylistsView() {
               <ChevronRight size={12} className="ml-auto" />
             </button>
             {ctxMenu.showFolderSub && (
-              <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+              <div
+                ref={folderSubRef}
+                className={clsx(
+                  'absolute top-0 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl',
+                  folderSubSide === 'left' ? 'right-full mr-1' : 'left-full ml-1'
+                )}
+              >
                 {ctxMoveTargets.length === 0 && (
                   <div className="px-3 py-1.5 text-gray-500">No other folders</div>
                 )}

+ 35 - 18
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro } from '../../../../shared/types'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -15,10 +15,10 @@ export default function SettingsView() {
   const [importStatus, setImportStatus] = useState<string | null>(null)
   const [defaultFps, setDefaultFps] = useState<string>('')
   const [fpsSaved, setFpsSaved] = useState(false)
+  const [recommendedFpsEnabled, setRecommendedFpsEnabled] = useState(false)
   const [resetFpsConfirm, setResetFpsConfirm] = useState(false)
   const [resetFpsMsg, setResetFpsMsg] = useState<string | null>(null)
 
-  // linux-wallpaperengine state
   const [lweStatus, setLweStatus] = useState<LweStatus | null>(null)
   const [lweRepoUrl, setLweRepoUrl] = useState('')
   const [lweRepoBranch, setLweRepoBranch] = useState('')
@@ -34,15 +34,12 @@ export default function SettingsView() {
   const [killingLwe, setKillingLwe] = useState(false)
   const [killMsg, setKillMsg] = useState<string | null>(null)
 
-  // Desktop icons overlay
   const [desktopIconsEnabled, setDesktopIconsEnabled] = useState(false)
 
-  // Backup
   const [backupPath, setBackupPath] = useState('')
   const [backupSaved, setBackupSaved] = useState(false)
   const [autoUnsubscribeAfterBackup, setAutoUnsubscribeAfterBackup] = useState(false)
 
-  // System tray & autostart
   const [trayEnabled, setTrayEnabled] = useState(false)
   const [killLweOnQuit, setKillLweOnQuit] = useState(false)
   const [autostartSupported, setAutostartSupported] = useState(false)
@@ -55,6 +52,7 @@ export default function SettingsView() {
     window.electronAPI.config.get().then((cfg) => {
       setWorkshopPath(cfg.workshopPath ?? cfg.defaultWorkshopPath)
       setDefaultFps(cfg.defaultFps != null ? String(cfg.defaultFps) : '')
+      setRecommendedFpsEnabled(cfg.recommendedFpsEnabled)
       setLweRepoUrl(cfg.lweRepoUrl ?? '')
       setLweRepoBranch(cfg.lweRepoBranch ?? '')
       setLweCmakeArgs(cfg.lweCmakeArgs ?? '')
@@ -74,7 +72,6 @@ export default function SettingsView() {
     window.electronAPI.playlist.getAll().then((all) => setPlaylists(all.map((p) => ({ id: p.id, title: p.title }))))
   }, [])
 
-  // Listen for install progress events
   useEffect(() => {
     const unsub = window.electronAPI.on.lweInstallProgress((progress) => {
       setLweProgress(progress)
@@ -191,6 +188,12 @@ export default function SettingsView() {
     setTimeout(() => setFpsSaved(false), 2000)
   }
 
+  async function handleRecommendedFpsToggle() {
+    const newVal = !recommendedFpsEnabled
+    setRecommendedFpsEnabled(newVal)
+    await window.electronAPI.config.setRecommendedFpsEnabled(newVal)
+  }
+
   async function handleResetFpsOverrides() {
     if (!resetFpsConfirm) { setResetFpsConfirm(true); return }
     setResetFpsConfirm(false)
@@ -258,7 +261,6 @@ export default function SettingsView() {
       <h2 className="text-lg font-semibold text-gray-100">Settings</h2>
 
       <div className="mt-6 max-w-lg space-y-8">
-        {/* Workshop path */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Workshop folder
@@ -294,7 +296,6 @@ export default function SettingsView() {
           </button>
         </div>
 
-        {/* Backup folder */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Backup folder
@@ -352,7 +353,6 @@ export default function SettingsView() {
           </label>
         </div>
 
-        {/* LWE launch options */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Wallpaper Engine Options
@@ -386,6 +386,32 @@ export default function SettingsView() {
                 </button>
               </div>
             </div>
+            <div>
+              <label className="mt-1 flex items-center gap-3 cursor-pointer">
+                <button
+                  onClick={handleRecommendedFpsToggle}
+                  className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                    recommendedFpsEnabled ? 'bg-indigo-600' : 'bg-white/10'
+                  }`}
+                >
+                  <span
+                    className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                      recommendedFpsEnabled ? 'translate-x-4' : 'translate-x-0.5'
+                    }`}
+                  />
+                </button>
+                <span className="flex items-center gap-2 text-sm text-gray-300">
+                  <Film size={16} />
+                  Recommended video settings
+                </span>
+              </label>
+              <p className="mt-2 text-xs text-gray-600">
+                Reads each video wallpaper's actual frame rate and launches it at that FPS
+                instead of the default limit, which fixes stutter on videos that don't
+                match it. Wallpapers with a manual FPS override are unaffected. Requires
+                ffprobe (part of ffmpeg).
+              </p>
+            </div>
             <div>
               <label className="block text-xs text-gray-400 mb-1">Reset per-wallpaper FPS</label>
               <p className="text-xs text-gray-600 mb-2">
@@ -423,7 +449,6 @@ export default function SettingsView() {
           </div>
         </div>
 
-        {/* Import WE config */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Wallpaper Engine Config
@@ -445,7 +470,6 @@ export default function SettingsView() {
           )}
         </div>
 
-        {/* linux-wallpaperengine */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             linux-wallpaperengine
@@ -455,7 +479,6 @@ export default function SettingsView() {
             scenes directly on your desktop.
           </p>
 
-          {/* Custom source repository */}
           <div className="mt-3 space-y-2">
             <label className="block text-xs text-gray-400">Source repository</label>
             <p className="text-xs text-gray-600">
@@ -553,7 +576,6 @@ export default function SettingsView() {
               )}
 
               <div className="flex flex-wrap gap-2">
-                {/* Install build deps */}
                 <button
                   onClick={handleInstallDeps}
                   disabled={isBusy || !distro || distro === 'unknown'}
@@ -572,7 +594,6 @@ export default function SettingsView() {
                   {depsInstalling ? 'Installing deps...' : 'Install build dependencies'}
                 </button>
 
-                {/* Build & install lwe */}
                 <button
                   onClick={handleInstallLwe}
                   disabled={isBusy}
@@ -601,7 +622,6 @@ export default function SettingsView() {
             </div>
           )}
 
-          {/* Kill stray processes */}
           <div className="mt-4">
             <button
               onClick={handleKillAllLwe}
@@ -642,7 +662,6 @@ export default function SettingsView() {
             </label>
           </div>
 
-          {/* Install progress */}
           {lweProgress && (
             <div className="mt-3 space-y-2">
               <div className="flex items-center gap-2">
@@ -667,7 +686,6 @@ export default function SettingsView() {
           )}
         </div>
 
-        {/* Desktop icons overlay */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             Desktop Icons
@@ -696,7 +714,6 @@ export default function SettingsView() {
           </label>
         </div>
 
-        {/* System tray & startup */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
             System Tray &amp; Startup

+ 368 - 33
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -1,8 +1,21 @@
 import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { useInfiniteQuery } from '@tanstack/react-query'
-import { Search, Loader2, SlidersHorizontal, X, ChevronDown, ChevronRight } from 'lucide-react'
+import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+  Search,
+  Loader2,
+  SlidersHorizontal,
+  X,
+  ChevronDown,
+  ChevronRight,
+  User,
+  ExternalLink,
+  Play,
+  Download
+} from 'lucide-react'
 import WorkshopCard from './WorkshopCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
+import DetailSidebar from '../common/DetailSidebar'
+import { useToast } from '../common/Toast'
 import type { WorkshopQueryType } from '@shared/types'
 import clsx from 'clsx'
 import {
@@ -15,9 +28,11 @@ import {
 } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useClickOutside } from '../../hooks/useClickOutside'
+import { useClampedPosition } from '../../hooks/useContextMenuPosition'
+import { useSubscriptionQueue } from '../../hooks/useSubscriptionQueue'
 import { toggle } from '../../utils/array'
 import { forEachIgnoringErrors } from '../../utils/async'
-import { openWorkshopPage } from '../../utils/steam'
+import { openWorkshopPage, openProfilePage } from '../../utils/steam'
 
 const STORAGE_KEY = 'we-workshop-filters'
 const STORAGE_VERSION = 2
@@ -134,28 +149,54 @@ interface CtxMenu {
   ids: string[]
 }
 
-export default function WorkshopBrowser() {
+interface Marquee {
+  startX: number
+  startY: number
+  endX: number
+  endY: number
+}
+
+interface WorkshopBrowserProps {
+  creatorFilter?: string | null
+  onClearCreatorFilter?: () => void
+  onBrowseCreator?: (creatorSteamId: string) => void
+}
+
+export default function WorkshopBrowser({
+  creatorFilter = null,
+  onClearCreatorFilter,
+  onBrowseCreator
+}: WorkshopBrowserProps) {
+  const queryClient = useQueryClient()
+  const { showToast } = useToast()
+  const { entries: subscribeEntries, enqueue: enqueueSubscribe, clear: clearSubscribeEntries } =
+    useSubscriptionQueue(() => queryClient.invalidateQueries({ queryKey: ['library'] }))
   const [searchText, setSearchText] = useState('')
   const [queryType, setQueryType] = useState<WorkshopQueryType>('RankedByPublicationDate')
   const [showFilters, setShowFilters] = useState(true)
   const [filters, setFilters] = useState<WorkshopFilterState>(loadFilters)
   const [selection, setSelection] = useState<Set<string>>(new Set())
+  const [lastClickedId, setLastClickedId] = useState<string | null>(null)
+  const [marquee, setMarquee] = useState<Marquee | null>(null)
+  const marqueeActive = useRef(false)
+  const gridRef = useRef<HTMLDivElement>(null)
   const [ctxMenu, setCtxMenu] = useState<CtxMenu | null>(null)
   const ctxRef = useRef<HTMLDivElement>(null)
   const [page, setPage] = useState(1)
   const [pageSize, setPageSize] = useState(50)
   const [previewSize, setPreviewSize] = usePreviewSize()
+  const [detailId, setDetailId] = useState<string | null>(null)
 
   useClickOutside(ctxRef, () => setCtxMenu(null), !!ctxMenu)
+  const ctxPos = useClampedPosition(ctxRef, ctxMenu)
 
   useEffect(() => {
     saveFilters(filters)
   }, [filters])
 
-  // Reset to page 1 when query params change
   useEffect(() => {
     setPage(1)
-  }, [searchText, queryType, filters, pageSize])
+  }, [searchText, queryType, filters, pageSize, creatorFilter])
 
   function update<K extends keyof WorkshopFilterState>(key: K, tag: string) {
     setFilters((prev) => ({ ...prev, [key]: toggle(prev[key] as string[], tag) }))
@@ -204,19 +245,13 @@ export default function WorkshopBrowser() {
     setFilters((prev) => ({ ...DEFAULT_STATE, filterMode: prev.filterMode }))
   }
 
-  function toggleSelect(id: string) {
-    setSelection((prev) => {
-      const next = new Set(prev)
-      if (next.has(id)) next.delete(id)
-      else next.add(id)
-      return next
-    })
-  }
-
   const openCtxMenu = useCallback(
     (e: React.MouseEvent, itemId: string) => {
       e.preventDefault()
       const ids = selection.has(itemId) ? [...selection] : [itemId]
+      if (!selection.has(itemId)) {
+        setSelection(new Set(ids))
+      }
       setCtxMenu({ x: e.clientX, y: e.clientY, ids })
     },
     [selection]
@@ -226,21 +261,29 @@ export default function WorkshopBrowser() {
     setCtxMenu(null)
   }
 
-  async function ctxSubscribe() {
+  function ctxSubscribe() {
     if (!ctxMenu) return
-    await forEachIgnoringErrors(ctxMenu.ids, (id) => window.electronAPI.steam.subscribe(id))
+    enqueueSubscribe(ctxMenu.ids)
     closeCtxMenu()
   }
 
+  async function unsubscribeIds(ids: string[]) {
+    clearSubscribeEntries(ids)
+    await forEachIgnoringErrors(ids, (id) => window.electronAPI.steam.unsubscribe(id))
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+  }
+
   async function ctxUnsubscribe() {
     if (!ctxMenu) return
-    await forEachIgnoringErrors(ctxMenu.ids, (id) => window.electronAPI.steam.unsubscribe(id))
+    const ids = ctxMenu.ids
     closeCtxMenu()
+    await unsubscribeIds(ids)
   }
 
   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'] })
     closeCtxMenu()
   }
 
@@ -250,16 +293,29 @@ export default function WorkshopBrowser() {
     closeCtxMenu()
   }
 
+  function ctxPlay() {
+    if (!ctxMenu || ctxMenu.ids.length !== 1) return
+    handlePlay(ctxMenu.ids[0])
+    closeCtxMenu()
+  }
+
   const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, error } =
     useInfiniteQuery({
-      queryKey: ['workshop', searchText, queryType, steamTags],
+      queryKey: ['workshop', creatorFilter, searchText, queryType, steamTags],
       queryFn: ({ pageParam = 1 }) =>
-        window.electronAPI.workshop.query({
-          searchText: searchText || undefined,
-          queryType,
-          tags: steamTags.length > 0 ? steamTags : undefined,
-          page: pageParam as number
-        }),
+        creatorFilter
+          ? window.electronAPI.workshop.queryByCreator(creatorFilter, {
+              searchText: searchText || undefined,
+              queryType,
+              tags: steamTags.length > 0 ? steamTags : undefined,
+              page: pageParam as number
+            })
+          : window.electronAPI.workshop.query({
+              searchText: searchText || undefined,
+              queryType,
+              tags: steamTags.length > 0 ? steamTags : undefined,
+              page: pageParam as number
+            }),
       getNextPageParam: (lastPage, allPages) => {
         const fetched = allPages.length * 50
         return fetched < lastPage.totalResults ? allPages.length + 1 : undefined
@@ -272,6 +328,48 @@ export default function WorkshopBrowser() {
     [data]
   )
 
+  const { data: votedIds } = useQuery({
+    queryKey: ['steam-voted-ids'],
+    queryFn: () => window.electronAPI.steam.getVotedIds(),
+    staleTime: Infinity
+  })
+  const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+
+  const { data: libraryWallpapers = [] } = useQuery({
+    queryKey: ['library'],
+    queryFn: () => window.electronAPI.library.getAll()
+  })
+  const playableSet = useMemo(
+    () =>
+      new Set(
+        libraryWallpapers
+          .filter((w) => w.localPath && !w.downloading && !w.downloadFailed)
+          .map((w) => w.id)
+      ),
+    [libraryWallpapers]
+  )
+
+  const { data: lweStatus } = useQuery({
+    queryKey: ['lwe-status'],
+    queryFn: () => window.electronAPI.lwe.status()
+  })
+
+  async function handlePlay(id: string) {
+    try {
+      await window.electronAPI.wallpaper.apply({ wallpaperId: id })
+    } catch (err) {
+      showToast((err as Error).message)
+    }
+  }
+
+  function ctxBrowseCreator() {
+    if (!ctxMenu || ctxMenu.ids.length !== 1) return
+    const item = allItems.find((i) => i.publishedFileId === ctxMenu.ids[0])
+    if (!item) return
+    onBrowseCreator?.(item.creatorSteamId)
+    closeCtxMenu()
+  }
+
   // In OR mode: client-side OR filtering for multi-selected categories
   const items = useMemo(() => {
     if (filters.filterMode !== 'or') return allItems
@@ -307,9 +405,154 @@ export default function WorkshopBrowser() {
 
   const assetSelected = filters.types.includes('Asset') || effectiveTypes.length === 0
 
+  // Card click handler: normal = select single, ctrl = toggle, shift = range
+  const handleCardSelect = useCallback(
+    (itemId: string, e: React.MouseEvent) => {
+      if (e.ctrlKey || e.metaKey) {
+        setSelection((prev) => {
+          const next = new Set(prev)
+          if (next.has(itemId)) next.delete(itemId)
+          else next.add(itemId)
+          return next
+        })
+        setLastClickedId(itemId)
+      } else if (e.shiftKey && lastClickedId) {
+        const ids = paginatedItems.map((i) => i.publishedFileId)
+        const from = ids.indexOf(lastClickedId)
+        const to = ids.indexOf(itemId)
+        if (from !== -1 && to !== -1) {
+          const start = Math.min(from, to)
+          const end = Math.max(from, to)
+          const rangeIds = ids.slice(start, end + 1)
+          setSelection((prev) => {
+            const next = new Set(prev)
+            for (const id of rangeIds) next.add(id)
+            return next
+          })
+        }
+      } else {
+        setSelection(new Set([itemId]))
+        setLastClickedId(itemId)
+      }
+    },
+    [lastClickedId, paginatedItems]
+  )
+
+  const handleMarqueeStart = useCallback((e: React.MouseEvent) => {
+    if ((e.target as HTMLElement).closest('[data-workshop-id]')) return
+    if (e.button !== 0) return
+
+    const container = gridRef.current
+    if (!container) return
+
+    const rect = container.getBoundingClientRect()
+    const x = e.clientX - rect.left + container.scrollLeft
+    const y = e.clientY - rect.top + container.scrollTop
+
+    marqueeActive.current = true
+    setMarquee({ startX: x, startY: y, endX: x, endY: y })
+
+    if (!e.ctrlKey && !e.metaKey) {
+      setSelection(new Set())
+    }
+  }, [])
+
+  const handleMarqueeMove = useCallback(
+    (e: React.MouseEvent) => {
+      if (!marqueeActive.current || !marquee) return
+
+      const container = gridRef.current
+      if (!container) return
+
+      const rect = container.getBoundingClientRect()
+      const x = e.clientX - rect.left + container.scrollLeft
+      const y = e.clientY - rect.top + container.scrollTop
+
+      setMarquee((prev) => (prev ? { ...prev, endX: x, endY: y } : null))
+    },
+    [marquee]
+  )
+
+  const handleMarqueeEnd = useCallback(() => {
+    if (!marqueeActive.current || !marquee) return
+    marqueeActive.current = false
+
+    const container = gridRef.current
+    if (!container) return
+
+    const mx1 = Math.min(marquee.startX, marquee.endX)
+    const my1 = Math.min(marquee.startY, marquee.endY)
+    const mx2 = Math.max(marquee.startX, marquee.endX)
+    const my2 = Math.max(marquee.startY, marquee.endY)
+
+    if (Math.abs(mx2 - mx1) < 5 && Math.abs(my2 - my1) < 5) {
+      setMarquee(null)
+      return
+    }
+
+    const containerRect = container.getBoundingClientRect()
+    const cards = container.querySelectorAll('[data-workshop-id]')
+    const hits = new Set<string>()
+
+    cards.forEach((card) => {
+      const cardRect = card.getBoundingClientRect()
+      const cx1 = cardRect.left - containerRect.left + container.scrollLeft
+      const cy1 = cardRect.top - containerRect.top + container.scrollTop
+      const cx2 = cx1 + cardRect.width
+      const cy2 = cy1 + cardRect.height
+
+      if (cx1 < mx2 && cx2 > mx1 && cy1 < my2 && cy2 > my1) {
+        const id = card.getAttribute('data-workshop-id')
+        if (id) hits.add(id)
+      }
+    })
+
+    setSelection((prev) => {
+      const next = new Set(prev)
+      hits.forEach((id) => next.add(id))
+      return next
+    })
+
+    setMarquee(null)
+  }, [marquee])
+
+  useEffect(() => {
+    function onKeyDown(e: KeyboardEvent) {
+      if (e.key === 'Escape') {
+        setSelection(new Set())
+      }
+      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
+        const active = document.activeElement
+        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
+        e.preventDefault()
+        setSelection(new Set(paginatedItems.map((i) => i.publishedFileId)))
+      }
+    }
+    window.addEventListener('keydown', onKeyDown)
+    return () => window.removeEventListener('keydown', onKeyDown)
+  }, [paginatedItems])
+
+  const marqueeRect = marquee
+    ? {
+        left: Math.min(marquee.startX, marquee.endX),
+        top: Math.min(marquee.startY, marquee.endY),
+        width: Math.abs(marquee.endX - marquee.startX),
+        height: Math.abs(marquee.endY - marquee.startY)
+      }
+    : null
+
+  const detailItem = detailId ? allItems.find((i) => i.publishedFileId === detailId) : undefined
+  const detailSubscribeState = detailId ? subscribeEntries.get(detailId)?.state : undefined
+  const detailSubscribed = detailItem
+    ? detailItem.isSubscribed ||
+      detailSubscribeState === 'download-queued' ||
+      detailSubscribeState === 'downloading' ||
+      detailSubscribeState === 'done' ||
+      detailSubscribeState === 'download-error'
+    : false
+
   return (
     <div className="flex h-full flex-col">
-      {/* Header bar */}
       <div className="flex items-center gap-3 border-b border-white/5 px-4 py-3">
         <button
           onClick={() => setShowFilters((v) => !v)}
@@ -356,13 +599,41 @@ export default function WorkshopBrowser() {
             <X size={14} /> Reset
           </button>
         )}
+        {selection.size > 0 && (
+          <>
+            <span className="text-xs text-gray-500">{selection.size} selected</span>
+            <button
+              onClick={() => enqueueSubscribe([...selection])}
+              className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm text-white hover:bg-indigo-500"
+            >
+              <Download size={14} /> Subscribe selection
+            </button>
+          </>
+        )}
       </div>
 
-      {/* Body: optional filter sidebar + content grid */}
+      {creatorFilter && (
+        <div className="flex items-center gap-2 border-b border-white/5 bg-indigo-600/10 px-4 py-2 text-xs text-indigo-200">
+          <User size={12} />
+          <span>Showing wallpapers from this creator</span>
+          <button
+            onClick={() => openProfilePage(creatorFilter)}
+            className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10"
+          >
+            <ExternalLink size={11} /> View Steam profile
+          </button>
+          <button
+            onClick={() => onClearCreatorFilter?.()}
+            className="ml-auto flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-gray-300 hover:bg-white/10"
+          >
+            <X size={11} /> Clear
+          </button>
+        </div>
+      )}
+
       <div className="flex flex-1 overflow-hidden">
         {showFilters && (
           <div className="w-52 flex-shrink-0 overflow-y-auto border-r border-white/5 bg-[#0d0d0d] px-3 py-3 space-y-3">
-            {/* AND / OR toggle */}
             <div className="flex items-center justify-between">
               <span className="text-xs text-gray-500">Match</span>
               <div className="flex rounded-md overflow-hidden text-xs">
@@ -523,7 +794,14 @@ export default function WorkshopBrowser() {
           </div>
         )}
 
-        <div className="flex-1 overflow-y-auto p-4">
+        <div
+          ref={gridRef}
+          className="relative flex-1 overflow-y-auto p-4 select-none"
+          onMouseDown={handleMarqueeStart}
+          onMouseMove={handleMarqueeMove}
+          onMouseUp={handleMarqueeEnd}
+          onMouseLeave={handleMarqueeEnd}
+        >
           {isLoading && (
             <div className="flex h-40 items-center justify-center text-gray-500">
               <Loader2 size={24} className="animate-spin" />
@@ -552,17 +830,36 @@ export default function WorkshopBrowser() {
                 key={item.publishedFileId}
                 item={item}
                 selected={selection.has(item.publishedFileId)}
-                onToggleSelect={() => toggleSelect(item.publishedFileId)}
+                isLiked={votedSet.has(item.publishedFileId)}
+                canPlay={playableSet.has(item.publishedFileId)}
+                lweInstalled={lweStatus?.installed ?? false}
+                subscribeState={subscribeEntries.get(item.publishedFileId)?.state}
+                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'] })}
+                onPlay={() => handlePlay(item.publishedFileId)}
+                onSubscribe={() => enqueueSubscribe([item.publishedFileId])}
+                onOpenDetail={() => setDetailId(item.publishedFileId)}
               />
             ))}
           </div>
+          {marqueeRect && marqueeRect.width > 3 && marqueeRect.height > 3 && (
+            <div
+              className="pointer-events-none absolute z-20 border border-indigo-500 bg-indigo-500/15"
+              style={{
+                left: marqueeRect.left,
+                top: marqueeRect.top,
+                width: marqueeRect.width,
+                height: marqueeRect.height
+              }}
+            />
+          )}
           {isFetchingNextPage && (
             <div className="mt-4 flex justify-center text-gray-500">
               <Loader2 size={20} className="animate-spin" />
             </div>
           )}
-          {/* Pagination controls */}
           {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">
@@ -599,20 +896,50 @@ export default function WorkshopBrowser() {
             </div>
           )}
         </div>
+
+        {detailId && detailItem && (
+          <DetailSidebar
+            id={detailId}
+            fallbackTitle={detailItem.title}
+            fallbackPreviewUrl={detailItem.previewUrl}
+            fallbackTags={detailItem.tags}
+            fallbackAuthorSteamId={detailItem.creatorSteamId}
+            isSubscribed={detailSubscribed}
+            isLiked={votedSet.has(detailId)}
+            canPlay={playableSet.has(detailId)}
+            lweInstalled={lweStatus?.installed ?? false}
+            onClose={() => setDetailId(null)}
+            onSubscribe={() => enqueueSubscribe([detailId])}
+            onUnsubscribe={() => unsubscribeIds([detailId])}
+            onLiked={() => queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })}
+            onPlay={() => handlePlay(detailId)}
+            onBrowseCreator={onBrowseCreator}
+          />
+        )}
       </div>
 
-      {/* Context menu */}
       {ctxMenu && (
         <div
           ref={ctxRef}
           className="fixed z-50 min-w-[180px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+          style={{ left: ctxPos?.x ?? ctxMenu.x, top: ctxPos?.y ?? ctxMenu.y }}
         >
           {ctxMenu.ids.length > 1 && (
             <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
               {ctxMenu.ids.length} items selected
             </div>
           )}
+          {ctxMenu.ids.length === 1 && playableSet.has(ctxMenu.ids[0]) && (
+            <>
+              <button
+                onClick={ctxPlay}
+                className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+              >
+                <Play size={12} /> Play wallpaper
+              </button>
+              <div className="my-1 border-t border-white/5" />
+            </>
+          )}
           <button
             onClick={ctxSubscribe}
             className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
@@ -645,6 +972,14 @@ export default function WorkshopBrowser() {
           >
             Open in Steam Workshop
           </button>
+          {ctxMenu.ids.length === 1 && (
+            <button
+              onClick={ctxBrowseCreator}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <User size={12} /> Browse wallpapers from this creator
+            </button>
+          )}
         </div>
       )}
     </div>

+ 171 - 23
src/renderer/src/components/workshop/WorkshopCard.tsx

@@ -1,47 +1,91 @@
 import { useState } from 'react'
-import { Download, Check, Loader2 } from 'lucide-react'
+import { Download, Check, Loader2, ThumbsUp, ExternalLink, Play, Clock, AlertTriangle } from 'lucide-react'
 import type { WorkshopItem } from '@shared/types'
+import type { SubscribeState } from '../../hooks/useSubscriptionQueue'
 import clsx from 'clsx'
+import { openWorkshopPage } from '../../utils/steam'
 
 interface WorkshopCardProps {
   item: WorkshopItem
   selected?: boolean
-  onToggleSelect?: () => void
+  isLiked?: boolean
+  canPlay?: boolean
+  lweInstalled?: boolean
+  subscribeState?: SubscribeState
+  downloadPercentage?: number
+  onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
+  onLiked?: () => void
+  onPlay?: () => void | Promise<void>
+  onSubscribe?: () => void
+  onOpenDetail?: () => void
 }
 
 export default function WorkshopCard({
   item,
   selected = false,
-  onToggleSelect,
-  onContextMenu
+  isLiked = false,
+  canPlay = false,
+  lweInstalled = false,
+  subscribeState,
+  downloadPercentage,
+  onSelect,
+  onContextMenu,
+  onLiked,
+  onPlay,
+  onSubscribe,
+  onOpenDetail
 }: WorkshopCardProps) {
-  const [isSubscribing, setIsSubscribing] = useState(false)
-  const [subscribed, setSubscribed] = useState(item.isSubscribed)
+  const [isLiking, setIsLiking] = useState(false)
+  const [isApplying, setIsApplying] = useState(false)
 
-  async function handleSubscribe(e: React.MouseEvent) {
+  const subscribed =
+    item.isSubscribed ||
+    subscribeState === 'download-queued' ||
+    subscribeState === 'downloading' ||
+    subscribeState === 'done' ||
+    subscribeState === 'download-error'
+
+  function handleSubscribe(e: React.MouseEvent) {
+    e.stopPropagation()
+    onSubscribe?.()
+  }
+
+  async function handlePlay(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (!canPlay || isApplying) return
+    setIsApplying(true)
+    try {
+      await onPlay?.()
+    } finally {
+      setIsApplying(false)
+    }
+  }
+
+  async function handleLike(e: React.MouseEvent) {
     e.stopPropagation()
-    setIsSubscribing(true)
+    if (isLiked || isLiking) return
+    setIsLiking(true)
     try {
-      await window.electronAPI.steam.subscribe(item.publishedFileId)
-      setSubscribed(true)
+      await window.electronAPI.steam.vote(item.publishedFileId, true)
+      onLiked?.()
     } catch (err) {
-      console.error('Subscribe failed:', err)
+      console.error('Like failed:', err)
     } finally {
-      setIsSubscribing(false)
+      setIsLiking(false)
     }
   }
 
   return (
     <div
+      data-workshop-id={item.publishedFileId}
       className={clsx(
         'group relative overflow-hidden rounded-lg bg-[#1a1a1a] transition-all hover:ring-1',
         selected ? 'ring-2 ring-indigo-500' : 'hover:ring-indigo-500/50'
       )}
       onContextMenu={onContextMenu}
-      onClick={onToggleSelect}
+      onClick={onSelect}
     >
-      {/* Selection checkbox */}
       <div
         className={clsx(
           'absolute left-2 top-2 z-10 transition-opacity',
@@ -60,8 +104,13 @@ export default function WorkshopCard({
         </div>
       </div>
 
-      {/* Preview image */}
-      <div className="aspect-video overflow-hidden bg-[#111]">
+      <div
+        className="aspect-video overflow-hidden bg-[#111] relative cursor-pointer"
+        onClick={(e) => {
+          e.stopPropagation()
+          onOpenDetail?.()
+        }}
+      >
         {item.previewUrl ? (
           <img
             src={item.previewUrl}
@@ -72,29 +121,128 @@ export default function WorkshopCard({
         ) : (
           <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
         )}
+        {subscribeState === 'downloading' && downloadPercentage != null && (
+          <div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
+            <div
+              className="h-full bg-indigo-500 transition-[width]"
+              style={{ width: `${downloadPercentage}%` }}
+            />
+          </div>
+        )}
+        {subscribeState === 'download-error' && (
+          <button
+            onClick={handleSubscribe}
+            title="Subscribed, but the download failed (disk full?) - click to retry"
+            className="absolute bottom-2 right-2 flex h-5 w-5 items-center justify-center rounded bg-red-600/80 text-white hover:bg-red-600"
+          >
+            <AlertTriangle size={12} />
+          </button>
+        )}
+        {(subscribeState === 'download-queued' || subscribeState === 'downloading') && (
+          <div
+            className="absolute bottom-2 right-2 flex h-5 w-5 items-center justify-center rounded bg-black/60 text-indigo-300"
+            title={
+              subscribeState === 'downloading' && downloadPercentage != null
+                ? `Downloading... ${downloadPercentage}%`
+                : 'Queued to download'
+            }
+          >
+            {subscribeState === 'downloading' ? (
+              <Loader2 size={12} className="animate-spin" />
+            ) : (
+              <Clock size={12} />
+            )}
+          </div>
+        )}
       </div>
 
-      {/* Info */}
       <div className="p-3">
         <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>
+        <div className="mt-2 flex items-center gap-1.5">
+          {onLiked && (
+            <button
+              onClick={handleLike}
+              disabled={isLiked || isLiking}
+              title={isLiked ? 'Already liked on Steam' : 'Like on Steam'}
+              className={clsx(
+                'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:cursor-default',
+                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} />}
+              {isLiked ? 'Liked' : 'Like'}
+            </button>
+          )}
+          <button
+            onClick={(e) => {
+              e.stopPropagation()
+              openWorkshopPage(item.publishedFileId)
+            }}
+            title="Open in Steam Workshop"
+            className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
+          >
+            <ExternalLink size={11} />
+            Steam
+          </button>
+        </div>
       </div>
 
-      {/* Subscribe button */}
-      <div className="absolute right-2 top-2" onClick={(e) => e.stopPropagation()}>
+      <div className="absolute right-2 top-2 flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
+        {canPlay && (
+          <button
+            onClick={handlePlay}
+            disabled={isApplying || !lweInstalled}
+            title={lweInstalled ? 'Play wallpaper' : 'Install linux-wallpaperengine in Settings first'}
+            className={clsx(
+              'flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white transition-opacity disabled:cursor-not-allowed',
+              lweInstalled
+                ? 'opacity-0 hover:bg-indigo-600 group-hover:opacity-100'
+                : 'text-gray-500 opacity-0 group-hover:opacity-100'
+            )}
+          >
+            {isApplying ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
+          </button>
+        )}
         {subscribed ? (
-          <div className="flex h-7 w-7 items-center justify-center rounded-full bg-green-600/80 text-white">
+          <div
+            className="flex h-7 w-7 items-center justify-center rounded-full bg-green-600/80 text-white"
+            title="Subscribed"
+          >
             <Check size={14} />
           </div>
+        ) : subscribeState === 'queued' ? (
+          <div
+            className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-gray-300"
+            title="Queued to subscribe"
+          >
+            <Clock size={14} />
+          </div>
+        ) : subscribeState === 'subscribing' ? (
+          <div
+            className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-indigo-300"
+            title="Subscribing..."
+          >
+            <Loader2 size={14} className="animate-spin" />
+          </div>
+        ) : subscribeState === 'subscribe-error' ? (
+          <button
+            onClick={handleSubscribe}
+            title="Subscribe failed, click to retry"
+            className="flex h-7 w-7 items-center justify-center rounded-full bg-red-600/80 text-white hover:bg-red-600"
+          >
+            <AlertTriangle size={14} />
+          </button>
         ) : (
           <button
             onClick={handleSubscribe}
-            disabled={isSubscribing}
-            className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-indigo-600 group-hover:opacity-100 disabled:cursor-not-allowed"
+            className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-indigo-600 group-hover:opacity-100"
           >
-            {isSubscribing ? <Loader2 size={14} className="animate-spin" /> : <Download size={14} />}
+            <Download size={14} />
           </button>
         )}
       </div>

+ 55 - 0
src/renderer/src/hooks/useContextMenuPosition.ts

@@ -0,0 +1,55 @@
+import { useLayoutEffect, useState, type RefObject } from 'react'
+
+export interface Point {
+  x: number
+  y: number
+}
+
+// Right-click menus anchor at the cursor, which is often close to a window
+// edge - without clamping, the menu overflows the window and its text gets
+// cut off instead of staying fully visible. Measures the menu after it
+// mounts and nudges it back inside the viewport if needed.
+export function useClampedPosition(
+  ref: RefObject<HTMLElement | null>,
+  point: Point | null | undefined,
+  padding = 8
+): Point | null {
+  const [result, setResult] = useState<{ raw: Point; clamped: Point } | null>(null)
+
+  useLayoutEffect(() => {
+    if (!point) return
+    const el = ref.current
+    if (!el) return
+    const rect = el.getBoundingClientRect()
+    const maxX = window.innerWidth - rect.width - padding
+    const maxY = window.innerHeight - rect.height - padding
+    setResult({
+      raw: point,
+      clamped: {
+        x: Math.max(padding, Math.min(point.x, maxX)),
+        y: Math.max(padding, Math.min(point.y, maxY))
+      }
+    })
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [ref, point?.x, point?.y, padding])
+
+  if (!point) return null
+  if (result && result.raw.x === point.x && result.raw.y === point.y) return result.clamped
+  return point
+}
+
+// For a submenu that normally opens to the right of its parent (left-full):
+// flips it to open to the left instead when it would run off the right edge.
+export function useFlipSide(ref: RefObject<HTMLElement | null>, active: boolean, padding = 8): 'left' | 'right' {
+  const [side, setSide] = useState<'left' | 'right'>('right')
+
+  useLayoutEffect(() => {
+    if (!active) return
+    const el = ref.current
+    if (!el) return
+    const rect = el.getBoundingClientRect()
+    setSide(rect.right > window.innerWidth - padding ? 'left' : 'right')
+  }, [ref, active, padding])
+
+  return side
+}

+ 130 - 0
src/renderer/src/hooks/useSubscriptionQueue.ts

@@ -0,0 +1,130 @@
+import { useCallback, useRef, useState } from 'react'
+import type { DownloadProgressEvent } from '@shared/types'
+
+export type SubscribeState =
+  | 'queued'
+  | 'subscribing'
+  | 'subscribe-error'
+  | 'download-queued'
+  | 'downloading'
+  | 'done'
+  | 'download-error'
+
+export interface SubscribeEntry {
+  state: SubscribeState
+  percentage?: number
+}
+
+// Subscribing on Steam and downloading the item locally are separate concerns:
+// a full disk (or any other download failure) shouldn't stop someone from
+// being subscribed. So there are two independent queues. Subscribing is a
+// quick Steam API call and races ahead for every selected item; downloads are
+// drained one at a time in their own queue (concurrent downloads is what made
+// "subscribe 4+ items" unreliable), decoupled from how fast items get
+// subscribed. A 'download-error' still leaves the item subscribed - only
+// 'subscribe-error' means the item never actually got subscribed.
+const SETTLE_TIMEOUT_MS = 90_000
+
+export function useSubscriptionQueue(onSettled?: () => void) {
+  const [entries, setEntries] = useState<Map<string, SubscribeEntry>>(new Map())
+  const subscribeQueueRef = useRef<string[]>([])
+  const downloadQueueRef = useRef<string[]>([])
+  const activeRef = useRef<Set<string>>(new Set())
+  const subscribingRef = useRef(false)
+  const downloadingRef = useRef(false)
+
+  const setEntry = useCallback((id: string, entry: SubscribeEntry | null) => {
+    setEntries((prev) => {
+      const next = new Map(prev)
+      if (entry === null) next.delete(id)
+      else next.set(id, entry)
+      return next
+    })
+  }, [])
+
+  const waitForSettled = useCallback((id: string) => {
+    return new Promise<'completed' | 'error'>((resolve) => {
+      let settled = false
+      let off: () => void = () => {}
+      const finish = (status: 'completed' | 'error') => {
+        if (settled) return
+        settled = true
+        clearTimeout(timer)
+        off()
+        resolve(status)
+      }
+      off = window.electronAPI.on.downloadProgress((progress: DownloadProgressEvent) => {
+        if (progress.itemId !== id) return
+        if (progress.status === 'downloading') {
+          setEntry(id, { state: 'downloading', percentage: progress.percentage })
+          return
+        }
+        finish(progress.status)
+      })
+      const timer = setTimeout(() => finish('error'), SETTLE_TIMEOUT_MS)
+    })
+  }, [setEntry])
+
+  const processDownloadQueue = useCallback(async () => {
+    if (downloadingRef.current) return
+    downloadingRef.current = true
+    while (downloadQueueRef.current.length > 0) {
+      const id = downloadQueueRef.current.shift()!
+      setEntry(id, { state: 'downloading', percentage: 0 })
+      const result = await waitForSettled(id)
+      setEntry(id, result === 'completed' ? { state: 'done' } : { state: 'download-error' })
+      activeRef.current.delete(id)
+      onSettled?.()
+    }
+    downloadingRef.current = false
+  }, [setEntry, waitForSettled, onSettled])
+
+  const processSubscribeQueue = useCallback(async () => {
+    if (subscribingRef.current) return
+    subscribingRef.current = true
+    while (subscribeQueueRef.current.length > 0) {
+      const id = subscribeQueueRef.current.shift()!
+      setEntry(id, { state: 'subscribing' })
+      try {
+        await window.electronAPI.steam.subscribe(id)
+        setEntry(id, { state: 'download-queued' })
+        downloadQueueRef.current.push(id)
+        processDownloadQueue()
+      } catch {
+        setEntry(id, { state: 'subscribe-error' })
+        activeRef.current.delete(id)
+        onSettled?.()
+      }
+    }
+    subscribingRef.current = false
+  }, [setEntry, processDownloadQueue, onSettled])
+
+  const enqueue = useCallback(
+    (ids: string[]) => {
+      let added = false
+      for (const id of ids) {
+        if (activeRef.current.has(id)) continue
+        activeRef.current.add(id)
+        subscribeQueueRef.current.push(id)
+        setEntry(id, { state: 'queued' })
+        added = true
+      }
+      if (added) processSubscribeQueue()
+    },
+    [processSubscribeQueue, setEntry]
+  )
+
+  const clear = useCallback(
+    (ids: string[]) => {
+      for (const id of ids) {
+        activeRef.current.delete(id)
+        subscribeQueueRef.current = subscribeQueueRef.current.filter((q) => q !== id)
+        downloadQueueRef.current = downloadQueueRef.current.filter((q) => q !== id)
+        setEntry(id, null)
+      }
+    },
+    [setEntry]
+  )
+
+  return { entries, enqueue, clear }
+}

+ 10 - 0
src/renderer/src/utils/steam.ts

@@ -1,3 +1,13 @@
 export function openWorkshopPage(id: string): void {
   window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
 }
+
+export function openProfilePage(steamId: string): void {
+  window.electronAPI.shell.openExternal(`steam://url/SteamIDPage/${steamId}`)
+}
+
+// Backup-sourced wallpapers use their folder name as id, which isn't always
+// a real Steam published file id (e.g. a manually renamed backup folder).
+export function isWorkshopId(id: string): boolean {
+  return /^\d+$/.test(id)
+}

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

@@ -2,6 +2,7 @@ export const IpcChannels = {
   // Workshop browsing (steamworks.js)
   WORKSHOP_QUERY: 'workshop:query',
   WORKSHOP_GET_ITEM: 'workshop:get-item',
+  WORKSHOP_QUERY_BY_CREATOR: 'workshop:query-by-creator',
 
   // Steamworks operations
   STEAM_SUBSCRIBE: 'steam:subscribe',
@@ -15,6 +16,7 @@ export const IpcChannels = {
   STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
   STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
   STEAM_REDOWNLOAD: 'steam:redownload',
+  STEAM_GET_AUTHOR_INFO: 'steam:get-author-info',
 
   // Library management
   LIBRARY_GET_ALL: 'library:get-all',
@@ -28,6 +30,7 @@ export const IpcChannels = {
   LIBRARY_SCAN: 'library:scan',
   LIBRARY_DISTINCT_TAGS: 'library:distinct-tags',
   LIBRARY_RESET_FPS_OVERRIDES: 'library:reset-fps-overrides',
+  LIBRARY_CHECK_UNAVAILABLE: 'library:check-unavailable',
 
   // Folders
   FOLDERS_GET_ALL: 'folders:get-all',
@@ -48,6 +51,7 @@ export const IpcChannels = {
   CONFIG_GET: 'config:get',
   CONFIG_SET_WORKSHOP_PATH: 'config:set-workshop-path',
   CONFIG_SET_DEFAULT_FPS: 'config:set-default-fps',
+  CONFIG_SET_RECOMMENDED_FPS: 'config:set-recommended-fps',
   CONFIG_SET_LWE_REPO: 'config:set-lwe-repo',
   CONFIG_SET_LWE_CMAKE_ARGS: 'config:set-lwe-cmake-args',
   CONFIG_SET_BACKUP_PATH: 'config:set-backup-path',
@@ -103,6 +107,9 @@ export const IpcChannels = {
   LWE_LAUNCH: 'lwe:launch',
   LWE_STOP: 'lwe:stop',
   LWE_KILL_ALL: 'lwe:kill-all',
+  LWE_LIST_OBJECTS: 'lwe:list-objects',
+  LWE_HOTSWAP_SETTINGS: 'lwe:hotswap-settings',
+  LWE_LIST_PROPERTIES: 'lwe:list-properties',
 
   // Desktop icons overlay
   DESKTOP_ICONS_SET_ENABLED: 'desktop-icons:set-enabled',

+ 42 - 0
src/shared/types.ts

@@ -45,7 +45,41 @@ export interface WallpaperMeta {
   downloading?: boolean
   downloadFailed?: boolean
   fpsOverride?: number
+  volumeOverride?: number
+  /** Forces the "xray" scene effect's reveal spot to cover the whole masked area instead of following the mouse */
+  xrayFullReveal?: boolean
   backedUp?: boolean
+  /** Set by a "check unavailable" scan: true if the item has been removed from the Steam Workshop */
+  unavailable?: boolean
+  /** Object/layer ids or names to force-hide via linux-wallpaperengine --disable-object */
+  disabledObjects?: string[]
+  /** Object/layer ids or names to force-show via linux-wallpaperengine --enable-object */
+  enabledObjects?: string[]
+  /** Property name -> override value, applied via linux-wallpaperengine --set-property name=value */
+  propertyOverrides?: Record<string, string>
+}
+
+export interface LweSceneObject {
+  id: string
+  name: string
+  type: 'image' | 'particle' | 'text' | 'sound' | 'unknown'
+}
+
+export interface LweProperty {
+  name: string
+  type: 'slider' | 'boolean' | 'color' | 'combo' | 'text' | 'scene-texture' | 'file' | 'textinput' | 'unknown'
+  text?: string
+  value: string
+  min?: number
+  max?: number
+  step?: number
+  options?: { value: string; label: string }[]
+}
+
+export interface WorkshopAuthorInfo {
+  steamId: string
+  name: string
+  avatarUrl: string
 }
 
 export interface WorkshopItem {
@@ -78,6 +112,14 @@ export interface WorkshopQueryResult {
   totalResults: number
 }
 
+export interface CreatorWorkshopQueryParams {
+  searchText?: string
+  tags?: string[]
+  excludedTags?: string[]
+  queryType?: WorkshopQueryType
+  page?: number
+}
+
 export interface ApplyWallpaperOptions {
   wallpaperId: string
   displayIndex?: number