Преглед на файлове

added feature to backup wallpapers, added playlist feature

UwU преди 2 месеца
родител
ревизия
da08e792ef
променени са 33 файла, в които са добавени 2602 реда и са изтрити 99 реда
  1. 34 0
      src/main/index.ts
  2. 61 0
      src/main/ipc/backup.handlers.ts
  3. 57 2
      src/main/ipc/config.handlers.ts
  4. 4 0
      src/main/ipc/index.ts
  5. 59 0
      src/main/ipc/playlist.handlers.ts
  6. 5 51
      src/main/ipc/wallpaper.handlers.ts
  7. 61 0
      src/main/services/autostart.service.ts
  8. 152 0
      src/main/services/backup.service.ts
  9. 62 1
      src/main/services/config.service.ts
  10. 65 15
      src/main/services/library.service.ts
  11. 4 3
      src/main/services/lwe.service.ts
  12. 248 0
      src/main/services/playlist-player.service.ts
  13. 159 0
      src/main/services/playlist.service.ts
  14. 98 0
      src/main/services/tray.service.ts
  15. 15 0
      src/main/services/wallpaper-state.service.ts
  16. 53 2
      src/main/services/wallpaper.service.ts
  17. 5 4
      src/main/services/watcher.service.ts
  18. 14 0
      src/main/services/workshop.service.ts
  19. 92 4
      src/preload/index.ts
  20. 3 1
      src/renderer/src/App.tsx
  21. 36 0
      src/renderer/src/components/common/PreviewSizeToggle.tsx
  22. 4 3
      src/renderer/src/components/layout/Sidebar.tsx
  23. 47 2
      src/renderer/src/components/layout/StatusBar.tsx
  24. 137 5
      src/renderer/src/components/library/LibraryView.tsx
  25. 55 2
      src/renderer/src/components/library/WallpaperCard.tsx
  26. 131 0
      src/renderer/src/components/playlist/PlaylistItemRow.tsx
  27. 465 0
      src/renderer/src/components/playlist/PlaylistsView.tsx
  28. 133 0
      src/renderer/src/components/playlist/WallpaperPickerModal.tsx
  29. 214 1
      src/renderer/src/components/settings/SettingsView.tsx
  30. 10 1
      src/renderer/src/components/workshop/WorkshopBrowser.tsx
  31. 38 0
      src/renderer/src/hooks/usePreviewSize.ts
  32. 34 1
      src/shared/ipc-channels.ts
  33. 47 1
      src/shared/types.ts

+ 34 - 0
src/main/index.ts

@@ -6,6 +6,12 @@ import { initLibrary } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
 import { initDesktopIcons, cleanupDesktopIcons } from './services/desktop-icons.service'
