瀏覽代碼

add playlist context menu, never delete backup on unsubscribe

UwU 2 月之前
父節點
當前提交
19ad7fdbfe

+ 1 - 1
package.json

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

+ 10 - 1
src/main/ipc/steam.handlers.ts

@@ -3,6 +3,7 @@ import * as fs from 'fs'
 import { IpcChannels } from '@shared/ipc-channels'
 import * as steam from '../services/steam.service'
 import * as library from '../services/library.service'
+import * as backup from '../services/backup.service'
 
 function startDownloadPoll(win: BrowserWindow, itemId: string): void {
   const idBig = BigInt(itemId)
@@ -50,7 +51,15 @@ export function registerSteamHandlers(win: BrowserWindow): void {
     if (wallpaper?.localPath) {
       try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
     }
-    library.deleteWallpaper(itemId)
+    if (wallpaper?.backedUp && backup.isBackedUp(itemId)) {
+      library.updateWallpaper(itemId, {
+        source: 'backup',
+        localPath: backup.getBackupDir(itemId),
+        subscribed: false
+      })
+    } else {
+      library.deleteWallpaper(itemId)
+    }
     return { ok: true }
   })
 

+ 3 - 0
src/main/ipc/wallpaper.handlers.ts

@@ -3,6 +3,7 @@ import { IpcChannels } from '@shared/ipc-channels'
 import { applyWallpaperMeta, detectEnvironment } from '../services/wallpaper.service'
 import { getWallpaper, updateWallpaper } from '../services/library.service'
 import { setActiveWallpaperId, getActiveWallpaperId } from '../services/wallpaper-state.service'
