Kaynağa Gözat

Add pattern history display, queue management, and multi-table UX improvements

- Add pattern execution history showing last run time and speed in browse panel
- Add "Play Next" and "Add to Queue" buttons to insert patterns into running playlist
- Replace custom slide-in panel with shadcn Sheet component for better UX
- Show table name in header when multiple tables are connected
- Fix CORS configuration for cross-origin multi-table access
- Cache parsed coordinates in state to avoid re-parsing large files on Pi Zero 2W
- Use thread executor instead of process pool for coordinate parsing (reduces memory pressure)
- Add Now Playing button to desktop header for quick access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 hafta önce
ebeveyn
işleme
c40544c91e

+ 27 - 11
frontend/index.html

@@ -15,18 +15,34 @@
 
     <!-- Check for custom favicon -->
     <script>
-      fetch('/api/settings')
-        .then(r => r.json())
-        .then(settings => {
-          if (settings.app && settings.app.custom_logo) {
-            document.getElementById('favicon-ico').href = '/static/custom/favicon.ico';
-            document.getElementById('apple-touch-icon').href = '/static/custom/' + settings.app.custom_logo;
+      // Get base URL for active table (supports multi-table connections)
+      (function() {
+        var baseUrl = '';
+        try {
+          var stored = localStorage.getItem('duneweaver_tables');
+          var activeId = localStorage.getItem('duneweaver_active_table');
+          if (stored && activeId) {
+            var data = JSON.parse(stored);
+            var active = (data.tables || []).find(function(t) { return t.id === activeId; });
+            if (active && !active.isCurrent && active.url && active.url !== window.location.origin) {
+              baseUrl = active.url.replace(/\/$/, '');
+            }
           }
-          if (settings.app && settings.app.name) {
-            document.title = settings.app.name;
-          }
-        })
-        .catch(() => {});
+        } catch (e) {}
+
+        fetch(baseUrl + '/api/settings')
+          .then(function(r) { return r.json(); })
+          .then(function(settings) {
+            if (settings.app && settings.app.custom_logo) {
+              document.getElementById('favicon-ico').href = baseUrl + '/static/custom/favicon.ico';
+              document.getElementById('apple-touch-icon').href = baseUrl + '/static/custom/' + settings.app.custom_logo;
+            }
+            if (settings.app && settings.app.name) {
+              document.title = settings.app.name;
+            }
+          })
+          .catch(function() {});
+      })();
     </script>
   </head>
   <body>

Dosya farkı çok büyük olduğundan ihmal edildi
+ 807 - 47
frontend/package-lock.json


+ 1 - 0
frontend/package.json

@@ -54,6 +54,7 @@
     "globals": "^16.5.0",
     "lucide-react": "^0.562.0",
     "postcss": "^8.5.6",
+    "shadcn": "^3.7.0",
     "tailwind-merge": "^3.4.0",
     "tailwindcss": "^4.1.18",
     "tailwindcss-animate": "^1.0.7",

+ 51 - 3
frontend/src/components/NowPlayingBar.tsx

@@ -84,6 +84,10 @@ interface SortableQueueItemProps {
   file: string
   index: number
   previewUrl: string | null
+  isFirst: boolean
+  isLast: boolean
+  onMoveToTop: () => void
+  onMoveToBottom: () => void
 }
 
 function SortableQueueItem({
@@ -91,6 +95,10 @@ function SortableQueueItem({
   file,
   index,
   previewUrl,
+  isFirst,
+  isLast,
+  onMoveToTop,
+  onMoveToBottom,
 }: SortableQueueItemProps) {
   const {
     attributes,
@@ -112,7 +120,7 @@ function SortableQueueItem({
     <div
       ref={setNodeRef}
       style={style}
-      className={`flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
+      className={`group flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
     >
       {/* Drag handle */}
       <div
@@ -124,7 +132,7 @@ function SortableQueueItem({
       </div>
 
       {/* Preview thumbnail */}
-      <div className="w-14 h-14 rounded-full overflow-hidden bg-muted border shrink-0">
+      <div className="w-28 h-28 rounded-full overflow-hidden bg-muted border shrink-0">
         {previewUrl ? (
           <img
             src={previewUrl}
@@ -134,7 +142,7 @@ function SortableQueueItem({
           />
         ) : (
           <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
+            <span className="material-icons-outlined text-muted-foreground text-4xl">image</span>
           </div>
         )}
       </div>
@@ -144,6 +152,26 @@ function SortableQueueItem({
         <p className="text-sm truncate">{formatPatternName(file)}</p>
         <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
+
+      {/* Move to top/bottom buttons - visible on hover */}
+      <div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
+        <button
+          onClick={onMoveToTop}
+          disabled={isFirst}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to top"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_top</span>
+        </button>
+        <button
+          onClick={onMoveToBottom}
+          disabled={isLast}
+          className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
+          title="Move to bottom"
+        >
+          <span className="material-icons-outlined text-sm">vertical_align_bottom</span>
+        </button>
+      </div>
     </div>
   )
 }
@@ -757,6 +785,19 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     }
   }
 
+  // Helper to move queue item to a specific position
+  const moveToPosition = async (fromIndex: number, toIndex: number) => {
+    if (fromIndex === toIndex) return
+    try {
+      await apiClient.post('/reorder_playlist', {
+        from_index: fromIndex,
+        to_index: toIndex
+      })
+    } catch {
+      toast.error('Failed to reorder')
+    }
+  }
+
   // Don't render if not visible
   if (!isVisible) {
     return null
@@ -1219,6 +1260,9 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
                 }
 
+                const firstUpcomingIndex = upcomingFiles[0].index
+                const lastUpcomingIndex = upcomingFiles[upcomingFiles.length - 1].index
+
                 return (
                   <DndContext
                     sensors={sensors}
@@ -1237,6 +1281,10 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                             file={file}
                             index={index}
                             previewUrl={queuePreviews[file] || null}
+                            isFirst={index === firstUpcomingIndex}
+                            isLast={index === lastUpcomingIndex}
+                            onMoveToTop={() => moveToPosition(index, firstUpcomingIndex)}
+                            onMoveToBottom={() => moveToPosition(index, lastUpcomingIndex)}
                           />
                         ))}
                       </div>

+ 1 - 1
frontend/src/components/TableSelector.tsx

@@ -128,7 +128,7 @@ export function TableSelector() {
           >
             <Layers className="h-4 w-4" />
             <span className="hidden sm:inline max-w-[120px] truncate">
-              {activeTable?.name || 'Select Table'}
+              {activeTable?.appName || activeTable?.name || 'Select Table'}
             </span>
             <ChevronDown className="h-3 w-3 opacity-50" />
           </Button>

+ 50 - 20
frontend/src/components/layout/Layout.tsx

@@ -24,7 +24,10 @@ export function Layout() {
   const location = useLocation()
 
   // Multi-table context - must be called before any hooks that depend on activeTable
-  const { activeTable } = useTable()
+  const { activeTable, tables } = useTable()
+
+  // Use table name as app name when multiple tables exist
+  const hasMultipleTables = tables.length > 1
 
   const [isDark, setIsDark] = useState(() => {
     if (typeof window !== 'undefined') {
@@ -39,6 +42,9 @@ export function Layout() {
   const [appName, setAppName] = useState(DEFAULT_APP_NAME)
   const [customLogo, setCustomLogo] = useState<string | null>(null)
 
+  // Display name: use table name when multiple tables exist, otherwise use settings app name
+  const displayName = hasMultipleTables && activeTable?.name ? activeTable.name : appName
+
   // Connection status
   const [isConnected, setIsConnected] = useState(false)
   const [isBackendConnected, setIsBackendConnected] = useState(false)
@@ -153,8 +159,12 @@ export function Layout() {
   // Now Playing bar state
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
+  const [currentPlayingFile, setCurrentPlayingFile] = useState<string | null>(null) // Track current file for header button
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
 
+  // Derive isCurrentlyPlaying from currentPlayingFile
+  const isCurrentlyPlaying = Boolean(currentPlayingFile)
+
   // Listen for playback-started event (dispatched when user starts a pattern)
   useEffect(() => {
     const handlePlaybackStarted = () => {
@@ -232,7 +242,11 @@ export function Layout() {
               setIsHoming(newIsHoming)
             }
             // Auto-open/close Now Playing bar based on playback state
-            const isPlaying = data.data.is_running || data.data.is_paused
+            // Track current file - this is the most reliable indicator of playback
+            const currentFile = data.data.current_file || null
+            setCurrentPlayingFile(currentFile)
+
+            const isPlaying = Boolean(currentFile) || Boolean(data.data.is_running) || Boolean(data.data.is_paused)
             // Skip auto-open on first message (page refresh) - only react to state changes
             if (wasPlayingRef.current !== null) {
               if (isPlaying && !wasPlayingRef.current) {
@@ -283,6 +297,7 @@ export function Layout() {
     const unsubscribe = apiClient.onBaseUrlChange(() => {
       if (isMounted) {
         wasPlayingRef.current = null // Reset playing state for new table
+        setCurrentPlayingFile(null) // Reset playback state for new table
         setIsConnected(false) // Reset connection status until new table reports
         setIsBackendConnected(false) // Show connecting state
         connectWebSocket()
@@ -509,11 +524,11 @@ export function Layout() {
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
     if (currentNav) {
-      document.title = `${currentNav.title} | ${appName}`
+      document.title = `${currentNav.title} | ${displayName}`
     } else {
-      document.title = appName
+      document.title = displayName
     }
-  }, [location.pathname, appName])
+  }, [location.pathname, displayName])
 
   useEffect(() => {
     if (isDark) {
@@ -1128,11 +1143,11 @@ export function Layout() {
         <div className="flex h-14 items-center justify-between px-4">
           <Link to="/" className="flex items-center gap-2">
             <img
-              src={customLogo ? `/static/custom/${customLogo}` : '/static/android-chrome-192x192.png'}
-              alt={appName}
+              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"
             />
-            <span className="font-semibold text-lg">{appName}</span>
+            <span className="font-semibold text-lg">{displayName}</span>
             <span
               className={`w-2 h-2 rounded-full ${
                 !isBackendConnected
@@ -1153,7 +1168,19 @@ export function Layout() {
 
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-1">
-            <TableSelector />
+            {/* 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>
             <Button
               variant="ghost"
               size="icon"
@@ -1196,10 +1223,24 @@ export function Layout() {
             >
               <span className="material-icons-outlined">power_settings_new</span>
             </Button>
+            <TableSelector />
           </div>
 
           {/* Mobile actions */}
           <div className="flex md:hidden items-center gap-1">
+            {/* 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}>
               <PopoverTrigger asChild>
@@ -1291,17 +1332,6 @@ export function Layout() {
         onClose={() => setIsNowPlayingOpen(false)}
       />
 
-      {/* Floating Now Playing Button */}
-      {!isNowPlayingOpen && (
-        <button
-          onClick={() => setIsNowPlayingOpen(true)}
-          className="fixed right-4 z-30 w-12 h-12 rounded-full bg-primary text-primary-foreground shadow-lg flex items-center justify-center transition-all duration-200 hover:bg-primary/90 hover:shadow-xl hover:scale-110 active:scale-95"
-          style={{ bottom: 'calc(5rem + env(safe-area-inset-bottom, 0px))' }}
-          title="Now Playing"
-        >
-          <span className="material-icons">play_circle</span>
-        </button>
-      )}
 
       {/* Logs Drawer */}
       <div

+ 138 - 0
frontend/src/components/ui/sheet.tsx

@@ -0,0 +1,138 @@
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Overlay
+    className={cn(
+      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+    ref={ref}
+  />
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+  {
+    variants: {
+      side: {
+        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+        bottom:
+          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+        right:
+          "inset-y-0 right-0 h-full w-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-md",
+      },
+    },
+    defaultVariants: {
+      side: "right",
+    },
+  }
+)
+
+interface SheetContentProps
+  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+    VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Content>,
+  SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+  <SheetPortal>
+    <SheetOverlay />
+    <SheetPrimitive.Content
+      ref={ref}
+      className={cn(sheetVariants({ side }), className)}
+      {...props}
+    >
+      {children}
+      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </SheetPrimitive.Close>
+    </SheetPrimitive.Content>
+  </SheetPortal>
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-2 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Title
+    ref={ref}
+    className={cn("text-lg font-semibold text-foreground", className)}
+    {...props}
+  />
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+  Sheet,
+  SheetPortal,
+  SheetOverlay,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+}

+ 1 - 0
frontend/src/contexts/TableContext.tsx

@@ -12,6 +12,7 @@ import { apiClient } from '@/lib/apiClient'
 export interface Table {
   id: string
   name: string
+  appName?: string // Application name from settings (e.g., "Dune Weaver")
   url: string
   host?: string
   port?: number

+ 147 - 110
frontend/src/pages/BrowsePage.tsx

@@ -21,6 +21,12 @@ import {
   SelectTrigger,
   SelectValue,
 } from '@/components/ui/select'
+import {
+  Sheet,
+  SheetContent,
+  SheetHeader,
+  SheetTitle,
+} from '@/components/ui/sheet'
 
 // Types
 interface PatternMetadata {
@@ -89,6 +95,12 @@ export function BrowsePage() {
   const [speed, setSpeed] = useState(1)
   const [progress, setProgress] = useState(0)
 
+  // Pattern execution history state
+  const [patternHistory, setPatternHistory] = useState<{
+    actual_time_formatted: string | null
+    speed: number | null
+  } | null>(null)
+
   // Canvas and animation refs
   const canvasRef = useRef<HTMLCanvasElement>(null)
   const animationRef = useRef<number | null>(null)
@@ -565,36 +577,27 @@ export function BrowsePage() {
     }
   }, [coordinates, drawPattern])
 
-  const handlePatternClick = (pattern: PatternMetadata) => {
+  const handlePatternClick = async (pattern: PatternMetadata) => {
     setSelectedPattern(pattern)
     setIsPanelOpen(true)
     setPreExecution('adaptive')
-  }
+    setPatternHistory(null) // Reset while loading
 
-  const handleClosePanel = () => {
-    setIsPanelOpen(false)
-  }
-
-  // Swipe to close panel handling
-  const panelRef = useRef<HTMLDivElement>(null)
-  const panelTouchStartX = useRef<number | null>(null)
-
-  const handlePanelTouchStart = (e: React.TouchEvent) => {
-    panelTouchStartX.current = e.touches[0].clientX
-  }
-  const handlePanelTouchEnd = (e: React.TouchEvent) => {
-    if (panelTouchStartX.current === null) return
-    const touchEndX = e.changedTouches[0].clientX
-    const deltaX = touchEndX - panelTouchStartX.current
-    // Swipe right more than 50px to close
-    if (deltaX > 50) {
-      handleClosePanel()
+    // Fetch pattern execution history
+    try {
+      const history = await apiClient.get<{
+        actual_time_formatted: string | null
+        speed: number | null
+      }>(`/api/pattern_history/${encodeURIComponent(pattern.path)}`)
+      setPatternHistory(history)
+    } catch {
+      // Silently ignore - history is optional
     }
-    panelTouchStartX.current = null
   }
 
   const handleOpenAnimatedPreview = async () => {
     if (!selectedPattern) return
+    setIsPanelOpen(false) // Close sheet before opening preview
     setIsAnimatedPreviewOpen(true)
     setIsPlaying(false)
     setProgress(0)
@@ -698,6 +701,25 @@ export function BrowsePage() {
     }
   }
 
+  const handleAddToQueue = async (position: 'next' | 'end') => {
+    if (!selectedPattern) return
+
+    try {
+      await apiClient.post('/add_to_queue', {
+        pattern: selectedPattern.path,
+        position,
+      })
+      toast.success(position === 'next' ? 'Playing next' : 'Added to queue')
+    } catch (error) {
+      const message = error instanceof Error ? error.message : 'Failed to add to queue'
+      if (message.includes('400') || message.includes('No playlist')) {
+        toast.error('No playlist is currently running')
+      } else {
+        toast.error(message)
+      }
+    }
+  }
+
   const getPreviewUrl = (path: string) => {
     const preview = previews[path]
     return preview?.image_data || null
@@ -705,7 +727,7 @@ export function BrowsePage() {
 
   const formatCoordinate = (coord: { x: number; y: number } | null) => {
     if (!coord) return '(-, -)'
-    return `(${coord.x.toFixed(2)}, ${coord.y.toFixed(2)})`
+    return `(${coord.x.toFixed(1)}, ${coord.y.toFixed(1)})`
   }
 
   const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
@@ -781,7 +803,7 @@ export function BrowsePage() {
   }
 
   return (
-    <div className={`flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4 transition-all duration-300 ${isPanelOpen ? 'lg:mr-[28rem]' : ''}`}>
+    <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4">
       {/* Hidden file input for pattern upload */}
       <input
         ref={fileInputRef}
@@ -957,67 +979,57 @@ export function BrowsePage() {
 
       <div className="h-48" />
 
-      {/* Slide-in Preview Panel */}
-      <div
-        className={`fixed top-0 bottom-0 right-0 w-full max-w-md transform transition-transform duration-300 ease-in-out z-40 ${
-          isPanelOpen ? 'translate-x-0' : 'translate-x-full'
-        }`}
-        ref={panelRef}
-        onTouchStart={handlePanelTouchStart}
-        onTouchEnd={handlePanelTouchEnd}
-      >
-        <div className="h-full bg-background border-l shadow-xl flex flex-col">
-          <header className="flex h-14 items-center justify-between border-b px-4 shrink-0">
-            <h2 className="text-lg font-semibold truncate pr-4">
+      {/* Pattern Details Sheet */}
+      <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
+        <SheetContent className="flex flex-col p-0 overflow-hidden">
+          <SheetHeader className="px-6 py-4 shrink-0">
+            <SheetTitle className="truncate pr-8">
               {selectedPattern?.name || 'Pattern Details'}
-            </h2>
-            <Button
-              variant="ghost"
-              size="icon"
-              onClick={handleClosePanel}
-              className="rounded-full text-muted-foreground"
-            >
-              <span className="material-icons-outlined">close</span>
-            </Button>
-          </header>
+            </SheetTitle>
+          </SheetHeader>
 
           {selectedPattern && (
             <div className="p-6 overflow-y-auto flex-1">
               {/* Clickable Round Preview Image */}
-              <div
-                className="mb-6 aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
-                onClick={handleOpenAnimatedPreview}
-              >
-                {getPreviewUrl(selectedPattern.path) ? (
-                  <img
-                    src={getPreviewUrl(selectedPattern.path)!}
-                    alt={selectedPattern.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-4xl text-muted-foreground">
-                      image
-                    </span>
-                  </div>
-                )}
-                {/* Play overlay on hover */}
-                <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20">
-                  <div className="bg-background rounded-full w-12 h-12 flex items-center justify-center shadow-lg">
-                    <span className="material-icons text-2xl">play_arrow</span>
+              <div className="mb-6">
+                <div
+                  className="aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
+                  onClick={handleOpenAnimatedPreview}
+                >
+                  {getPreviewUrl(selectedPattern.path) ? (
+                    <img
+                      src={getPreviewUrl(selectedPattern.path)!}
+                      alt={selectedPattern.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-4xl text-muted-foreground">
+                        image
+                      </span>
+                    </div>
+                  )}
+                  {/* Play badge - always visible */}
+                  <div className="absolute bottom-2 right-2 bg-background/90 backdrop-blur-sm rounded-full w-10 h-10 flex items-center justify-center shadow-md border group-hover:scale-110 transition-transform">
+                    <span className="material-icons text-xl">play_arrow</span>
                   </div>
+                  {/* Hover overlay */}
+                  <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20 rounded-full" />
                 </div>
+                <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
               </div>
 
               {/* Coordinates */}
-              <div className="mb-6 flex justify-between text-sm">
+              <div className="mb-4 flex justify-between text-sm">
                 <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">flag</span>
                   <span className="text-muted-foreground">First:</span>
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
                   </span>
                 </div>
                 <div className="flex items-center gap-2">
+                  <span className="material-icons-outlined text-muted-foreground text-base">check</span>
                   <span className="text-muted-foreground">Last:</span>
                   <span className="font-semibold">
                     {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
@@ -1025,6 +1037,24 @@ export function BrowsePage() {
                 </div>
               </div>
 
+              {/* Last Played Info */}
+              {patternHistory?.actual_time_formatted && (
+                <div className="mb-4 flex justify-between text-sm">
+                  <div className="flex items-center gap-2">
+                    <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
+                    <span className="text-muted-foreground">Last run:</span>
+                    <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
+                  </div>
+                  {patternHistory.speed !== null && (
+                    <div className="flex items-center gap-2">
+                      <span className="material-icons-outlined text-muted-foreground text-base">speed</span>
+                      <span className="text-muted-foreground">Speed:</span>
+                      <span className="font-semibold">{patternHistory.speed}</span>
+                    </div>
+                  )}
+                </div>
+              )}
+
               {/* Pre-Execution Options */}
               <div className="mb-6">
                 <Label className="text-sm font-semibold mb-3 block">Pre-Execution Action</Label>
@@ -1054,58 +1084,65 @@ export function BrowsePage() {
 
               {/* Action Buttons */}
               <div className="space-y-3">
-                <Button
-                  onClick={handleRunPattern}
-                  disabled={isRunning}
-                  className="w-full gap-2"
-                  size="lg"
-                >
-                  {isRunning ? (
-                    <span className="material-icons-outlined animate-spin text-lg">sync</span>
-                  ) : (
-                    <span className="material-icons text-lg">play_arrow</span>
+                {/* Play + Delete row */}
+                <div className="flex gap-2">
+                  <Button
+                    onClick={handleRunPattern}
+                    disabled={isRunning}
+                    className="flex-1 gap-2"
+                    size="lg"
+                  >
+                    {isRunning ? (
+                      <span className="material-icons-outlined animate-spin text-lg">sync</span>
+                    ) : (
+                      <span className="material-icons text-lg">play_arrow</span>
+                    )}
+                    Play
+                  </Button>
+
+                  {canDelete && (
+                    <Button
+                      variant="outline"
+                      onClick={handleDeletePattern}
+                      className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
+                      size="lg"
+                    >
+                      <span className="material-icons text-lg">delete</span>
+                    </Button>
                   )}
-                  Play
-                </Button>
-
-                <Button
-                  variant="secondary"
-                  onClick={handleDeletePattern}
-                  disabled={!canDelete}
-                  className={`w-full gap-2 ${
-                    canDelete
-                      ? 'border-destructive text-destructive hover:bg-destructive/10'
-                      : 'opacity-50 cursor-not-allowed'
-                  }`}
-                  size="lg"
-                >
-                  <span className="material-icons text-lg">delete</span>
-                  Delete
-                </Button>
+                </div>
 
-                {!canDelete && selectedPattern && (
-                  <p className="text-xs text-muted-foreground text-center">
-                    Only custom patterns can be deleted
-                  </p>
-                )}
+                {/* Queue buttons */}
+                <div className="flex gap-2">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('next')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_play</span>
+                    Play Next
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    className="flex-1 gap-1.5"
+                    onClick={() => handleAddToQueue('end')}
+                  >
+                    <span className="material-icons-outlined text-base">playlist_add</span>
+                    Add to Queue
+                  </Button>
+                </div>
               </div>
             </div>
           )}
-        </div>
-      </div>
-
-      {/* Backdrop for mobile panel */}
-      {isPanelOpen && (
-        <div
-          className="fixed inset-0 bg-black/50 z-30 lg:hidden"
-          onClick={handleClosePanel}
-        />
-      )}
+        </SheetContent>
+      </Sheet>
 
       {/* Animated Preview Modal */}
       {isAnimatedPreviewOpen && (
         <div
-          className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4"
+          className="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4"
           onClick={handleCloseAnimatedPreview}
         >
           <div

+ 7 - 7
frontend/src/pages/SettingsPage.tsx

@@ -489,16 +489,16 @@ export function SettingsPage() {
   const updateBranding = (customLogo: string | null) => {
     const timestamp = Date.now() // Cache buster
 
-    // Update favicon links
+    // Update favicon links (use apiClient.getAssetUrl for multi-table support)
     const faviconIco = document.getElementById('favicon-ico') as HTMLLinkElement
     const appleTouchIcon = document.getElementById('apple-touch-icon') as HTMLLinkElement
 
     if (customLogo) {
-      if (faviconIco) faviconIco.href = `/static/custom/favicon.ico?v=${timestamp}`
-      if (appleTouchIcon) appleTouchIcon.href = `/static/custom/${customLogo}?v=${timestamp}`
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/custom/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/custom/${customLogo}?v=${timestamp}`)
     } else {
-      if (faviconIco) faviconIco.href = `/static/favicon.ico?v=${timestamp}`
-      if (appleTouchIcon) appleTouchIcon.href = `/static/apple-touch-icon.png?v=${timestamp}`
+      if (faviconIco) faviconIco.href = apiClient.getAssetUrl(`/static/favicon.ico?v=${timestamp}`)
+      if (appleTouchIcon) appleTouchIcon.href = apiClient.getAssetUrl(`/static/apple-touch-icon.png?v=${timestamp}`)
     }
 
     // Dispatch event for Layout to update header logo
@@ -1113,13 +1113,13 @@ export function SettingsPage() {
                   <div className="w-16 h-16 rounded-full overflow-hidden border bg-background flex items-center justify-center shrink-0">
                     {settings.custom_logo ? (
                       <img
-                        src={`/static/custom/${settings.custom_logo}`}
+                        src={apiClient.getAssetUrl(`/static/custom/${settings.custom_logo}`)}
                         alt="Custom Logo"
                         className="w-full h-full object-cover"
                       />
                     ) : (
                       <img
-                        src="/static/android-chrome-192x192.png"
+                        src={apiClient.getAssetUrl('/static/android-chrome-192x192.png')}
                         alt="Default Logo"
                         className="w-full h-full object-cover"
                       />

+ 1 - 0
frontend/vite.config.ts

@@ -71,6 +71,7 @@ export default defineConfig({
       '/resume_execution': 'http://localhost:8080',
       '/skip_pattern': 'http://localhost:8080',
       '/reorder_playlist': 'http://localhost:8080',
+      '/add_to_queue': 'http://localhost:8080',
       '/run_theta_rho': 'http://localhost:8080',
       '/run_playlist': 'http://localhost:8080',
       // Movement

+ 109 - 14
main.py

@@ -288,10 +288,11 @@ app = FastAPI(lifespan=lifespan)
 
 # Add CORS middleware to allow cross-origin requests from other Dune Weaver frontends
 # This enables multi-table control from a single frontend
+# Note: allow_credentials must be False when allow_origins=["*"] (browser security requirement)
 app.add_middleware(
     CORSMiddleware,
     allow_origins=["*"],  # Allow all origins for local network access
-    allow_credentials=True,
+    allow_credentials=False,
     allow_methods=["*"],
     allow_headers=["*"],
 )
@@ -730,7 +731,7 @@ async def update_settings(settings_update: SettingsUpdate):
     Only include the categories and fields you want to update.
     All fields are optional - only provided values will be updated.
 
-    Example: {"app": {"name": "My Sand Table"}, "auto_play": {"enabled": true}}
+    Example: {"app": {"name": "Dune Weaver"}, "auto_play": {"enabled": true}}
     """
     updated_categories = []
     requires_restart = False
@@ -942,7 +943,7 @@ async def update_table_info(update: TableInfoUpdate):
     The table ID is immutable after generation.
     """
     if update.name is not None:
-        state.table_name = update.name.strip() or "My Sand Table"
+        state.table_name = update.name.strip() or "Dune Weaver"
         state.save()
         logger.info(f"Table name updated to: {state.table_name}")
 
@@ -1503,26 +1504,39 @@ async def get_theta_rho_coordinates(request: GetCoordinatesRequest):
         # Normalize file path for cross-platform compatibility and remove prefixes
         file_name = normalize_file_path(request.file_name)
         file_path = os.path.join(THETA_RHO_DIR, file_name)
-        
+
+        # Check if we can use cached coordinates (already loaded for current playback)
+        # This avoids re-parsing large files (2MB+) which can cause issues on Pi Zero 2W
+        current_file = state.current_playing_file
+        if current_file and state._current_coordinates:
+            # Normalize current file path for comparison
+            current_normalized = normalize_file_path(current_file)
+            if current_normalized == file_name:
+                logger.debug(f"Using cached coordinates for {file_name}")
+                return {
+                    "success": True,
+                    "coordinates": state._current_coordinates,
+                    "total_points": len(state._current_coordinates)
+                }
+
         # Check file existence asynchronously
         exists = await asyncio.to_thread(os.path.exists, file_path)
         if not exists:
             raise HTTPException(status_code=404, detail=f"File {file_name} not found")
 
-        # Parse the theta-rho file in a separate process for CPU-intensive work
-        # This prevents blocking the motion control thread
-        loop = asyncio.get_running_loop()
-        coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, file_path)
-        
+        # Parse the theta-rho file in a thread (not process) to avoid memory pressure
+        # on resource-constrained devices like Pi Zero 2W
+        coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
+
         if not coordinates:
             raise HTTPException(status_code=400, detail="No valid coordinates found in file")
-        
+
         return {
             "success": True,
             "coordinates": coordinates,
             "total_points": len(coordinates)
         }
-        
+
     except Exception as e:
         logger.error(f"Error getting coordinates for {request.file_name}: {str(e)}")
         raise HTTPException(status_code=500, detail=str(e))
@@ -1771,6 +1785,24 @@ async def preview_thr(request: DeleteFileRequest):
         logger.error(f"Failed to generate or serve preview for {request.file_name}: {str(e)}")
         raise HTTPException(status_code=500, detail=f"Failed to serve preview image: {str(e)}")
 
+@app.get("/api/pattern_history/{pattern_name:path}")
+async def get_pattern_history(pattern_name: str):
+    """Get the most recent execution history for a pattern.
+
+    Returns the last completed execution time and speed for the given pattern.
+    """
+    from modules.core.pattern_manager import get_pattern_execution_history
+
+    # Get just the filename if a full path was provided
+    filename = os.path.basename(pattern_name)
+    if not filename.endswith('.thr'):
+        filename = f"{filename}.thr"
+
+    history = get_pattern_execution_history(filename)
+    if history:
+        return history
+    return {"actual_time_seconds": None, "actual_time_formatted": None, "speed": None, "timestamp": None}
+
 @app.get("/preview/{encoded_filename}")
 async def serve_preview(encoded_filename: str):
     """Serve a preview image for a pattern file."""
@@ -2227,6 +2259,43 @@ async def reorder_playlist(request: dict):
 
     return {"success": True}
 
+@app.post("/add_to_queue")
+async def add_to_queue(request: dict):
+    """Add a pattern to the current playlist queue.
+
+    Args:
+        pattern: The pattern file path to add (e.g., 'circle.thr' or 'subdirectory/pattern.thr')
+        position: 'next' to play after current pattern, 'end' to add to end of queue
+    """
+    if not state.current_playlist:
+        raise HTTPException(status_code=400, detail="No playlist is currently running")
+
+    pattern = request.get("pattern")
+    position = request.get("position", "end")  # 'next' or 'end'
+
+    if not pattern:
+        raise HTTPException(status_code=400, detail="pattern is required")
+
+    # Verify the pattern file exists
+    pattern_path = os.path.join(pattern_manager.THETA_RHO_DIR, pattern)
+    if not os.path.exists(pattern_path):
+        raise HTTPException(status_code=404, detail="Pattern file not found")
+
+    playlist = list(state.current_playlist)
+    current_index = state.current_playlist_index
+
+    if position == "next":
+        # Insert right after the current pattern
+        insert_index = current_index + 1
+    else:
+        # Add to end
+        insert_index = len(playlist)
+
+    playlist.insert(insert_index, pattern)
+    state.current_playlist = playlist
+
+    return {"success": True, "position": insert_index}
+
 @app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
 async def get_custom_clear_patterns():
     """Get the currently configured custom clear patterns."""
@@ -2653,6 +2722,15 @@ async def preview_thr_batch(request: dict):
 
     async def process_single_file(file_name):
         """Process a single file and return its preview data."""
+        # Check in-memory cache first (for current and next playing patterns)
+        normalized_for_cache = normalize_file_path(file_name)
+        if state._current_preview and state._current_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for current: {file_name}")
+            return file_name, state._current_preview[1]
+        if state._next_preview and state._next_preview[0] == normalized_for_cache:
+            logger.debug(f"Using cached preview for next: {file_name}")
+            return file_name, state._next_preview[1]
+
         # Acquire semaphore to limit concurrent processing
         async with get_preview_semaphore():
             t1 = time.time()
@@ -2685,9 +2763,8 @@ async def preview_thr_batch(request: dict):
                     last_coord_obj = metadata.get('last_coordinate')
                 else:
                     logger.debug(f"Metadata cache miss for {file_name}, parsing file")
-                    # Use process pool for CPU-intensive parsing
-                    loop = asyncio.get_running_loop()
-                    coordinates = await loop.run_in_executor(pool_module.get_pool(), parse_theta_rho_file, pattern_file_path)
+                    # Use thread pool to avoid memory pressure on resource-constrained devices
+                    coordinates = await asyncio.to_thread(parse_theta_rho_file, pattern_file_path)
                     first_coord = coordinates[0] if coordinates else None
                     last_coord = coordinates[-1] if coordinates else None
                     first_coord_obj = {"x": first_coord[0], "y": first_coord[1]} if first_coord else None
@@ -2701,6 +2778,24 @@ async def preview_thr_batch(request: dict):
                     "first_coordinate": first_coord_obj,
                     "last_coordinate": last_coord_obj
                 }
+
+                # Cache preview for current/next pattern to speed up subsequent requests
+                current_file = state.current_playing_file
+                if current_file:
+                    current_normalized = normalize_file_path(current_file)
+                    if normalized_file_name == current_normalized:
+                        state._current_preview = (normalized_file_name, result)
+                        logger.debug(f"Cached preview for current: {file_name}")
+                    elif state.current_playlist:
+                        # Check if this is the next pattern in playlist
+                        playlist = state.current_playlist
+                        idx = state.current_playlist_index
+                        if idx is not None and idx + 1 < len(playlist):
+                            next_file = normalize_file_path(playlist[idx + 1])
+                            if normalized_file_name == next_file:
+                                state._next_preview = (normalized_file_name, result)
+                                logger.debug(f"Cached preview for next: {file_name}")
+
                 logger.debug(f"Processed {file_name} in {time.time() - t1:.2f}s")
                 return file_name, result
             except Exception as e:

+ 46 - 0
modules/core/pattern_manager.py

@@ -107,6 +107,49 @@ def get_last_completed_execution_time(pattern_name: str, speed: float) -> Option
         logger.error(f"Failed to read execution time log: {e}")
         return None
 
+def get_pattern_execution_history(pattern_name: str) -> Optional[dict]:
+    """Get the most recent completed execution for a pattern (any speed).
+
+    Args:
+        pattern_name: Name of the pattern file (e.g., 'circle.thr')
+
+    Returns:
+        Dict with execution time info if found, None otherwise.
+        Format: {"actual_time_seconds": float, "actual_time_formatted": str,
+                 "speed": int, "timestamp": str}
+    """
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return None
+
+    try:
+        matching_entry = None
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns
+                    if (entry.get('completed', False) and
+                        entry.get('pattern_name') == pattern_name):
+                        # Keep the most recent match (last one in file)
+                        matching_entry = entry
+                except json.JSONDecodeError:
+                    continue
+
+        if matching_entry:
+            return {
+                "actual_time_seconds": matching_entry.get('actual_time_seconds'),
+                "actual_time_formatted": matching_entry.get('actual_time_formatted'),
+                "speed": matching_entry.get('speed'),
+                "timestamp": matching_entry.get('timestamp')
+            }
+        return None
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return None
+
 # Asyncio primitives - initialized lazily to avoid event loop issues
 # These must be created in the context of the running event loop
 pause_event: Optional[asyncio.Event] = None
@@ -765,6 +808,9 @@ async def _execute_pattern_internal(file_path):
     coordinates = await asyncio.to_thread(parse_theta_rho_file, file_path)
     total_coordinates = len(coordinates)
 
+    # Cache coordinates in state for frontend preview (avoids re-parsing large files)
+    state._current_coordinates = coordinates
+
     if total_coordinates < 2:
         logger.warning("Not enough coordinates for interpolation")
         return False

+ 12 - 3
modules/core/state.py

@@ -15,6 +15,9 @@ class AppState:
     def __init__(self):
         # Private variables for properties
         self._current_playing_file = None
+        self._current_coordinates = None  # Cache parsed coordinates for current file (avoids re-parsing large files)
+        self._current_preview = None  # Cache (file_name, base64_data) for current pattern preview
+        self._next_preview = None  # Cache (file_name, base64_data) for next pattern preview
         self._pause_requested = False
         self._speed = 100
         self._current_playlist = None
@@ -105,7 +108,7 @@ class AppState:
 
         # Multi-table identity (for network discovery)
         self.table_id = str(uuid.uuid4())  # UUID generated on first run, persistent across restarts
-        self.table_name = "My Sand Table"  # User-customizable table name
+        self.table_name = "Dune Weaver"  # User-customizable table name
 
         # Custom branding settings (filenames only, files stored in static/custom/)
         # Favicon is auto-generated from logo as logo-favicon.ico
@@ -152,8 +155,14 @@ class AppState:
 
     @current_playing_file.setter
     def current_playing_file(self, value):
+        # Clear cached data when file changes or is unset
+        if value != self._current_playing_file or value is None:
+            self._current_coordinates = None
+            self._current_preview = None
+            self._next_preview = None
+
         self._current_playing_file = value
-        
+
         # force an empty string (and not None) if we need to unset
         if value == None:
             value = ""
@@ -378,7 +387,7 @@ class AppState:
         self.table_id = data.get("table_id", None)
         if self.table_id is None:
             self.table_id = str(uuid.uuid4())
-        self.table_name = data.get("table_name", "My Sand Table")
+        self.table_name = data.get("table_name", "Dune Weaver")
         self.custom_logo = data.get("custom_logo", None)
         self.auto_play_enabled = data.get("auto_play_enabled", False)
         self.auto_play_playlist = data.get("auto_play_playlist", None)

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor