فهرست منبع

better sorting, better playlists

UwU 2 ماه پیش
والد
کامیت
71754f7483

+ 3 - 0
package.json

@@ -60,6 +60,9 @@
       ],
       "category": "Utility"
     },
+    "appImage": {
+      "artifactName": "WE-Manager-${version}.${ext}"
+    },
     "files": [
       "out/**/*",
       "node_modules/**/*"

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

@@ -50,6 +50,9 @@ export function registerPlaylistHandlers(): void {
   )
 
   ipcMain.handle(IpcChannels.PLAYLIST_START, (_e, id: string) => player.startPlaylist(id))
+  ipcMain.handle(IpcChannels.PLAYLIST_PLAY_ITEM, (_e, id: string, wallpaperId: string) =>
+    player.playItem(id, wallpaperId)
+  )
   ipcMain.handle(IpcChannels.PLAYLIST_STOP, () => player.stopPlaylist())
   ipcMain.handle(IpcChannels.PLAYLIST_PAUSE, () => player.pausePlaylist())
   ipcMain.handle(IpcChannels.PLAYLIST_RESUME, () => player.resumePlaylist())

+ 25 - 6
src/main/services/library.service.ts

@@ -180,9 +180,10 @@ export function normalizeType(raw?: string): WallpaperType {
 
 export function normalizeRating(raw?: string): ContentRating {
   switch (raw?.toLowerCase()) {
+    case 'everyone': return 'everyone'
     case 'questionable': return 'questionable'
     case 'mature': return 'mature'
-    default: return 'everyone'
+    default: return 'uncategorized'
   }
 }
 
@@ -249,7 +250,7 @@ function buildIncompleteMeta(
     id: workshopId,
     title: existing?.title ?? `Wallpaper ${workshopId}`,
     type: existing?.type ?? 'scene',
-    contentRating: existing?.contentRating ?? 'everyone',
+    contentRating: existing?.contentRating ?? 'uncategorized',
     localPath,
     createdAt: existing?.createdAt ?? now,
     updatedAt: now,
@@ -407,12 +408,23 @@ export function deleteFolder(id: string): void {
   store.set('folders', folders)
 }
 
+/** A wallpaper can only live in one folder at a time, so adding it here pulls it out of any other. */
 export function addItemsToFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
-  return updateFolder(folderId, (folder) => {
+  const folders = store.get('folders')
+  const moving = new Set(itemIds)
+  for (const folder of folders) {
+    if (folder.id === folderId) continue
+    folder.items = folder.items.filter((id) => !moving.has(id))
+  }
+
+  const target = findAndMutate(folders, folderId, (folder) => {
     const set = new Set(folder.items)
     for (const id of itemIds) set.add(id)
     folder.items = [...set]
   })
+
+  if (target) store.set('folders', folders)
+  return target
 }
 
 export function removeItemsFromFolder(folderId: string, itemIds: string[]): WallpaperFolder | null {
@@ -423,18 +435,25 @@ export function removeItemsFromFolder(folderId: string, itemIds: string[]): Wall
 }
 
 /**
- * Remove folder item IDs that don't exist in the wallpapers store.
- * Returns the number of stale IDs removed.
+ * Remove folder item IDs that don't exist in the wallpapers store, and drop
+ * items from any folder beyond the first that also claims them (a wallpaper
+ * can only belong to one folder).
+ * Returns the number of stale/duplicate IDs removed.
  */
 export function cleanupFolders(): number {
   const wallpapers = store.get('wallpapers')
   const validIds = new Set(Object.keys(wallpapers))
   const folders = store.get('folders')
+  const seen = new Set<string>()
   let removed = 0
 
   for (const folder of folders) {
     const before = folder.items.length
-    folder.items = folder.items.filter((id) => validIds.has(id))
+    folder.items = folder.items.filter((id) => {
+      if (!validIds.has(id) || seen.has(id)) return false
+      seen.add(id)
+      return true
+    })
     removed += before - folder.items.length
   }
 

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

@@ -174,6 +174,38 @@ export function startPlaylist(id: string): PlaylistPlaybackState {
   return state
 }
 
+export async function playItem(playlistId: string, wallpaperId: string): Promise<PlaylistPlaybackState> {
+  const playlist = getPlaylist(playlistId)
+  if (!playlist) throw new Error(`Playlist ${playlistId} not found`)
+  if (!findItem(playlist, wallpaperId)) throw new Error(`Wallpaper ${wallpaperId} is not in this playlist`)
+
+  clearTimer()
+  if (state.playlistId !== playlistId) {
+    state = {
+      playlistId: playlist.id,
+      currentItemId: null,
+      currentIndex: -1,
+      isPlaying: true,
+      order: buildOrder(playlist)
+    }
+  }
+
+  let index = state.order.indexOf(wallpaperId)
+  if (index === -1) {
+    state.order = buildOrder(playlist)
+    index = state.order.indexOf(wallpaperId)
+  }
+  state.currentIndex = index
+  state.isPlaying = true
+  store.set('activePlaylistId', playlist.id)
+  store.set('isPlaying', true)
+
+  await applyCurrent(playlist)
+  emitState()
+  scheduleNext(playlist)
+  return state
+}
+
 export function stopPlaylist(): PlaylistPlaybackState {
   clearTimer()
   state = { playlistId: null, currentItemId: null, currentIndex: -1, isPlaying: false, order: [] }

+ 2 - 0
src/preload/index.ts

@@ -150,6 +150,8 @@ const api = {
       ipcRenderer.invoke(IpcChannels.PLAYLIST_UPDATE_ITEM, id, wallpaperId, patch),
     start: (id: string): Promise<PlaylistPlaybackState> =>
       ipcRenderer.invoke(IpcChannels.PLAYLIST_START, id),
+    playItem: (id: string, wallpaperId: string): Promise<PlaylistPlaybackState> =>
+      ipcRenderer.invoke(IpcChannels.PLAYLIST_PLAY_ITEM, id, wallpaperId),
     stop: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_STOP),
     pause: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_PAUSE),
     resume: (): Promise<PlaylistPlaybackState> => ipcRenderer.invoke(IpcChannels.PLAYLIST_RESUME),

+ 49 - 0
src/renderer/src/components/common/Toast.tsx

@@ -0,0 +1,49 @@
+import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from 'react'
+import { CheckCircle2 } from 'lucide-react'
+
+interface ToastItem {
+  id: number
+  message: string
+}
+
+interface ToastContextValue {
+  showToast: (message: string) => void
+}
+
+const ToastContext = createContext<ToastContextValue | null>(null)
+
+export function useToast(): ToastContextValue {
+  const ctx = useContext(ToastContext)
+  if (!ctx) throw new Error('useToast must be used within a ToastProvider')
+  return ctx
+}
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+  const [toasts, setToasts] = useState<ToastItem[]>([])
+  const nextId = useRef(0)
+
+  const showToast = useCallback((message: string) => {
+    const id = nextId.current++
+    setToasts((prev) => [...prev, { id, message }])
+    setTimeout(() => {
+      setToasts((prev) => prev.filter((t) => t.id !== id))
+    }, 2000)
+  }, [])
+
+  return (
+    <ToastContext.Provider value={{ showToast }}>
+      {children}
+      <div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col items-end gap-2">
+        {toasts.map((t) => (
+          <div
+            key={t.id}
+            className="flex items-center gap-1.5 rounded-lg border border-green-500/30 bg-green-600/90 px-3 py-2 text-xs font-medium text-white shadow-2xl"
+          >
+            <CheckCircle2 size={13} />
+            {t.message}
+          </div>
+        ))}
+      </div>
+    </ToastContext.Provider>
+  )
+}

+ 63 - 26
src/renderer/src/components/library/LibraryView.tsx

@@ -25,9 +25,10 @@ import {
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
 import PreviewSizeToggle from '../common/PreviewSizeToggle'
+import { useToast } from '../common/Toast'
 import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
-import { WE_TYPES, WE_AGE_RATINGS } from '../../constants/weFilters'
+import { WE_TYPES, WE_LIBRARY_AGE_RATINGS } from '../../constants/weFilters'
 import { usePreviewSize, previewGridStyle } from '../../hooks/usePreviewSize'
 import { useClickOutside } from '../../hooks/useClickOutside'
 import { toggle } from '../../utils/array'
@@ -61,7 +62,8 @@ const TYPE_MAP: Record<string, string> = {
 const RATING_MAP: Record<string, string> = {
   Everyone: 'everyone',
   Questionable: 'questionable',
-  Mature: 'mature'
+  Mature: 'mature',
+  Uncategorized: 'uncategorized'
 }
 const SOURCE_MAP: Record<string, string> = {
   Workshop: 'workshop',
@@ -335,7 +337,7 @@ export default function LibraryView() {
         const ratings = filterState.ageRatings
           .map((r) => RATING_MAP[r])
           .filter(Boolean)
-        if (!ratings.includes(w.contentRating ?? 'everyone')) return false
+        if (!ratings.includes(w.contentRating ?? 'uncategorized')) return false
       }
       if (filterState.genres.length > 0) {
         if (!filterState.genres.some((g) => w.tags.includes(g))) return false
@@ -371,6 +373,15 @@ export default function LibraryView() {
     return set
   }, [folders, existingIds])
 
+  // Each wallpaper lives in at most one folder; map wallpaper id -> its folder id
+  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])
+
   // Real item count per folder (only items that exist in the library)
   const folderCounts = useMemo(() => {
     const map = new Map<string, number>()
@@ -434,6 +445,27 @@ export default function LibraryView() {
   })
   const isBackupConfigured = config?.isBackupConfigured ?? false
 
+  const { data: playbackState } = useQuery({
+    queryKey: ['playlist-playback-state'],
+    queryFn: () => window.electronAPI.playlist.getState()
+  })
+  const currentPlaylistId = playbackState?.playlistId ?? null
+
+  useEffect(() => {
+    return window.electronAPI.on.playlistStateChanged((state) => {
+      queryClient.setQueryData(['playlist-playback-state'], state)
+    })
+  }, [queryClient])
+
+  const { showToast } = useToast()
+
+  async function addToCurrentPlaylist(wallpaperIds: string[]) {
+    if (!currentPlaylistId || wallpaperIds.length === 0) return
+    await window.electronAPI.playlist.addItems(currentPlaylistId, wallpaperIds)
+    queryClient.invalidateQueries({ queryKey: ['playlists'] })
+    showToast('Added to current playlist')
+  }
+
   const [backingUpSelection, setBackingUpSelection] = useState(false)
   const [backupScanning, setBackupScanning] = useState(false)
   const [backupStatus, setBackupStatus] = useState<string | null>(null)
@@ -688,10 +720,15 @@ export default function LibraryView() {
         e.preventDefault()
         setSelectedIds(new Set(wallpapers.map((w) => w.id)))
       }
+      if (e.key === '+' && selectedIds.size > 0) {
+        const active = document.activeElement
+        if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) return
+        addToCurrentPlaylist(Array.from(selectedIds))
+      }
     }
     window.addEventListener('keydown', onKeyDown)
     return () => window.removeEventListener('keydown', onKeyDown)
-  }, [wallpapers])
+  }, [wallpapers, selectedIds, currentPlaylistId])
 
   // Compute marquee rect for rendering (in container-relative px)
   const marqueeRect = marquee