+import { getPlaybackState, pausePlaylist } from '../services/playlist-player.service'
 import type { ApplyWallpaperOptions } from '@shared/types'
 
 export function registerWallpaperHandlers(): void {
@@ -19,6 +20,8 @@ export function registerWallpaperHandlers(): void {
     const wallpaper = getWallpaper(options.wallpaperId)
     if (!wallpaper) throw new Error(`Wallpaper ${options.wallpaperId} not found in library`)
 
+    if (getPlaybackState().isPlaying) pausePlaylist()
+
     const appliedPath = await applyWallpaperMeta(wallpaper, { backend: options.backend })
 
     setActiveWallpaperId(options.wallpaperId)

+ 32 - 16
src/main/services/library.service.ts

@@ -7,7 +7,7 @@ import { randomUUID } from 'crypto'
 import { cleanupPlaylists } from './playlist.service'
 import { getWorkshopTimesUpdated } from './workshop.service'
 import { findAndMutate } from '../utils/collections'
-import { isSteamRunning, isItemDownloading } from './steam.service'
+import { isSteamRunning, getItemDownloadStatus, getSubscribedItems } from './steam.service'
 
 interface LibraryStore {
   wallpapers: Record<string, WallpaperMeta>
@@ -245,7 +245,9 @@ function buildIncompleteMeta(
   existing?: WallpaperMeta
 ): WallpaperMeta {
   const now = Date.now()
-  const downloading = isSteamRunning() ? isItemDownloading(BigInt(workshopId)) : true
+  const { downloading, failed } = isSteamRunning()
+    ? getItemDownloadStatus(BigInt(workshopId))
+    : { downloading: true, failed: false }
   return {
     id: workshopId,
     title: existing?.title ?? `Wallpaper ${workshopId}`,
@@ -260,7 +262,7 @@ function buildIncompleteMeta(
     tags: existing?.tags ?? [],
     categories: existing?.categories ?? [],
     downloading,
-    downloadFailed: !downloading
+    downloadFailed: failed
   }
 }
 
@@ -288,6 +290,7 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
 
   const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
   const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
+  const subscribedIds = isSteamRunning() ? new Set(getSubscribedItems()) : new Set<string>()
 
   // Load entire store once, mutate in memory, write once at the end
   const wallpapers = store.get('wallpapers')
@@ -295,14 +298,30 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
   let skipped = 0
   let removed = 0
 
-  // Remove wallpapers whose directories no longer exist on disk
+  // Remove wallpapers whose directories no longer exist on disk, unless Steam
+  // still has them subscribed (those get tracked below instead, so a failed
+  // or never-started download doesn't just vanish from the library)
   for (const id of Object.keys(wallpapers)) {
-    if (!onDisk.has(id)) {
+    if (!onDisk.has(id) && !subscribedIds.has(id)) {
       delete wallpapers[id]
       removed++
     }
   }
 
+  const trackIncomplete = (id: string, localPath: string): void => {
+    const existing = wallpapers[id]
+    const meta = buildIncompleteMeta(id, localPath, existing)
+    if (!existing) {
+      wallpapers[id] = meta
+      imported++
+    } else if (existing.downloading !== meta.downloading || existing.downloadFailed !== meta.downloadFailed) {
+      wallpapers[id] = { ...existing, downloading: meta.downloading, downloadFailed: meta.downloadFailed }
+      skipped++
+    } else {
+      skipped++
+    }
+  }
+
   const toRebuild: { entry: fs.Dirent; localPath: string; pj: ProjectJson }[] = []
   const cacheHits: string[] = []
   for (const entry of entries) {
@@ -312,17 +331,7 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     const pj = readProjectJson(localPath)
 
     if (!pj) {
-      const existing = wallpapers[entry.name]
-      const meta = buildIncompleteMeta(entry.name, localPath, existing)
-      if (!existing) {
-        wallpapers[entry.name] = meta
-        imported++
-      } else if (existing.downloading !== meta.downloading || existing.downloadFailed !== meta.downloadFailed) {
-        wallpapers[entry.name] = { ...existing, downloading: meta.downloading, downloadFailed: meta.downloadFailed }
-        skipped++
-      } else {
-        skipped++
-      }
+      trackIncomplete(entry.name, localPath)
       continue
     }
 
@@ -336,6 +345,13 @@ export async function scanLibrary(): Promise<{ imported: number; skipped: number
     toRebuild.push({ entry, localPath, pj })
   }
 
+  // Items Steam still lists as subscribed but that never got a local folder
+  // at all (the download failed before writing anything, or hasn't started)
+  for (const id of subscribedIds) {
+    if (onDisk.has(id)) continue
+    trackIncomplete(id, path.join(workshopPath, id))
+  }
+
   const timesUpdated = await safeGetWorkshopTimesUpdated([
     ...toRebuild.map(({ entry }) => entry.name),
     ...cacheHits

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

@@ -340,7 +340,7 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
     // Local repo: hand the build dir back to the invoking user instead of deleting it, so the
     // next unprivileged cmake/make can still write into what `make install` (root) just touched.
     const reclaim = isLocalDir
-      ? `chown -R ${process.getuid()}:${process.getgid()} ${JSON.stringify(BUILD_DIR)}`
+      ? `chown -R ${process.getuid!()}:${process.getgid!()} ${JSON.stringify(BUILD_DIR)}`
       : `rm -rf ${JSON.stringify(BUILD_DIR)}`
     const installScript = `cd ${JSON.stringify(cmakeBuild)} && export PATH=${JSON.stringify(pathEnv)} && make install && patchelf --set-rpath /usr/local/lib64:/usr/local/lib /usr/local/linux-wallpaperengine 2>/dev/null; ldconfig; echo ${manifestMarker}; cat install_manifest.txt 2>/dev/null; ${reclaim}`
     const { stdout: installOutput } = await execFileAsync(installTool, ['bash', '-c', installScript], {

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

@@ -158,10 +158,21 @@ export function getItemState(itemId: bigint): number {
 }
 
 // EItemState flags from the Steamworks SDK (isteamugc.h)
-const ITEM_STATE_DOWNLOAD_PENDING = 32
+const ITEM_STATE_NEEDS_UPDATE = 8
 const ITEM_STATE_DOWNLOADING = 16
+const ITEM_STATE_DOWNLOAD_PENDING = 32
 
 export function isItemDownloading(itemId: bigint): boolean {
   const state = getItemState(itemId)
   return (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
 }
+
+// NeedsUpdate stays set while Steam hasn't caught the local copy up to the
+// server version. If it's set but nothing is actively downloading/pending,
+// Steam gave up on the transfer (e.g. it errored out or the disk filled up).
+export function getItemDownloadStatus(itemId: bigint): { downloading: boolean; failed: boolean } {
+  const state = getItemState(itemId)
+  const downloading = (state & (ITEM_STATE_DOWNLOADING | ITEM_STATE_DOWNLOAD_PENDING)) !== 0
+  const failed = !downloading && (state & ITEM_STATE_NEEDS_UPDATE) !== 0
+  return { downloading, failed }
+}

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

@@ -16,6 +16,7 @@ interface PlaylistItemRowProps {
   onPlay: () => void
   onRemove: () => void
   onUpdate: (patch: { volume?: number; durationSec?: number }) => void
+  onContextMenu: (e: React.MouseEvent) => void
   defaultDurationSec: number
   defaultVolume: number
 }
@@ -32,6 +33,7 @@ export default function PlaylistItemRow({
   onPlay,
   onRemove,
   onUpdate,
+  onContextMenu,
   defaultDurationSec,
   defaultVolume
 }: PlaylistItemRowProps) {
@@ -51,6 +53,7 @@ export default function PlaylistItemRow({
         e.preventDefault()
         onDrop()
       }}
+      onContextMenu={onContextMenu}
       className={clsx(
         'rounded-lg border transition-colors',
         isCurrent ? 'border-indigo-500/60 bg-indigo-500/10' : 'border-white/5 bg-[#1a1a1a]'

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

@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
 import {
   ListVideo,
@@ -11,12 +11,23 @@ import {
   SkipForward,
   SkipBack,
   Shuffle,
-  ArrowDownWideNarrow
+  ArrowDownWideNarrow,
+  ExternalLink,
+  FolderOpen,
+  FolderInput,
+  Folder,
+  ChevronRight,
+  Eye,
+  ThumbsUp,
+  ThumbsDown,
+  Archive
 } from 'lucide-react'
 import clsx from 'clsx'
 import type { Playlist, PlaylistPlaybackState, PlaylistSettings } from '@shared/types'
 import PlaylistItemRow from './PlaylistItemRow'
 import WallpaperPickerModal from './WallpaperPickerModal'
+import { useClickOutside } from '../../hooks/useClickOutside'
+import { openWorkshopPage } from '../../utils/steam'
 
 const SORT_OPTIONS: { value: PlaylistSettings['sortBy']; label: string }[] = [
   { value: 'manual', label: 'Manual order' },
@@ -36,6 +47,15 @@ export default function PlaylistsView() {
   const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
   const [error, setError] = useState<string | null>(null)
 
+  const [ctxMenu, setCtxMenu] = useState<{
+    x: number
+    y: number
+    wallpaperId: string
+    showFolderSub: boolean
+  } | null>(null)
+  const ctxMenuRef = useRef<HTMLDivElement>(null)
+  useClickOutside(ctxMenuRef, () => setCtxMenu(null), !!ctxMenu)
+
   const { data: playlists = [] } = useQuery({
     queryKey: ['playlists'],
     queryFn: () => window.electronAPI.playlist.getAll()
@@ -47,6 +67,24 @@ export default function PlaylistsView() {
   })
   const wallpaperById = useMemo(() => new Map(wallpapers.map((w) => [w.id, w])), [wallpapers])
 
+  const { data: folders = [], refetch: refetchFolders } = useQuery({
+    queryKey: ['folders'],
+    queryFn: () => window.electronAPI.folders.getAll()
+  })
+  const folderOfItem = useMemo(() => {
+    const map = new Map<string, string>()
+    for (const f of folders) {
+      for (const id of f.items) map.set(id, f.id)
+    }
+    return map
+  }, [folders])
+
+  const { data: config } = useQuery({
+    queryKey: ['config'],
+    queryFn: () => window.electronAPI.config.get()
+  })
+  const isBackupConfigured = config?.isBackupConfigured ?? false
+
   const { data: playbackState } = useQuery({
     queryKey: ['playlist-playback-state'],
     queryFn: () => window.electronAPI.playlist.getState()
@@ -121,6 +159,75 @@ export default function PlaylistsView() {
     invalidatePlaylists()
   }
 
+  function openItemCtxMenu(e: React.MouseEvent, wallpaperId: string) {
+    e.preventDefault()
+    setCtxMenu({ x: e.clientX, y: e.clientY, wallpaperId, showFolderSub: false })
+  }
+
+  function closeCtxMenu() {
+    setCtxMenu(null)
+  }
+
+  const ctxWallpaper = ctxMenu ? wallpaperById.get(ctxMenu.wallpaperId) : undefined
+  const ctxHasVideo = Boolean(ctxWallpaper?.type === 'video' && ctxWallpaper.file)
+  const ctxHasBackupable = ctxWallpaper?.source === 'workshop'
+  const ctxFolderId = ctxMenu ? folderOfItem.get(ctxMenu.wallpaperId) : undefined
+  const ctxMoveTargets = folders.filter((f) => f.id !== ctxFolderId)
+
+  async function ctxUnsubscribe() {
+    if (!ctxMenu) return
+    await window.electronAPI.steam.unsubscribe(ctxMenu.wallpaperId)
+    closeCtxMenu()
+    queryClient.invalidateQueries({ queryKey: ['library-all'] })
+    refetchFolders()
+  }
+
+  async function ctxVote(up: boolean) {
+    if (!ctxMenu) return
+    await window.electronAPI.steam.vote(ctxMenu.wallpaperId, up)
+    queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+    closeCtxMenu()
+  }
+
+  function ctxOpenInSteam() {
+    if (!ctxMenu) return
+    openWorkshopPage(ctxMenu.wallpaperId)
+    closeCtxMenu()
+  }
+
+  async function ctxBackup() {
+    if (!ctxMenu) return
+    closeCtxMenu()
+    await window.electronAPI.backup.item(ctxMenu.wallpaperId)
+    queryClient.invalidateQueries({ queryKey: ['library-all'] })
+  }
+
+  async function ctxOpenLocally() {
+    if (!ctxMenu || !ctxWallpaper?.localPath) return closeCtxMenu()
+    await window.electronAPI.shell.openPath(ctxWallpaper.localPath)
+    closeCtxMenu()
+  }
+
+  async function ctxPreviewVideo() {
+    if (!ctxWallpaper?.localPath || !ctxWallpaper.file) return closeCtxMenu()
+    await window.electronAPI.shell.openWithDefault(ctxWallpaper.localPath + '/' + ctxWallpaper.file)
+    closeCtxMenu()
+  }
+
+  async function ctxMoveToFolder(folderId: string) {
+    if (!ctxMenu) return
+    await window.electronAPI.folders.addItems(folderId, [ctxMenu.wallpaperId])
+    refetchFolders()
+    closeCtxMenu()
+  }
+
+  async function ctxRemoveFromFolder() {
+    if (!ctxMenu || !ctxFolderId) return
+    await window.electronAPI.folders.removeItems(ctxFolderId, [ctxMenu.wallpaperId])
+    refetchFolders()
+    closeCtxMenu()
+  }
+
   function handleDragStart(index: number) {
     setDragIndex(index)
   }
@@ -461,6 +568,7 @@ export default function PlaylistsView() {
                   onPlay={() => handlePlayItem(item.wallpaperId)}
                   onRemove={() => handleRemoveItem(item.wallpaperId)}
                   onUpdate={(patch) => handleUpdateItem(item.wallpaperId, patch)}
+                  onContextMenu={(e) => openItemCtxMenu(e, item.wallpaperId)}
                   defaultDurationSec={activePlaylist.settings.defaultDurationSec}
                   defaultVolume={activePlaylist.settings.defaultVolume}
                 />
@@ -477,6 +585,106 @@ export default function PlaylistsView() {
           onClose={() => setPickerOpen(false)}
         />
       )}
+
+      {ctxMenu && (
+        <div
+          ref={ctxMenuRef}
+          className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        >
+          {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"
+          >
+            Unsubscribe
+          </button>
+
+          <div className="my-1 border-t border-white/5" />
+
+          <button
+            onClick={() => ctxVote(true)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ThumbsUp size={12} /> Like
+          </button>
+          <button
+            onClick={() => ctxVote(false)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ThumbsDown size={12} /> Dislike
+          </button>
+
+          <div className="my-1 border-t border-white/5" />
+
+          <button
+            onClick={ctxOpenInSteam}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <ExternalLink size={12} /> Open in Steam Workshop
+          </button>
+          <button
+            onClick={ctxOpenLocally}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            <FolderOpen size={12} /> Open wallpaper locally
+          </button>
+
+          {ctxHasVideo && (
+            <button
+              onClick={ctxPreviewVideo}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <Eye size={12} /> Preview in media player
+            </button>
+          )}
+
+          <div className="my-1 border-t border-white/5" />
+
+          <div className="relative">
+            <button
+              onClick={() => setCtxMenu((prev) => prev ? { ...prev, showFolderSub: !prev.showFolderSub } : null)}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+            >
+              <FolderInput size={12} /> Move to folder
+              <ChevronRight size={12} className="ml-auto" />
+            </button>
+            {ctxMenu.showFolderSub && (
+              <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
+                {ctxMoveTargets.length === 0 && (
+                  <div className="px-3 py-1.5 text-gray-500">No other folders</div>
+                )}
+                {ctxMoveTargets.map((f) => (
+                  <button
+                    key={f.id}
+                    onClick={() => ctxMoveToFolder(f.id)}
+                    className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+                  >
+                    <Folder size={12} /> {f.title}
+                  </button>
+                ))}
+              </div>
+            )}
+          </div>
+
+          {ctxFolderId && (
+            <button
+              onClick={ctxRemoveFromFolder}
+              className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+            >
+              <Trash2 size={12} /> Remove from folder
+            </button>
+          )}
+        </div>
+      )}
     </div>
   )
 }