+import { getTrayEnabled, getAutostartPlaylistId } from './services/config.service'
+import { createTray } from './services/tray.service'
+import { initPlaylistPlayer, startPlaylist } from './services/playlist-player.service'
+
+const startMinimized = process.argv.includes('--minimized')
+let isQuitting = false
 
 function createWindow(): BrowserWindow {
   const win = new BrowserWindow({
@@ -15,6 +21,7 @@ function createWindow(): BrowserWindow {
     minHeight: 600,
     backgroundColor: '#0f0f0f',
     titleBarStyle: 'hiddenInset',
+    show: !(startMinimized && getTrayEnabled()),
     webPreferences: {
       preload: path.join(__dirname, '../preload/index.cjs'),
       contextIsolation: true,
@@ -28,6 +35,15 @@ function createWindow(): BrowserWindow {
     return { action: 'deny' }
   })
 
+  // When the tray is enabled, closing the window hides it instead of quitting so the
+  // tray icon remains the only way back in (and the app keeps running for playlists).
+  win.on('close', (event) => {
+    if (getTrayEnabled() && !isQuitting) {
+      event.preventDefault()
+      win.hide()
+    }
+  })
+
   if (process.env.NODE_ENV === 'development' && process.env['ELECTRON_RENDERER_URL']) {
     win.loadURL(process.env['ELECTRON_RENDERER_URL'])
   } else {
@@ -55,6 +71,22 @@ app.whenReady().then(() => {
   registerAllHandlers(win)
   startWatcher(win)
   initDesktopIcons()
+  const resumed = initPlaylistPlayer(win)
+
+  if (getTrayEnabled()) createTray(win)
+
+  const autostartPlaylistId = getAutostartPlaylistId()
+  if (!resumed && autostartPlaylistId) {
+    try {
+      startPlaylist(autostartPlaylistId)
+    } catch (err) {
+      console.error('[App] Failed to autostart playlist:', err)
+    }
+  }
+})
+
+app.on('before-quit', () => {
+  isQuitting = true
 })
 
 app.on('window-all-closed', () => {
@@ -65,5 +97,7 @@ app.on('window-all-closed', () => {
 app.on('activate', () => {
   if (BrowserWindow.getAllWindows().length === 0) {
     createWindow()
+  } else {
+    BrowserWindow.getAllWindows()[0].show()
   }
 })

+ 61 - 0
src/main/ipc/backup.handlers.ts

@@ -0,0 +1,61 @@
+import { ipcMain, BrowserWindow } from 'electron'
+import * as fs from 'fs'
+import { IpcChannels } from '@shared/ipc-channels'
+import * as backup from '../services/backup.service'
+import * as library from '../services/library.service'
+import * as steam from '../services/steam.service'
+import { getAutoUnsubscribeAfterBackup } from '../services/config.service'
+
+async function backupOne(id: string, win: BrowserWindow): Promise<{ ok: boolean; unsubscribed: boolean }> {
+  const wallpaper = library.getWallpaper(id)
+  if (!wallpaper?.localPath) throw new Error(`Wallpaper ${id} has no local files to back up`)
+
+  await backup.backupWallpaper(id, wallpaper.localPath, win)
+  library.updateWallpaper(id, { backedUp: true })
+
+  let unsubscribed = false
+  if (getAutoUnsubscribeAfterBackup()) {
+    await steam.unsubscribeFromItem(BigInt(id))
+    try {
+      fs.rmSync(wallpaper.localPath, { recursive: true, force: true })
+    } catch {
+      /* ignore */
+    }
+    library.updateWallpaper(id, {
+      source: 'backup',
+      localPath: backup.getBackupDir(id),
+      subscribed: false
+    })
+    unsubscribed = true
+  }
+
+  return { ok: true, unsubscribed }
+}
+
+export function registerBackupHandlers(win: BrowserWindow): void {
+  ipcMain.handle(IpcChannels.BACKUP_ITEM, async (_e, itemId: string) => {
+    return backupOne(itemId, win)
+  })
+
+  ipcMain.handle(IpcChannels.BACKUP_SELECTION, async (_e, itemIds: string[]) => {
+    let backedUp = 0
+    let failed = 0
+    let unsubscribed = 0
+
+    for (const id of itemIds) {
+      try {
+        const result = await backupOne(id, win)
+        backedUp++
+        if (result.unsubscribed) unsubscribed++
+      } catch {
+        failed++
+      }
+    }
+
+    return { backedUp, failed, unsubscribed }
+  })
+
+  ipcMain.handle(IpcChannels.BACKUP_SCAN, () => {
+    return backup.scanBackupFolder()
+  })
+}

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

@@ -10,12 +10,26 @@ import {
   getLweRepoBranch,
   setLweRepo,
   importWEConfigFile,
-  createFreshConfig
+  createFreshConfig,
+  getConfiguredBackupPath,
+  setConfiguredBackupPath,
+  isBackupPathConfigured,
+  getAutoUnsubscribeAfterBackup,
+  setAutoUnsubscribeAfterBackup,
+  getTrayEnabled,
+  setTrayEnabled,
+  getAutostartEnabled,
+  getAutostartMinimized,
+  setAutostart,
+  getAutostartPlaylistId,
+  setAutostartPlaylistId
 } from '../services/config.service'
 import { getDefaultWorkshopPath } from '../utils/paths'
 import { DEFAULT_LWE_REPO } from '@shared/constants'
 import { restartWatcher } from '../services/watcher.service'
 import { importWEConfig } from '../services/library.service'
+import { createTray, destroyTray } from '../services/tray.service'
+import { isAutostartSupported, setAutostartEnabled } from '../services/autostart.service'
 
 export function registerConfigHandlers(): void {
   ipcMain.handle(IpcChannels.CONFIG_GET, () => ({
@@ -25,9 +39,50 @@ export function registerConfigHandlers(): void {
     defaultFps: getDefaultFps(),
     lweRepoUrl: getLweRepoUrl(),
     lweRepoBranch: getLweRepoBranch(),
-    defaultLweRepoUrl: DEFAULT_LWE_REPO
+    defaultLweRepoUrl: DEFAULT_LWE_REPO,
+    backupPath: getConfiguredBackupPath(),
+    isBackupConfigured: isBackupPathConfigured(),
+    autoUnsubscribeAfterBackup: getAutoUnsubscribeAfterBackup(),
+    trayEnabled: getTrayEnabled(),
+    autostartSupported: isAutostartSupported(),
+    autostartEnabled: getAutostartEnabled(),
+    autostartMinimized: getAutostartMinimized(),
+    autostartPlaylistId: getAutostartPlaylistId()
   }))
 
+  ipcMain.handle(IpcChannels.CONFIG_GET_AUTOSTART_SUPPORTED, () => isAutostartSupported())
+
+  ipcMain.handle(IpcChannels.CONFIG_SET_TRAY_ENABLED, (_e, enabled: boolean) => {
+    setTrayEnabled(enabled)
+    if (enabled) {
+      const win = BrowserWindow.getAllWindows()[0]
+      if (win) createTray(win)
+    } else {
+      destroyTray()
+    }
+    return { ok: true }
+  })
+
+  ipcMain.handle(
+    IpcChannels.CONFIG_SET_AUTOSTART,
+    (_e, enabled: boolean, minimized: boolean, playlistId: string | null) => {
+      setAutostart(enabled, minimized)
+      setAutostartPlaylistId(playlistId)
+      setAutostartEnabled(enabled, minimized)
+      return { ok: true }
+    }
+  )
+
+  ipcMain.handle(IpcChannels.CONFIG_SET_BACKUP_PATH, (_e, newPath: string) => {
+    setConfiguredBackupPath(newPath)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.CONFIG_SET_AUTO_UNSUBSCRIBE, (_e, enabled: boolean) => {
+    setAutoUnsubscribeAfterBackup(enabled)
+    return { ok: true }
+  })
+
   ipcMain.handle(IpcChannels.CONFIG_SET_DEFAULT_FPS, (_e, fps: number | null) => {
     setDefaultFps(fps)
     return { ok: true }

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

@@ -8,6 +8,8 @@ import { registerFolderHandlers } from './folders.handlers'
 import { registerShellHandlers } from './shell.handlers'
 import { registerLweHandlers } from './lwe.handlers'
 import { registerDesktopIconsHandlers } from './desktop-icons.handlers'
+import { registerBackupHandlers } from './backup.handlers'
+import { registerPlaylistHandlers } from './playlist.handlers'
 
 export function registerAllHandlers(win: BrowserWindow): void {
   registerSteamHandlers(win)
@@ -19,4 +21,6 @@ export function registerAllHandlers(win: BrowserWindow): void {
   registerShellHandlers()
   registerLweHandlers(win)
   registerDesktopIconsHandlers()
+  registerBackupHandlers(win)
+  registerPlaylistHandlers()
 }

+ 59 - 0
src/main/ipc/playlist.handlers.ts

@@ -0,0 +1,59 @@
+import { ipcMain } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { PlaylistSettings } from '@shared/types'
+import * as playlists from '../services/playlist.service'
+import * as player from '../services/playlist-player.service'
+
+export function registerPlaylistHandlers(): void {
+  ipcMain.handle(IpcChannels.PLAYLIST_GET_ALL, () => playlists.getAllPlaylists())
+
+  ipcMain.handle(IpcChannels.PLAYLIST_GET_ONE, (_e, id: string) => playlists.getPlaylist(id))
+
+  ipcMain.handle(IpcChannels.PLAYLIST_CREATE, (_e, title: string) =>
+    playlists.createPlaylist(title)
+  )
+
+  ipcMain.handle(IpcChannels.PLAYLIST_RENAME, (_e, id: string, title: string) =>
+    playlists.renamePlaylist(id, title)
+  )
+
+  ipcMain.handle(IpcChannels.PLAYLIST_DELETE, (_e, id: string) => {
+    if (player.getPlaybackState().playlistId === id) player.stopPlaylist()
+    playlists.deletePlaylist(id)
+    return { ok: true }
+  })
+
+  ipcMain.handle(
+    IpcChannels.PLAYLIST_UPDATE_SETTINGS,
+    (_e, id: string, patch: Partial<PlaylistSettings>) =>
+      playlists.updatePlaylistSettings(id, patch)
+  )
+
+  ipcMain.handle(IpcChannels.PLAYLIST_ADD_ITEMS, (_e, id: string, wallpaperIds: string[]) =>
+    playlists.addItemsToPlaylist(id, wallpaperIds)
+  )
+
+  ipcMain.handle(IpcChannels.PLAYLIST_REMOVE_ITEMS, (_e, id: string, wallpaperIds: string[]) =>
+    playlists.removeItemsFromPlaylist(id, wallpaperIds)
+  )
+
+  ipcMain.handle(
+    IpcChannels.PLAYLIST_REORDER_ITEMS,
+    (_e, id: string, orderedWallpaperIds: string[]) =>
+      playlists.reorderPlaylistItems(id, orderedWallpaperIds)
+  )
+
+  ipcMain.handle(
+    IpcChannels.PLAYLIST_UPDATE_ITEM,
+    (_e, id: string, wallpaperId: string, patch: { volume?: number; durationSec?: number }) =>
+      playlists.updatePlaylistItem(id, wallpaperId, patch)
+  )
+
+  ipcMain.handle(IpcChannels.PLAYLIST_START, (_e, id: string) => player.startPlaylist(id))
+  ipcMain.handle(IpcChannels.PLAYLIST_STOP, () => player.stopPlaylist())
+  ipcMain.handle(IpcChannels.PLAYLIST_PAUSE, () => player.pausePlaylist())
+  ipcMain.handle(IpcChannels.PLAYLIST_RESUME, () => player.resumePlaylist())
+  ipcMain.handle(IpcChannels.PLAYLIST_NEXT, () => player.nextItem())
+  ipcMain.handle(IpcChannels.PLAYLIST_PREVIOUS, () => player.previousItem())
+  ipcMain.handle(IpcChannels.PLAYLIST_GET_STATE, () => player.getPlaybackState())
+}

+ 5 - 51
src/main/ipc/wallpaper.handlers.ts

@@ -1,20 +1,9 @@
 import { ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
-import { applyWallpaper, detectEnvironment } from '../services/wallpaper.service'
+import { applyWallpaperMeta, detectEnvironment } from '../services/wallpaper.service'
 import { getWallpaper, updateWallpaper } from '../services/library.service'
-import { getLweStatus, launchLweAsync } from '../services/lwe.service'
-import { getDefaultFps } from '../services/config.service'
+import { setActiveWallpaperId, getActiveWallpaperId } from '../services/wallpaper-state.service'
 import type { ApplyWallpaperOptions } from '@shared/types'
-import Store from 'electron-store'
-import * as os from 'os'
-import * as fs from 'fs'
-import * as path from 'path'
-
-interface StoreSchema {
-  activeWallpaperId?: string
-}
-
-const store = new Store<StoreSchema>()
 
 export function registerWallpaperHandlers(): void {
   ipcMain.handle(IpcChannels.WALLPAPER_DETECT_ENV, () => {
@@ -22,30 +11,17 @@ export function registerWallpaperHandlers(): void {
   })
 
   ipcMain.handle(IpcChannels.WALLPAPER_GET_ACTIVE, () => {
-    const id = store.get('activeWallpaperId')
+    const id = getActiveWallpaperId()
     return id ? getWallpaper(id) : null
   })
 
   ipcMain.handle(IpcChannels.WALLPAPER_APPLY, async (_e, options: ApplyWallpaperOptions) => {
     const wallpaper = getWallpaper(options.wallpaperId)
     if (!wallpaper) throw new Error(`Wallpaper ${options.wallpaperId} not found in library`)
-    if (!wallpaper.localPath) throw new Error(`Wallpaper ${options.wallpaperId} has no local path`)
 
-    const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
-    const lwe = getLweStatus()
+    const appliedPath = await applyWallpaperMeta(wallpaper, { backend: options.backend })
 
-    let appliedPath: string
-    if (isAnimated && os.platform() === 'linux' && lwe.installed) {
-      const fps = wallpaper.fpsOverride ?? getDefaultFps() ?? undefined
-      await launchLweAsync(wallpaper.localPath, { fps })
-      appliedPath = wallpaper.localPath
-    } else {
-      // Static fallback: set the preview image as a plain wallpaper
-      appliedPath = findWallpaperImage(wallpaper.localPath)
-      await applyWallpaper(appliedPath, options.backend)
-    }
-
-    store.set('activeWallpaperId', options.wallpaperId)
+    setActiveWallpaperId(options.wallpaperId)
     updateWallpaper(options.wallpaperId, {
       appliedCount: (wallpaper.appliedCount ?? 0) + 1,
       lastAppliedAt: Date.now()
@@ -54,25 +30,3 @@ export function registerWallpaperHandlers(): void {
     return { ok: true, appliedPath }
   })
 }
-
-function findWallpaperImage(dirPath: string): string {
-  if (!fs.existsSync(dirPath)) {
-    throw new Error(`Directory not found: ${dirPath}`)
-  }
-  if (!fs.statSync(dirPath).isDirectory()) {
-    return dirPath
-  }
-
-  const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
-  const files = fs.readdirSync(dirPath)
-
-  const preview = files.find((f) => f.toLowerCase().startsWith('preview'))
-  if (preview && imageExtensions.includes(path.extname(preview).toLowerCase())) {
-    return path.join(dirPath, preview)
-  }
-
-  const image = files.find((f) => imageExtensions.includes(path.extname(f).toLowerCase()))
-  if (image) return path.join(dirPath, image)
-
-  throw new Error(`No image found in ${dirPath}`)
-}

+ 61 - 0
src/main/services/autostart.service.ts

@@ -0,0 +1,61 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import * as os from 'os'
+import { app } from 'electron'
+
+const DESKTOP_FILE_NAME = 'we-manager.desktop'
+
+function getAutostartDir(): string {
+  return path.join(os.homedir(), '.config', 'autostart')
+}
+
+function getAutostartDesktopPath(): string {
+  return path.join(getAutostartDir(), DESKTOP_FILE_NAME)
+}
+
+/**
+ * Autostart only makes sense for a packaged app: Electron's app.setLoginItemSettings
+ * doesn't support Linux at all, and pointing a desktop entry at an electron-vite dev
+ * invocation wouldn't survive a reboot anyway.
+ */
+export function isAutostartSupported(): boolean {
+  return process.platform === 'linux' && app.isPackaged
+}
+
+/** Resolves the launchable executable path, accounting for AppImage's mount indirection. */
+function resolveExecutablePath(): string {
+  return process.env.APPIMAGE ?? app.getPath('exe')
+}
+
+export function setAutostartEnabled(enabled: boolean, minimized: boolean): void {
+  if (!isAutostartSupported()) return
+
+  const dir = getAutostartDir()
+  const filePath = getAutostartDesktopPath()
+
+  if (!enabled) {
+    if (fs.existsSync(filePath)) fs.unlinkSync(filePath)
+    return
+  }
+
+  fs.mkdirSync(dir, { recursive: true })
+
+  const exePath = resolveExecutablePath()
+  const execLine = minimized ? `"${exePath}" --minimized` : `"${exePath}"`
+
+  const contents = [
+    '[Desktop Entry]',
+    'Type=Application',
+    'Name=WE Manager',
+    `Exec=${execLine}`,
+    'Terminal=false',
+    'X-GNOME-Autostart-enabled=true',
+    ''
+  ].join('\n')
+
+  fs.writeFileSync(filePath, contents, 'utf8')
+}
+
+export function isAutostartFileEnabled(): boolean {
+  return fs.existsSync(getAutostartDesktopPath())
+}

+ 152 - 0
src/main/services/backup.service.ts

@@ -0,0 +1,152 @@
+import { BrowserWindow } from 'electron'
+import * as fs from 'fs'
+import * as path from 'path'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { WallpaperMeta } from '@shared/types'
+import { getConfiguredBackupPath } from './config.service'
+import * as library from './library.service'
+import { readProjectJson, normalizeType, normalizeRating, getDirSize } from './library.service'
+
+export function getBackupDir(id: string): string {
+  const backupPath = getConfiguredBackupPath()
+  if (!backupPath) throw new Error('Backup path must be configured before backing up')
+  return path.join(backupPath, id)
+}
+
+export function isBackedUp(id: string): boolean {
+  try {
+    return fs.existsSync(getBackupDir(id))
+  } catch {
+    return false
+  }
+}
+
+function walkFiles(dirPath: string, baseDir = dirPath): { relPath: string; size: number }[] {
+  const results: { relPath: string; size: number }[] = []
+  for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
+    const full = path.join(dirPath, entry.name)
+    if (entry.isDirectory()) {
+      results.push(...walkFiles(full, baseDir))
+    } else if (entry.isFile()) {
+      const rel = path.relative(baseDir, full)
+      results.push({ relPath: rel, size: fs.statSync(full).size })
+    }
+  }
+  return results
+}
+
+export async function backupWallpaper(
+  id: string,
+  sourcePath: string,
+  win: BrowserWindow
+): Promise<void> {
+  const destDir = getBackupDir(id)
+  const files = walkFiles(sourcePath)
+  const bytesTotal = files.reduce((sum, f) => sum + f.size, 0)
+  let bytesCopied = 0
+
+  fs.mkdirSync(destDir, { recursive: true })
+
+  try {
+    for (const file of files) {
+      const srcFile = path.join(sourcePath, file.relPath)
+      const destFile = path.join(destDir, file.relPath)
+      fs.mkdirSync(path.dirname(destFile), { recursive: true })
+      await fs.promises.copyFile(srcFile, destFile)
+      bytesCopied += file.size
+
+      if (!win.isDestroyed()) {
+        win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+          itemId: id,
+          bytesCopied,
+          bytesTotal,
+          percentage: bytesTotal > 0 ? Math.round((bytesCopied / bytesTotal) * 100) : 100,
+          status: 'copying'
+        })
+      }
+    }
+  } catch (err) {
+    if (!win.isDestroyed()) {
+      win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+        itemId: id,
+        bytesCopied,
+        bytesTotal,
+        percentage: 0,
+        status: 'error',
+        message: (err as Error).message
+      })
+    }
+    throw err
+  }
+
+  if (!win.isDestroyed()) {
+    win.webContents.send(IpcChannels.EVENT_BACKUP_PROGRESS, {
+      itemId: id,
+      bytesCopied,
+      bytesTotal,
+      percentage: 100,
+      status: 'completed'
+    })
+  }
+}
+
+function buildBackupMeta(
+  id: string,
+  localPath: string,
+  existing?: WallpaperMeta
+): WallpaperMeta {
+  const pj = readProjectJson(localPath)
+  const now = Date.now()
+  return {
+    id,
+    title: pj?.title ?? existing?.title ?? `Wallpaper ${id}`,
+    type: normalizeType(pj?.type),
+    contentRating: normalizeRating(pj?.contentrating),
+    description: pj?.description ?? existing?.description,
+    previewLocal: pj?.preview ? path.join(localPath, pj.preview) : undefined,
+    localPath,
+    file: pj?.file,
+    fileSize: getDirSize(localPath),
+    createdAt: existing?.createdAt ?? now,
+    updatedAt: now,
+    subscribed: false,
+    appliedCount: existing?.appliedCount ?? 0,
+    source: 'backup',
+    tags: pj?.tags ?? existing?.tags ?? [],
+    categories: existing?.categories ?? [],
+    backedUp: true
+  }
+}
+
+export function scanBackupFolder(): { imported: number; skipped: number; linked: number } {
+  const backupPath = getConfiguredBackupPath()
+  if (!backupPath || !fs.existsSync(backupPath)) return { imported: 0, skipped: 0, linked: 0 }
+
+  let imported = 0
+  let skipped = 0
+  let linked = 0
+
+  for (const entry of fs.readdirSync(backupPath, { withFileTypes: true })) {
+    if (!entry.isDirectory()) continue
+
+    const id = entry.name
+    const localBackupPath = path.join(backupPath, id)
+    const existing = library.getWallpaper(id)
+
+    if (existing && existing.source === 'workshop') {
+      if (!existing.backedUp) {
+        library.updateWallpaper(id, { backedUp: true })
+        linked++
+      } else {
+        skipped++
+      }
+      continue
+    }
+
+    const meta = buildBackupMeta(id, localBackupPath, existing ?? undefined)
+    library.upsertWallpaper(meta)
+    imported++
+  }
+
+  return { imported, skipped, linked }
+}

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

@@ -9,6 +9,12 @@ interface AppConfig {
   defaultFps: number | null
   lweRepoUrl: string | null
   lweRepoBranch: string | null
+  backupPath: string | null
+  autoUnsubscribeAfterBackup: boolean
+  trayEnabled: boolean
+  autostartEnabled: boolean
+  autostartMinimized: boolean
+  autostartPlaylistId: string | null
 }
 
 const store = new Store<AppConfig>({
@@ -17,7 +23,13 @@ const store = new Store<AppConfig>({
     workshopPath: null,
     defaultFps: null,
     lweRepoUrl: null,
-    lweRepoBranch: null
+    lweRepoBranch: null,
+    backupPath: null,
+    autoUnsubscribeAfterBackup: false,
+    trayEnabled: false,
+    autostartEnabled: false,
+    autostartMinimized: false,
+    autostartPlaylistId: null
   }
 })
 
@@ -33,6 +45,55 @@ export function isWorkshopPathConfigured(): boolean {
   return store.get('workshopPath') !== null
 }
 
+export function getConfiguredBackupPath(): string | null {
+  return store.get('backupPath')
+}
+
+export function setConfiguredBackupPath(p: string): void {
+  store.set('backupPath', p)
+}
+
+export function isBackupPathConfigured(): boolean {
+  return store.get('backupPath') !== null
+}
+
+export function getAutoUnsubscribeAfterBackup(): boolean {
+  return store.get('autoUnsubscribeAfterBackup')
+}
+
+export function setAutoUnsubscribeAfterBackup(enabled: boolean): void {
+  store.set('autoUnsubscribeAfterBackup', enabled)
+}
+
+export function getTrayEnabled(): boolean {
+  return store.get('trayEnabled')
+}
+
+export function setTrayEnabled(enabled: boolean): void {
+  store.set('trayEnabled', enabled)
+}
+
+export function getAutostartEnabled(): boolean {
+  return store.get('autostartEnabled')
+}
+
+export function getAutostartMinimized(): boolean {
+  return store.get('autostartMinimized')
+}
+
+export function setAutostart(enabled: boolean, minimized: boolean): void {
+  store.set('autostartEnabled', enabled)
+  store.set('autostartMinimized', minimized)
+}
+
+export function getAutostartPlaylistId(): string | null {
+  return store.get('autostartPlaylistId')
+}
+
+export function setAutostartPlaylistId(id: string | null): void {
+  store.set('autostartPlaylistId', id)
+}
+
 export function getDefaultFps(): number | null {
   return store.get('defaultFps')
 }

+ 65 - 15
src/main/services/library.service.ts

@@ -4,6 +4,8 @@ import { getWorkshopPath } from '../utils/paths'
 import * as path from 'path'
 import * as fs from 'fs'
 import { randomUUID } from 'crypto'
+import { cleanupPlaylists } from './playlist.service'
+import { getWorkshopTimesUpdated } from './workshop.service'
 
 interface LibraryStore {
   wallpapers: Record<string, WallpaperMeta>
@@ -85,10 +87,11 @@ export function upsertWallpaper(meta: WallpaperMeta): void {
   store.set('wallpapers', wallpapers)
 }
 
+// updatedAt tracks the workshop item's own timestamp (set in buildMeta), not local edits.
 export function updateWallpaper(id: string, patch: Partial<WallpaperMeta>): void {
   const current = getWallpaper(id)
   if (!current) throw new Error(`Wallpaper ${id} not found`)
-  upsertWallpaper({ ...current, ...patch, updatedAt: Date.now() })
+  upsertWallpaper({ ...current, ...patch, updatedAt: current.updatedAt })
 }
 
 export function deleteWallpaper(id: string): void {
@@ -144,7 +147,7 @@ export function getDistinctTags(): string[] {
   return [...set].sort()
 }
 
-interface ProjectJson {
+export interface ProjectJson {
   title?: string
   type?: string
   file?: string
@@ -155,7 +158,7 @@ interface ProjectJson {
   workshopid?: string
 }
 
-function readProjectJson(localPath: string): ProjectJson | null {
+export function readProjectJson(localPath: string): ProjectJson | null {
   try {
     const raw = fs.readFileSync(path.join(localPath, 'project.json'), 'utf8')
     return JSON.parse(raw) as ProjectJson
@@ -164,7 +167,7 @@ function readProjectJson(localPath: string): ProjectJson | null {
   }
 }
 
-function normalizeType(raw?: string): WallpaperType {
+export function normalizeType(raw?: string): WallpaperType {
   switch (raw?.toLowerCase()) {
     case 'video': return 'video'
     case 'web': return 'web'
@@ -173,7 +176,7 @@ function normalizeType(raw?: string): WallpaperType {
   }
 }
 
-function normalizeRating(raw?: string): ContentRating {
+export function normalizeRating(raw?: string): ContentRating {
   switch (raw?.toLowerCase()) {
     case 'questionable': return 'questionable'
     case 'mature': return 'mature'
@@ -181,7 +184,7 @@ function normalizeRating(raw?: string): ContentRating {
   }
 }
 
-function getDirSize(dirPath: string): number {
+export function getDirSize(dirPath: string): number {
   let total = 0
   try {
     for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
@@ -196,11 +199,20 @@ function getDirSize(dirPath: string): number {
   return total
 }
 
+async function safeGetWorkshopTimesUpdated(ids: string[]): Promise<Map<string, number>> {
+  try {
+    return await getWorkshopTimesUpdated(ids)
+  } catch {
+    return new Map()
+  }
+}
+
 function buildMeta(
   workshopId: string,
   localPath: string,
   pj: ProjectJson | null,
-  existing?: WallpaperMeta
+  existing?: WallpaperMeta,
+  workshopTimeUpdated?: number
 ): WallpaperMeta {
   const now = Date.now()
   const { downloading: _d, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
@@ -217,23 +229,30 @@ function buildMeta(
     file: pj?.file,
     fileSize: getDirSize(localPath),
     createdAt: existing?.createdAt ?? now,
-    updatedAt: now,
+    updatedAt: workshopTimeUpdated ? workshopTimeUpdated * 1000 : (existing?.updatedAt ?? now),
     subscribed: true,
     source: 'workshop',
     tags: pj?.tags ?? existing?.tags ?? []
   }
 }
 
-export function importWallpaperById(workshopId: string): WallpaperMeta | null {
+export async function importWallpaperById(workshopId: string): Promise<WallpaperMeta | null> {
   const localPath = path.join(getWorkshopPath(), workshopId)
   if (!fs.existsSync(localPath)) return null
 
-  const meta = buildMeta(workshopId, localPath, readProjectJson(localPath), getWallpaper(workshopId) ?? undefined)
+  const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
+  const meta = buildMeta(
+    workshopId,
+    localPath,
+    readProjectJson(localPath),
+    getWallpaper(workshopId) ?? undefined,
+    timesUpdated.get(workshopId)
+  )
   upsertWallpaper(meta)
   return meta
 }
 
-export function scanLibrary(): { imported: number; skipped: number; removed: number } {
+export async function scanLibrary(): Promise<{ imported: number; skipped: number; removed: number }> {
   const workshopPath = getWorkshopPath()
   if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0, removed: 0 }
 
@@ -254,6 +273,8 @@ export function scanLibrary(): { imported: number; skipped: number; removed: num
     }
   }
 
+  const toRebuild: { entry: fs.Dirent; localPath: string; pj: ProjectJson }[] = []
+  const cacheHits: string[] = []
   for (const entry of entries) {
     if (!entry.isDirectory()) continue
 
@@ -286,25 +307,54 @@ export function scanLibrary(): { imported: number; skipped: number; removed: num
       continue
     }
 
-    // Skip already-imported entries that have all required fields (cache hit)
+    // Skip already-imported entries that have all required fields (cache hit),
+    // but still track them so their updatedAt can be resynced with Steam below.
     const existing = wallpapers[entry.name]
     if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
+      cacheHits.push(entry.name)
       skipped++
       continue
     }
 
-    wallpapers[entry.name] = buildMeta(entry.name, localPath, pj, existing ?? undefined)
+    toRebuild.push({ entry, localPath, pj })
+  }
+
+  const timesUpdated = await safeGetWorkshopTimesUpdated([
+    ...toRebuild.map(({ entry }) => entry.name),
+    ...cacheHits
+  ])
+
+  for (const { entry, localPath, pj } of toRebuild) {
+    wallpapers[entry.name] = buildMeta(
+      entry.name,
+      localPath,
+      pj,
+      wallpapers[entry.name] ?? undefined,
+      timesUpdated.get(entry.name)
+    )
     imported++
   }
 
-  if (imported > 0 || removed > 0) {
+  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 }
+      resynced = true
+    }
+  }
+
+  if (imported > 0 || removed > 0 || resynced) {
     store.set('wallpapers', wallpapers)
   }
 
   console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
 
-  // Auto-cleanup folder items that reference non-existent wallpapers
+  // Auto-cleanup folder/playlist items that reference non-existent wallpapers
   cleanupFolders()
+  cleanupPlaylists(new Set(Object.keys(wallpapers)))
 
   return { imported, skipped, removed }
 }

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

@@ -470,10 +470,10 @@ function buildLweEnvVars(): string[] {
   return vars
 }
 
-/** Build the common LWE args (assets dir, screen roots, fps). */
+/** Build the common LWE args (assets dir, screen roots, fps, volume). */
 function buildLweArgs(
   wallpaperPath: string,
-  options: { screenRoot?: string; fps?: number }
+  options: { screenRoot?: string; fps?: number; volume?: number }
 ): string[] {
   const args: string[] = []
   const assetsDir = findWeAssetsDir()
@@ -488,6 +488,7 @@ function buildLweArgs(
   }
 
   if (options.fps) args.push('--fps', String(options.fps))
+  if (options.volume !== undefined) args.push('--volume', String(options.volume))
   args.push(wallpaperPath)
   return args
 }
@@ -515,7 +516,7 @@ function hotReloadLwe(wallpaperPath: string): boolean {
 /** 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 } = {}
+  options: { screenRoot?: string; fps?: number; volume?: number } = {}
 ): Promise<void> {
   // Hot-reload if already running
   if (isLweRunning() && hotReloadLwe(wallpaperPath)) {

+ 248 - 0
src/main/services/playlist-player.service.ts

@@ -0,0 +1,248 @@
+import Store from 'electron-store'
+import type { BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { Playlist, PlaylistItem, PlaylistPlaybackState } from '@shared/types'
+import { getPlaylist } from './playlist.service'
+import { getWallpaper, updateWallpaper } from './library.service'
+import { applyWallpaperMeta } from './wallpaper.service'
+import { setActiveWallpaperId } from './wallpaper-state.service'
+
+interface PlaybackStore {
+  activePlaylistId: string | null
+  isPlaying: boolean
+}
+
+const store = new Store<PlaybackStore>({
+  name: 'playlist-playback',
+  defaults: { activePlaylistId: null, isPlaying: false }
+})
+
+let win: BrowserWindow | null = null
+let timer: NodeJS.Timeout | null = null
+let applying = false
+const listeners = new Set<() => void>()
+
+/** Subscribe to playback state changes (used by the tray to keep its menu labels accurate). */
+export function onPlaybackStateChanged(cb: () => void): () => void {
+  listeners.add(cb)
+  return () => listeners.delete(cb)
+}
+
+let state: PlaylistPlaybackState = {
+  playlistId: null,
+  currentItemId: null,
+  currentIndex: -1,
+  isPlaying: false,
+  order: []
+}
+
+function shuffle<T>(arr: T[]): T[] {
+  const copy = [...arr]
+  for (let i = copy.length - 1; i > 0; i--) {
+    const j = Math.floor(Math.random() * (i + 1))
+    ;[copy[i], copy[j]] = [copy[j], copy[i]]
+  }
+  return copy
+}
+
+function buildOrder(playlist: Playlist): string[] {
+  if (playlist.settings.randomize) {
+    return shuffle(playlist.items.map((i) => i.wallpaperId))
+  }
+  const items = [...playlist.items]
+  switch (playlist.settings.sortBy) {
+    case 'title':
+      items.sort((a, b) => {
+        const wa = getWallpaper(a.wallpaperId)
+        const wb = getWallpaper(b.wallpaperId)
+        return (wa?.title ?? '').localeCompare(wb?.title ?? '')
+      })
+      break
+    case 'createdAt':
+      items.sort((a, b) => {
+        const wa = getWallpaper(a.wallpaperId)
+        const wb = getWallpaper(b.wallpaperId)
+        return (wa?.createdAt ?? 0) - (wb?.createdAt ?? 0)
+      })
+      break
+    default:
+      // manual: keep playlist item order as-is
+      break
+  }
+  return items.map((i) => i.wallpaperId)
+}
+
+function clearTimer(): void {
+  if (timer) {
+    clearTimeout(timer)
+    timer = null
+  }
+}
+
+function emitState(): void {
+  if (win && !win.isDestroyed()) {
+    win.webContents.send(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, state)
+  }
+  for (const cb of listeners) cb()
+}
+
+function findItem(playlist: Playlist, wallpaperId: string): PlaylistItem | undefined {
+  return playlist.items.find((i) => i.wallpaperId === wallpaperId)
+}
+
+async function applyCurrent(playlist: Playlist): Promise<void> {
+  const wallpaperId = state.order[state.currentIndex]
+  if (!wallpaperId) return
+
+  const wallpaper = getWallpaper(wallpaperId)
+  if (!wallpaper) return
+
+  const item = findItem(playlist, wallpaperId)
+  const volume = item?.volume ?? playlist.settings.defaultVolume
+
+  applying = true
+  try {
+    await applyWallpaperMeta(wallpaper, { volume })
+    setActiveWallpaperId(wallpaperId)
+    updateWallpaper(wallpaperId, {
+      appliedCount: (wallpaper.appliedCount ?? 0) + 1,
+      lastAppliedAt: Date.now()
+    })
+    state.currentItemId = wallpaperId
+  } catch (err) {
+    console.error('[PlaylistPlayer] Failed to apply wallpaper:', err)
+  } finally {
+    applying = false
+  }
+}
+
+function scheduleNext(playlist: Playlist): void {
+  clearTimer()
+  if (!state.isPlaying) return
+
+  const wallpaperId = state.order[state.currentIndex]
+  const item = wallpaperId ? findItem(playlist, wallpaperId) : undefined
+  const durationSec = item?.durationSec ?? playlist.settings.defaultDurationSec
+
+  timer = setTimeout(() => {
+    void advance()
+  }, durationSec * 1000)
+}
+
+async function advance(): Promise<void> {
+  if (!state.playlistId) return
+  const playlist = getPlaylist(state.playlistId)
+  if (!playlist || playlist.items.length === 0) {
+    stopPlaylist()
+    return
+  }
+
+  state.currentIndex++
+  if (state.currentIndex >= state.order.length) {
+    // Loop: rebuild order (reshuffles if randomize is on)
+    state.order = buildOrder(playlist)
+    state.currentIndex = 0
+  }
+
+  await applyCurrent(playlist)
+  emitState()
+  scheduleNext(playlist)
+}
+
+export function startPlaylist(id: string): PlaylistPlaybackState {
+  const playlist = getPlaylist(id)
+  if (!playlist) throw new Error(`Playlist ${id} not found`)
+  if (playlist.items.length === 0) throw new Error('Playlist has no wallpapers')
+
+  clearTimer()
+  state = {
+    playlistId: playlist.id,
+    currentItemId: null,
+    currentIndex: 0,
+    isPlaying: true,
+    order: buildOrder(playlist)
+  }
+  store.set('activePlaylistId', playlist.id)
+  store.set('isPlaying', true)
+
+  void applyCurrent(playlist).then(() => {
+    emitState()
+    scheduleNext(playlist)
+  })
+  emitState()
+
+  return state
+}
+
+export function stopPlaylist(): PlaylistPlaybackState {
+  clearTimer()
+  state = { playlistId: null, currentItemId: null, currentIndex: -1, isPlaying: false, order: [] }
+  store.set('activePlaylistId', null)
+  store.set('isPlaying', false)
+  emitState()
+  return state
+}
+
+export function pausePlaylist(): PlaylistPlaybackState {
+  if (!state.playlistId) return state
+  clearTimer()
+  state.isPlaying = false
+  store.set('isPlaying', false)
+  emitState()
+  return state
+}
+
+export function resumePlaylist(): PlaylistPlaybackState {
+  if (!state.playlistId) return state
+  const playlist = getPlaylist(state.playlistId)
+  if (!playlist) return state
+  state.isPlaying = true
+  store.set('isPlaying', true)
+  scheduleNext(playlist)
+  emitState()
+  return state
+}
+
+export async function nextItem(): Promise<PlaylistPlaybackState> {
+  if (!state.playlistId || applying) return state
+  const playlist = getPlaylist(state.playlistId)
+  if (!playlist) return state
+  await advance()
+  return state
+}
+
+export async function previousItem(): Promise<PlaylistPlaybackState> {
+  if (!state.playlistId || applying) return state
+  const playlist = getPlaylist(state.playlistId)
+  if (!playlist || playlist.items.length === 0) return state
+
+  state.currentIndex--
+  if (state.currentIndex < 0) state.currentIndex = state.order.length - 1
+
+  await applyCurrent(playlist)
+  emitState()
+  scheduleNext(playlist)
+  return state
+}
+
+export function getPlaybackState(): PlaylistPlaybackState {
+  return state
+}
+
+/**
+ * Call once on app startup. Resumes whichever playlist was active when the app last
+ * closed. Returns true if a playlist was resumed, so the caller can skip the
+ * "autostart playlist" config setting to avoid starting two playlists at once.
+ */
+export function initPlaylistPlayer(window: BrowserWindow): boolean {
+  win = window
+  const activeId = store.get('activePlaylistId')
+  if (!activeId) return false
+  const playlist = getPlaylist(activeId)
+  if (!playlist || playlist.items.length === 0) return false
+
+  const wasPlaying = store.get('isPlaying')
+  startPlaylist(activeId)
+  if (!wasPlaying) pausePlaylist()
+  return true
+}

+ 159 - 0
src/main/services/playlist.service.ts

@@ -0,0 +1,159 @@
+import Store from 'electron-store'
+import { randomUUID } from 'crypto'
+import type { Playlist, PlaylistItem, PlaylistSettings } from '@shared/types'
+
+interface PlaylistStore {
+  playlists: Playlist[]
+}
+
+const DEFAULT_SETTINGS: PlaylistSettings = {
+  randomize: false,
+  sortBy: 'manual',
+  defaultDurationSec: 600,
+  defaultVolume: 100
+}
+
+const store = new Store<PlaylistStore>({
+  name: 'playlists',
+  defaults: { playlists: [] }
+})
+
+export function getAllPlaylists(): Playlist[] {
+  return store.get('playlists')
+}
+
+export function getPlaylist(id: string): Playlist | null {
+  return store.get('playlists').find((p) => p.id === id) ?? null
+}
+
+export function createPlaylist(title: string): Playlist {
+  const now = Date.now()
+  const playlist: Playlist = {
+    id: randomUUID(),
+    title,
+    items: [],
+    settings: { ...DEFAULT_SETTINGS },
+    createdAt: now,
+    updatedAt: now
+  }
+  const playlists = store.get('playlists')
+  playlists.push(playlist)
+  store.set('playlists', playlists)
+  return playlist
+}
+
+export function renamePlaylist(id: string, title: string): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  playlist.title = title
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+export function deletePlaylist(id: string): void {
+  const playlists = store.get('playlists').filter((p) => p.id !== id)
+  store.set('playlists', playlists)
+}
+
+export function updatePlaylistSettings(
+  id: string,
+  patch: Partial<PlaylistSettings>
+): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  playlist.settings = { ...playlist.settings, ...patch }
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+export function addItemsToPlaylist(id: string, wallpaperIds: string[]): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  const existing = new Set(playlist.items.map((i) => i.wallpaperId))
+  for (const wallpaperId of wallpaperIds) {
+    if (!existing.has(wallpaperId)) {
+      playlist.items.push({ wallpaperId })
+      existing.add(wallpaperId)
+    }
+  }
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+export function removeItemsFromPlaylist(id: string, wallpaperIds: string[]): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  const remove = new Set(wallpaperIds)
+  playlist.items = playlist.items.filter((i) => !remove.has(i.wallpaperId))
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+/** Reorders items to match the given wallpaperId sequence (used for manual drag-reorder). */
+export function reorderPlaylistItems(id: string, orderedWallpaperIds: string[]): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  const byId = new Map(playlist.items.map((i) => [i.wallpaperId, i]))
+  const reordered: PlaylistItem[] = []
+  for (const wallpaperId of orderedWallpaperIds) {
+    const item = byId.get(wallpaperId)
+    if (item) {
+      reordered.push(item)
+      byId.delete(wallpaperId)
+    }
+  }
+  // Append anything not mentioned in orderedWallpaperIds (defensive, shouldn't normally happen)
+  reordered.push(...byId.values())
+  playlist.items = reordered
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+export function updatePlaylistItem(
+  id: string,
+  wallpaperId: string,
+  patch: { volume?: number; durationSec?: number }
+): Playlist | null {
+  const playlists = store.get('playlists')
+  const playlist = playlists.find((p) => p.id === id)
+  if (!playlist) return null
+  const item = playlist.items.find((i) => i.wallpaperId === wallpaperId)
+  if (!item) return null
+  if ('volume' in patch) item.volume = patch.volume
+  if ('durationSec' in patch) item.durationSec = patch.durationSec
+  playlist.updatedAt = Date.now()
+  store.set('playlists', playlists)
+  return playlist
+}
+
+/**
+ * Remove playlist item references that don't exist in the library anymore.
+ * Returns the number of stale references removed.
+ */
+export function cleanupPlaylists(validWallpaperIds: Set<string>): number {
+  const playlists = store.get('playlists')
+  let removed = 0
+
+  for (const playlist of playlists) {
+    const before = playlist.items.length
+    playlist.items = playlist.items.filter((i) => validWallpaperIds.has(i.wallpaperId))
+    removed += before - playlist.items.length
+  }
+
+  if (removed > 0) {
+    store.set('playlists', playlists)
+    console.log(`[Playlist] Cleanup: removed ${removed} stale item(s)`)
+  }
+
+  return removed
+}

+ 98 - 0
src/main/services/tray.service.ts

@@ -0,0 +1,98 @@
+import { Tray, Menu, nativeImage, type BrowserWindow } from 'electron'
+import { app } from 'electron'
+import {
+  getPlaybackState,
+  pausePlaylist,
+  resumePlaylist,
+  nextItem,
+  onPlaybackStateChanged
+} from './playlist-player.service'
+import { getAllPlaylists } from './playlist.service'
+
+// 32x32 solid indigo circle on a transparent background, generated once and inlined so
+// the app doesn't need a bundled icon asset just for the tray.
+const TRAY_ICON_DATA_URL =
+  'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAcklEQVR4nO3O2w3AIAxDUabrL/sPwBztArxM7ARVseTve0rJgatPe2cPC8sgaJgKscZNCFb8CMGOQwhVfBsRClDHl4hQgFd8iEhAAhIQDvBEdONXADwQ0/gVACViK65CQHE24ijOQpjiFggtjEJk4d/uAxec83TU+/bHAAAAAElFTkSuQmCC'
+
+let tray: Tray | null = null
+let trayWin: BrowserWindow | null = null
+let unsubscribePlayback: (() => void) | null = null
+
+function buildMenu(): Menu {
+  const state = getPlaybackState()
+  const playlists = getAllPlaylists()
+  const activePlaylist = playlists.find((p) => p.id === state.playlistId)
+
+  return Menu.buildFromTemplate([
+    {
+      label: trayWin?.isVisible() ? 'Hide WE Manager' : 'Show WE Manager',
+      click: () => {
+        if (!trayWin) return
+        if (trayWin.isVisible()) trayWin.hide()
+        else trayWin.show()
+      }
+    },
+    { type: 'separator' },
+    {
+      label: activePlaylist
+        ? `Playing: ${activePlaylist.title}`
+        : 'No playlist active',
+      enabled: false
+    },
+    {
+      label: state.isPlaying ? 'Pause' : 'Resume',
+      enabled: state.playlistId !== null,
+      click: () => {
+        if (state.isPlaying) pausePlaylist()
+        else resumePlaylist()
+        refreshMenu()
+      }
+    },
+    {
+      label: 'Next Wallpaper',
+      enabled: state.playlistId !== null,
+      click: () => {
+        void nextItem().then(refreshMenu)
+      }
+    },
+    { type: 'separator' },
+    {
+      label: 'Quit',
+      click: () => app.quit()
+    }
+  ])
+}
+
+function refreshMenu(): void {
+  if (tray) tray.setContextMenu(buildMenu())
+}
+
+export function createTray(win: BrowserWindow): void {
+  if (tray) return
+  trayWin = win
+
+  const icon = nativeImage.createFromDataURL(TRAY_ICON_DATA_URL)
+  tray = new Tray(icon)
+  tray.setToolTip('WE Manager')
+  tray.setContextMenu(buildMenu())
+  tray.on('click', () => {
+    if (!trayWin) return
+    if (trayWin.isVisible()) trayWin.hide()
+    else trayWin.show()
+  })
+
+  unsubscribePlayback = onPlaybackStateChanged(refreshMenu)
+}
+
+export function destroyTray(): void {
+  if (tray) {
+    tray.destroy()
+    tray = null
+  }
+  unsubscribePlayback?.()
+  unsubscribePlayback = null
+}
+
+export function isTrayActive(): boolean {
+  return tray !== null
+}

+ 15 - 0
src/main/services/wallpaper-state.service.ts

@@ -0,0 +1,15 @@
+import Store from 'electron-store'
+
+interface StoreSchema {
+  activeWallpaperId?: string
+}
+
+const store = new Store<StoreSchema>()
+
+export function getActiveWallpaperId(): string | undefined {
+  return store.get('activeWallpaperId')
+}
+
+export function setActiveWallpaperId(id: string): void {
+  store.set('activeWallpaperId', id)
+}

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

@@ -1,9 +1,12 @@
 import { execFile } from 'child_process'
 import { promisify } from 'util'
 import * as os from 'os'
+import * as fs from 'fs'
+import * as path from 'path'
 import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
-import type { WallpaperBackend, WallpaperEnvironment } from '@shared/types'
-import { getLweStatus } from './lwe.service'
+import type { WallpaperBackend, WallpaperEnvironment, WallpaperMeta } from '@shared/types'
+import { getLweStatus, launchLweAsync } from './lwe.service'
+import { getDefaultFps } from './config.service'
 
 const execFileAsync = promisify(execFile)
 
@@ -167,3 +170,51 @@ async function applyWindows(imagePath: string): Promise<void> {
     env: { ...process.env, WE_WALLPAPER_PATH: imagePath }
   })
 }
+
+function findWallpaperImage(dirPath: string): string {
+  if (!fs.existsSync(dirPath)) {
+    throw new Error(`Directory not found: ${dirPath}`)
+  }
+  if (!fs.statSync(dirPath).isDirectory()) {
+    return dirPath
+  }
+
+  const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
+  const files = fs.readdirSync(dirPath)
+
+  const preview = files.find((f) => f.toLowerCase().startsWith('preview'))
+  if (preview && imageExtensions.includes(path.extname(preview).toLowerCase())) {
+    return path.join(dirPath, preview)
+  }
+
+  const image = files.find((f) => imageExtensions.includes(path.extname(f).toLowerCase()))
+  if (image) return path.join(dirPath, image)
+
+  throw new Error(`No image found in ${dirPath}`)
+}
+
+/**
+ * Apply a library wallpaper: renders animated/scene/web/video wallpapers via
+ * linux-wallpaperengine when available, otherwise falls back to a static image.
+ * Shared by the manual "apply" IPC handler and the playlist playback engine.
+ */
+export async function applyWallpaperMeta(
+  wallpaper: WallpaperMeta,
+  options: { fps?: number; volume?: number; backend?: WallpaperBackend } = {}
+): Promise<string> {
+  if (!wallpaper.localPath) throw new Error(`Wallpaper ${wallpaper.id} has no local path`)
+
+  const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
+  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 })
+    return wallpaper.localPath
+  }
+
+  // Static fallback: set the preview image as a plain wallpaper
+  const appliedPath = findWallpaperImage(wallpaper.localPath)
+  await applyWallpaper(appliedPath, options.backend)
+  return appliedPath
+}

+ 5 - 4
src/main/services/watcher.service.ts

@@ -48,10 +48,11 @@ export function restartWatcher(): void {
       if (!isDirectory) return
 
       console.log('[Watcher] New wallpaper detected:', filename)
-      const meta = importWallpaperById(filename)
-      if (meta && watcherWin && !watcherWin.isDestroyed()) {
-        watcherWin.webContents.send(IpcChannels.EVENT_WALLPAPER_IMPORTED, meta)
-      }
+      importWallpaperById(filename).then((meta) => {
+        if (meta && watcherWin && !watcherWin.isDestroyed()) {
+          watcherWin.webContents.send(IpcChannels.EVENT_WALLPAPER_IMPORTED, meta)
+        }
+      })
     }, 1000)
   })
 }

+ 14 - 0
src/main/services/workshop.service.ts

@@ -56,6 +56,20 @@ export async function getWorkshopItem(publishedFileId: string): Promise<Workshop
   return transformItem(item, subscribedSet)
 }
 
+export async function getWorkshopTimesUpdated(
+  publishedFileIds: string[]
+): Promise<Map<string, number>> {
+  const map = new Map<string, number>()
+  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.timeUpdated)
+  }
+  return map
+}
+
 function transformItem(
   item: NonNullable<Awaited<ReturnType<ReturnType<typeof getClient>['workshop']['getItem']>>>,
   subscribedSet: Set<string>

+ 92 - 4
src/preload/index.ts

@@ -12,7 +12,11 @@ import type {
   WallpaperEnvironment,
   LweStatus,
   LweInstallProgress,
-  LinuxDistro
+  LinuxDistro,
+  BackupProgressEvent,
+  Playlist,
+  PlaylistSettings,
+  PlaylistPlaybackState
 } from '../shared/types'
 
 const api = {
@@ -68,14 +72,33 @@ const api = {
   },
 
   config: {
-    get: (): Promise<{ workshopPath: string | null; defaultWorkshopPath: string; isConfigured: boolean; defaultFps: number | null; lweRepoUrl: string | null; lweRepoBranch: string | null; defaultLweRepoUrl: string }> =>
-      ipcRenderer.invoke(IpcChannels.CONFIG_GET),
+    get: (): Promise<{
+      workshopPath: string | null
+      defaultWorkshopPath: string
+      isConfigured: boolean
+      defaultFps: number | null
+      lweRepoUrl: string | null
+      lweRepoBranch: string | null
+      defaultLweRepoUrl: string
+      backupPath: string | null
+      isBackupConfigured: boolean
+      autoUnsubscribeAfterBackup: boolean
+      trayEnabled: boolean
+      autostartSupported: boolean
+      autostartEnabled: boolean
+      autostartMinimized: boolean
+      autostartPlaylistId: string | null
+    }> => ipcRenderer.invoke(IpcChannels.CONFIG_GET),
     setWorkshopPath: (p: string): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_WORKSHOP_PATH, p),
     setDefaultFps: (fps: number | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_DEFAULT_FPS, fps),
     setLweRepo: (url: string | null, branch: string | null): Promise<{ ok: boolean }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_SET_LWE_REPO, url, branch),
+    setBackupPath: (p: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_BACKUP_PATH, p),
+    setAutoUnsubscribeAfterBackup: (enabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_AUTO_UNSUBSCRIBE, enabled),
     pickFolder: (): Promise<string | null> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_PICK_FOLDER),
     pickFile: (): Promise<string | null> =>
@@ -83,7 +106,53 @@ const api = {
     importWE: (sourcePath: string): Promise<{ folders: number; playlists: number; configPath: string }> =>
       ipcRenderer.invoke(IpcChannels.CONFIG_IMPORT_WE, sourcePath),
     createFresh: (): Promise<{ configPath: string }> =>
-      ipcRenderer.invoke(IpcChannels.CONFIG_CREATE_FRESH)
+      ipcRenderer.invoke(IpcChannels.CONFIG_CREATE_FRESH),
+    setTrayEnabled: (enabled: boolean): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_TRAY_ENABLED, enabled),
+    setAutostart: (
+      enabled: boolean,
+      minimized: boolean,
+      playlistId: string | null
+    ): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_SET_AUTOSTART, enabled, minimized, playlistId),
+    getAutostartSupported: (): Promise<boolean> =>
+      ipcRenderer.invoke(IpcChannels.CONFIG_GET_AUTOSTART_SUPPORTED)
+  },
+
+  playlist: {
+    getAll: (): Promise<Playlist[]> => ipcRenderer.invoke(IpcChannels.PLAYLIST_GET_ALL),
+    getOne: (id: string): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_GET_ONE, id),
+    create: (title: string): Promise<Playlist> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_CREATE, title),
+    rename: (id: string, title: string): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_RENAME, id, title),
+    delete: (id: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_DELETE, id),
+    updateSettings: (id: string, patch: Partial<PlaylistSettings>): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_UPDATE_SETTINGS, id, patch),
+    addItems: (id: string, wallpaperIds: string[]): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_ADD_ITEMS, id, wallpaperIds),
+    removeItems: (id: string, wallpaperIds: string[]): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_REMOVE_ITEMS, id, wallpaperIds),
+    reorderItems: (id: string, orderedWallpaperIds: string[]): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_REORDER_ITEMS, id, orderedWallpaperIds),
+    updateItem: (
+      id: string,
+      wallpaperId: string,
+      patch: { volume?: number; durationSec?: number }
+    ): Promise<Playlist | null> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_UPDATE_ITEM, id, wallpaperId, patch),
+    start: (id: string): Promise<PlaylistPlaybackState> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_START, id),
+    stop: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_STOP),
+    pause: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_PAUSE),
+    resume: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_RESUME),
+    next: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_NEXT),
+    previous: (): Promise<PlaylistPlaybackState> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_PREVIOUS),
+    getState: (): Promise<PlaylistPlaybackState> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_GET_STATE)
   },
 
   folders: {
@@ -131,6 +200,15 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LWE_STOP)
   },
 
+  backup: {
+    item: (itemId: string): Promise<{ ok: boolean; unsubscribed: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.BACKUP_ITEM, itemId),
+    selection: (itemIds: string[]): Promise<{ backedUp: number; failed: number; unsubscribed: number }> =>
+      ipcRenderer.invoke(IpcChannels.BACKUP_SELECTION, itemIds),
+    scan: (): Promise<{ imported: number; skipped: number; linked: number }> =>
+      ipcRenderer.invoke(IpcChannels.BACKUP_SCAN)
+  },
+
   desktopIcons: {
     setEnabled: (enabled: boolean): Promise<{ ok: boolean; enabled: boolean }> =>
       ipcRenderer.invoke(IpcChannels.DESKTOP_ICONS_SET_ENABLED, enabled),
@@ -169,6 +247,16 @@ const api = {
       const listener = (_: Electron.IpcRendererEvent, progress: LweInstallProgress) => cb(progress)
       ipcRenderer.on(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, listener)
+    },
+    backupProgress: (cb: (progress: BackupProgressEvent) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, progress: BackupProgressEvent) => cb(progress)
+      ipcRenderer.on(IpcChannels.EVENT_BACKUP_PROGRESS, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_BACKUP_PROGRESS, listener)
+    },
+    playlistStateChanged: (cb: (state: PlaylistPlaybackState) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, state: PlaylistPlaybackState) => cb(state)
+      ipcRenderer.on(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_PLAYLIST_STATE_CHANGED, listener)
     }
   }
 }

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

