Pārlūkot izejas kodu

added new like checker in rust with steam api, added screen res filter to library, added plenty of new features

UwU 5 dienas atpakaļ
vecāks
revīzija
991ada3ea6

+ 1 - 1
package.json

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

+ 9 - 1
src/main/index.ts

@@ -3,7 +3,7 @@ import path from 'path'
 import { pathToFileURL } from 'url'
 import { IpcChannels } from '@shared/ipc-channels'
 import { initSteam, isSteamRunning, startVotedItemsSync } from './services/steam.service'
-import { initLibrary, checkUnavailableWallpapers } from './services/library.service'
+import { initLibrary, checkUnavailableWallpapers, backfillResolutions } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
 import { initDesktopIcons, cleanupDesktopIcons } from './services/desktop-icons.service'
@@ -26,6 +26,13 @@ function startBackgroundSync(win: BrowserWindow): void {
     if (!win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_VOTED_IDS_CHANGED, ids)
   })
 
+  const syncResolutions = async (): Promise<void> => {
+    if (await backfillResolutions() && !win.isDestroyed()) {
+      win.webContents.send(IpcChannels.EVENT_LIBRARY_CHANGED)
+    }
+  }
+  win.webContents.once('did-finish-load', () => void syncResolutions())
+
   setInterval(async () => {
     if (win.isDestroyed()) return
     const running = isSteamRunning()
@@ -35,6 +42,7 @@ function startBackgroundSync(win: BrowserWindow): void {
     try {
       const { changed } = await checkUnavailableWallpapers()
       if (changed && !win.isDestroyed()) win.webContents.send(IpcChannels.EVENT_LIBRARY_CHANGED)
+      await syncResolutions()
     } catch {
       // transient Steam hiccup - try again next tick
     }

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

@@ -68,6 +68,7 @@ export function registerLweHandlers(win: BrowserWindow): void {
         offsetX?: number
         offsetY?: number
         disableParallax?: boolean
+        expandCanvas?: boolean
         cornerColor?: string
         speed?: number
         propertyOverrides?: Record<string, string>

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

@@ -165,6 +165,7 @@ function buildBackupMeta(
     appliedCount: existing?.appliedCount ?? 0,
     source: 'backup',
     tags: pj?.tags ?? existing?.tags ?? [],
+    resolutions: existing?.resolutions,
     categories: existing?.categories ?? [],
     backedUp: true
   }

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

@@ -39,7 +39,7 @@ const store = new Store<AppConfig>({
     workshopPath: null,
     defaultFps: null,
     recommendedFpsEnabled: false,
-    recommendedWebFpsEnabled: false,
+    recommendedWebFpsEnabled: true,
     lweRepoUrl: null,
     lweRepoBranch: null,
     lweCmakeArgs: null,
@@ -172,8 +172,8 @@ export function setRecommendedFpsEnabled(enabled: boolean): void {
 }
 
 /** When enabled, web wallpapers without a manual FPS override launch at RECOMMENDED_WEB_FPS
- *  instead of the default limit - CEF paints internally at that rate regardless, so a lower
- *  default just throttles how often the engine displays what CEF already rendered. */
+ *  instead of the default limit - the page only produces a frame when the engine presents one,
+ *  so a lower default makes the animation itself choppy. */
 export function getRecommendedWebFpsEnabled(): boolean {
   return store.get('recommendedWebFpsEnabled')
 }

+ 57 - 3
src/main/services/library.service.ts

@@ -12,6 +12,7 @@ import {
   getUnavailableWorkshopItems
 } from './workshop.service'
 import { findAndMutate } from '../utils/collections'
+import { RESOLUTION_TAGS } from '@shared/resolutions'
 import {
   isSteamRunning,
   getItemDownloadStatus,
@@ -224,6 +225,15 @@ function deriveRatingFromTags(tags: string[]): ContentRating | undefined {
   return match ? normalizeRating(match) : undefined
 }
 
+function sameTags(a: string[] | undefined, b: string[] | undefined): boolean {
+  if (!a || !b) return a === b
+  return a.length === b.length && a.every((t) => b.includes(t))
+}
+
+function deriveResolutionsFromTags(tags: string[]): string[] {
+  return tags.filter((t) => RESOLUTION_TAGS.has(t))
+}
+
 const RATING_RESTRICTIVENESS: Record<ContentRating, number> = {
   uncategorized: 0,
   everyone: 1,
@@ -317,6 +327,7 @@ function buildMeta(
     subscribed: true,
     source: 'workshop',
     tags: pj?.tags ?? existing?.tags ?? [],
+    resolutions: liveWorkshopTags ? deriveResolutionsFromTags(liveWorkshopTags) : existing?.resolutions,
     authorSteamId: authorSteamId ?? existing?.authorSteamId
   }
 }
@@ -348,6 +359,7 @@ function buildIncompleteMeta(
     appliedCount: existing?.appliedCount ?? 0,
     source: 'workshop',
     tags,
+    resolutions: workshopTags ? deriveResolutionsFromTags(workshopTags) : existing?.resolutions,
     categories: existing?.categories ?? [],
     downloading,
     downloadFailed: failed,
@@ -371,7 +383,16 @@ export async function importWallpaperById(workshopId: string): Promise<Wallpaper
   }
 
   const timesUpdated = await safeGetWorkshopTimesUpdated([workshopId])
-  const meta = buildMeta(workshopId, localPath, pj, existing, timesUpdated.get(workshopId), authorSteamId)
+  const liveTags = await safeGetWorkshopTags([workshopId])
+  const meta = buildMeta(
+    workshopId,
+    localPath,
+    pj,
+    existing,
+    timesUpdated.get(workshopId),
+    authorSteamId,
+    liveTags.get(workshopId)
+  )
   upsertWallpaper(meta)
   return meta
 }
@@ -389,6 +410,7 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   let imported = 0
   let skipped = 0
   let removed = 0
+  let resynced = false
 
   // Remove workshop wallpapers whose directories no longer exist on disk, unless Steam
   // still has them subscribed (those get tracked below instead, so a failed
@@ -458,7 +480,8 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
       existing.downloadFailed !== meta.downloadFailed ||
       existing.type !== meta.type ||
       existing.contentRating !== meta.contentRating ||
-      existing.authorSteamId !== meta.authorSteamId
+      existing.authorSteamId !== meta.authorSteamId ||
+      !sameTags(existing.resolutions, meta.resolutions)
     ) {
       wallpapers[id] = {
         ...existing,
@@ -467,8 +490,10 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
         type: meta.type,
         contentRating: meta.contentRating,
         tags: meta.tags,
+        resolutions: meta.resolutions,
         authorSteamId: meta.authorSteamId
       }
+      resynced = true
       skipped++
     } else {
       skipped++
@@ -497,7 +522,6 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     imported++
   }
 
-  let resynced = false
   for (const id of cacheHits) {
     const seconds = timesUpdated.get(id)
     const authorId = authors.get(id)
@@ -540,6 +564,12 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
         patch.contentRating = stricter
       }
     }
+    if (live) {
+      const resolutions = deriveResolutionsFromTags(live)
+      if (!sameTags(wallpapers[id].resolutions, resolutions)) {
+        patch.resolutions = resolutions
+      }
+    }
     if (Object.keys(patch).length > 0) {
       wallpapers[id] = { ...wallpapers[id], ...patch }
       resynced = true
@@ -598,6 +628,30 @@ export async function checkUnavailableWallpapers(): Promise<{
   return { checked: candidates.length, unavailable: unavailableIds.size, changed }
 }
 
+// Resolution only comes from the live Workshop tags (project.json never has it), so items
+// imported before that was stored, or while Steam was offline, need a separate pass.
+export async function backfillResolutions(): Promise<boolean> {
+  if (!isSteamRunning()) return false
+
+  const wallpapers = store.get('wallpapers')
+  const missing = Object.values(wallpapers).filter(
+    (w) => w.source === 'workshop' && w.resolutions === undefined
+  )
+  if (missing.length === 0) return false
+
+  const tags = await safeGetWorkshopTags(missing.map((w) => w.id))
+  let changed = false
+  for (const w of missing) {
+    const live = tags.get(w.id)
+    if (!live) continue
+    wallpapers[w.id] = { ...w, resolutions: deriveResolutionsFromTags(live) }
+    changed = true
+  }
+  console.log(`[Library] Resolution tags fetched for ${tags.size} of ${missing.length} items`)
+  if (changed) store.set('wallpapers', wallpapers)
+  return changed
+}
+
 export function getAllFolders(): WallpaperFolder[] {
   return store.get('folders')
 }

+ 40 - 1
src/main/services/lwe.service.ts

@@ -33,7 +33,7 @@ import {
   getDisablePuppetAnimation,
   getDisableAnimations
 } from './config.service'
-import { DEFAULT_LWE_REPO } from '@shared/constants'
+import { DEFAULT_LWE_FPS, DEFAULT_LWE_REPO } from '@shared/constants'
 
 const execFileAsync = promisify(execFile)
 
@@ -422,6 +422,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     invalidateSoundVolumeSupport()
     invalidateEffectFlagsSupport()
     invalidateDisableAnimationsSupport()
+    invalidateExpandCanvasSupport()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -539,6 +540,7 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
     invalidateSoundVolumeSupport()
     invalidateEffectFlagsSupport()
     invalidateDisableAnimationsSupport()
+    invalidateExpandCanvasSupport()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {
@@ -758,6 +760,29 @@ function supportsDisableAnimations(): boolean {
   return disableAnimationsSupported
 }
 
+let expandCanvasSupported: boolean | null = null
+
+function invalidateExpandCanvasSupport(): void {
+  expandCanvasSupported = null
+}
+
+function supportsExpandCanvas(): boolean {
+  if (expandCanvasSupported !== null) return expandCanvasSupported
+  try {
+    const binaryPath = getLweBinaryPath()
+    const envVars = buildLweEnvVars()
+    const stdout = execFileSync('env', [...envVars, binaryPath, '--help'], {
+      timeout: 5_000,
+      encoding: 'utf8',
+      maxBuffer: 2 * 1024 * 1024
+    })
+    expandCanvasSupported = stdout.includes('--expand-canvas')
+  } catch {
+    expandCanvasSupported = false
+  }
+  return expandCanvasSupported
+}
+
 export function parseCustomArgs(raw: string): string[] {
   const tokens: string[] = []
   const re = /"([^"]*)"|'([^']*)'|(\S+)/g
@@ -785,6 +810,7 @@ function buildLweArgs(
     offsetX?: number
     offsetY?: number
     disableParallax?: boolean
+    expandCanvas?: boolean
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
@@ -819,6 +845,7 @@ function buildLweArgs(
     args.push('--offset', `${options.offsetX ?? 0},${options.offsetY ?? 0}`)
   }
   if (options.disableParallax) args.push('--disable-parallax')
+  if (options.expandCanvas && supportsExpandCanvas()) args.push('--expand-canvas')
   if (options.cornerColor) args.push('--corner-color', options.cornerColor)
   if (options.speed !== undefined) args.push('--speed', String(options.speed))
   if ((options.disabledObjects?.length || options.enabledObjects?.length) && supportsObjectFlags()) {
@@ -942,12 +969,14 @@ export function hotswapLweSettings(options: {
   disabledObjects?: string[]
   enabledObjects?: string[]
   volume?: number
+  fps?: number
   xray?: boolean
   scaling?: string
   zoom?: number
   offsetX?: number
   offsetY?: number
   disableParallax?: boolean
+  expandCanvas?: boolean
   cornerColor?: string
   speed?: number
   propertyOverrides?: Record<string, string>
@@ -970,6 +999,7 @@ export function hotswapLweSettings(options: {
     for (const id of options.enabledObjects ?? []) lines.push(`enable-object=${id}`)
   }
   if (options.volume !== undefined) lines.push(`volume=${options.volume}`)
+  if (options.fps !== undefined) lines.push(`fps=${options.fps}`)
   if (options.xray !== undefined) lines.push(`xray=${options.xray ? 'on' : 'off'}`)
   if (options.scaling !== undefined) lines.push(`scaling=${options.scaling}`)
   if (options.zoom !== undefined) lines.push(`zoom=${options.zoom}`)
@@ -977,6 +1007,7 @@ export function hotswapLweSettings(options: {
     lines.push(`offset=${options.offsetX ?? 0},${options.offsetY ?? 0}`)
   }
   if (options.disableParallax !== undefined) lines.push(`disable-parallax=${options.disableParallax ? 'on' : 'off'}`)
+  if (options.expandCanvas !== undefined) lines.push(`expand-canvas=${options.expandCanvas ? 'on' : 'off'}`)
   if (options.cornerColor !== undefined) lines.push(`corner-color=${options.cornerColor}`)
   if (options.speed !== undefined) lines.push(`speed=${options.speed}`)
   if (options.audioScreen !== undefined) lines.push(`audio-screen=${options.audioScreen}`)
@@ -1021,6 +1052,7 @@ export async function launchLweAsync(
     offsetX?: number
     offsetY?: number
     disableParallax?: boolean
+    expandCanvas?: boolean
     cornerColor?: string
     speed?: number
     audioSensitivity?: Record<string, number>
@@ -1042,12 +1074,19 @@ export async function launchLweAsync(
     !options.enabledEffects?.length &&
     hotswapLweSettings({
       path: wallpaperPath,
+      // the engine drops the previous wallpaper's overrides on a path swap, the new one's go with it
+      disabledObjects: options.disabledObjects ?? [],
+      enabledObjects: options.enabledObjects ?? [],
+      // the running engine keeps the previous wallpaper's limit, so a wallpaper without one has to
+      // put it back to what a fresh launch without --fps gets
+      fps: options.fps ?? DEFAULT_LWE_FPS,
       xray: options.xrayFullReveal,
       scaling: options.scalingMode ?? 'default',
       zoom: options.zoom ?? 1,
       offsetX: options.offsetX ?? 0,
       offsetY: options.offsetY ?? 0,
       disableParallax: options.disableParallax ?? false,
+      expandCanvas: options.expandCanvas ?? false,
       cornerColor: options.cornerColor ?? '000000',
       speed: options.speed ?? 1,
       propertyOverrides: options.propertyOverrides ?? {},

+ 33 - 3
src/main/services/playlist-player.service.ts

@@ -8,6 +8,7 @@ import { applyWallpaperMeta } from './wallpaper.service'
 import { setActiveWallpaperId } from './wallpaper-state.service'
 import { getLweStatus } from './lwe.service'
 import { getAutostartPlaylistId } from './config.service'
+import { findWallpaperVideoFile, getVideoDurationSec } from '../utils/video'
 
 interface PlaybackStore {
   activePlaylistId: string | null
@@ -22,6 +23,7 @@ const store = new Store<PlaybackStore>({
 let win: BrowserWindow | null = null
 let timer: NodeJS.Timeout | null = null
 let applying = false
+let scheduleToken = 0
 const listeners = new Set<() => void>()
 
 /** Subscribe to playback state changes (used by the tray to keep its menu labels accurate). */
@@ -75,6 +77,7 @@ function buildOrder(playlist: Playlist): string[] {
 }
 
 function clearTimer(): void {
+  scheduleToken++
   if (timer) {
     clearTimeout(timer)
     timer = null
@@ -118,6 +121,16 @@ async function applyCurrent(playlist: Playlist): Promise<void> {
   }
 }
 
+// Small margin so the last frames aren't cut off by engine startup/hotswap time
+const VIDEO_END_GRACE_SEC = 2
+
+async function getVideoLengthSec(wallpaperId: string): Promise<number | undefined> {
+  const wallpaper = getWallpaper(wallpaperId)
+  if (wallpaper?.type !== 'video' || !wallpaper.localPath) return undefined
+  const videoFile = findWallpaperVideoFile(wallpaper.localPath, wallpaper.file)
+  return videoFile ? getVideoDurationSec(videoFile) : undefined
+}
+
 function scheduleNext(playlist: Playlist): void {
   clearTimer()
   if (!state.isPlaying) return
@@ -125,10 +138,27 @@ function scheduleNext(playlist: Playlist): void {
   const wallpaperId = state.order[state.currentIndex]
   const item = wallpaperId ? findItem(playlist, wallpaperId) : undefined
   const durationSec = item?.durationSec ?? playlist.settings.defaultDurationSec
+  const token = scheduleToken
+
+  const arm = (sec: number): void => {
+    timer = setTimeout(() => {
+      void advance()
+    }, sec * 1000)
+  }
 
-  timer = setTimeout(() => {
-    void advance()
-  }, durationSec * 1000)
+  if (!playlist.settings.finishVideos || !wallpaperId) {
+    arm(durationSec)
+    return
+  }
+
+  void getVideoLengthSec(wallpaperId).then((videoSec) => {
+    if (token !== scheduleToken) return
+    if (videoSec !== undefined && videoSec > durationSec) {
+      arm(videoSec + VIDEO_END_GRACE_SEC)
+    } else {
+      arm(durationSec)
+    }
+  })
 }
 
 async function advance(): Promise<void> {

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

@@ -11,6 +11,7 @@ const DEFAULT_SETTINGS: PlaylistSettings = {
   randomize: false,
   sortBy: 'manual',
   defaultDurationSec: 600,
+  finishVideos: false,
   defaultVolume: 100
 }
 

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

@@ -245,6 +245,8 @@ export async function applyWallpaperMeta(
       offsetY: wallpaper.offsetY,
       // Explicit even when off, same reasoning as xrayFullReveal above.
       disableParallax: wallpaper.disableParallax ?? false,
+      // Explicit even when off, same reasoning as xrayFullReveal above.
+      expandCanvas: wallpaper.expandCanvas ?? false,
       cornerColor: wallpaper.cornerColor,
       speed: wallpaper.playbackSpeed,
       audioSensitivity: wallpaper.audioSensitivity,

+ 25 - 16
src/main/services/workshop.service.ts

@@ -126,16 +126,31 @@ export async function queryWorkshopByCreator(
   }
 }
 
+// A details query only fetches its first result page, and Steam pages those at 50 items -
+// anything past that in a single getItems() call silently comes back missing.
+const DETAILS_QUERY_BATCH = 50
+
+async function getItemsBatched(publishedFileIds: string[]) {
+  const client = getClient()
+  const items = []
+  for (let i = 0; i < publishedFileIds.length; i += DETAILS_QUERY_BATCH) {
+    const batch = publishedFileIds.slice(i, i + DETAILS_QUERY_BATCH)
+    const result = await client.workshop.getItems(batch.map((id) => BigInt(id)))
+    for (const item of result.items) {
+      if (item) items.push(item)
+    }
+  }
+  return items
+}
+
 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)
+  for (const item of await getItemsBatched(publishedFileIds)) {
+    map.set(item.publishedFileId.toString(), item.timeUpdated)
   }
   return map
 }
@@ -147,10 +162,8 @@ export async function getWorkshopTags(publishedFileIds: string[]): Promise<Map<s
   const map = new Map<string, string[]>()
   if (publishedFileIds.length === 0) return map
 
-  const client = getClient()
-  const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
-  for (const item of result.items) {
-    if (item) map.set(item.publishedFileId.toString(), item.tags ?? [])
+  for (const item of await getItemsBatched(publishedFileIds)) {
+    map.set(item.publishedFileId.toString(), item.tags ?? [])
   }
   return map
 }
@@ -161,10 +174,8 @@ export async function getWorkshopAuthors(publishedFileIds: string[]): Promise<Ma
   const map = new Map<string, string>()
   if (publishedFileIds.length === 0) return map
 
-  const client = getClient()
-  const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
-  for (const item of result.items) {
-    if (item) map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
+  for (const item of await getItemsBatched(publishedFileIds)) {
+    map.set(item.publishedFileId.toString(), item.owner.steamId64.toString())
   }
   return map
 }
@@ -178,10 +189,8 @@ export async function getUnavailableWorkshopItems(publishedFileIds: string[]): P
   const unavailable = new Set(publishedFileIds)
   if (publishedFileIds.length === 0) return unavailable
 
-  const client = getClient()
-  const result = await client.workshop.getItems(publishedFileIds.map((id) => BigInt(id)))
-  for (const item of result.items) {
-    if (item) unavailable.delete(item.publishedFileId.toString())
+  for (const item of await getItemsBatched(publishedFileIds)) {
+    unavailable.delete(item.publishedFileId.toString())
   }
   return unavailable
 }

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

@@ -58,3 +58,28 @@ export async function getVideoFps(filePath: string): Promise<number | undefined>
     return undefined
   }
 }
+
+const durationCache = new Map<string, number | undefined>()
+
+/** Read a video's length in seconds via ffprobe. Returns undefined if ffprobe is missing or fails. */
+export async function getVideoDurationSec(filePath: string): Promise<number | undefined> {
+  if (durationCache.has(filePath)) return durationCache.get(filePath)
+  if (!isCommandAvailable('ffprobe')) return undefined
+
+  try {
+    const { stdout } = await execFileAsync('ffprobe', [
+      '-v', 'error',
+      '-show_entries', 'format=duration',
+      '-of', 'csv=p=0',
+      filePath
+    ], { timeout: 10_000, encoding: 'utf8' })
+
+    const duration = Number(stdout.trim())
+    const result = isFinite(duration) && duration > 0 ? duration : undefined
+    durationCache.set(filePath, result)
+    return result
+  } catch {
+    durationCache.set(filePath, undefined)
+    return undefined
+  }
+}

+ 1 - 0
src/preload/index.ts

@@ -267,6 +267,7 @@ const api = {
       offsetX?: number
       offsetY?: number
       disableParallax?: boolean
+      expandCanvas?: boolean
       cornerColor?: string
       speed?: number
       propertyOverrides?: Record<string, string>

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

@@ -29,6 +29,7 @@ import {
   ZoomOut,
   Move,
   Move3d,
+  Expand,
   Palette,
   Gauge,
   Zap,
@@ -534,6 +535,7 @@ export default function DetailSidebar({
             offsetX: patch.offsetX,
             offsetY: patch.offsetY,
             disableParallax: patch.disableParallax,
+            expandCanvas: patch.expandCanvas,
             cornerColor: patch.cornerColor,
             speed: patch.playbackSpeed,
             propertyOverrides: patch.propertyOverrides,
@@ -784,6 +786,19 @@ export default function DetailSidebar({
     }
   }
 
+  const [isCommittingExpandCanvas, setIsCommittingExpandCanvas] = useState(false)
+  const expandCanvas = libraryMeta?.expandCanvas ?? false
+
+  async function toggleExpandCanvas() {
+    if (isCommittingExpandCanvas) return
+    setIsCommittingExpandCanvas(true)
+    try {
+      await persistAndMaybeRelaunch({ expandCanvas: !expandCanvas })
+    } finally {
+      setIsCommittingExpandCanvas(false)
+    }
+  }
+
   const [customArgsDraft, setCustomArgsDraft] = useState<string | null>(null)
   const [isCommittingCustomArgs, setIsCommittingCustomArgs] = useState(false)
   const customArgs = customArgsDraft ?? libraryMeta?.customArgs ?? ''
@@ -1324,6 +1339,27 @@ export default function DetailSidebar({
                 <ToggleLeft size={14} className="text-gray-600" />
               )}
             </button>
+            <button
+              onClick={toggleExpandCanvas}
+              disabled={isCommittingExpandCanvas}
+              title="Grow the scene's canvas so layers taller or wider than the camera show in full instead of cropped. Pair with Disable parallax and Fit scaling to see the whole picture"
+              className="mt-1.5 flex w-full items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 hover:text-gray-300 disabled:opacity-40"
+            >
+              <Expand size={12} />
+              <span className="flex-1 text-left">Show full image</span>
+              {isActive && (
+                <span className="rounded-full bg-green-600/30 px-1.5 normal-case text-green-300">
+                  live
+                </span>
+              )}
+              {isCommittingExpandCanvas ? (
+                <Loader2 size={14} className="animate-spin text-gray-500" />
+              ) : expandCanvas ? (
+                <ToggleRight size={14} className="text-indigo-400" />
+              ) : (
+                <ToggleLeft size={14} className="text-gray-600" />
+              )}
+            </button>
           </div>
         )}
 

+ 108 - 2
src/renderer/src/components/library/LibraryView.tsx

@@ -33,7 +33,7 @@ import DetailSidebar from '../common/DetailSidebar'
 import { useToast } from '../common/Toast'
 import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
-import { WE_TYPES, WE_LIBRARY_AGE_RATINGS } from '../../constants/weFilters'
+import { WE_TYPES, WE_LIBRARY_AGE_RATINGS, WE_RESOLUTION_GROUPS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useSelectableGrid } from '../../hooks/useSelectableGrid'
 import { useClickOutside } from '../../hooks/useClickOutside'
@@ -49,6 +49,7 @@ interface LibraryFilterState {
   types: string[]
   ageRatings: string[]
   genres: string[]
+  resolutions: string[]
   sources: string[]
   failedOnly: boolean
   likedOnly: boolean
@@ -59,6 +60,7 @@ const DEFAULT_STATE: LibraryFilterState = {
   types: [],
   ageRatings: [],
   genres: [],
+  resolutions: [],
   sources: [],
   failedOnly: false,
   likedOnly: false,
@@ -211,6 +213,92 @@ function TagDropdown({
   )
 }
 
+function ResolutionDropdown({
+  counts,
+  selected,
+  onChange
+}: {
+  counts: Map<string, number>
+  selected: string[]
+  onChange: (resolutions: string[]) => void
+}) {
+  const [open, setOpen] = useState(false)
+  const ref = useRef<HTMLDivElement>(null)
+
+  useClickOutside(ref, () => setOpen(false))
+
+  return (
+    <div className="relative" ref={ref}>
+      <button
+        onClick={() => setOpen((v) => !v)}
+        className={clsx(
+          'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs transition-colors',
+          selected.length > 0
+            ? 'bg-indigo-600 text-white'
+            : 'bg-white/5 text-gray-300 hover:bg-white/10'
+        )}
+      >
+        Resolution
+        {selected.length > 0 && (
+          <span className="rounded-full bg-white/20 px-1.5">{selected.length}</span>
+        )}
+        <ChevronDown size={12} />
+      </button>
+      {open && (
+        <div className="absolute right-0 top-full z-50 mt-1 w-60 rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+          <div className="max-h-80 overflow-y-auto">
+            {WE_RESOLUTION_GROUPS.map((group) => {
+              const groupTags = group.items.map((i) => i.tag)
+              const allChecked = groupTags.every((t) => selected.includes(t))
+              return (
+                <div key={group.label}>
+                  <label className="flex cursor-pointer items-center gap-2 px-3 pb-1 pt-2 hover:bg-white/5">
+                    <input
+                      type="checkbox"
+                      checked={allChecked}
+                      onChange={() =>
+                        onChange(
+                          allChecked
+                            ? selected.filter((t) => !groupTags.includes(t))
+                            : [...new Set([...selected, ...groupTags])]
+                        )
+                      }
+                      className="accent-indigo-500"
+                    />
+                    <span className="text-[11px] font-semibold uppercase tracking-wide text-gray-500">
+                      {group.label}
+                    </span>
+                  </label>
+                  {group.items.map((item) => {
+                    const count = counts.get(item.tag) ?? 0
+                    return (
+                      <label
+                        key={item.tag}
+                        className="flex cursor-pointer items-center gap-2 py-1 pl-6 pr-3 hover:bg-white/5"
+                      >
+                        <input
+                          type="checkbox"
+                          checked={selected.includes(item.tag)}
+                          onChange={() => onChange(toggle(selected, item.tag))}
+                          className="accent-indigo-500"
+                        />
+                        <span className={clsx('flex-1 text-xs', count > 0 ? 'text-gray-300' : 'text-gray-600')}>
+                          {item.label}
+                        </span>
+                        <span className="text-[11px] text-gray-600">{count}</span>
+                      </label>
+                    )
+                  })}
+                </div>
+              )
+            })}
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}
+
 function FolderMenu({
   x,
   y,
@@ -324,6 +412,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     filterState.types.length +
     filterState.ageRatings.length +
     filterState.genres.length +
+    filterState.resolutions.length +
     filterState.sources.length +
     (filterState.failedOnly ? 1 : 0) +
     (filterState.likedOnly ? 1 : 0) +
@@ -386,6 +475,9 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
       if (filterState.genres.length > 0) {
         if (!filterState.genres.some((g) => w.tags.includes(g))) return false
       }
+      if (filterState.resolutions.length > 0) {
+        if (!filterState.resolutions.some((r) => w.resolutions?.includes(r))) 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
@@ -493,6 +585,14 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
     [filtered, allFolderItemIds]
   )
 
+  const resolutionCounts = useMemo(() => {
+    const counts = new Map<string, number>()
+    for (const w of allWallpapers) {
+      for (const r of w.resolutions ?? []) counts.set(r, (counts.get(r) ?? 0) + 1)
+    }
+    return counts
+  }, [allWallpapers])
+
   const { data: availableTags = [] } = useQuery({
     queryKey: ['library-tags'],
     queryFn: () => window.electronAPI.library.distinctTags()
@@ -1129,6 +1229,12 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
           onChange={(tags) => setFilterState((prev) => ({ ...prev, genres: tags }))}
         />
 
+        <ResolutionDropdown
+          counts={resolutionCounts}
+          selected={filterState.resolutions}
+          onChange={(resolutions) => setFilterState((prev) => ({ ...prev, resolutions }))}
+        />
+
         {activeCount > 0 && (
           <button
             onClick={() => setFilterState(DEFAULT_STATE)}
@@ -1470,7 +1576,7 @@ export default function LibraryView({ onBrowseCreator }: LibraryViewProps) {
             id={detailId}
             fallbackTitle={detailWallpaper.title}
             fallbackPreviewUrl={getPreviewSrc(detailWallpaper)}
-            fallbackTags={detailWallpaper.tags}
+            fallbackTags={[...detailWallpaper.tags, ...(detailWallpaper.resolutions ?? [])]}
             fallbackAuthorSteamId={detailWallpaper.authorSteamId}
             localFileSize={detailWallpaper.fileSize}
             isSubscribed={detailWallpaper.subscribed}

+ 27 - 1
src/renderer/src/components/playlist/PlaylistsView.tsx

@@ -11,6 +11,7 @@ import {
   SkipForward,
   SkipBack,
   Shuffle,
+  Film,
   ArrowDownWideNarrow,
   ExternalLink,
   FolderOpen,
@@ -594,6 +595,28 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
                   sec
                 </label>
 
+                <label
+                  className="flex items-center gap-2 text-xs text-gray-400"
+                  title="Videos longer than the duration play to the end once before switching. Shorter videos loop until the duration is up."
+                >
+                  <button
+                    onClick={() => updateSettings({ finishVideos: !activePlaylist.settings.finishVideos })}
+                    className={clsx(
+                      'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors',
+                      activePlaylist.settings.finishVideos ? '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.finishVideos ? 'translate-x-4' : 'translate-x-0.5'
+                      )}
+                    />
+                  </button>
+                  <Film size={12} />
+                  Finish videos
+                </label>
+
                 <label className="flex items-center gap-2 text-xs text-gray-400">
                   Default volume
                   <input
@@ -659,7 +682,10 @@ export default function PlaylistsView({ onBrowseCreator }: PlaylistsViewProps) {
           id={detailId}
           fallbackTitle={wallpaperById.get(detailId)!.title}
           fallbackPreviewUrl={getPreviewSrc(wallpaperById.get(detailId))}
-          fallbackTags={wallpaperById.get(detailId)!.tags}
+          fallbackTags={[
+            ...wallpaperById.get(detailId)!.tags,
+            ...(wallpaperById.get(detailId)!.resolutions ?? [])
+          ]}
           fallbackAuthorSteamId={wallpaperById.get(detailId)!.authorSteamId}
           localFileSize={wallpaperById.get(detailId)!.fileSize}
           isSubscribed={wallpaperById.get(detailId)!.subscribed}

+ 5 - 4
src/renderer/src/components/settings/SettingsView.tsx

@@ -17,7 +17,7 @@ export default function SettingsView() {
   const [defaultFps, setDefaultFps] = useState<string>('')
   const [fpsSaved, setFpsSaved] = useState(false)
   const [recommendedFpsEnabled, setRecommendedFpsEnabled] = useState(false)
-  const [recommendedWebFpsEnabled, setRecommendedWebFpsEnabled] = useState(false)
+  const [recommendedWebFpsEnabled, setRecommendedWebFpsEnabled] = useState(true)
   const [resetFpsConfirm, setResetFpsConfirm] = useState(false)
   const [resetFpsMsg, setResetFpsMsg] = useState<string | null>(null)
 
@@ -526,9 +526,10 @@ export default function SettingsView() {
               </label>
               <p className="mt-2 text-xs text-gray-600">
                 Launches web (CEF-based) wallpapers at 60 FPS instead of the default limit.
-                The engine renders web content internally at 60 FPS regardless, so a lower
-                default just throttles how often that gets displayed, making HTML/Live2D
-                wallpapers feel choppy. Wallpapers with a manual FPS override are unaffected.
+                The page produces a new frame each time the engine presents one (up to
+                about 60 per second), so a lower default makes HTML/Live2D wallpapers
+                animate at that lower rate. Wallpapers with a manual FPS override are
+                unaffected.
               </p>
             </div>
             <div>

+ 2 - 60
src/renderer/src/constants/weFilters.ts

@@ -1,13 +1,10 @@
+export { WE_RESOLUTION_GROUPS, type ResolutionGroup } from '@shared/resolutions'
+
 export interface FilterItem {
   tag: string
   label: string
 }
 
-export interface ResolutionGroup {
-  label: string
-  items: FilterItem[]
-}
-
 export const WE_SHOW_ONLY: FilterItem[] = [
   { tag: 'Approved', label: 'Approved' },
   { tag: 'Mobile', label: 'Mobile Compatible' },
@@ -46,61 +43,6 @@ export const WE_LIBRARY_AGE_RATINGS: FilterItem[] = [
   { tag: 'Uncategorized', label: 'Uncategorized' }
 ]
 
-export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
-  {
-    label: 'Widescreen',
-    items: [
-      { tag: 'Standard Definition', label: 'Standard Definition' },
-      { tag: '1280 x 720', label: '1280 x 720' },
-      { tag: '1366 x 768', label: '1366 x 768' },
-      { tag: '1920 x 1080', label: '1920 x 1080' },
-      { tag: '2560 x 1440', label: '2560 x 1440' },
-      { tag: '3840 x 2160', label: '3840 x 2160' }
-    ]
-  },
-  {
-    label: 'Ultrawide',
-    items: [
-      { tag: 'Ultrawide 2560 x 1080', label: '2560 x 1080' },
-      { tag: 'Ultrawide 3440 x 1440', label: '3440 x 1440' },
-      { tag: 'Ultrawide 3840 x 1600', label: '3840 x 1600' }
-    ]
-  },
-  {
-    label: 'Dual Monitor',
-    items: [
-      { tag: 'Dual 3840 x 1080', label: '3840 x 1080' },
-      { tag: 'Dual 5120 x 1440', label: '5120 x 1440' },
-      { tag: 'Dual 7680 x 2160', label: '7680 x 2160' }
-    ]
-  },
-  {
-    label: 'Triple Monitor',
-    items: [
-      { tag: 'Triple 4096 x 768', label: '4096 x 768' },
-      { tag: 'Triple 5760 x 1080', label: '5760 x 1080' },
-      { tag: 'Triple 7680 x 1440', label: '7680 x 1440' },
-      { tag: 'Triple 11520 x 2160', label: '11520 x 2160' }
-    ]
-  },
-  {
-    label: 'Portrait / Phone',
-    items: [
-      { tag: 'Portrait 720 x 1280', label: '720 x 1280' },
-      { tag: 'Portrait 1080 x 1920', label: '1080 x 1920' },
-      { tag: 'Portrait 1440 x 2560', label: '1440 x 2560' },
-      { tag: 'Portrait 2160 x 3840', label: '2160 x 3840' }
-    ]
-  },
-  {
-    label: 'Other',
-    items: [
-      { tag: 'Other Resolution', label: 'Other Resolution' },
-      { tag: 'Dynamic Resolution', label: 'Dynamic Resolution' }
-    ]
-  }
-]
-
 export const WE_GENRES: FilterItem[] = [
   { tag: 'Abstract', label: 'Abstract' },
   { tag: 'Animal', label: 'Animal' },

+ 2 - 3
src/shared/constants.ts

@@ -7,7 +7,6 @@ export const STANDALONE_APP_ID = 480
 
 export const DEFAULT_LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
 
-// CEF's own windowless_frame_rate is set to max(60, maximumFPS) regardless of --fps (see
-// CWeb.cpp), so it's already trying to paint at least this fast internally - a lower --fps just
-// throttles how often the engine's outer render loop actually picks up and displays a frame.
 export const RECOMMENDED_WEB_FPS = 60
+
+export const DEFAULT_LWE_FPS = 60

+ 61 - 0
src/shared/resolutions.ts

@@ -0,0 +1,61 @@
+export interface ResolutionGroup {
+  label: string
+  items: { tag: string; label: string }[]
+}
+
+export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
+  {
+    label: 'Widescreen',
+    items: [
+      { tag: 'Standard Definition', label: 'Standard Definition' },
+      { tag: '1280 x 720', label: '1280 x 720' },
+      { tag: '1366 x 768', label: '1366 x 768' },
+      { tag: '1920 x 1080', label: '1920 x 1080' },
+      { tag: '2560 x 1440', label: '2560 x 1440' },
+      { tag: '3840 x 2160', label: '3840 x 2160' }
+    ]
+  },
+  {
+    label: 'Ultrawide',
+    items: [
+      { tag: 'Ultrawide 2560 x 1080', label: '2560 x 1080' },
+      { tag: 'Ultrawide 3440 x 1440', label: '3440 x 1440' },
+      { tag: 'Ultrawide 3840 x 1600', label: '3840 x 1600' }
+    ]
+  },
+  {
+    label: 'Dual Monitor',
+    items: [
+      { tag: 'Dual 3840 x 1080', label: '3840 x 1080' },
+      { tag: 'Dual 5120 x 1440', label: '5120 x 1440' },
+      { tag: 'Dual 7680 x 2160', label: '7680 x 2160' }
+    ]
+  },
+  {
+    label: 'Triple Monitor',
+    items: [
+      { tag: 'Triple 4096 x 768', label: '4096 x 768' },
+      { tag: 'Triple 5760 x 1080', label: '5760 x 1080' },
+      { tag: 'Triple 7680 x 1440', label: '7680 x 1440' },
+      { tag: 'Triple 11520 x 2160', label: '11520 x 2160' }
+    ]
+  },
+  {
+    label: 'Portrait / Phone',
+    items: [
+      { tag: 'Portrait 720 x 1280', label: '720 x 1280' },
+      { tag: 'Portrait 1080 x 1920', label: '1080 x 1920' },
+      { tag: 'Portrait 1440 x 2560', label: '1440 x 2560' },
+      { tag: 'Portrait 2160 x 3840', label: '2160 x 3840' }
+    ]
+  },
+  {
+    label: 'Other',
+    items: [
+      { tag: 'Other Resolution', label: 'Other Resolution' },
+      { tag: 'Dynamic Resolution', label: 'Dynamic Resolution' }
+    ]
+  }
+]
+
+export const RESOLUTION_TAGS = new Set(WE_RESOLUTION_GROUPS.flatMap((g) => g.items.map((i) => i.tag)))

+ 9 - 0
src/shared/types.ts

@@ -47,6 +47,8 @@ export interface WallpaperMeta {
   authorName?: string
   source: 'workshop' | 'local' | 'backup'
   tags: string[]
+  /** Steam Workshop resolution tags ("1920 x 1080", "Ultrawide 3440 x 1440", ...), from the live item */
+  resolutions?: string[]
   categories: string[]
   downloading?: boolean
   downloadFailed?: boolean
@@ -63,6 +65,11 @@ export interface WallpaperMeta {
   offsetY?: number
   /** Force-disables the scene's mouse parallax effect via linux-wallpaperengine --disable-parallax */
   disableParallax?: boolean
+  /**
+   * Grows the scene's render canvas so layers that stick out past the camera (a tall picture only
+   * meant to be revealed by parallax) show in full, via linux-wallpaperengine --expand-canvas
+   */
+  expandCanvas?: boolean
   /**
    * Color shown outside the wallpaper's bounds (Center/Fit letterboxing, zoomed-out scaling) as a
    * "RRGGBB"/"RRGGBBAA" hex string, applied via linux-wallpaperengine --corner-color. Default: black.
@@ -268,6 +275,8 @@ export interface PlaylistSettings {
   sortBy: 'manual' | 'title' | 'createdAt'
   /** How long each wallpaper plays before advancing, in seconds */
   defaultDurationSec: number
+  /** Video wallpapers longer than their duration play to the end once before advancing */
+  finishVideos?: boolean
   /** 0-100, used for items without a per-item volume override */
   defaultVolume: number
 }