Explorar el Código

Improve UI with floating Now Playing button and various fixes

- Add centered floating Now Playing button above nav bar (all screens)
- Remove Now Playing button from header to declutter
- Fix table selector dropdown alignment with header pill
- Show cache progress percentage on mobile
- Fix toast notifications blocked by Dynamic Island in PWA mode
- Improve lazy loading for pattern previews in playlists and queue

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris hace 2 semanas
padre
commit
51fd92ab10

+ 48 - 23
frontend/src/components/NowPlayingBar.tsx

@@ -88,6 +88,7 @@ interface SortableQueueItemProps {
   isLast: boolean
   isLast: boolean
   onMoveToTop: () => void
   onMoveToTop: () => void
   onMoveToBottom: () => void
   onMoveToBottom: () => void
+  requestPreview: (file: string) => void
 }
 }
 
 
 function SortableQueueItem({
 function SortableQueueItem({
@@ -99,6 +100,7 @@ function SortableQueueItem({
   isLast,
   isLast,
   onMoveToTop,
   onMoveToTop,
   onMoveToBottom,
   onMoveToBottom,
+  requestPreview,
 }: SortableQueueItemProps) {
 }: SortableQueueItemProps) {
   const {
   const {
     attributes,
     attributes,
@@ -109,6 +111,31 @@ function SortableQueueItem({
     isDragging,
     isDragging,
   } = useSortable({ id })
   } = useSortable({ id })
 
 
+  const previewContainerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  // Lazy load preview when item becomes visible
+  useEffect(() => {
+    if (!previewContainerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(file)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '50px' }
+    )
+
+    observer.observe(previewContainerRef.current)
+
+    return () => observer.disconnect()
+  }, [file, previewUrl, requestPreview])
+
   const style = {
   const style = {
     transform: CSS.Transform.toString(transform),
     transform: CSS.Transform.toString(transform),
     transition,
     transition,
@@ -132,7 +159,7 @@ function SortableQueueItem({
       </div>
       </div>
 
 
       {/* Preview thumbnail */}
       {/* Preview thumbnail */}
-      <div className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
+      <div ref={previewContainerRef} className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
         {previewUrl ? (
         {previewUrl ? (
           <img
           <img
             src={previewUrl}
             src={previewUrl}
@@ -753,25 +780,28 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
 
 
   // Track which files we've already requested previews for
   // Track which files we've already requested previews for
   const requestedPreviewsRef = useRef<Set<string>>(new Set())
   const requestedPreviewsRef = useRef<Set<string>>(new Set())
+  const pendingQueuePreviewsRef = useRef<Set<string>>(new Set())
+  const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
 
 
-  // Fetch queue previews when dialog opens
-  useEffect(() => {
-    if (!showQueue || !status?.playlist?.files) {
-      return
-    }
+  // Batched queue preview fetching - collects requests and fetches in batches
+  const requestQueuePreview = useCallback((file: string) => {
+    // Skip if already loaded or pending
+    if (queuePreviews[file] || requestedPreviewsRef.current.has(file) || pendingQueuePreviewsRef.current.has(file)) return
+
+    pendingQueuePreviewsRef.current.add(file)
 
 
-    // Filter out files we've already requested
-    const filesToFetch = status.playlist.files.filter(f => !requestedPreviewsRef.current.has(f))
-    if (filesToFetch.length === 0) return
+    // Debounce batch fetch
+    if (batchTimeoutRef.current) clearTimeout(batchTimeoutRef.current)
+    batchTimeoutRef.current = setTimeout(async () => {
+      const filesToFetch = Array.from(pendingQueuePreviewsRef.current)
+      pendingQueuePreviewsRef.current.clear()
+      if (filesToFetch.length === 0) return
 
 
-    // Mark these as requested immediately to prevent duplicate requests
-    filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
+      // Mark as requested
+      filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
 
 
-    // Fetch in batches of 20 to avoid overwhelming the server
-    const batchSize = 20
-    const fetchBatch = async (batch: string[]) => {
       try {
       try {
-        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: batch })
+        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: filesToFetch })
         const newPreviews: Record<string, string> = {}
         const newPreviews: Record<string, string> = {}
         for (const [file, result] of Object.entries(data)) {
         for (const [file, result] of Object.entries(data)) {
           if (result.image_data) {
           if (result.image_data) {
@@ -784,14 +814,8 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
       } catch (err) {
       } catch (err) {
         console.error('Failed to fetch queue previews:', err)
         console.error('Failed to fetch queue previews:', err)
       }
       }
-    }
-
-    // Fetch first batch immediately, then stagger the rest
-    for (let i = 0; i < filesToFetch.length; i += batchSize) {
-      const batch = filesToFetch.slice(i, i + batchSize)
-      setTimeout(() => fetchBatch(batch), (i / batchSize) * 200)
-    }
-  }, [showQueue, status?.playlist?.files])
+    }, 100)
+  }, [queuePreviews])
 
 
   // Helper to reorder array (move item from one index to another)
   // Helper to reorder array (move item from one index to another)
   const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
   const reorderArray = (arr: string[], fromIndex: number, toIndex: number): string[] => {
@@ -1369,6 +1393,7 @@ export function NowPlayingBar({ isLogsOpen = false, logsDrawerHeight = 256, isVi
                             isFirst={index === firstUpcomingIndex}
                             isFirst={index === firstUpcomingIndex}
                             isLast={index === lastUpcomingIndex}
                             isLast={index === lastUpcomingIndex}
                             onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
                             onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
+                            requestPreview={requestQueuePreview}
                             onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
                             onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
                           />
                           />
                         ))}
                         ))}

+ 20 - 14
frontend/src/components/TableSelector.tsx

@@ -33,7 +33,11 @@ import {
   Trash2,
   Trash2,
 } from 'lucide-react'
 } from 'lucide-react'
 
 
-export function TableSelector() {
+interface TableSelectorProps {
+  children?: React.ReactNode
+}
+
+export function TableSelector({ children }: TableSelectorProps) {
   const {
   const {
     tables,
     tables,
     activeTable,
     activeTable,
@@ -120,18 +124,20 @@ export function TableSelector() {
     <>
     <>
       <Popover open={isOpen} onOpenChange={setIsOpen}>
       <Popover open={isOpen} onOpenChange={setIsOpen}>
         <PopoverTrigger asChild>
         <PopoverTrigger asChild>
-          <Button
-            variant="ghost"
-            size="sm"
-            className="gap-2 h-9 px-2"
-          >
-            <Layers className="h-4 w-4" />
-            <span className="hidden sm:inline max-w-[120px] truncate">
-              {activeTable?.appName || activeTable?.name || 'Select Table'}
-            </span>
-          </Button>
+          {children || (
+            <Button
+              variant="ghost"
+              size="sm"
+              className="gap-2 h-9 px-2"
+            >
+              <Layers className="h-4 w-4" />
+              <span className="hidden sm:inline max-w-[120px] truncate">
+                {activeTable?.appName || activeTable?.name || 'Select Table'}
+              </span>
+            </Button>
+          )}
         </PopoverTrigger>
         </PopoverTrigger>
-        <PopoverContent className="w-72 p-2" align="end">
+        <PopoverContent className="w-72 p-2" align="start" sideOffset={12} alignOffset={-56}>
           <div className="space-y-2">
           <div className="space-y-2">
             {/* Header */}
             {/* Header */}
             <div className="px-2 py-1">
             <div className="px-2 py-1">
@@ -251,7 +257,7 @@ export function TableSelector() {
               />
               />
             </div>
             </div>
           </div>
           </div>
-          <DialogFooter>
+          <DialogFooter className="gap-2 sm:gap-0">
             <Button variant="secondary" onClick={() => setShowAddDialog(false)}>
             <Button variant="secondary" onClick={() => setShowAddDialog(false)}>
               Cancel
               Cancel
             </Button>
             </Button>
@@ -277,7 +283,7 @@ export function TableSelector() {
               autoFocus
               autoFocus
             />
             />
           </div>
           </div>
-          <DialogFooter>
+          <DialogFooter className="gap-2 sm:gap-0">
             <Button variant="secondary" onClick={() => setShowRenameDialog(false)}>
             <Button variant="secondary" onClick={() => setShowRenameDialog(false)}>
               Cancel
               Cancel
             </Button>
             </Button>

+ 57 - 59
frontend/src/components/layout/Layout.tsx

@@ -1196,54 +1196,49 @@ export function Layout() {
         )}
         )}
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
         <div className="relative w-full max-w-5xl mx-auto px-3 sm:px-4 pt-3 pointer-events-none">
           <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
           <div className="flex h-12 items-center justify-between px-4 rounded-full bg-card shadow-lg border border-border pointer-events-auto">
-          <Link to="/" className="flex items-center gap-2">
-            <img
-              src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
-              alt={displayName}
-              className="w-8 h-8 rounded-full object-cover"
-            />
-            <ShinyText
-              text={displayName}
-              className="font-semibold text-lg"
-              speed={4}
-              color={isDark ? '#a8a8a8' : '#555555'}
-              shineColor={isDark ? '#ffffff' : '#999999'}
-              spread={75}
-            />
-            <span
-              className={`w-2 h-2 rounded-full ${
-                !isBackendConnected
-                  ? 'bg-gray-400'
-                  : isConnected
-                    ? 'bg-green-500 animate-pulse'
-                    : 'bg-red-500'
-              }`}
-              title={
-                !isBackendConnected
-                  ? 'Backend not connected'
-                  : isConnected
-                    ? 'Table connected'
-                    : 'Table disconnected'
-              }
-            />
-          </Link>
+          <div className="flex items-center gap-2">
+            <Link to="/">
+              <img
+                src={customLogo ? apiClient.getAssetUrl(`/static/custom/${customLogo}`) : apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
+                alt={displayName}
+                className="w-8 h-8 rounded-full object-cover"
+              />
+            </Link>
+            <TableSelector>
+              <button className="flex items-center gap-1.5 hover:opacity-80 transition-opacity group">
+                <ShinyText
+                  text={displayName}
+                  className="font-semibold text-lg"
+                  speed={4}
+                  color={isDark ? '#a8a8a8' : '#555555'}
+                  shineColor={isDark ? '#ffffff' : '#999999'}
+                  spread={75}
+                />
+                <span className="material-icons-outlined text-muted-foreground text-sm group-hover:text-foreground transition-colors">
+                  expand_more
+                </span>
+                <span
+                  className={`w-2 h-2 rounded-full ${
+                    !isBackendConnected
+                      ? 'bg-gray-400'
+                      : isConnected
+                        ? 'bg-green-500 animate-pulse'
+                        : 'bg-red-500'
+                  }`}
+                  title={
+                    !isBackendConnected
+                      ? 'Backend not connected'
+                      : isConnected
+                        ? 'Table connected'
+                        : 'Table disconnected'
+                  }
+                />
+              </button>
+            </TableSelector>
+          </div>
 
 
           {/* Desktop actions */}
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-0 ml-2">
           <div className="hidden md:flex items-center gap-0 ml-2">
-            {/* Now Playing button */}
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
-              className="rounded-full"
-              aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
-              title={currentPlayingFile ? `Playing: ${currentPlayingFile}` : 'Not Playing'}
-            >
-              <span className="material-icons-outlined">
-                {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
-              </span>
-            </Button>
-            <TableSelector />
             <Popover>
             <Popover>
               <PopoverTrigger asChild>
               <PopoverTrigger asChild>
                 <Button
                 <Button
@@ -1295,20 +1290,6 @@ export function Layout() {
 
 
           {/* Mobile actions */}
           {/* Mobile actions */}
           <div className="flex md:hidden items-center gap-0 ml-2">
           <div className="flex md:hidden items-center gap-0 ml-2">
-            {/* Now Playing button */}
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={() => setIsNowPlayingOpen(!isNowPlayingOpen)}
-              className="rounded-full"
-              aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
-              title={currentPlayingFile ? `Playing: ${currentPlayingFile}` : 'Not Playing'}
-            >
-              <span className="material-icons-outlined">
-                {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
-              </span>
-            </Button>
-            <TableSelector />
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
             <Popover open={isMobileMenuOpen} onOpenChange={setIsMobileMenuOpen}>
               <PopoverTrigger asChild>
               <PopoverTrigger asChild>
                 <Button
                 <Button
@@ -1505,6 +1486,23 @@ export function Layout() {
         )}
         )}
       </div>
       </div>
 
 
+      {/* Floating Now Playing Button - hidden when Now Playing bar is open */}
+      {!isNowPlayingOpen && (
+        <button
+          onClick={() => setIsNowPlayingOpen(true)}
+          className="fixed z-40 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full bg-card border border-border shadow-lg transition-all hover:shadow-xl hover:scale-105 active:scale-95"
+          style={{ bottom: 'calc(4.5rem + env(safe-area-inset-bottom, 0px))' }}
+          aria-label={isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+        >
+          <span className={`material-icons-outlined text-xl ${isCurrentlyPlaying ? 'text-primary' : 'text-muted-foreground'}`}>
+            {isCurrentlyPlaying ? 'play_circle' : 'stop_circle'}
+          </span>
+          <span className="text-sm font-medium">
+            {isCurrentlyPlaying ? 'Now Playing' : 'Not Playing'}
+          </span>
+        </button>
+      )}
+
       {/* Bottom Navigation */}
       {/* Bottom Navigation */}
       <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
       <nav className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-card pb-safe">
         <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">
         <div className="max-w-5xl mx-auto grid grid-cols-5 h-16">

+ 10 - 0
frontend/src/components/ui/sonner.tsx

@@ -5,12 +5,18 @@ type ToasterProps = React.ComponentProps<typeof Sonner>
 
 
 const Toaster = ({ ...props }: ToasterProps) => {
 const Toaster = ({ ...props }: ToasterProps) => {
   const [theme, setTheme] = useState<"light" | "dark">("light")
   const [theme, setTheme] = useState<"light" | "dark">("light")
+  const [isStandalone, setIsStandalone] = useState(false)
 
 
   useEffect(() => {
   useEffect(() => {
     // Check initial theme
     // Check initial theme
     const isDark = document.documentElement.classList.contains("dark")
     const isDark = document.documentElement.classList.contains("dark")
     setTheme(isDark ? "dark" : "light")
     setTheme(isDark ? "dark" : "light")
 
 
+    // Check if running as PWA (standalone mode)
+    const standalone = window.matchMedia('(display-mode: standalone)').matches ||
+      (window.navigator as unknown as { standalone?: boolean }).standalone === true
+    setIsStandalone(standalone)
+
     // Watch for theme changes
     // Watch for theme changes
     const observer = new MutationObserver((mutations) => {
     const observer = new MutationObserver((mutations) => {
       mutations.forEach((mutation) => {
       mutations.forEach((mutation) => {
@@ -25,10 +31,14 @@ const Toaster = ({ ...props }: ToasterProps) => {
     return () => observer.disconnect()
     return () => observer.disconnect()
   }, [])
   }, [])
 
 
+  // Use larger offset for PWA to account for Dynamic Island/notch (59px typical + 16px padding)
+  const offset = isStandalone ? 75 : 16
+
   return (
   return (
     <Sonner
     <Sonner
       theme={theme}
       theme={theme}
       className="toaster group"
       className="toaster group"
+      offset={offset}
       toastOptions={{
       toastOptions={{
         classNames: {
         classNames: {
           toast:
           toast:

+ 6 - 2
frontend/src/pages/BrowsePage.tsx

@@ -954,13 +954,17 @@ export function BrowsePage() {
             <Button
             <Button
               variant="outline"
               variant="outline"
               onClick={handleCacheAllPreviews}
               onClick={handleCacheAllPreviews}
-              className="shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card shadow-sm px-0 sm:px-4 justify-center sm:justify-start gap-2"
+              className={`shrink-0 rounded-full bg-card shadow-sm gap-2 ${
+                isCaching
+                  ? 'h-9 sm:h-11 w-auto px-3 sm:px-4'
+                  : 'h-9 w-9 sm:h-11 sm:w-auto px-0 sm:px-4 justify-center sm:justify-start'
+              }`}
               title="Cache All Previews"
               title="Cache All Previews"
             >
             >
               {isCaching ? (
               {isCaching ? (
                 <>
                 <>
                   <span className="material-icons-outlined animate-spin text-lg">sync</span>
                   <span className="material-icons-outlined animate-spin text-lg">sync</span>
-                  <span className="hidden sm:inline text-sm">{cacheProgress}%</span>
+                  <span className="text-sm">{cacheProgress}%</span>
                 </>
                 </>
               ) : (
               ) : (
                 <>
                 <>

+ 84 - 64
frontend/src/pages/PlaylistsPage.tsx

@@ -222,10 +222,7 @@ export function PlaylistsPage() {
       const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
       setPlaylistPatterns(data.files || [])
       setPlaylistPatterns(data.files || [])
 
 
-      // Load previews for playlist patterns
-      if (data.files?.length > 0) {
-        loadPreviewsForPaths(data.files)
-      }
+      // Previews are now lazy-loaded via IntersectionObserver in LazyPatternPreview
     } catch (error) {
     } catch (error) {
       console.error('Error fetching playlist:', error)
       console.error('Error fetching playlist:', error)
       toast.error('Failed to load playlist')
       toast.error('Failed to load playlist')
@@ -411,12 +408,7 @@ export function PlaylistsPage() {
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSelectedPatternPaths(new Set(playlistPatterns))
     setSearchQuery('')
     setSearchQuery('')
     setIsPickerOpen(true)
     setIsPickerOpen(true)
-
-    // Load previews for all patterns
-    if (allPatterns.length > 0) {
-      const paths = allPatterns.slice(0, 50).map(p => p.path)
-      loadPreviewsForPaths(paths)
-    }
+    // Previews are lazy-loaded via IntersectionObserver in LazyPatternPreview
   }
   }
 
 
   const handleSavePatterns = async () => {
   const handleSavePatterns = async () => {
@@ -428,7 +420,7 @@ export function PlaylistsPage() {
       setPlaylistPatterns(newPatterns)
       setPlaylistPatterns(newPatterns)
       setIsPickerOpen(false)
       setIsPickerOpen(false)
       toast.success('Playlist updated')
       toast.success('Playlist updated')
-      loadPreviewsForPaths(newPatterns)
+      // Previews are lazy-loaded via IntersectionObserver
     } catch (error) {
     } catch (error) {
       toast.error('Failed to update playlist')
       toast.error('Failed to update playlist')
     }
     }
@@ -689,44 +681,31 @@ export function PlaylistsPage() {
               </div>
               </div>
             ) : (
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
-                {playlistPatterns.map((path, index) => {
-                  const previewUrl = getPreviewUrl(path)
-                  if (!previewUrl && !previews[path]) {
-                    requestPreview(path)
-                  }
-                  return (
-                    <div
-                      key={`${path}-${index}`}
-                      className="flex flex-col items-center gap-1.5 sm:gap-2 group"
-                    >
-                      <div className="relative w-full aspect-square">
-                        <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
-                          {previewUrl ? (
-                            <img
-                              src={previewUrl}
-                              alt={getPatternName(path)}
-                              className="w-full h-full object-cover pattern-preview"
-                            />
-                          ) : (
-                            <div className="w-full h-full flex items-center justify-center">
-                              <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
-                                image
-                              </span>
-                            </div>
-                          )}
-                        </div>
-                        <button
-                          className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
-                          onClick={() => handleRemovePattern(path)}
-                          title="Remove from playlist"
-                        >
-                          <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
-                        </button>
+                {playlistPatterns.map((path, index) => (
+                  <div
+                    key={`${path}-${index}`}
+                    className="flex flex-col items-center gap-1.5 sm:gap-2 group"
+                  >
+                    <div className="relative w-full aspect-square">
+                      <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
+                        <LazyPatternPreview
+                          path={path}
+                          previewUrl={getPreviewUrl(path)}
+                          requestPreview={requestPreview}
+                          alt={getPatternName(path)}
+                        />
                       </div>
                       </div>
-                      <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                      <button
+                        className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
+                        onClick={() => handleRemovePattern(path)}
+                        title="Remove from playlist"
+                      >
+                        <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
+                      </button>
                     </div>
                     </div>
-                  )
-                })}
+                    <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
+                  </div>
+                ))}
               </div>
               </div>
             )}
             )}
           </div>
           </div>
@@ -1015,10 +994,6 @@ export function PlaylistsPage() {
               <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
               <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
                 {filteredPatterns.map(pattern => {
                 {filteredPatterns.map(pattern => {
                   const isSelected = selectedPatternPaths.has(pattern.path)
                   const isSelected = selectedPatternPaths.has(pattern.path)
-                  const previewUrl = getPreviewUrl(pattern.path)
-                  if (!previewUrl && !previews[pattern.path]) {
-                    requestPreview(pattern.path)
-                  }
                   return (
                   return (
                     <div
                     <div
                       key={pattern.path}
                       key={pattern.path}
@@ -1032,19 +1007,12 @@ export function PlaylistsPage() {
                             : 'border-transparent hover:border-muted-foreground/30'
                             : 'border-transparent hover:border-muted-foreground/30'
                         }`}
                         }`}
                       >
                       >
-                        {previewUrl ? (
-                          <img
-                            src={previewUrl}
-                            alt={pattern.name}
-                            className="w-full h-full object-cover pattern-preview"
-                          />
-                        ) : (
-                          <div className="w-full h-full flex items-center justify-center">
-                            <span className="material-icons-outlined text-muted-foreground">
-                              image
-                            </span>
-                          </div>
-                        )}
+                        <LazyPatternPreview
+                          path={pattern.path}
+                          previewUrl={getPreviewUrl(pattern.path)}
+                          requestPreview={requestPreview}
+                          alt={pattern.name}
+                        />
                         {isSelected && (
                         {isSelected && (
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
                           <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
                             <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
                             <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
@@ -1077,3 +1045,55 @@ export function PlaylistsPage() {
     </div>
     </div>
   )
   )
 }
 }
+
+// Lazy-loading pattern preview component
+interface LazyPatternPreviewProps {
+  path: string
+  previewUrl: string | null
+  requestPreview: (path: string) => void
+  alt: string
+  className?: string
+}
+
+function LazyPatternPreview({ path, previewUrl, requestPreview, alt, className = '' }: LazyPatternPreviewProps) {
+  const containerRef = useRef<HTMLDivElement>(null)
+  const hasRequestedRef = useRef(false)
+
+  useEffect(() => {
+    if (!containerRef.current || previewUrl || hasRequestedRef.current) return
+
+    const observer = new IntersectionObserver(
+      (entries) => {
+        entries.forEach((entry) => {
+          if (entry.isIntersecting && !hasRequestedRef.current) {
+            hasRequestedRef.current = true
+            requestPreview(path)
+            observer.disconnect()
+          }
+        })
+      },
+      { rootMargin: '100px' }
+    )
+
+    observer.observe(containerRef.current)
+
+    return () => observer.disconnect()
+  }, [path, previewUrl, requestPreview])
+
+  return (
+    <div ref={containerRef} className={`w-full h-full flex items-center justify-center ${className}`}>
+      {previewUrl ? (
+        <img
+          src={previewUrl}
+          alt={alt}
+          loading="lazy"
+          className="w-full h-full object-cover pattern-preview"
+        />
+      ) : (
+        <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
+          image
+        </span>
+      )}
+    </div>
+  )
+}