@@ -2,11 +2,12 @@ import { useState, useEffect, Component, type ReactNode } from 'react'
 import Sidebar from './components/layout/Sidebar'
 import WorkshopBrowser from './components/workshop/WorkshopBrowser'
 import LibraryView from './components/library/LibraryView'
+import PlaylistsView from './components/playlist/PlaylistsView'
 import SettingsView from './components/settings/SettingsView'
 import SetupScreen from './components/setup/SetupScreen'
 import StatusBar from './components/layout/StatusBar'
 
-type View = 'workshop' | 'library' | 'settings'
+type View = 'workshop' | 'library' | 'playlists' | 'settings'
 
 class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
   state = { error: null }
@@ -58,6 +59,7 @@ export default function App() {
           <main className="flex-1 overflow-hidden">
             {activeView === 'workshop' && <WorkshopBrowser />}
             {activeView === 'library' && <LibraryView />}
+            {activeView === 'playlists' && <PlaylistsView />}
             {activeView === 'settings' && <SettingsView />}
           </main>
         </div>

+ 36 - 0
src/renderer/src/components/common/PreviewSizeToggle.tsx

@@ -0,0 +1,36 @@
+import clsx from 'clsx'
+import type { PreviewSize } from '../../hooks/usePreviewSize'
+
+const OPTIONS: { value: PreviewSize; label: string }[] = [
+  { value: 'small', label: 'S' },
+  { value: 'normal', label: 'M' },
+  { value: 'big', label: 'L' }
+]
+
+export default function PreviewSizeToggle({
+  value,
+  onChange
+}: {
+  value: PreviewSize
+  onChange: (size: PreviewSize) => void
+}) {
+  return (
+    <div className="flex rounded-lg overflow-hidden text-xs" title="Preview size">
+      {OPTIONS.map((opt) => (
+        <button
+          key={opt.value}
+          onClick={() => onChange(opt.value)}
+          title={opt.value[0].toUpperCase() + opt.value.slice(1)}
+          className={clsx(
+            'px-2.5 py-2 transition-colors',
+            value === opt.value
+              ? 'bg-indigo-600 text-white'
+              : 'bg-white/5 text-gray-400 hover:text-gray-200'
+          )}
+        >
+          {opt.label}
+        </button>
+      ))}
+    </div>
+  )
+}

+ 4 - 3
src/renderer/src/components/layout/Sidebar.tsx

@@ -1,7 +1,7 @@
-import { Globe, Library, Settings } from 'lucide-react'
+import { Globe, Library, ListVideo, Settings } from 'lucide-react'
 import clsx from 'clsx'
 
-type View = 'workshop' | 'library' | 'settings'
+type View = 'workshop' | 'library' | 'playlists' | 'settings'
 
 interface SidebarProps {
   activeView: View
@@ -11,7 +11,8 @@ interface SidebarProps {
 export default function Sidebar({ activeView, onNavigate }: SidebarProps) {
   const items = [
     { id: 'workshop' as View, label: 'Workshop', icon: Globe },
-    { id: 'library' as View, label: 'Library', icon: Library }
+    { id: 'library' as View, label: 'Library', icon: Library },
+    { id: 'playlists' as View, label: 'Playlists', icon: ListVideo }
   ]
 
   return (

+ 47 - 2
src/renderer/src/components/layout/StatusBar.tsx

@@ -1,8 +1,11 @@
 import { useEffect, useState } from 'react'
-import { Wifi, WifiOff } from 'lucide-react'
+import { Wifi, WifiOff, ListVideo, Play, Pause, SkipForward } from 'lucide-react'
+import type { PlaylistPlaybackState } from '@shared/types'
 
 export default function StatusBar() {
   const [steamRunning, setSteamRunning] = useState(false)
+  const [playback, setPlayback] = useState<PlaylistPlaybackState | null>(null)
+  const [playlistTitle, setPlaylistTitle] = useState<string | null>(null)
 
   useEffect(() => {
     window.electronAPI.steam.isRunning().then(setSteamRunning).catch(() => setSteamRunning(false))
@@ -11,8 +14,30 @@ export default function StatusBar() {
     return unsub
   }, [])
 
+  useEffect(() => {
+    window.electronAPI.playlist.getState().then(setPlayback)
+    return window.electronAPI.on.playlistStateChanged(setPlayback)
+  }, [])
+
+  useEffect(() => {
+    if (!playback?.playlistId) {
+      setPlaylistTitle(null)
+      return
+    }
+    window.electronAPI.playlist.getOne(playback.playlistId).then((p) => setPlaylistTitle(p?.title ?? null))
+  }, [playback?.playlistId])
+
+  async function handlePauseResume() {
+    if (playback?.isPlaying) await window.electronAPI.playlist.pause()
+    else await window.electronAPI.playlist.resume()
+  }
+
+  async function handleNext() {
+    await window.electronAPI.playlist.next()
+  }
+
   return (
-    <footer className="flex h-6 items-center gap-2 border-t border-white/5 bg-[#0a0a0a] px-3 text-xs text-gray-500">
+    <footer className="flex h-6 items-center gap-3 border-t border-white/5 bg-[#0a0a0a] px-3 text-xs text-gray-500">
       <span className="flex items-center gap-1">
         {steamRunning ? (
           <>
@@ -26,6 +51,26 @@ export default function StatusBar() {
           </>
         )}
       </span>
+
+      {playback?.playlistId && (
+        <>
+          <span className="h-3 w-px bg-white/10" />
+          <span className="flex items-center gap-1.5">
+            <ListVideo size={12} className="text-indigo-400" />
+            {playlistTitle ?? 'Playlist'}
+          </span>
+          <button
+            onClick={handlePauseResume}
+            title={playback.isPlaying ? 'Pause' : 'Resume'}
+            className="text-gray-400 hover:text-gray-200"
+          >
+            {playback.isPlaying ? <Pause size={11} /> : <Play size={11} />}
+          </button>
+          <button onClick={handleNext} title="Next" className="text-gray-400 hover:text-gray-200">
+            <SkipForward size={11} />
+          </button>
+        </>
+      )}
     </footer>
   )
 }

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

@@ -19,12 +19,15 @@ import {
   Eye,
   ThumbsUp,
   ThumbsDown,
-  Eraser
+  Eraser,
+  Archive
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
+import PreviewSizeToggle from '../common/PreviewSizeToggle'
 import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
 import { WE_TYPES, WE_AGE_RATINGS } from '../../constants/weFilters'
+import { usePreviewSize, PREVIEW_SIZE_MIN_PX } from '../../hooks/usePreviewSize'
 
 const STORAGE_KEY = 'we-library-filters'
 
@@ -32,9 +35,10 @@ interface LibraryFilterState {
   types: string[]
   ageRatings: string[]
   genres: string[]
+  sources: string[]
 }
 
-const DEFAULT_STATE: LibraryFilterState = { types: [], ageRatings: [], genres: [] }
+const DEFAULT_STATE: LibraryFilterState = { types: [], ageRatings: [], genres: [], sources: [] }
 
 const TYPE_MAP: Record<string, string> = {
   Scene: 'scene',
@@ -47,6 +51,10 @@ const RATING_MAP: Record<string, string> = {
   Questionable: 'questionable',
   Mature: 'mature'
 }
+const SOURCE_MAP: Record<string, string> = {
+  Workshop: 'workshop',
+  Backup: 'backup'
+}
 
 function loadFilters(): LibraryFilterState {
   try {
@@ -239,6 +247,7 @@ export default function LibraryView() {
   // 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"
@@ -287,7 +296,10 @@ export default function LibraryView() {
   }
 
   const activeCount =
-    filterState.types.length + filterState.ageRatings.length + filterState.genres.length
+    filterState.types.length +
+    filterState.ageRatings.length +
+    filterState.genres.length +
+    filterState.sources.length
 
   const filters: LibraryFilters = {
     sortBy,
@@ -329,6 +341,10 @@ export default function LibraryView() {
       if (filterState.genres.length > 0) {
         if (!filterState.genres.some((g) => w.tags.includes(g))) return false
       }
+      if (filterState.sources.length > 0) {
+        const sources = filterState.sources.map((s) => SOURCE_MAP[s]).filter(Boolean)
+        if (!sources.includes(w.source)) return false
+      }
       return true
     })
   }, [allWallpapers, filterState])
@@ -407,6 +423,43 @@ export default function LibraryView() {
   })
   const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
 
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const isBackupConfigured = config?.isBackupConfigured ?? false
+
+  const [backingUpSelection, setBackingUpSelection] = useState(false)
+  const [backupScanning, setBackupScanning] = useState(false)
+  const [backupStatus, setBackupStatus] = useState<string | null>(null)
+
+  async function handleBackupSelection() {
+    if (selectedIds.size === 0) return
+    setBackingUpSelection(true)
+    setBackupStatus(null)
+    try {
+      const result = await window.electronAPI.backup.selection(Array.from(selectedIds))
+      setBackupStatus(
+        `Backed up ${result.backedUp}${result.unsubscribed > 0 ? ` (${result.unsubscribed} unsubscribed)` : ''}${result.failed > 0 ? `, ${result.failed} failed` : ''}.`
+      )
+    } finally {
+      setBackingUpSelection(false)
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    }
+  }
+
+  async function handleScanBackups() {
+    setBackupScanning(true)
+    setBackupStatus(null)
+    try {
+      const result = await window.electronAPI.backup.scan()
+      setBackupStatus(`Backup scan: ${result.imported} imported, ${result.linked} linked.`)
+    } finally {
+      setBackupScanning(false)
+      queryClient.invalidateQueries({ queryKey: ['library'] })
+    }
+  }
+
   async function handleScan() {
     setScanning(true)
     setScanResult(null)
@@ -696,6 +749,17 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
+  async function ctxBackup() {
+    if (!ctxMenu) return
+    closeCtxMenu()
+    if (ctxMenu.ids.length === 1) {
+      await window.electronAPI.backup.item(ctxMenu.ids[0])
+    } else {
+      await window.electronAPI.backup.selection(ctxMenu.ids)
+    }
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+  }
+
   async function ctxOpenLocally() {
     if (!ctxMenu) return
     for (const w of ctxWallpapers) {
@@ -745,6 +809,7 @@ export default function LibraryView() {
 
   // 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')
 
   return (
     <div className="flex h-full flex-col">
@@ -791,6 +856,7 @@ export default function LibraryView() {
           <option value={100}>100 / page</option>
           <option value={200}>200 / page</option>
         </select>
+        <PreviewSizeToggle value={previewSize} onChange={setPreviewSize} />
         <button
           onClick={handleScan}
           disabled={scanning}
@@ -804,6 +870,19 @@ export default function LibraryView() {
           )}
           Scan
         </button>
+        <button
+          onClick={handleScanBackups}
+          disabled={backupScanning || !isBackupConfigured}
+          title={isBackupConfigured ? 'Scan backup folder' : 'Configure a backup folder in Settings first'}
+          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"
+        >
+          {backupScanning ? (
+            <Loader2 size={14} className="animate-spin" />
+          ) : (
+            <Archive size={14} />
+          )}
+          Scan backups
+        </button>
       </div>
 
       {/* Filter row */}
@@ -838,6 +917,19 @@ export default function LibraryView() {
 
         <div className="h-3 w-px bg-white/10" />
 
+        <div className="flex gap-0.5">
+          {Object.keys(SOURCE_MAP).map((label) => (
+            <Chip
+              key={label}
+              label={label}
+              active={filterState.sources.includes(label)}
+              onClick={() => updateFilter('sources', label)}
+            />
+          ))}
+        </div>
+
+        <div className="h-3 w-px bg-white/10" />
+
         <TagDropdown
           available={availableTags}
           selected={filterState.genres}
@@ -853,7 +945,23 @@ export default function LibraryView() {
           </button>
         )}
 
-        <span className="ml-auto text-xs text-gray-600">
+        {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>
+        )}
+
+        <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`}
@@ -866,6 +974,12 @@ export default function LibraryView() {
         </div>
       )}
 
+      {backupStatus && (
+        <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
+          {backupStatus}
+        </div>
+      )}
+
       {/* Main content: folder sidebar + grid */}
       <div className="flex flex-1 overflow-hidden">
         {/* Folder sidebar */}
@@ -1055,7 +1169,12 @@ export default function LibraryView() {
             </div>
           )}
 
-          <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4">
+          <div
+            className="grid gap-4"
+            style={{
+              gridTemplateColumns: `repeat(auto-fill, minmax(${PREVIEW_SIZE_MIN_PX[previewSize]}px, 1fr))`
+            }}
+          >
             {wallpapers.map((wallpaper) => (
               <WallpaperCard
                 key={wallpaper.id}
@@ -1063,9 +1182,13 @@ export default function LibraryView() {
                 selected={selectedIds.has(wallpaper.id)}
                 lweInstalled={lweStatus?.installed ?? false}
                 isLiked={votedSet.has(wallpaper.id)}
+                isBackupConfigured={isBackupConfigured}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }
+                onBackedUp={() =>
+                  queryClient.invalidateQueries({ queryKey: ['library'] })
+                }
                 onLiked={() => {
                   queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
                     old ? [...old, wallpaper.id] : [wallpaper.id]
@@ -1148,6 +1271,15 @@ export default function LibraryView() {
             </div>
           )}
 
+          {ctxHasBackupable && isBackupConfigured && (
+            <button
+              onClick={ctxBackup}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <Archive size={12} /> Backup
+            </button>
+          )}
+
           <button
             onClick={ctxUnsubscribe}
             className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"

+ 55 - 2
src/renderer/src/components/library/WallpaperCard.tsx

@@ -1,5 +1,5 @@
-import { useState } from 'react'
-import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download } from 'lucide-react'
+import { useState, useEffect } from 'react'
+import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download, Archive } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 
@@ -8,9 +8,11 @@ interface WallpaperCardProps {
   selected?: boolean
   lweInstalled?: boolean
   isLiked?: boolean
+  isBackupConfigured?: boolean
   onApplied?: () => void
   onLiked?: () => void
   onUnsubscribed?: () => void
+  onBackedUp?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
 }
@@ -20,9 +22,11 @@ export default function WallpaperCard({
   selected,
   lweInstalled,
   isLiked = false,
+  isBackupConfigured = false,
   onApplied,
   onLiked,
   onUnsubscribed,
+  onBackedUp,
   onSelect,
   onContextMenu
 }: WallpaperCardProps) {
@@ -31,6 +35,21 @@ export default function WallpaperCard({
   const [unsubState, setUnsubState] = useState<'idle' | 'confirm' | 'pending'>('idle')
   const [error, setError] = useState<string | null>(null)
   const [fpsInput, setFpsInput] = useState(wallpaper.fpsOverride != null ? String(wallpaper.fpsOverride) : '')
+  const [isBackingUp, setIsBackingUp] = useState(false)
+  const [backupPercentage, setBackupPercentage] = useState<number | null>(null)
+
+  useEffect(() => {
+    if (!isBackingUp) return
+    return window.electronAPI.on.backupProgress((progress) => {
+      if (progress.itemId !== wallpaper.id) return
+      if (progress.status === 'copying') {
+        setBackupPercentage(progress.percentage)
+      } else {
+        setIsBackingUp(false)
+        setBackupPercentage(null)
+      }
+    })
+  }, [isBackingUp, wallpaper.id])
 
   async function handleApply(e: React.MouseEvent) {
     e.stopPropagation()
@@ -69,6 +88,21 @@ export default function WallpaperCard({
     window.electronAPI.library.update(wallpaper.id, { fpsOverride: parsed })
   }
 
+  async function handleBackup(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (isBackingUp) return
+    setIsBackingUp(true)
+    setError(null)
+    try {
+      await window.electronAPI.backup.item(wallpaper.id)
+      onBackedUp?.()
+    } catch (err) {
+      setError((err as Error).message)
+      setIsBackingUp(false)
+      setBackupPercentage(null)
+    }
+  }
+
   async function handleUnsubscribeClick(e: React.MouseEvent) {
     e.stopPropagation()
     if (unsubState === 'idle') { setUnsubState('confirm'); return }
@@ -135,6 +169,14 @@ export default function WallpaperCard({
             <span className="text-xs text-gray-300">Downloading...</span>
           </div>
         )}
+        {(wallpaper.backedUp || wallpaper.source === 'backup') && (
+          <div
+            className="absolute bottom-2 right-2 flex h-5 w-5 items-center justify-center rounded bg-black/60 text-indigo-300"
+            title="Backed up locally"
+          >
+            <Archive size={12} />
+          </div>
+        )}
       </div>
 
       <div className="p-3">
@@ -180,6 +222,17 @@ export default function WallpaperCard({
             {isLiking ? <Loader2 size={11} className="animate-spin" /> : <ThumbsUp size={11} />}
             {isLiked ? 'Liked' : 'Like'}
           </button>
+          {wallpaper.source === 'workshop' && isBackupConfigured && (
+            <button
+              onClick={handleBackup}
+              disabled={isBackingUp}
+              title={wallpaper.backedUp ? 'Back up again' : 'Back up locally'}
+              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 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {isBackingUp ? <Loader2 size={11} className="animate-spin" /> : <Archive size={11} />}
+              {isBackingUp && backupPercentage != null ? `${backupPercentage}%` : 'Backup'}
+            </button>
+          )}
           {unsubState === 'confirm' ? (
             <div className="flex items-center gap-1">
               <span className="text-xs text-gray-400">Sure?</span>

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

@@ -0,0 +1,131 @@
+import { useState } from 'react'
+import { GripVertical, Trash2, Volume2, Clock, ChevronDown, ChevronUp } from 'lucide-react'
+import clsx from 'clsx'
+import type { PlaylistItem, WallpaperMeta } from '@shared/types'
+
+interface PlaylistItemRowProps {
+  item: PlaylistItem
+  wallpaper: WallpaperMeta | undefined
+  index: number
+  isPlaying: boolean
+  isCurrent: boolean
+  onDragStart: (index: number) => void
+  onDragOver: (index: number) => void
+  onDrop: () => void
+  onRemove: () => void
+  onUpdate: (patch: { volume?: number; durationSec?: number }) => void
+  defaultDurationSec: number
+  defaultVolume: number
+}
+
+export default function PlaylistItemRow({
+  item,
+  wallpaper,
+  index,
+  isCurrent,
+  onDragStart,
+  onDragOver,
+  onDrop,
+  onRemove,
+  onUpdate,
+  defaultDurationSec,
+  defaultVolume
+}: PlaylistItemRowProps) {
+  const [expanded, setExpanded] = useState(false)
+
+  const previewSrc = wallpaper?.previewLocal
+    ? `wallpaper://${wallpaper.previewLocal}`
+    : wallpaper?.previewUrl
+
+  return (
+    <div
+      draggable
+      onDragStart={() => onDragStart(index)}
+      onDragOver={(e) => {
+        e.preventDefault()
+        onDragOver(index)
+      }}
+      onDrop={(e) => {
+        e.preventDefault()
+        onDrop()
+      }}
+      className={clsx(
+        'rounded-lg border transition-colors',
+        isCurrent ? 'border-indigo-500/60 bg-indigo-500/10' : 'border-white/5 bg-[#1a1a1a]'
+      )}
+    >
+      <div className="flex items-center gap-2 p-2">
+        <GripVertical size={14} className="shrink-0 cursor-grab text-gray-600" />
+        <span className="w-5 shrink-0 text-center text-xs text-gray-600">{index + 1}</span>
+        <div className="h-10 w-16 shrink-0 overflow-hidden rounded bg-[#111]">
+          {previewSrc && (
+            <img src={previewSrc} alt="" className="h-full w-full object-cover" loading="lazy" />
+          )}
+        </div>
+        <div className="min-w-0 flex-1">
+          <p className="truncate text-sm text-gray-200" title={wallpaper?.title}>
+            {wallpaper?.title ?? item.wallpaperId}
+          </p>
+          <p className="text-xs text-gray-600">
+            {item.durationSec ?? defaultDurationSec}s · Vol {item.volume ?? defaultVolume}
+          </p>
+        </div>
+        <button
+          onClick={() => setExpanded((v) => !v)}
+          title="Per-wallpaper overrides"
+          className="rounded p-1.5 text-gray-500 hover:bg-white/5 hover:text-gray-300"
+        >
+          {expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
+        </button>
+        <button
+          onClick={onRemove}
+          title="Remove from playlist"
+          className="rounded p-1.5 text-gray-500 hover:bg-red-500/20 hover:text-red-400"
+        >
+          <Trash2 size={14} />
+        </button>
+      </div>
+
+      {expanded && (
+        <div className="flex flex-wrap items-center gap-4 border-t border-white/5 px-3 py-2">
+          <label className="flex items-center gap-2 text-xs text-gray-400">
+            <Volume2 size={12} />
+            Volume
+            <input
+              type="range"
+              min={0}
+              max={100}
+              value={item.volume ?? defaultVolume}
+              onChange={(e) => onUpdate({ volume: Number(e.target.value) })}
+              className="w-24 accent-indigo-500"
+            />
+            <span className="w-8 text-gray-300">{item.volume ?? defaultVolume}</span>
+            {item.volume !== undefined && (
+              <button
+                onClick={() => onUpdate({ volume: undefined })}
+                className="text-gray-600 hover:text-gray-300"
+              >
+                Reset
+              </button>
+            )}
+          </label>
+          <label className="flex items-center gap-2 text-xs text-gray-400">
+            <Clock size={12} />
+            Duration (s)
+            <input
+              type="number"
+              min={1}
+              placeholder={String(defaultDurationSec)}
+              value={item.durationSec ?? ''}
+              onChange={(e) => {
+                const v = e.target.value.trim()
+                onUpdate({ durationSec: v === '' ? undefined : Number(v) })
+              }}
+              className="w-20 rounded bg-white/5 px-2 py-1 text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+            />
+          </label>
+        </div>
+      )}
+    </div>
+  )
+}

+ 465 - 0
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -0,0 +1,465 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import {
+  ListVideo,
+  Plus,
+  Pencil,
+  Trash2,
+  Play,
+  Pause,
+  Square,
+  SkipForward,
+  SkipBack,
+  Shuffle,
+  ArrowDownWideNarrow
+} from 'lucide-react'
+import clsx from 'clsx'
+import type { Playlist, PlaylistPlaybackState, PlaylistSettings } from '@shared/types'
+import PlaylistItemRow from './PlaylistItemRow'
+import WallpaperPickerModal from './WallpaperPickerModal'
+
+const SORT_OPTIONS: { value: PlaylistSettings['sortBy']; label: string }[] = [
+  { value: 'manual', label: 'Manual order' },
+  { value: 'title', label: 'Title' },
+  { value: 'createdAt', label: 'Date added' }
+]
+
+export default function PlaylistsView() {
+  const queryClient = useQueryClient()
+  const [activePlaylistId, setActivePlaylistId] = useState<string | null>(null)
+  const [creating, setCreating] = useState(false)
+  const [newTitle, setNewTitle] = useState('')
+  const [renamingId, setRenamingId] = useState<string | null>(null)
+  const [renameValue, setRenameValue] = useState('')
+  const [pickerOpen, setPickerOpen] = useState(false)
+  const [dragIndex, setDragIndex] = useState<number | null>(null)
+  const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
+  const [error, setError] = useState<string | null>(null)
+
+  const { data: playlists = [] } = useQuery({
+    queryKey: ['playlists'],
+    queryFn: () => window.electronAPI.playlist.getAll()
+  })
+
+  const { data: wallpapers = [] } = useQuery({
+    queryKey: ['library-all'],
+    queryFn: () => window.electronAPI.library.getAll()
+  })
+  const wallpaperById = useMemo(() => new Map(wallpapers.map((w) => [w.id, w])), [wallpapers])
+
+  const { data: playbackState } = useQuery({
+    queryKey: ['playlist-playback-state'],
+    queryFn: () => window.electronAPI.playlist.getState()
+  })
+
+  useEffect(() => {
+    return window.electronAPI.on.playlistStateChanged((state: PlaylistPlaybackState) => {
+      queryClient.setQueryData(['playlist-playback-state'], state)
+    })
+  }, [queryClient])
+
+  const activePlaylist = playlists.find((p) => p.id === activePlaylistId) ?? null
+
+  function invalidatePlaylists() {
+    queryClient.invalidateQueries({ queryKey: ['playlists'] })
+  }
+
+  async function handleCreate() {
+    if (!newTitle.trim()) {
+      setCreating(false)
+      return
+    }
+    const playlist = await window.electronAPI.playlist.create(newTitle.trim())
+    setNewTitle('')
+    setCreating(false)
+    invalidatePlaylists()
+    setActivePlaylistId(playlist.id)
+  }
+
+  async function handleRename(id: string) {
+    if (!renameValue.trim()) {
+      setRenamingId(null)
+      return
+    }
+    await window.electronAPI.playlist.rename(id, renameValue.trim())
+    setRenamingId(null)
+    invalidatePlaylists()
+  }
+
+  async function handleDelete(id: string) {
+    await window.electronAPI.playlist.delete(id)
+    if (activePlaylistId === id) setActivePlaylistId(null)
+    invalidatePlaylists()
+    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+  }
+
+  async function updateSettings(patch: Partial<PlaylistSettings>) {
+    if (!activePlaylist) return
+    await window.electronAPI.playlist.updateSettings(activePlaylist.id, patch)
+    invalidatePlaylists()
+  }
+
+  async function handleAddWallpapers(wallpaperIds: string[]) {
+    if (!activePlaylist) return
+    await window.electronAPI.playlist.addItems(activePlaylist.id, wallpaperIds)
+    setPickerOpen(false)
+    invalidatePlaylists()
+  }
+
+  async function handleRemoveItem(wallpaperId: string) {
+    if (!activePlaylist) return
+    await window.electronAPI.playlist.removeItems(activePlaylist.id, [wallpaperId])
+    invalidatePlaylists()
+  }
+
+  async function handleUpdateItem(
+    wallpaperId: string,
+    patch: { volume?: number; durationSec?: number }
+  ) {
+    if (!activePlaylist) return
+    await window.electronAPI.playlist.updateItem(activePlaylist.id, wallpaperId, patch)
+    invalidatePlaylists()
+  }
+
+  function handleDragStart(index: number) {
+    setDragIndex(index)
+  }
+
+  function handleDragOver(index: number) {
+    setDragOverIndex(index)
+  }
+
+  async function handleDrop() {
+    if (!activePlaylist || dragIndex === null || dragOverIndex === null || dragIndex === dragOverIndex) {
+      setDragIndex(null)
+      setDragOverIndex(null)
+      return
+    }
+    const order = activePlaylist.items.map((i) => i.wallpaperId)
+    const [moved] = order.splice(dragIndex, 1)
+    order.splice(dragOverIndex, 0, moved)
+    setDragIndex(null)
+    setDragOverIndex(null)
+    await window.electronAPI.playlist.reorderItems(activePlaylist.id, order)
+    invalidatePlaylists()
+  }
+
+  async function handlePlay() {
+    if (!activePlaylist) return
+    setError(null)
+    try {
+      await window.electronAPI.playlist.start(activePlaylist.id)
+      queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+    } catch (err) {
+      setError((err as Error).message)
+    }
+  }
+
+  async function handlePauseResume() {
+    if (isThisPlaylistActive && playbackState?.isPlaying) {
+      await window.electronAPI.playlist.pause()
+    } else {
+      await window.electronAPI.playlist.resume()
+    }
+    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+  }
+
+  async function handleStop() {
+    await window.electronAPI.playlist.stop()
+    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+  }
+
+  async function handleNext() {
+    await window.electronAPI.playlist.next()
+    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+  }
+
+  async function handlePrevious() {
+    await window.electronAPI.playlist.previous()
+    queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+  }
+
+  const isThisPlaylistActive = playbackState?.playlistId === activePlaylist?.id
+  const existingIds = useMemo(
+    () => new Set(activePlaylist?.items.map((i) => i.wallpaperId) ?? []),
+    [activePlaylist]
+  )
+
+  return (
+    <div className="flex h-full">
+      <div className="flex w-56 shrink-0 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">
+            Playlists
+          </span>
+          <button
+            onClick={() => {
+              setCreating(true)
+              setNewTitle('')
+            }}
+            title="Create playlist"
+            className="rounded p-0.5 text-gray-600 hover:text-gray-300"
+          >
+            <Plus size={14} />
+          </button>
+        </div>
+
+        {creating && (
+          <div className="flex items-center gap-1 px-3 py-1.5">
+            <ListVideo size={14} className="shrink-0 text-gray-500" />
+            <input
+              autoFocus
+              value={newTitle}
+              onChange={(e) => setNewTitle(e.target.value)}
+              onBlur={handleCreate}
+              onKeyDown={(e) => {
+                if (e.key === 'Enter') handleCreate()
+                if (e.key === 'Escape') setCreating(false)
+              }}
+              placeholder="Playlist name..."
+              className="flex-1 bg-transparent text-sm text-gray-200 outline-none placeholder-gray-600"
+            />
+          </div>
+        )}
+
+        {playlists.length === 0 && !creating && (
+          <p className="px-3 py-4 text-xs text-gray-600">
+            No playlists yet. Create one to play wallpapers in sequence.
+          </p>
+        )}
+
+        {playlists.map((p) => (
+          <button
+            key={p.id}
+            onClick={() => setActivePlaylistId(p.id)}
+            className={clsx(
+              'group flex items-center gap-2 px-3 py-2 text-sm',
+              activePlaylistId === p.id
+                ? 'bg-white/5 text-white'
+                : 'text-gray-400 hover:bg-white/5 hover:text-gray-200'
+            )}
+          >
+            <ListVideo
+              size={14}
+              className={clsx(
+                'shrink-0',
+                playbackState?.playlistId === p.id ? 'text-indigo-400' : 'text-gray-600'
+              )}
+            />
+            {renamingId === p.id ? (
+              <input
+                autoFocus
+                value={renameValue}
+                onChange={(e) => setRenameValue(e.target.value)}
+                onBlur={() => handleRename(p.id)}
+                onKeyDown={(e) => {
+                  if (e.key === 'Enter') handleRename(p.id)
+                  if (e.key === 'Escape') setRenamingId(null)
+                }}
+                onClick={(e) => e.stopPropagation()}
+                className="flex-1 bg-transparent text-sm text-gray-200 outline-none"
+              />
+            ) : (
+              <span className="flex-1 truncate text-left">{p.title}</span>
+            )}
+            <span className="text-xs text-gray-600">{p.items.length}</span>
+            <span
+              onClick={(e) => {
+                e.stopPropagation()
+                setRenamingId(p.id)
+                setRenameValue(p.title)
+              }}
+              className="hidden rounded p-0.5 text-gray-600 hover:text-gray-300 group-hover:block"
+            >
+              <Pencil size={11} />
+            </span>
+            <span
+              onClick={(e) => {
+                e.stopPropagation()
+                handleDelete(p.id)
+              }}
+              className="hidden rounded p-0.5 text-gray-600 hover:text-red-400 group-hover:block"
+            >
+              <Trash2 size={11} />
+            </span>
+          </button>
+        ))}
+      </div>
+
+      <div className="flex flex-1 flex-col overflow-hidden">
+        {!activePlaylist ? (
+          <div className="flex h-full items-center justify-center text-gray-600">
+            <p>Select or create a playlist to get started.</p>
+          </div>
+        ) : (
+          <>
+            <div className="border-b border-white/5 px-4 py-3">
+              <div className="flex items-center justify-between">
+                <h2 className="text-lg font-semibold text-gray-100">{activePlaylist.title}</h2>
+                <div className="flex items-center gap-2">
+                  {isThisPlaylistActive ? (
+                    <>
+                      <button
+                        onClick={handlePrevious}
+                        title="Previous"
+                        className="rounded-lg bg-white/5 p-2 text-gray-300 hover:bg-white/10"
+                      >
+                        <SkipBack size={14} />
+                      </button>
+                      <button
+                        onClick={handlePauseResume}
+                        title={playbackState?.isPlaying ? 'Pause' : 'Resume'}
+                        className="rounded-lg bg-indigo-600 p-2 text-white hover:bg-indigo-500"
+                      >
+                        {playbackState?.isPlaying ? <Pause size={14} /> : <Play size={14} />}
+                      </button>
+                      <button
+                        onClick={handleNext}
+                        title="Next"
+                        className="rounded-lg bg-white/5 p-2 text-gray-300 hover:bg-white/10"
+                      >
+                        <SkipForward size={14} />
+                      </button>
+                      <button
+                        onClick={handleStop}
+                        title="Stop"
+                        className="rounded-lg bg-white/5 p-2 text-gray-300 hover:bg-red-500/20 hover:text-red-400"
+                      >
+                        <Square size={14} />
+                      </button>
+                    </>
+                  ) : (
+                    <button
+                      onClick={handlePlay}
+                      disabled={activePlaylist.items.length === 0}
+                      className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
+                    >
+                      <Play size={14} />
+                      Play playlist
+                    </button>
+                  )}
+                </div>
+              </div>
+              {error && <p className="mt-2 text-xs text-red-400">{error}</p>}
+
+              <div className="mt-3 flex flex-wrap items-center gap-4">
+                <label className="flex items-center gap-2 text-xs text-gray-400">
+                  <button
+                    onClick={() => updateSettings({ randomize: !activePlaylist.settings.randomize })}
+                    className={clsx(
+                      'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
+                      activePlaylist.settings.randomize ? 'bg-indigo-600' : 'bg-white/10'
+                    )}
+                  >
+                    <span
+                      className={clsx(
+                        'inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform',
+                        activePlaylist.settings.randomize ? 'translate-x-4' : 'translate-x-0.5'
+                      )}
+                    />
+                  </button>
+                  <Shuffle size={12} />
+                  Randomize
+                </label>
+
+                {!activePlaylist.settings.randomize && (
+                  <label className="flex items-center gap-2 text-xs text-gray-400">
+                    <ArrowDownWideNarrow size={12} />
+                    Sort by
+                    <select
+                      value={activePlaylist.settings.sortBy}
+                      onChange={(e) =>
+                        updateSettings({ sortBy: e.target.value as PlaylistSettings['sortBy'] })
+                      }
+                      className="rounded bg-white/5 px-2 py-1 text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a]"
+                    >
+                      {SORT_OPTIONS.map((o) => (
+                        <option key={o.value} value={o.value}>
+                          {o.label}
+                        </option>
+                      ))}
+                    </select>
+                  </label>
+                )}
+
+                <label className="flex items-center gap-2 text-xs text-gray-400">
+                  Duration
+                  <input
+                    type="number"
+                    min={1}
+                    value={activePlaylist.settings.defaultDurationSec}
+                    onChange={(e) =>
+                      updateSettings({ defaultDurationSec: Math.max(1, Number(e.target.value)) })
+                    }
+                    className="w-20 rounded bg-white/5 px-2 py-1 text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+                  />
+                  sec
+                </label>
+
+                <label className="flex items-center gap-2 text-xs text-gray-400">
+                  Default volume
+                  <input
+                    type="range"
+                    min={0}
+                    max={100}
+                    value={activePlaylist.settings.defaultVolume}
+                    onChange={(e) => updateSettings({ defaultVolume: Number(e.target.value) })}
+                    className="w-24 accent-indigo-500"
+                  />
+                  <span className="w-8 text-gray-300">{activePlaylist.settings.defaultVolume}</span>
+                </label>
+              </div>
+            </div>
+
+            <div className="flex items-center justify-between px-4 pt-3">
+              <span className="text-xs text-gray-600">
+                {activePlaylist.items.length} wallpaper
+                {activePlaylist.items.length !== 1 ? 's' : ''}
+              </span>
+              <button
+                onClick={() => setPickerOpen(true)}
+                className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-1.5 text-xs text-gray-300 hover:bg-white/10"
+              >
+                <Plus size={12} />
+                Add wallpapers
+              </button>
+            </div>
+
+            <div className="flex-1 overflow-y-auto p-4 space-y-2">
+              {activePlaylist.items.length === 0 && (
+                <div className="flex h-32 items-center justify-center text-sm text-gray-600">
+                  This playlist is empty. Add wallpapers to get started.
+                </div>
+              )}
+              {activePlaylist.items.map((item, index) => (
+                <PlaylistItemRow
+                  key={item.wallpaperId}
+                  item={item}
+                  wallpaper={wallpaperById.get(item.wallpaperId)}
+                  index={index}
+                  isPlaying={Boolean(isThisPlaylistActive && playbackState?.isPlaying)}
+                  isCurrent={isThisPlaylistActive && playbackState?.currentItemId === item.wallpaperId}
+                  onDragStart={handleDragStart}
+                  onDragOver={handleDragOver}
+                  onDrop={handleDrop}
+                  onRemove={() => handleRemoveItem(item.wallpaperId)}
+                  onUpdate={(patch) => handleUpdateItem(item.wallpaperId, patch)}
+                  defaultDurationSec={activePlaylist.settings.defaultDurationSec}
+                  defaultVolume={activePlaylist.settings.defaultVolume}
+                />
+              ))}
+            </div>
+          </>
+        )}
+      </div>
+
+      {pickerOpen && activePlaylist && (
+        <WallpaperPickerModal
+          excludeIds={existingIds}
+          onAdd={handleAddWallpapers}
+          onClose={() => setPickerOpen(false)}
+        />
+      )}
+    </div>
+  )
+}

+ 133 - 0
src/renderer/src/components/playlist/WallpaperPickerModal.tsx

@@ -0,0 +1,133 @@
+import { useMemo, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { Search, X, Plus, Check } from 'lucide-react'
+
+interface WallpaperPickerModalProps {
+  excludeIds: Set<string>
+  onAdd: (wallpaperIds: string[]) => void
+  onClose: () => void
+}
+
+export default function WallpaperPickerModal({
+  excludeIds,
+  onAdd,
+  onClose
+}: WallpaperPickerModalProps) {
+  const [search, setSearch] = useState('')
+  const [selected, setSelected] = useState<Set<string>>(new Set())
+
+  const { data: wallpapers = [], isLoading } = useQuery({
+    queryKey: ['library', { sortBy: 'title', sortDir: 'asc' }],
+    queryFn: () => window.electronAPI.library.getAll({ sortBy: 'title', sortDir: 'asc' })
+  })
+
+  const available = useMemo(
+    () => wallpapers.filter((w) => !excludeIds.has(w.id)),
+    [wallpapers, excludeIds]
+  )
+
+  const filtered = useMemo(() => {
+    if (!search.trim()) return available
+    const q = search.toLowerCase()
+    return available.filter((w) => w.title.toLowerCase().includes(q))
+  }, [available, search])
+
+  function toggle(id: string) {
+    setSelected((prev) => {
+      const next = new Set(prev)
+      if (next.has(id)) next.delete(id)
+      else next.add(id)
+      return next
+    })
+  }
+
+  function handleAdd() {
+    if (selected.size === 0) return
+    onAdd(Array.from(selected))
+  }
+
+  return (
+    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-8">
+      <div className="flex h-full max-h-[600px] w-full max-w-2xl flex-col rounded-xl border border-white/10 bg-[#161616] shadow-2xl">
+        <div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
+          <h3 className="text-sm font-semibold text-gray-100">Add wallpapers to playlist</h3>
+          <button
+            onClick={onClose}
+            className="rounded p-1 text-gray-500 hover:bg-white/5 hover:text-gray-300"
+          >
+            <X size={16} />
+          </button>
+        </div>
+
+        <div className="border-b border-white/5 px-4 py-2">
+          <div className="relative">
+            <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-500" />
+            <input
+              autoFocus
+              type="text"
+              placeholder="Search library..."
+              value={search}
+              onChange={(e) => setSearch(e.target.value)}
+              className="w-full rounded-lg bg-white/5 py-1.5 pl-8 pr-3 text-sm text-gray-200 placeholder-gray-500 outline-none focus:ring-1 focus:ring-indigo-500"
+            />
+          </div>
+        </div>
+
+        <div className="flex-1 overflow-y-auto p-3">
+          {isLoading && <p className="p-4 text-center text-xs text-gray-500">Loading...</p>}
+          {!isLoading && filtered.length === 0 && (
+            <p className="p-4 text-center text-xs text-gray-500">No wallpapers found.</p>
+          )}
+          <div className="grid grid-cols-3 gap-2">
+            {filtered.map((w) => {
+              const isSelected = selected.has(w.id)
+              const previewSrc = w.previewLocal ? `wallpaper://${w.previewLocal}` : w.previewUrl
+              return (
+                <button
+                  key={w.id}
+                  onClick={() => toggle(w.id)}
+                  className={`group relative overflow-hidden rounded-lg bg-[#1f1f1f] text-left transition-all ${
+                    isSelected ? 'ring-2 ring-indigo-500' : 'hover:ring-1 hover:ring-indigo-500/50'
+                  }`}
+                >
+                  <div className="aspect-video bg-[#111]">
+                    {previewSrc ? (
+                      <img
+                        src={previewSrc}
+                        alt={w.title}
+                        className="h-full w-full object-cover"
+                        loading="lazy"
+                      />
+                    ) : (
+                      <div className="flex h-full items-center justify-center text-xs text-gray-600">
+                        No preview
+                      </div>
+                    )}
+                  </div>
+                  <div className="flex items-center justify-between gap-1 p-2">
+                    <span className="truncate text-xs text-gray-300" title={w.title}>
+                      {w.title}
+                    </span>
+                    {isSelected && <Check size={12} className="shrink-0 text-indigo-400" />}
+                  </div>
+                </button>
+              )
+            })}
+          </div>
+        </div>
+
+        <div className="flex items-center justify-between border-t border-white/5 px-4 py-3">
+          <span className="text-xs text-gray-500">{selected.size} selected</span>
+          <button
+            onClick={handleAdd}
+            disabled={selected.size === 0}
+            className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
+          >
+            <Plus size={14} />
+            Add to playlist
+          </button>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 214 - 1
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 } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro } from '../../../../shared/types'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -34,6 +34,19 @@ export default function SettingsView() {
   // 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 [autostartSupported, setAutostartSupported] = useState(false)
+  const [autostartEnabled, setAutostartEnabledState] = useState(false)
+  const [autostartMinimized, setAutostartMinimized] = useState(false)
+  const [autostartPlaylistId, setAutostartPlaylistId] = useState<string | null>(null)
+  const [playlists, setPlaylists] = useState<{ id: string; title: string }[]>([])
+
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
       setWorkshopPath(cfg.workshopPath ?? cfg.defaultWorkshopPath)
@@ -41,10 +54,18 @@ export default function SettingsView() {
       setLweRepoUrl(cfg.lweRepoUrl ?? '')
       setLweRepoBranch(cfg.lweRepoBranch ?? '')
       setDefaultLweRepo(cfg.defaultLweRepoUrl)
+      setBackupPath(cfg.backupPath ?? '')
+      setAutoUnsubscribeAfterBackup(cfg.autoUnsubscribeAfterBackup)
+      setTrayEnabled(cfg.trayEnabled)
+      setAutostartSupported(cfg.autostartSupported)
+      setAutostartEnabledState(cfg.autostartEnabled)
+      setAutostartMinimized(cfg.autostartMinimized)
+      setAutostartPlaylistId(cfg.autostartPlaylistId)
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
     window.electronAPI.desktopIcons.getEnabled().then(setDesktopIconsEnabled)
+    window.electronAPI.playlist.getAll().then((all) => setPlaylists(all.map((p) => ({ id: p.id, title: p.title }))))
   }, [])
 
   // Listen for install progress events
@@ -163,6 +184,46 @@ export default function SettingsView() {
     await window.electronAPI.desktopIcons.setEnabled(newVal)
   }
 
+  async function handleBrowseBackup() {
+    const picked = await window.electronAPI.config.pickFolder()
+    if (picked) {
+      setBackupPath(picked)
+      setBackupSaved(false)
+    }
+  }
+
+  async function handleSaveBackup() {
+    await window.electronAPI.config.setBackupPath(backupPath)
+    setBackupSaved(true)
+    setTimeout(() => setBackupSaved(false), 2000)
+  }
+
+  async function handleAutoUnsubscribeToggle() {
+    const newVal = !autoUnsubscribeAfterBackup
+    setAutoUnsubscribeAfterBackup(newVal)
+    await window.electronAPI.config.setAutoUnsubscribeAfterBackup(newVal)
+  }
+
+  async function handleTrayToggle() {
+    const newVal = !trayEnabled
+    setTrayEnabled(newVal)
+    await window.electronAPI.config.setTrayEnabled(newVal)
+  }
+
+  async function saveAutostart(patch: {
+    enabled?: boolean
+    minimized?: boolean
+    playlistId?: string | null
+  }) {
+    const enabled = patch.enabled ?? autostartEnabled
+    const minimized = patch.minimized ?? autostartMinimized
+    const playlistId = 'playlistId' in patch ? patch.playlistId! : autostartPlaylistId
+    setAutostartEnabledState(enabled)
+    setAutostartMinimized(minimized)
+    setAutostartPlaylistId(playlistId)
+    await window.electronAPI.config.setAutostart(enabled, minimized, playlistId)
+  }
+
   const isBusy = lweInstalling || depsInstalling || uninstalling
 
   return (
@@ -206,6 +267,64 @@ export default function SettingsView() {
           </button>
         </div>
 
+        {/* Backup folder */}
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            Backup folder
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Local folder for keeping your own copies of wallpapers, independent of the
+            Steam Workshop. Point this at an existing folder to load wallpapers already
+            backed up there.
+          </p>
+          <div className="mt-2 flex gap-2">
+            <input
+              type="text"
+              value={backupPath}
+              onChange={(e) => {
+                setBackupPath(e.target.value)
+                setBackupSaved(false)
+              }}
+              placeholder="Not configured"
+              className="flex-1 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+            />
+            <button
+              onClick={handleBrowseBackup}
+              className="flex items-center gap-2 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-300 hover:bg-white/10"
+            >
+              <FolderOpen size={16} />
+              Browse
+            </button>
+          </div>
+          <button
+            onClick={handleSaveBackup}
+            disabled={!backupPath}
+            className="mt-3 flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
+          >
+            <Save size={14} />
+            {backupSaved ? 'Saved!' : 'Save'}
+          </button>
+
+          <label className="mt-4 flex items-center gap-3 cursor-pointer">
+            <button
+              onClick={handleAutoUnsubscribeToggle}
+              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                autoUnsubscribeAfterBackup ? 'bg-indigo-600' : 'bg-white/10'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  autoUnsubscribeAfterBackup ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+            <span className="flex items-center gap-2 text-sm text-gray-300">
+              <Archive size={16} />
+              Unsubscribe from Steam Workshop after backup
+            </span>
+          </label>
+        </div>
+
         {/* LWE launch options */}
         <div>
           <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
@@ -497,6 +616,100 @@ export default function SettingsView() {
             </span>
           </label>
         </div>
+
+        {/* System tray & startup */}
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            System Tray &amp; Startup
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Keep WE Manager running in the background and optionally launch it automatically.
+          </p>
+
+          <label className="mt-3 flex items-center gap-3 cursor-pointer">
+            <button
+              onClick={handleTrayToggle}
+              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                trayEnabled ? 'bg-indigo-600' : 'bg-white/10'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  trayEnabled ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+            <span className="flex items-center gap-2 text-sm text-gray-300">
+              <LayoutGrid size={16} />
+              Enable system tray (closing the window minimizes to tray instead of quitting)
+            </span>
+          </label>
+
+          <div className="mt-4 space-y-3">
+            <label className="flex items-center gap-3 cursor-pointer">
+              <button
+                onClick={() => autostartSupported && saveAutostart({ enabled: !autostartEnabled })}
+                disabled={!autostartSupported}
+                className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40 ${
+                  autostartEnabled ? 'bg-indigo-600' : 'bg-white/10'
+                }`}
+              >
+                <span
+                  className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                    autostartEnabled ? 'translate-x-4' : 'translate-x-0.5'
+                  }`}
+                />
+              </button>
+              <span className="flex items-center gap-2 text-sm text-gray-300">
+                <Power size={16} />
+                Launch on system startup
+              </span>
+            </label>
+            {!autostartSupported && (
+              <p className="text-xs text-yellow-500/80">
+                Only available in a packaged build (AppImage/deb), not in development mode.
+              </p>
+            )}
+
+            <label className="flex items-center gap-3 cursor-pointer pl-12">
+              <button
+                onClick={() =>
+                  trayEnabled && autostartEnabled && saveAutostart({ minimized: !autostartMinimized })
+                }
+                disabled={!trayEnabled || !autostartEnabled}
+                className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40 ${
+                  autostartMinimized ? 'bg-indigo-600' : 'bg-white/10'
+                }`}
+              >
+                <span
+                  className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                    autostartMinimized ? 'translate-x-4' : 'translate-x-0.5'
+                  }`}
+                />
+              </button>
+              <span className="text-sm text-gray-300">
+                Start minimized to tray
+                {!trayEnabled && <span className="text-gray-600"> (enable system tray first)</span>}
+              </span>
+            </label>
+
+            <div className="pl-12">
+              <label className="block text-xs text-gray-400 mb-1">Auto-play playlist on startup</label>
+              <select
+                value={autostartPlaylistId ?? ''}
+                onChange={(e) => saveAutostart({ playlistId: e.target.value || null })}
+                className="w-56 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
+              >
+                <option value="">None</option>
+                {playlists.map((p) => (
+                  <option key={p.id} value={p.id}>
+                    {p.title}
+                  </option>
+                ))}
+              </select>
+            </div>
+          </div>
+        </div>
       </div>
     </div>
   )

+ 10 - 1
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -2,6 +2,7 @@ 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 WorkshopCard from './WorkshopCard'
+import PreviewSizeToggle from '../common/PreviewSizeToggle'
 import type { WorkshopQueryType } from '@shared/types'
 import clsx from 'clsx'
 import {
@@ -12,6 +13,7 @@ import {
   WE_RESOLUTION_GROUPS,
   WE_GENRES
 } from '../../constants/weFilters'
+import { usePreviewSize, PREVIEW_SIZE_MIN_PX } from '../../hooks/usePreviewSize'
 
 const STORAGE_KEY = 'we-workshop-filters'
 const STORAGE_VERSION = 2
@@ -142,6 +144,7 @@ export default function WorkshopBrowser() {
   const ctxRef = useRef<HTMLDivElement>(null)
   const [page, setPage] = useState(1)
   const [pageSize, setPageSize] = useState(50)
+  const [previewSize, setPreviewSize] = usePreviewSize()
 
   // Close context menu on outside click (mousedown so right-click on another card works)
   useEffect(() => {
@@ -362,6 +365,7 @@ export default function WorkshopBrowser() {
           <option value="RankedByTotalUniqueSubscriptions">Most Subscribed</option>
           <option value="RankedByLastUpdatedDate">Recently Updated</option>
         </select>
+        <PreviewSizeToggle value={previewSize} onChange={setPreviewSize} />
         {activeCount > 0 && (
           <button
             onClick={clearAll}
@@ -560,7 +564,12 @@ export default function WorkshopBrowser() {
               No results found
             </div>
           )}
-          <div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4">
+          <div
+            className="grid gap-4"
+            style={{
+              gridTemplateColumns: `repeat(auto-fill, minmax(${PREVIEW_SIZE_MIN_PX[previewSize]}px, 1fr))`
+            }}
+          >
             {paginatedItems.map((item) => (
               <WorkshopCard
                 key={item.publishedFileId}

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

@@ -0,0 +1,38 @@
+import { useEffect, useState } from 'react'
+
+export type PreviewSize = 'small' | 'normal' | 'big'
+
+export const PREVIEW_SIZE_MIN_PX: Record<PreviewSize, number> = {
+  small: 130,
+  normal: 200,
+  big: 280
+}
+
+const STORAGE_KEY = 'we-preview-size'
+
+function isPreviewSize(value: unknown): value is PreviewSize {
+  return value === 'small' || value === 'normal' || value === 'big'
+}
+
+function loadPreviewSize(): PreviewSize {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY)
+    return isPreviewSize(raw) ? raw : 'normal'
+  } catch {
+    return 'normal'
+  }
+}
+
+export function usePreviewSize(): [PreviewSize, (size: PreviewSize) => void] {
+  const [size, setSize] = useState<PreviewSize>(loadPreviewSize)
+
+  useEffect(() => {
+    try {
+      localStorage.setItem(STORAGE_KEY, size)
+    } catch {
+      // ignore write failures (e.g. storage disabled)
+    }
+  }, [size])
+
+  return [size, setSize]
+}

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

@@ -48,11 +48,42 @@ export const IpcChannels = {
   CONFIG_SET_WORKSHOP_PATH: 'config:set-workshop-path',
   CONFIG_SET_DEFAULT_FPS: 'config:set-default-fps',
   CONFIG_SET_LWE_REPO: 'config:set-lwe-repo',
+  CONFIG_SET_BACKUP_PATH: 'config:set-backup-path',
+  CONFIG_SET_AUTO_UNSUBSCRIBE: 'config:set-auto-unsubscribe',
   CONFIG_PICK_FOLDER: 'config:pick-folder',
   CONFIG_PICK_FILE: 'config:pick-file',
   CONFIG_IMPORT_WE: 'config:import-we',
   CONFIG_CREATE_FRESH: 'config:create-fresh',
 
+  // Backup
+  BACKUP_ITEM: 'backup:item',
+  BACKUP_SELECTION: 'backup:selection',
+  BACKUP_SCAN: 'backup:scan',
+
+  // Playlists
+  PLAYLIST_GET_ALL: 'playlist:get-all',
+  PLAYLIST_GET_ONE: 'playlist:get-one',
+  PLAYLIST_CREATE: 'playlist:create',
+  PLAYLIST_RENAME: 'playlist:rename',
+  PLAYLIST_DELETE: 'playlist:delete',
+  PLAYLIST_UPDATE_SETTINGS: 'playlist:update-settings',
+  PLAYLIST_ADD_ITEMS: 'playlist:add-items',
+  PLAYLIST_REMOVE_ITEMS: 'playlist:remove-items',
+  PLAYLIST_REORDER_ITEMS: 'playlist:reorder-items',
+  PLAYLIST_UPDATE_ITEM: 'playlist:update-item',
+  PLAYLIST_START: 'playlist:start',
+  PLAYLIST_STOP: 'playlist:stop',
+  PLAYLIST_PAUSE: 'playlist:pause',
+  PLAYLIST_RESUME: 'playlist:resume',
+  PLAYLIST_NEXT: 'playlist:next',
+  PLAYLIST_PREVIOUS: 'playlist:previous',
+  PLAYLIST_GET_STATE: 'playlist:get-state',
+
+  // Tray & autostart
+  CONFIG_SET_TRAY_ENABLED: 'config:set-tray-enabled',
+  CONFIG_SET_AUTOSTART: 'config:set-autostart',
+  CONFIG_GET_AUTOSTART_SUPPORTED: 'config:get-autostart-supported',
+
   // Shell operations
   SHELL_OPEN_PATH: 'shell:open-path',
   SHELL_OPEN_IN_FILE_MANAGER: 'shell:open-in-file-manager',
@@ -76,7 +107,9 @@ export const IpcChannels = {
   EVENT_DOWNLOAD_PROGRESS: 'event:download-progress',
   EVENT_WALLPAPER_IMPORTED: 'event:wallpaper-imported',
   EVENT_STEAM_STATUS: 'event:steam-status',
-  EVENT_LWE_INSTALL_PROGRESS: 'event:lwe-install-progress'
+  EVENT_LWE_INSTALL_PROGRESS: 'event:lwe-install-progress',
+  EVENT_BACKUP_PROGRESS: 'event:backup-progress',
+  EVENT_PLAYLIST_STATE_CHANGED: 'event:playlist-state-changed'
 } as const
 
 export type IpcChannel = (typeof IpcChannels)[keyof typeof IpcChannels]

+ 47 - 1
src/shared/types.ts

@@ -39,11 +39,12 @@ export interface WallpaperMeta {
   lastAppliedAt?: number
   authorSteamId?: string
   authorName?: string
-  source: 'workshop' | 'local'
+  source: 'workshop' | 'local' | 'backup'
   tags: string[]
   categories: string[]
   downloading?: boolean
   fpsOverride?: number
+  backedUp?: boolean
 }
 
 export interface WorkshopItem {
@@ -127,3 +128,48 @@ export interface LweInstallProgress {
   message: string
   percentage: number
 }
+
+export interface BackupProgressEvent {
+  itemId: string
+  bytesCopied: number
+  bytesTotal: number
+  percentage: number
+  status: 'copying' | 'completed' | 'error'
+  message?: string
+}
+
+export interface PlaylistItem {
+  wallpaperId: string
+  /** 0-100, overrides the playlist's defaultVolume when set */
+  volume?: number
+  /** Overrides the playlist's defaultDurationSec when set */
+  durationSec?: number
+}
+
+export interface PlaylistSettings {
+  /** Shuffle playback order; reshuffled every time the playlist loops back to the start */
+  randomize: boolean
+  /** Base ordering when randomize is off; 'manual' respects PlaylistItem[] order */
+  sortBy: 'manual' | 'title' | 'createdAt'
+  /** How long each wallpaper plays before advancing, in seconds */
+  defaultDurationSec: number
+  /** 0-100, used for items without a per-item volume override */
+  defaultVolume: number
+}
+
+export interface Playlist {
+  id: string
+  title: string
+  items: PlaylistItem[]
+  settings: PlaylistSettings
+  createdAt: number
+  updatedAt: number
+}
+
+export interface PlaylistPlaybackState {
+  playlistId: string | null
+  currentItemId: string | null
+  currentIndex: number
+  isPlaying: boolean
+  order: string[]
+}