@@ -786,9 +823,9 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
-  async function ctxMoveToDefault() {
+  async function ctxRemoveFromFolder() {
     if (!ctxMenu) return
-    // Remove from every folder that contains these items
+    // Each wallpaper lives in at most one folder, so pull it out of whichever one it's in
     for (const folder of folders) {
       const overlap = ctxMenu.ids.filter((id) => folder.items.includes(id))
       if (overlap.length > 0) {
@@ -799,17 +836,20 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
-  async function ctxRemoveFromFolder() {
-    if (!ctxMenu || !activeFolder) return
-    await window.electronAPI.folders.removeItems(activeFolder, ctxMenu.ids)
-    refetchFolders()
-    closeCtxMenu()
-  }
-
   // Check if any of the context menu items are video type
   const ctxHasVideo = ctxWallpapers.some((w) => w.type === 'video' && w.file)
   const ctxHasBackupable = ctxWallpapers.some((w) => w.source === 'workshop')
 
+  // Folder membership of the selected wallpapers, for the context menu
+  const ctxCurrentFolderIds = ctxMenu
+    ? new Set(ctxMenu.ids.map((id) => folderOfItem.get(id)).filter((id): id is string => !!id))
+    : new Set<string>()
+  const ctxHasFolder = ctxCurrentFolderIds.size > 0
+  // Only offer folders the selection isn't already fully inside as move targets
+  const ctxMoveTargets = folders.filter(
+    (f) => !(ctxCurrentFolderIds.size === 1 && ctxCurrentFolderIds.has(f.id))
+  )
+
   return (
     <div className="flex h-full flex-col">
       {/* Header bar */}
@@ -904,7 +944,7 @@ export default function LibraryView() {
         <div className="h-3 w-px bg-white/10" />
 
         <div className="flex gap-0.5">
-          {WE_AGE_RATINGS.map((item) => (
+          {WE_LIBRARY_AGE_RATINGS.map((item) => (
             <Chip
               key={item.tag}
               label={item.label}
@@ -1197,12 +1237,13 @@ export default function LibraryView() {
                 selected={selectedIds.has(wallpaper.id)}
                 lweInstalled={lweStatus?.installed ?? false}
                 isLiked={votedSet.has(wallpaper.id)}
-                isBackupConfigured={isBackupConfigured}
+                currentPlaylistId={currentPlaylistId}
+                previewSize={previewSize}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }
-                onBackedUp={() =>
-                  queryClient.invalidateQueries({ queryKey: ['library'] })
+                onAddedToPlaylist={() =>
+                  queryClient.invalidateQueries({ queryKey: ['playlists'] })
                 }
                 onLiked={() => {
                   queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
@@ -1356,14 +1397,10 @@ export default function LibraryView() {
             </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">
-                <button
-                  onClick={ctxMoveToDefault}
-                  className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-                >
-                  <Folder size={12} /> Default
-                </button>
-                {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
-                {folders.map((f) => (
+                {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)}
@@ -1376,7 +1413,7 @@ export default function LibraryView() {
             )}
           </div>
 
-          {activeFolder && (
+          {ctxHasFolder && (
             <button
               onClick={ctxRemoveFromFolder}
               className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"

+ 53 - 50
src/renderer/src/components/library/WallpaperCard.tsx

@@ -1,20 +1,23 @@
 import { useState, useEffect } from 'react'
-import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw } from 'lucide-react'
+import { Play, Loader2, Check, X, ThumbsUp, Trash2, ExternalLink, Download, Archive, AlertTriangle, RotateCw, Plus } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 import { getPreviewSrc } from '../../utils/preview'
 import { openWorkshopPage } from '../../utils/steam'
+import { useToast } from '../common/Toast'
+import type { PreviewSize } from '../../hooks/usePreviewSize'
 
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   selected?: boolean
   lweInstalled?: boolean
   isLiked?: boolean
-  isBackupConfigured?: boolean
+  currentPlaylistId?: string | null
+  previewSize?: PreviewSize
   onApplied?: () => void
   onLiked?: () => void
   onUnsubscribed?: () => void
-  onBackedUp?: () => void
+  onAddedToPlaylist?: () => void
   onRedownloaded?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
@@ -25,38 +28,26 @@ export default function WallpaperCard({
   selected,
   lweInstalled,
   isLiked = false,
-  isBackupConfigured = false,
+  currentPlaylistId = null,
+  previewSize = 'normal',
   onApplied,
   onLiked,
   onUnsubscribed,
-  onBackedUp,
+  onAddedToPlaylist,
   onRedownloaded,
   onSelect,
   onContextMenu
 }: WallpaperCardProps) {
+  const { showToast } = useToast()
   const [isApplying, setIsApplying] = useState(false)
   const [isLiking, setIsLiking] = useState(false)
   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)
+  const [isAddingToPlaylist, setIsAddingToPlaylist] = useState(false)
   const [isRedownloading, setIsRedownloading] = useState(false)
   const [downloadPercentage, setDownloadPercentage] = 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])
-
   useEffect(() => {
     if (!wallpaper.downloading) {
       setDownloadPercentage(null)
@@ -125,18 +116,19 @@ export default function WallpaperCard({
     window.electronAPI.library.update(wallpaper.id, { fpsOverride: parsed })
   }
 
-  async function handleBackup(e: React.MouseEvent) {
+  async function handleAddToPlaylist(e: React.MouseEvent) {
     e.stopPropagation()
-    if (isBackingUp) return
-    setIsBackingUp(true)
+    if (!currentPlaylistId || isAddingToPlaylist) return
+    setIsAddingToPlaylist(true)
     setError(null)
     try {
-      await window.electronAPI.backup.item(wallpaper.id)
-      onBackedUp?.()
+      await window.electronAPI.playlist.addItems(currentPlaylistId, [wallpaper.id])
+      showToast('Added to current playlist')
+      onAddedToPlaylist?.()
     } catch (err) {
       setError((err as Error).message)
-      setIsBackingUp(false)
-      setBackupPercentage(null)
+    } finally {
+      setIsAddingToPlaylist(false)
     }
   }
 
@@ -155,6 +147,7 @@ export default function WallpaperCard({
   }
 
   const previewSrc = getPreviewSrc(wallpaper)
+  const compact = previewSize !== 'big'
 
   return (
     <div
@@ -266,46 +259,50 @@ export default function WallpaperCard({
           />
         </div>
         {error && <p className="mt-1 text-xs text-red-400">{error}</p>}
-        <div className="mt-2 flex items-center gap-2">
+        <div className={clsx('mt-2 flex items-center', compact ? 'gap-1' : 'gap-2')}>
           <button
             onClick={handleLike}
             disabled={isLiked || isLiking}
             title={isLiked ? 'Already liked on Steam' : 'Like on Steam'}
             className={clsx(
-              'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:cursor-default',
+              'flex items-center gap-1 rounded text-xs transition-colors disabled:cursor-default',
+              compact ? 'p-1.5' : 'px-2 py-1',
               isLiked
                 ? 'bg-green-600/30 text-green-300'
                 : 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-200'
             )}
           >
             {isLiking ? <Loader2 size={11} className="animate-spin" /> : <ThumbsUp size={11} />}
-            {isLiked ? 'Liked' : 'Like'}
+            {!compact && (isLiked ? 'Liked' : 'Like')}
+          </button>
+          <button
+            onClick={handleAddToPlaylist}
+            disabled={!currentPlaylistId || isAddingToPlaylist}
+            title={currentPlaylistId ? 'Add to current playlist (+)' : 'No playlist is currently active'}
+            className={clsx(
+              'flex items-center gap-1 rounded bg-white/5 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200 disabled:cursor-not-allowed disabled:opacity-50',
+              compact ? 'p-1.5' : 'px-2 py-1'
+            )}
+          >
+            {isAddingToPlaylist ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />}
+            {!compact && 'Add'}
           </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>
+              {!compact && <span className="text-xs text-gray-400">Sure?</span>}
               <button
                 onClick={handleUnsubscribeClick}
-                className="rounded bg-red-600/80 px-2 py-1 text-xs text-white hover:bg-red-600"
+                title="Confirm unsubscribe"
+                className={clsx('rounded bg-red-600/80 text-xs text-white hover:bg-red-600', compact ? 'p-1.5' : 'px-2 py-1')}
               >
-                Yes
+                {compact ? <Check size={11} /> : 'Yes'}
               </button>
               <button
                 onClick={(e) => { e.stopPropagation(); setUnsubState('idle') }}
-                className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400 hover:bg-white/10"
+                title="Cancel"
+                className={clsx('rounded bg-white/5 text-xs text-gray-400 hover:bg-white/10', compact ? 'p-1.5' : 'px-2 py-1')}
               >
-                No
+                {compact ? <X size={11} /> : 'No'}
               </button>
             </div>
           ) : (
@@ -313,10 +310,13 @@ export default function WallpaperCard({
               onClick={handleUnsubscribeClick}
               disabled={unsubState === 'pending'}
               title="Unsubscribe and delete files"
-              className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-500/20 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
+              className={clsx(
+                'flex items-center gap-1 rounded bg-white/5 text-xs text-gray-400 transition-colors hover:bg-red-500/20 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50',
+                compact ? 'p-1.5' : 'px-2 py-1'
+              )}
             >
               {unsubState === 'pending' ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
-              Unsub
+              {!compact && 'Unsub'}
             </button>
           )}
           <button
@@ -325,10 +325,13 @@ export default function WallpaperCard({
               openWorkshopPage(wallpaper.id)
             }}
             title="Open in Steam Workshop"
-            className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
+            className={clsx(
+              'flex items-center gap-1 rounded bg-white/5 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200',
+              compact ? 'p-1.5' : 'px-2 py-1'
+            )}
           >
             <ExternalLink size={11} />
-            Steam
+            {!compact && 'Steam'}
           </button>
         </div>
       </div>

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

@@ -1,5 +1,5 @@
 import { useState } from 'react'
-import { GripVertical, Trash2, Volume2, Clock, ChevronDown, ChevronUp } from 'lucide-react'
+import { GripVertical, Trash2, Volume2, Clock, ChevronDown, ChevronUp, Play, Pause } from 'lucide-react'
 import clsx from 'clsx'
 import type { PlaylistItem, WallpaperMeta } from '@shared/types'
 import { getPreviewSrc } from '../../utils/preview'
@@ -13,6 +13,7 @@ interface PlaylistItemRowProps {
   onDragStart: (index: number) => void
   onDragOver: (index: number) => void
   onDrop: () => void
+  onPlay: () => void
   onRemove: () => void
   onUpdate: (patch: { volume?: number; durationSec?: number }) => void
   defaultDurationSec: number
@@ -23,10 +24,12 @@ export default function PlaylistItemRow({
   item,
   wallpaper,
   index,
+  isPlaying,
   isCurrent,
   onDragStart,
   onDragOver,
   onDrop,
+  onPlay,
   onRemove,
   onUpdate,
   defaultDurationSec,
@@ -56,11 +59,27 @@ export default function PlaylistItemRow({
       <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]">
+        <button
+          onClick={onPlay}
+          title={isCurrent && isPlaying ? 'Now playing' : 'Play this wallpaper'}
+          className="group relative 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={clsx(
+              'absolute inset-0 flex items-center justify-center bg-black/50 transition-opacity',
+              isCurrent && isPlaying ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
+            )}
+          >
+            {isCurrent && isPlaying ? (
+              <Pause size={14} className="text-white" />
+            ) : (
+              <Play size={14} className="text-white" />
+            )}
+          </div>
+        </button>
         <div className="min-w-0 flex-1">
           <p className="truncate text-sm text-gray-200" title={wallpaper?.title}>
             {wallpaper?.title ?? item.wallpaperId}

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

@@ -155,6 +155,22 @@ export default function PlaylistsView() {
     }
   }
 
+  async function handlePlayItem(wallpaperId: string) {
+    if (!activePlaylist) return
+    if (isThisPlaylistActive && playbackState?.isPlaying && playbackState?.currentItemId === wallpaperId) {
+      await window.electronAPI.playlist.pause()
+      queryClient.invalidateQueries({ queryKey: ['playlist-playback-state'] })
+      return
+    }
+    setError(null)
+    try {
+      await window.electronAPI.playlist.playItem(activePlaylist.id, wallpaperId)
+      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()
@@ -442,6 +458,7 @@ export default function PlaylistsView() {
                   onDragStart={handleDragStart}
                   onDragOver={handleDragOver}
                   onDrop={handleDrop}
+                  onPlay={() => handlePlayItem(item.wallpaperId)}
                   onRemove={() => handleRemoveItem(item.wallpaperId)}
                   onUpdate={(patch) => handleUpdateItem(item.wallpaperId, patch)}
                   defaultDurationSec={activePlaylist.settings.defaultDurationSec}

+ 7 - 0
src/renderer/src/constants/weFilters.ts

@@ -39,6 +39,13 @@ export const WE_AGE_RATINGS: FilterItem[] = [
   { tag: 'Mature', label: 'Mature' }
 ]
 
+// Local library only: items whose downloaded project.json never had a rating set.
+// Not a real Steam Workshop tag, so it can't be used to filter the live workshop query.
+export const WE_LIBRARY_AGE_RATINGS: FilterItem[] = [
+  ...WE_AGE_RATINGS,
+  { tag: 'Uncategorized', label: 'Uncategorized' }
+]
+
 export const WE_RESOLUTION_GROUPS: ResolutionGroup[] = [
   {
     label: 'Widescreen',

+ 4 - 1
src/renderer/src/main.tsx

@@ -2,6 +2,7 @@ import React from 'react'
 import ReactDOM from 'react-dom/client'
 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 import App from './App'
+import { ToastProvider } from './components/common/Toast'
 import './assets/index.css'
 
 const queryClient = new QueryClient({
@@ -16,7 +17,9 @@ const queryClient = new QueryClient({
 ReactDOM.createRoot(document.getElementById('root')!).render(
   <React.StrictMode>
     <QueryClientProvider client={queryClient}>
-      <App />
+      <ToastProvider>
+        <App />
+      </ToastProvider>
     </QueryClientProvider>
   </React.StrictMode>
 )

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

@@ -73,6 +73,7 @@ export const IpcChannels = {
   PLAYLIST_REORDER_ITEMS: 'playlist:reorder-items',
   PLAYLIST_UPDATE_ITEM: 'playlist:update-item',
   PLAYLIST_START: 'playlist:start',
+  PLAYLIST_PLAY_ITEM: 'playlist:play-item',
   PLAYLIST_STOP: 'playlist:stop',
   PLAYLIST_PAUSE: 'playlist:pause',
   PLAYLIST_RESUME: 'playlist:resume',

+ 1 - 1
src/shared/types.ts

@@ -19,7 +19,7 @@ export type WorkshopQueryType =
   | 'RankedByTextSearch'
   | 'RankedByLastUpdatedDate'
 
-export type ContentRating = 'everyone' | 'questionable' | 'mature'
+export type ContentRating = 'everyone' | 'questionable' | 'mature' | 'uncategorized'
 
 export interface WallpaperMeta {
   id: string