Selaa lähdekoodia

Add app security, settings/state split, and browse page improvements

- Split state.json into state.json (runtime) + settings.json (user config) with auto-migration
- Add security feature: Full Lockdown and Play Only modes with SHA-256 password
- Play Only mode hides Settings, Table Control, upload, and playlist editing
- Add lock/unlock button in header bar when security is active
- Base64 encode MQTT password in settings.json
- Add play count and last run time stats to pattern cards in browse page
- Default Most Played and Last Played sort to descending order
- Equalize Select dropdown left/right padding
- Fix TableControlPage tests: reset Zustand store in beforeEach to prevent
  real WebSocket status data from leaking into test environment
- Add settings.json to .gitignore
- Bump version to 4.1.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
tuanchris 5 kuukautta sitten
vanhempi
sitoutus
7ca0faf42f

+ 1 - 0
.gitignore

@@ -6,6 +6,7 @@ __pycache__/
 .idea
 # Ignore state and data JSON files, but not package.json
 state.json
+settings.json
 playlists.json
 metadata_cache.json
 tsconfig.json

+ 1 - 1
VERSION

@@ -1 +1 @@
-4.0.5
+4.1.0

+ 3 - 0
frontend/src/__tests__/pages/TableControlPage.test.tsx

@@ -3,10 +3,13 @@ import { renderWithProviders, screen, waitFor, userEvent } from '../../test/util
 import { server } from '../../test/mocks/server'
 import { http, HttpResponse } from 'msw'
 import { TableControlPage } from '../../pages/TableControlPage'
+import { useStatusStore } from '../../stores/useStatusStore'
 
 describe('TableControlPage', () => {
   beforeEach(() => {
     vi.clearAllMocks()
+    // Reset Zustand store to prevent real WebSocket data from leaking into tests
+    useStatusStore.setState({ status: null, isBackendConnected: false, connectionAttempts: 0 })
   })
 
   describe('Rendering', () => {

+ 179 - 6
frontend/src/components/layout/Layout.tsx

@@ -1,8 +1,9 @@
-import { Outlet, Link, useLocation } from 'react-router-dom'
+import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'
 import { useEffect, useState, useRef, useCallback, useMemo } from 'react'
 import { toast } from 'sonner'
 import { NowPlayingBar } from '@/components/NowPlayingBar'
 import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
 import { Separator } from '@/components/ui/separator'
 import { cacheAllPreviews } from '@/lib/previewCache'
@@ -25,6 +26,7 @@ const DEFAULT_APP_NAME = 'Dune Weaver'
 
 export function Layout() {
   const location = useLocation()
+  const navigate = useNavigate()
 
   // Scroll to top on route change
   useEffect(() => {
@@ -80,9 +82,19 @@ export function Layout() {
   // Update availability
   const [updateAvailable, setUpdateAvailable] = useState(false)
 
+  // Security state
+  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [hasSecurityPassword, setHasSecurityPassword] = useState(false)
+  const [isUnlocked, setIsUnlocked] = useState(() => {
+    return sessionStorage.getItem('security-unlocked') === 'true'
+  })
+  const [showPasswordDialog, setShowPasswordDialog] = useState(false)
+  const [passwordInput, setPasswordInput] = useState('')
+  const [passwordError, setPasswordError] = useState(false)
+
   // Fetch app settings
   const fetchAppSettings = () => {
-    apiClient.get<{ app?: { name?: string; custom_logo?: string } }>('/api/settings')
+    apiClient.get<{ app?: { name?: string; custom_logo?: string }; security?: { mode?: string; has_password?: boolean } }>('/api/settings')
       .then((settings) => {
         if (settings.app?.name) {
           setAppName(settings.app.name)
@@ -90,6 +102,10 @@ export function Layout() {
           setAppName(DEFAULT_APP_NAME)
         }
         setCustomLogo(settings.app?.custom_logo || null)
+        // Security settings
+        const mode = settings.security?.mode as 'off' | 'lockdown' | 'play_only' | undefined
+        setSecurityMode(mode || 'off')
+        setHasSecurityPassword(settings.security?.has_password || false)
       })
       .catch(() => {})
   }
@@ -97,14 +113,19 @@ export function Layout() {
   useEffect(() => {
     fetchAppSettings()
 
-    // Listen for branding updates from Settings page
+    // Listen for branding/security updates from Settings page
     const handleBrandingUpdate = () => {
       fetchAppSettings()
     }
+    const handleSecurityUpdate = () => {
+      fetchAppSettings()
+    }
     window.addEventListener('branding-updated', handleBrandingUpdate)
+    window.addEventListener('security-updated', handleSecurityUpdate)
 
     return () => {
       window.removeEventListener('branding-updated', handleBrandingUpdate)
+      window.removeEventListener('security-updated', handleSecurityUpdate)
     }
     // Refetch when active table changes
   }, [activeTable?.id])
@@ -589,6 +610,60 @@ export function Layout() {
     }
   }
 
+  // Security password verification
+  const handlePasswordSubmit = async (e: React.FormEvent) => {
+    e.preventDefault()
+    setPasswordError(false)
+    try {
+      const result = await apiClient.post<{ valid: boolean }>('/api/security/verify', {
+        password: passwordInput,
+      })
+      if (result.valid) {
+        sessionStorage.setItem('security-unlocked', 'true')
+        setIsUnlocked(true)
+        setPasswordInput('')
+        // If unlocking via play-only dialog, navigate to settings
+        if (showPasswordDialog) {
+          setShowPasswordDialog(false)
+          navigate('/settings')
+        }
+      } else {
+        setPasswordError(true)
+      }
+    } catch {
+      setPasswordError(true)
+    }
+  }
+
+  // Re-lock the app
+  const handleLock = () => {
+    sessionStorage.removeItem('security-unlocked')
+    setIsUnlocked(false)
+    navigate('/')
+  }
+
+  // Determine if security is active and blocking
+  const isLockdownActive = securityMode === 'lockdown' && hasSecurityPassword && !isUnlocked
+  const isPlayOnlyActive = securityMode === 'play_only' && hasSecurityPassword && !isUnlocked
+  const isSecurityUnlocked = securityMode !== 'off' && hasSecurityPassword && isUnlocked
+
+  // Redirect away from restricted pages if play_only is active and not unlocked
+  const restrictedPaths = ['/settings', '/table-control']
+  useEffect(() => {
+    if (isPlayOnlyActive && restrictedPaths.includes(location.pathname)) {
+      navigate('/')
+    }
+  }, [isPlayOnlyActive, location.pathname, navigate])
+
+  // Filter nav items based on security mode
+  const playOnlyHiddenPaths = ['/settings', '/table-control']
+  const visibleNavItems = useMemo(() => {
+    if (isPlayOnlyActive) {
+      return navItems.filter((item) => !playOnlyHiddenPaths.includes(item.path))
+    }
+    return navItems
+  }, [isPlayOnlyActive])
+
   // Update document title based on current page
   useEffect(() => {
     const currentNav = navItems.find((item) => item.path === location.pathname)
@@ -963,6 +1038,72 @@ export function Layout() {
 
   return (
     <div className="min-h-dvh bg-background flex flex-col">
+      {/* Security Lockdown Overlay */}
+      {isLockdownActive && (
+        <div className="fixed inset-0 z-[60] bg-background flex items-center justify-center p-4">
+          <div className="w-full max-w-sm space-y-6 text-center">
+            <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-2">
+              <span className="material-icons-outlined text-4xl text-primary">lock</span>
+            </div>
+            <h2 className="text-2xl font-bold">{displayName}</h2>
+            <p className="text-muted-foreground">This table is locked. Enter the password to continue.</p>
+            <form onSubmit={handlePasswordSubmit} className="space-y-3">
+              <Input
+                type="password"
+                placeholder="Password"
+                value={passwordInput}
+                onChange={(e) => { setPasswordInput(e.target.value); setPasswordError(false) }}
+                autoFocus
+              />
+              {passwordError && (
+                <p className="text-sm text-destructive">Incorrect password</p>
+              )}
+              <Button type="submit" className="w-full">Unlock</Button>
+            </form>
+          </div>
+        </div>
+      )}
+
+      {/* Security Password Dialog (for play-only mode) */}
+      {showPasswordDialog && (
+        <div className="fixed inset-0 z-[60] bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
+          <div className="bg-background rounded-lg shadow-xl w-full max-w-sm">
+            <div className="p-6 space-y-4">
+              <div className="text-center space-y-2">
+                <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-primary/10 mb-2">
+                  <span className="material-icons-outlined text-2xl text-primary">lock</span>
+                </div>
+                <h3 className="text-lg font-semibold">Settings Locked</h3>
+                <p className="text-sm text-muted-foreground">Enter the password to access settings.</p>
+              </div>
+              <form onSubmit={handlePasswordSubmit} className="space-y-3">
+                <Input
+                  type="password"
+                  placeholder="Password"
+                  value={passwordInput}
+                  onChange={(e) => { setPasswordInput(e.target.value); setPasswordError(false) }}
+                  autoFocus
+                />
+                {passwordError && (
+                  <p className="text-sm text-destructive">Incorrect password</p>
+                )}
+                <div className="flex gap-2">
+                  <Button
+                    type="button"
+                    variant="ghost"
+                    className="flex-1"
+                    onClick={() => { setShowPasswordDialog(false); setPasswordInput(''); setPasswordError(false) }}
+                  >
+                    Cancel
+                  </Button>
+                  <Button type="submit" className="flex-1">Unlock</Button>
+                </div>
+              </form>
+            </div>
+          </div>
+        </div>
+      )}
+
       {/* Sensor Homing Failure Popup */}
       {sensorHomingFailed && (
         <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
@@ -1381,6 +1522,17 @@ export function Layout() {
 
           {/* Desktop actions */}
           <div className="hidden md:flex items-center gap-0 ml-2">
+            {isSecurityUnlocked && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="rounded-full"
+                onClick={handleLock}
+                title="Lock"
+              >
+                <span className="material-icons-outlined">lock_open</span>
+              </Button>
+            )}
             {updateAvailable && (
               <Link to="/settings?section=version" title="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
@@ -1440,6 +1592,17 @@ export function Layout() {
 
           {/* Mobile actions */}
           <div className="flex md:hidden items-center gap-0 ml-2">
+            {isSecurityUnlocked && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="rounded-full"
+                onClick={handleLock}
+                title="Lock"
+              >
+                <span className="material-icons-outlined">lock_open</span>
+              </Button>
+            )}
             {updateAvailable && (
               <Link to="/settings?section=version" title="Software update available">
                 <span className="relative flex items-center justify-center w-8 h-8 rounded-full hover:bg-accent transition-colors">
@@ -1528,7 +1691,7 @@ export function Layout() {
               : 'calc(8rem + env(safe-area-inset-bottom, 0px))' // floating pill + nav + safe area
         }}
       >
-        <Outlet />
+        <Outlet context={{ isPlayOnlyActive }} />
       </main>
 
       {/* Now Playing Bar */}
@@ -1714,8 +1877,8 @@ export function Layout() {
 
       {/* Bottom Navigation */}
       <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">
-          {navItems.map((item) => {
+        <div className={`max-w-5xl mx-auto grid h-16`} style={{ gridTemplateColumns: `repeat(${visibleNavItems.length + (isPlayOnlyActive ? 1 : 0)}, minmax(0, 1fr))` }}>
+          {visibleNavItems.map((item) => {
             const isActive = location.pathname === item.path
             return (
               <Link
@@ -1738,6 +1901,16 @@ export function Layout() {
               </Link>
             )
           })}
+          {/* Lock icon replacing Settings when play-only mode is active */}
+          {isPlayOnlyActive && (
+            <button
+              onClick={() => { setShowPasswordDialog(true); setPasswordInput(''); setPasswordError(false) }}
+              className="relative flex flex-col items-center justify-center gap-1 transition-all duration-200 text-muted-foreground hover:text-foreground active:scale-95"
+            >
+              <span className="material-icons-outlined text-xl">lock</span>
+              <span className="text-xs font-medium">Settings</span>
+            </button>
+          )}
         </div>
       </nav>
     </div>

+ 1 - 1
frontend/src/components/ui/select.tsx

@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
   <SelectPrimitive.Item
     ref={ref}
     className={cn(
-      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+      "relative flex w-full cursor-default select-none items-center rounded-xl py-1.5 pl-8 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
       className
     )}
     {...props}

+ 1 - 1
frontend/src/lib/types.ts

@@ -20,7 +20,7 @@ export interface Playlist {
   files: string[]
 }
 
-export type SortOption = 'name' | 'date' | 'size' | 'favorites'
+export type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 export type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 export type RunMode = 'single' | 'indefinite'
 

+ 159 - 99
frontend/src/pages/BrowsePage.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react'
+import { useOutletContext } from 'react-router-dom'
 import { toast } from 'sonner'
 import {
   initPreviewCacheDB,
@@ -46,7 +47,7 @@ interface PreviewData {
 // Coordinates come as [theta, rho] tuples from the backend
 type Coordinate = [number, number]
 
-type SortOption = 'name' | 'date' | 'size' | 'favorites'
+type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
 type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
 
 const preExecutionOptions: { value: PreExecution; label: string }[] = [
@@ -66,6 +67,8 @@ interface PreviewContextType {
 const PreviewContext = createContext<PreviewContextType | null>(null)
 
 export function BrowsePage() {
+  const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
+
   // Data state
   const [patterns, setPatterns] = useState<PatternMetadata[]>([])
   const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
@@ -104,6 +107,8 @@ export function BrowsePage() {
   const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
     actual_time_formatted: string | null
     timestamp: string | null
+    play_count: number
+    last_played: string | null
   }>>({})
 
   // Canvas and animation refs
@@ -242,7 +247,7 @@ export function BrowsePage() {
       // Fetch patterns and history in parallel
       const [data, historyData] = await Promise.all([
         apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
-        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null }>>('/api/pattern_history_all')
+        apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null; play_count: number; last_played: string | null }>>('/api/pattern_history_all')
       ])
       setPatterns(data)
       setAllPatternHistories(historyData)
@@ -401,6 +406,28 @@ export function BrowsePage() {
           }
           break
         }
+        case 'plays': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aPlays = allPatternHistories[aKey]?.play_count ?? 0
+          const bPlays = allPatternHistories[bKey]?.play_count ?? 0
+          comparison = aPlays - bPlays
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name)
+          }
+          break
+        }
+        case 'last_played': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aTime = allPatternHistories[aKey]?.last_played || ''
+          const bTime = allPatternHistories[bKey]?.last_played || ''
+          comparison = aTime.localeCompare(bTime)
+          if (comparison === 0) {
+            comparison = a.name.localeCompare(b.name)
+          }
+          break
+        }
         default:
           return 0
       }
@@ -408,7 +435,7 @@ export function BrowsePage() {
     })
 
     return result
-  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites])
+  }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites, allPatternHistories])
 
   // Batched preview loading - collects requests and fetches in batches
   const requestPreview = useCallback((path: string) => {
@@ -803,33 +830,46 @@ export function BrowsePage() {
     setCacheProgress(0)
   }
 
-  // Handle pattern file upload
+  // Handle pattern file upload (supports multiple files)
   const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0]
-    if (!file) return
+    const files = e.target.files
+    if (!files || files.length === 0) return
 
-    // Validate file extension
-    if (!file.name.endsWith('.thr')) {
-      toast.error('Please select a .thr file')
+    // Validate all files have .thr extension
+    const invalidFiles = Array.from(files).filter(f => !f.name.endsWith('.thr'))
+    if (invalidFiles.length > 0) {
+      toast.error(`Invalid file${invalidFiles.length > 1 ? 's' : ''}: ${invalidFiles.map(f => f.name).join(', ')}. Only .thr files are accepted.`)
       return
     }
 
     setIsUploading(true)
-    try {
-      await apiClient.uploadFile('/upload_theta_rho', file)
-      toast.success(`Pattern "${file.name}" uploaded successfully`)
+    let successCount = 0
+    let failCount = 0
+
+    for (const file of Array.from(files)) {
+      try {
+        await apiClient.uploadFile('/upload_theta_rho', file)
+        successCount++
+      } catch (error) {
+        console.error(`Upload error for ${file.name}:`, error)
+        failCount++
+        toast.error(`Failed to upload "${file.name}"`)
+      }
+    }
 
-      // Refresh patterns list using the same function as initial load
+    if (successCount > 0) {
+      toast.success(
+        successCount === 1
+          ? `Pattern "${files[0].name}" uploaded successfully`
+          : `${successCount} pattern${successCount > 1 ? 's' : ''} uploaded successfully`
+      )
       await fetchPatterns()
-    } catch (error) {
-      console.error('Upload error:', error)
-      toast.error(error instanceof Error ? error.message : 'Failed to upload pattern')
-    } finally {
-      setIsUploading(false)
-      // Reset file input
-      if (fileInputRef.current) {
-        fileInputRef.current.value = ''
-      }
+    }
+
+    setIsUploading(false)
+    // Reset file input
+    if (fileInputRef.current) {
+      fileInputRef.current.value = ''
     }
   }
 
@@ -846,13 +886,16 @@ 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-0 sm:px-4">
       {/* Hidden file input for pattern upload */}
-      <input
-        ref={fileInputRef}
-        type="file"
-        accept=".thr"
-        onChange={handleFileUpload}
-        className="hidden"
-      />
+      {!isPlayOnlyActive && (
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept=".thr"
+          multiple
+          onChange={handleFileUpload}
+          className="hidden"
+        />
+      )}
 
       {/* Page Header */}
       <div className="flex items-start justify-between gap-4 pl-1">
@@ -862,19 +905,21 @@ export function BrowsePage() {
             {patterns.length} patterns available
           </p>
         </div>
-        <Button
-          variant="ghost"
-          onClick={() => fileInputRef.current?.click()}
-          disabled={isUploading}
-          className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
-        >
-          {isUploading ? (
-            <span className="material-icons-outlined animate-spin text-lg">sync</span>
-          ) : (
-            <span className="material-icons-outlined text-lg">add</span>
-          )}
-          <span className="hidden sm:inline">Add Pattern</span>
-        </Button>
+        {!isPlayOnlyActive && (
+          <Button
+            variant="ghost"
+            onClick={() => fileInputRef.current?.click()}
+            disabled={isUploading}
+            className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
+          >
+            {isUploading ? (
+              <span className="material-icons-outlined animate-spin text-lg">sync</span>
+            ) : (
+              <span className="material-icons-outlined text-lg">add</span>
+            )}
+            <span className="hidden sm:inline">Add Pattern</span>
+          </Button>
+        )}
       </div>
 
       {/* Filter Bar */}
@@ -922,7 +967,12 @@ export function BrowsePage() {
           </Select>
 
           {/* Sort - Icon on mobile, text on desktop */}
-          <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
+          <Select value={sortBy} onValueChange={(v) => {
+            const option = v as SortOption
+            setSortBy(option)
+            // Most Played and Last Played should default to descending (highest first)
+            setSortAsc(option !== 'plays' && option !== 'last_played')
+          }}>
             <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
               <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
               <SelectValue placeholder="Sort" />
@@ -932,6 +982,8 @@ export function BrowsePage() {
               <SelectItem value="name">Name</SelectItem>
               <SelectItem value="date">Modified</SelectItem>
               <SelectItem value="size">Size</SelectItem>
+              <SelectItem value="plays">Most Played</SelectItem>
+              <SelectItem value="last_played">Last Played</SelectItem>
             </SelectContent>
           </Select>
 
@@ -1016,6 +1068,7 @@ export function BrowsePage() {
                 isSelected={selectedPattern?.path === pattern.path}
                 isFavorite={favorites.has(pattern.path)}
                 playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
+                playCount={allPatternHistories[pattern.path.split('/').pop() || '']?.play_count ?? 0}
                 onToggleFavorite={toggleFavorite}
                 onClick={() => handlePatternClick(pattern)}
               />
@@ -1109,23 +1162,33 @@ 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>
-              )}
+              {/* Play History Info */}
+              {(() => {
+                const historyKey = selectedPattern.path.split('/').pop() || ''
+                const playCount = allPatternHistories[historyKey]?.play_count ?? 0
+                return (
+                  <>
+                    {(patternHistory?.actual_time_formatted || playCount > 0) && (
+                      <div className="mb-4 flex justify-between text-sm">
+                        {patternHistory?.actual_time_formatted && (
+                          <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>
+                        )}
+                        {playCount > 0 && (
+                          <div className="flex items-center gap-2">
+                            <span className="material-icons-outlined text-muted-foreground text-base">play_circle</span>
+                            <span className="text-muted-foreground">Plays:</span>
+                            <span className="font-semibold">{playCount}</span>
+                          </div>
+                        )}
+                      </div>
+                    )}
+                  </>
+                )
+              })()}
 
               {/* Pre-Execution Options */}
               <div className="mb-6">
@@ -1328,11 +1391,12 @@ interface PatternCardProps {
   isSelected: boolean
   isFavorite: boolean
   playTime: string | null
+  playCount: number
   onToggleFavorite: (path: string, e: React.MouseEvent) => void
   onClick: () => void
 }
 
-function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavorite, onClick }: PatternCardProps) {
+function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onToggleFavorite, onClick }: PatternCardProps) {
   const [imageLoaded, setImageLoaded] = useState(false)
   const [imageError, setImageError] = useState(false)
   const cardRef = useRef<HTMLButtonElement>(null)
@@ -1399,46 +1463,42 @@ function PatternCard({ pattern, isSelected, isFavorite, playTime, onToggleFavori
             </div>
           )}
         </div>
+      </div>
 
-        {/* Play time badge */}
-        {playTime && (
-          <div className="absolute -top-1 -right-1 bg-card/90 backdrop-blur-sm text-[10px] font-medium px-1.5 py-0.5 rounded-full border border-border shadow-sm">
-            {(() => {
-              // Parse time and convert to minutes only
-              // Try MM:SS or HH:MM:SS format first (e.g., "15:48" or "1:15:48")
-              const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
-              if (colonMatch) {
-                const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
-                const minutes = parseInt(colonMatch[2])
-                const seconds = parseInt(colonMatch[3])
-                const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
-                return totalMins > 0 ? `${totalMins}m` : '<1m'
-              }
-
-              // Try text-based formats
-              const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
-              if (match) {
-                if (match[1] && match[2]) {
-                  // "Xh Ym" format
-                  return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
-                } else if (match[3]) {
-                  // "X min" format
-                  return `${match[3]}m`
-                } else if (match[4] && match[5]) {
-                  // "Xm Ys" format - round to minutes
-                  const mins = parseInt(match[4])
-                  return mins > 0 ? `${mins}m` : '<1m'
-                } else if (match[6]) {
-                  // seconds only
-                  return '<1m'
+      {/* Stats row */}
+      {(playCount > 0 || playTime) && (
+        <div className="flex items-center w-full px-0.5 -mb-1 justify-between">
+          {playCount > 0 && (
+            <span className="flex items-center gap-0.5 text-xs text-muted-foreground" title={`Played ${playCount} time${playCount !== 1 ? 's' : ''}`}>
+              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>play_circle</span>
+              {playCount}x
+            </span>
+          )}
+          {playTime && (
+            <span className="flex items-center gap-0.5 text-xs text-muted-foreground ml-auto" title={`Last run: ${playTime}`}>
+              <span className="material-icons-outlined" style={{ fontSize: '13px' }}>schedule</span>
+              {(() => {
+                const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
+                if (colonMatch) {
+                  const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
+                  const minutes = parseInt(colonMatch[2])
+                  const seconds = parseInt(colonMatch[3])
+                  const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
+                  return totalMins > 0 ? `${totalMins}m` : '<1m'
                 }
-              }
-              // Fallback: show original
-              return playTime
-            })()}
-          </div>
-        )}
-      </div>
+                const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
+                if (match) {
+                  if (match[1] && match[2]) return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
+                  else if (match[3]) return `${match[3]}m`
+                  else if (match[4] && match[5]) { const mins = parseInt(match[4]); return mins > 0 ? `${mins}m` : '<1m' }
+                  else if (match[6]) return '<1m'
+                }
+                return playTime
+              })()}
+            </span>
+          )}
+        </div>
+      )}
 
       {/* Name and favorite row */}
       <div className="flex items-center justify-between w-full gap-1 px-0.5">

+ 131 - 59
frontend/src/pages/PlaylistsPage.tsx

@@ -1,4 +1,5 @@
 import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
+import { useOutletContext } from 'react-router-dom'
 import { toast } from 'sonner'
 import { Trash2 } from 'lucide-react'
 import { apiClient } from '@/lib/apiClient'
@@ -31,6 +32,8 @@ import {
 } from '@/components/ui/dialog'
 
 export function PlaylistsPage() {
+  const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
+
   // Playlists state
   const [playlists, setPlaylists] = useState<string[]>([])
   const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
@@ -41,6 +44,10 @@ export function PlaylistsPage() {
 
   // All patterns for the picker modal
   const [allPatterns, setAllPatterns] = useState<PatternMetadata[]>([])
+  const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
+    play_count: number
+    last_played: string | null
+  }>>({})
   const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
 
   // Pattern picker modal state
@@ -233,8 +240,12 @@ export function PlaylistsPage() {
 
   const fetchAllPatterns = async () => {
     try {
-      const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
+      const [data, historyData] = await Promise.all([
+        apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
+        apiClient.get<Record<string, { play_count: number; last_played: string | null }>>('/api/pattern_history_all')
+      ])
       setAllPatterns(data)
+      setAllPatternHistories(historyData)
     } catch (error) {
       console.error('Error fetching patterns:', error)
     }
@@ -500,12 +511,34 @@ export function PlaylistsPage() {
           }
           break
         }
+        case 'plays': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aPlays = allPatternHistories[aKey]?.play_count ?? 0
+          const bPlays = allPatternHistories[bKey]?.play_count ?? 0
+          cmp = aPlays - bPlays
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name)
+          }
+          break
+        }
+        case 'last_played': {
+          const aKey = a.path.split('/').pop() || ''
+          const bKey = b.path.split('/').pop() || ''
+          const aTime = allPatternHistories[aKey]?.last_played || ''
+          const bTime = allPatternHistories[bKey]?.last_played || ''
+          cmp = aTime.localeCompare(bTime)
+          if (cmp === 0) {
+            cmp = a.name.localeCompare(b.name)
+          }
+          break
+        }
       }
       return sortAsc ? cmp : -cmp
     })
 
     return filtered
-  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites])
+  }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc, favorites, allPatternHistories])
 
   // Get pattern name from path
   const getPatternName = (path: string) => {
@@ -542,17 +575,19 @@ export function PlaylistsPage() {
               <h2 className="text-lg font-semibold">My Playlists</h2>
               <p className="text-sm text-muted-foreground">{playlists.length} playlist{playlists.length !== 1 ? 's' : ''}</p>
             </div>
-            <Button
-              variant="ghost"
-              size="icon"
-              className="h-8 w-8"
-              onClick={() => {
-                setNewPlaylistName('')
-                setIsCreateModalOpen(true)
-              }}
-            >
-              <span className="material-icons-outlined text-xl">add</span>
-            </Button>
+            {!isPlayOnlyActive && (
+              <Button
+                variant="ghost"
+                size="icon"
+                className="h-8 w-8"
+                onClick={() => {
+                  setNewPlaylistName('')
+                  setIsCreateModalOpen(true)
+                }}
+              >
+                <span className="material-icons-outlined text-xl">add</span>
+              </Button>
+            )}
           </div>
 
           <nav className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
@@ -580,32 +615,34 @@ export function PlaylistsPage() {
                   <span className="material-icons-outlined text-lg">playlist_play</span>
                   <span className="truncate text-sm font-medium">{name}</span>
                 </div>
-                <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
-                  <Button
-                    variant="ghost"
-                    size="icon-sm"
-                    className="h-7 w-7"
-                    onClick={(e) => {
-                      e.stopPropagation()
-                      setPlaylistToRename(name)
-                      setNewPlaylistName(name)
-                      setIsRenameModalOpen(true)
-                    }}
-                  >
-                    <span className="material-icons-outlined text-base">edit</span>
-                  </Button>
-                  <Button
-                    variant="ghost"
-                    size="icon-sm"
-                    className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
-                    onClick={(e) => {
-                      e.stopPropagation()
-                      handleDeletePlaylist(name)
-                    }}
-                  >
-                    <Trash2 className="h-4 w-4" />
-                  </Button>
-                </div>
+                {!isPlayOnlyActive && (
+                  <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
+                    <Button
+                      variant="ghost"
+                      size="icon-sm"
+                      className="h-7 w-7"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        setPlaylistToRename(name)
+                        setNewPlaylistName(name)
+                        setIsRenameModalOpen(true)
+                      }}
+                    >
+                      <span className="material-icons-outlined text-base">edit</span>
+                    </Button>
+                    <Button
+                      variant="ghost"
+                      size="icon-sm"
+                      className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
+                      onClick={(e) => {
+                        e.stopPropagation()
+                        handleDeletePlaylist(name)
+                      }}
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </Button>
+                  </div>
+                )}
               </div>
             ))
           )}
@@ -643,15 +680,17 @@ export function PlaylistsPage() {
                 )}
               </div>
             </div>
-            <Button
-              onClick={openPatternPicker}
-              disabled={!selectedPlaylist}
-              size="sm"
-              className="gap-2"
-            >
-              <span className="material-icons-outlined text-base">add</span>
-              <span className="hidden sm:inline">Add Patterns</span>
-            </Button>
+            {!isPlayOnlyActive && (
+              <Button
+                onClick={openPatternPicker}
+                disabled={!selectedPlaylist}
+                size="sm"
+                className="gap-2"
+              >
+                <span className="material-icons-outlined text-base">add</span>
+                <span className="hidden sm:inline">Add Patterns</span>
+              </Button>
+            )}
           </header>
 
           {/* Patterns List */}
@@ -675,10 +714,12 @@ export function PlaylistsPage() {
                   <p className="font-medium">Empty playlist</p>
                   <p className="text-sm">Add patterns to get started</p>
                 </div>
-                <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
-                  <span className="material-icons-outlined text-base">add</span>
-                  Add Patterns
-                </Button>
+                {!isPlayOnlyActive && (
+                  <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
+                    <span className="material-icons-outlined text-base">add</span>
+                    Add Patterns
+                  </Button>
+                )}
               </div>
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
@@ -696,13 +737,15 @@ export function PlaylistsPage() {
                           alt={getPatternName(path)}
                         />
                       </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>
+                      {!isPlayOnlyActive && (
+                        <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>
                     <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
                   </div>
@@ -955,6 +998,8 @@ export function PlaylistsPage() {
                   <SelectItem value="name">Name</SelectItem>
                   <SelectItem value="date">Modified</SelectItem>
                   <SelectItem value="size">Size</SelectItem>
+                  <SelectItem value="plays">Most Played</SelectItem>
+                  <SelectItem value="last_played">Last Played</SelectItem>
                 </SelectContent>
               </Select>
 
@@ -973,6 +1018,33 @@ export function PlaylistsPage() {
 
               <div className="flex-1" />
 
+              {/* Select All / Deselect All toggle */}
+              <Button
+                variant="outline"
+                size="sm"
+                className="h-9 rounded-full bg-card shadow-sm text-sm gap-1.5"
+                onClick={() => {
+                  const allFilteredPaths = filteredPatterns.map(p => p.path)
+                  const allSelected = allFilteredPaths.every(p => selectedPatternPaths.has(p))
+                  setSelectedPatternPaths(prev => {
+                    const next = new Set(prev)
+                    if (allSelected) {
+                      allFilteredPaths.forEach(p => next.delete(p))
+                    } else {
+                      allFilteredPaths.forEach(p => next.add(p))
+                    }
+                    return next
+                  })
+                }}
+              >
+                <span className="material-icons-outlined text-base">
+                  {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'deselect' : 'select_all'}
+                </span>
+                <span className="hidden sm:inline">
+                  {filteredPatterns.length > 0 && filteredPatterns.every(p => selectedPatternPaths.has(p.path)) ? 'Deselect All' : 'Select All'}
+                </span>
+              </Button>
+
               {/* Selection count - compact on mobile */}
               <div className="flex items-center gap-1 sm:gap-2 text-sm bg-card rounded-full px-2 sm:px-3 py-2 shadow-sm border">
                 <span className="material-icons-outlined text-base text-primary">check_circle</span>

+ 162 - 0
frontend/src/pages/SettingsPage.tsx

@@ -170,6 +170,12 @@ export function SettingsPage() {
   // Pattern search state for clearing patterns
   const [patternFiles, setPatternFiles] = useState<string[]>([])
 
+  // Security state
+  const [securityMode, setSecurityMode] = useState<'off' | 'lockdown' | 'play_only'>('off')
+  const [securityPassword, setSecurityPassword] = useState('')
+  const [securityPasswordConfirm, setSecurityPasswordConfirm] = useState('')
+  const [hasExistingPassword, setHasExistingPassword] = useState(false)
+
   // Version state
   const [versionInfo, setVersionInfo] = useState<{
     current: string
@@ -221,6 +227,7 @@ export function SettingsPage() {
       case 'machine':
       case 'homing':
       case 'clearing':
+      case 'security':
         // These all share settings data
         if (!loadedSections.has('_settings')) {
           setLoadedSections((prev) => new Set(prev).add('_settings'))
@@ -383,6 +390,11 @@ export function SettingsPage() {
           time_slots: data.scheduled_pause.time_slots || [],
         })
       }
+      // Set security settings
+      if (data.security) {
+        setSecurityMode(data.security.mode || 'off')
+        setHasExistingPassword(data.security.has_password || false)
+      }
       // Set MQTT config from the same response
       if (data.mqtt) {
         setMqttConfig({
@@ -2173,6 +2185,156 @@ export function SettingsPage() {
           </AccordionContent>
         </AccordionItem>
 
+        {/* Security */}
+        <AccordionItem value="security" id="section-security" className="border rounded-lg px-4 overflow-visible bg-card">
+          <AccordionTrigger className="hover:no-underline">
+            <div className="flex items-center gap-3">
+              <span className="material-icons-outlined text-muted-foreground">
+                lock
+              </span>
+              <div className="text-left">
+                <div className="font-semibold">Security</div>
+                <div className="text-sm text-muted-foreground font-normal">
+                  App lock and access control
+                </div>
+              </div>
+            </div>
+          </AccordionTrigger>
+          <AccordionContent className="pt-4 pb-6 space-y-4">
+            <p className="text-sm text-muted-foreground">
+              Restrict access to the app to prevent unauthorized changes. Useful for shared spaces or when the table is accessible to children.
+            </p>
+
+            {/* Security Mode */}
+            <div className="space-y-3">
+              <Label className="text-sm font-medium">Security Mode</Label>
+              <RadioGroup
+                value={securityMode}
+                onValueChange={(value) => {
+                  const newMode = value as 'off' | 'lockdown' | 'play_only'
+                  if (newMode === 'off' && securityMode !== 'off') {
+                    if (!confirm('Turn off security? This will remove the password and unlock the app.')) return
+                  }
+                  setSecurityMode(newMode)
+                  // Clear password fields when switching modes
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                }}
+                className="space-y-2"
+              >
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="off" id="security-off" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-off" className="font-medium cursor-pointer">Off</Label>
+                    <p className="text-sm text-muted-foreground">No restrictions. Anyone can use the app.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="play_only" id="security-play-only" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-play-only" className="font-medium cursor-pointer">Play Only</Label>
+                    <p className="text-sm text-muted-foreground">Anyone can browse and play patterns. Settings require a password.</p>
+                  </div>
+                </div>
+                <div className="flex items-start gap-3 p-3 rounded-lg border">
+                  <RadioGroupItem value="lockdown" id="security-lockdown" className="mt-0.5" />
+                  <div>
+                    <Label htmlFor="security-lockdown" className="font-medium cursor-pointer">Full Lockdown</Label>
+                    <p className="text-sm text-muted-foreground">Password required to access the entire app.</p>
+                  </div>
+                </div>
+              </RadioGroup>
+            </div>
+
+            {/* Password fields (shown when mode != off) */}
+            {securityMode !== 'off' && (
+              <div className="space-y-3 pt-2">
+                <Separator />
+                {hasExistingPassword && (
+                  <p className="text-sm text-muted-foreground">
+                    A password is currently set. Enter a new password below to change it, or leave blank to keep the existing one.
+                  </p>
+                )}
+                <div className="space-y-2">
+                  <Label htmlFor="security-password">
+                    {hasExistingPassword ? 'New Password' : 'Password'}
+                  </Label>
+                  <Input
+                    id="security-password"
+                    type="password"
+                    placeholder={hasExistingPassword ? 'Leave blank to keep current' : 'Enter password'}
+                    value={securityPassword}
+                    onChange={(e) => setSecurityPassword(e.target.value)}
+                  />
+                </div>
+                <div className="space-y-2">
+                  <Label htmlFor="security-password-confirm">Confirm Password</Label>
+                  <Input
+                    id="security-password-confirm"
+                    type="password"
+                    placeholder="Confirm password"
+                    value={securityPasswordConfirm}
+                    onChange={(e) => setSecurityPasswordConfirm(e.target.value)}
+                  />
+                </div>
+                {securityPassword && securityPasswordConfirm && securityPassword !== securityPasswordConfirm && (
+                  <p className="text-sm text-destructive">Passwords do not match</p>
+                )}
+              </div>
+            )}
+
+            {/* Save button */}
+            <Button
+              onClick={async () => {
+                // Validate
+                if (securityMode !== 'off') {
+                  if (securityPassword && securityPassword !== securityPasswordConfirm) {
+                    toast.error('Passwords do not match')
+                    return
+                  }
+                  if (!hasExistingPassword && !securityPassword) {
+                    toast.error('Please set a password')
+                    return
+                  }
+                }
+
+                setIsLoading('security')
+                try {
+                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                  const payload: any = { security: { mode: securityMode } }
+                  if (securityPassword) {
+                    payload.security.password = securityPassword
+                  }
+                  await apiClient.patch('/api/settings', payload)
+                  toast.success('Security settings saved')
+                  setSecurityPassword('')
+                  setSecurityPasswordConfirm('')
+                  setHasExistingPassword(securityMode !== 'off')
+                  // Notify Layout to refetch security state
+                  window.dispatchEvent(new CustomEvent('security-updated'))
+                } catch {
+                  toast.error('Failed to save security settings')
+                } finally {
+                  setIsLoading(null)
+                }
+              }}
+              disabled={
+                isLoading === 'security' ||
+                (securityMode !== 'off' && securityPassword !== '' && securityPassword !== securityPasswordConfirm) ||
+                (securityMode !== 'off' && !hasExistingPassword && !securityPassword)
+              }
+              className="w-full gap-2"
+            >
+              {isLoading === 'security' ? (
+                <span className="material-icons-outlined animate-spin">sync</span>
+              ) : (
+                <span className="material-icons-outlined">save</span>
+              )}
+              Save Security Settings
+            </Button>
+          </AccordionContent>
+        </AccordionItem>
+
         {/* Software Version */}
         <AccordionItem value="version" id="section-version" className="border rounded-lg px-4 overflow-visible bg-card">
           <AccordionTrigger className="hover:no-underline">

+ 40 - 1
main.py

@@ -25,6 +25,7 @@ from modules.core.version_manager import version_manager
 from modules.core.log_handler import init_memory_handler, get_memory_handler
 import json
 import base64
+import hashlib
 import time
 import subprocess
 
@@ -443,6 +444,13 @@ class MachineSettingsUpdate(BaseModel):
     table_type_override: Optional[str] = None  # Override detected table type, or empty string/"auto" to clear
     timezone: Optional[str] = None  # IANA timezone (e.g., "America/New_York", "UTC")
 
+class SecuritySettingsUpdate(BaseModel):
+    mode: Optional[str] = None  # "off", "lockdown", "play_only"
+    password: Optional[str] = None  # Write-only, stored as SHA-256 hash
+
+class SecurityVerifyRequest(BaseModel):
+    password: str
+
 class SettingsUpdate(BaseModel):
     """Request model for PATCH /api/settings - all fields optional for partial updates"""
     app: Optional[AppSettingsUpdate] = None
@@ -454,6 +462,7 @@ class SettingsUpdate(BaseModel):
     led: Optional[LedSettingsUpdate] = None
     mqtt: Optional[MqttSettingsUpdate] = None
     machine: Optional[MachineSettingsUpdate] = None
+    security: Optional[SecuritySettingsUpdate] = None
 
 # Store active WebSocket connections
 active_status_connections = set()
@@ -717,6 +726,10 @@ async def get_all_settings():
                 {"value": "dune_weaver", "label": "Dune Weaver"},
                 {"value": "dune_weaver_pro", "label": "Dune Weaver Pro"}
             ]
+        },
+        "security": {
+            "mode": state.security_mode,
+            "has_password": bool(state.security_password_hash)
         }
     }
 
@@ -953,6 +966,20 @@ async def update_settings(settings_update: SettingsUpdate):
                 logger.warning(f"Invalid timezone '{m.timezone}': {e}")
         updated_categories.append("machine")
 
+    # Security settings
+    if settings_update.security:
+        sec = settings_update.security
+        if sec.mode is not None:
+            if sec.mode not in ("off", "lockdown", "play_only"):
+                raise HTTPException(status_code=400, detail="Invalid security mode. Must be 'off', 'lockdown', or 'play_only'.")
+            state.security_mode = sec.mode
+            # When turning off, clear the password hash
+            if sec.mode == "off":
+                state.security_password_hash = ""
+        if sec.password is not None and sec.password != "":
+            state.security_password_hash = hashlib.sha256(sec.password.encode('utf-8')).hexdigest()
+        updated_categories.append("security")
+
     # Save state
     state.save()
 
@@ -969,6 +996,14 @@ async def update_settings(settings_update: SettingsUpdate):
         "led_reinit_needed": led_reinit_needed
     }
 
+@app.post("/api/security/verify", tags=["settings"])
+async def verify_security_password(request: SecurityVerifyRequest):
+    """Verify a security password against the stored hash."""
+    if not state.security_password_hash:
+        return {"valid": False}
+    input_hash = hashlib.sha256(request.password.encode('utf-8')).hexdigest()
+    return {"valid": input_hash == state.security_password_hash}
+
 # ============================================================================
 # Multi-Table Identity Endpoints
 # ============================================================================
@@ -2160,6 +2195,7 @@ async def get_all_pattern_history():
 
     try:
         history_map = {}
+        play_counts = {}
         with open(EXECUTION_LOG_FILE, 'r') as f:
             for line in f:
                 line = line.strip()
@@ -2171,12 +2207,15 @@ async def get_all_pattern_history():
                     if entry.get('completed', False):
                         pattern_name = entry.get('pattern_name')
                         if pattern_name:
+                            play_counts[pattern_name] = play_counts.get(pattern_name, 0) + 1
                             # Keep the most recent match (last one in file wins)
                             history_map[pattern_name] = {
                                 "actual_time_seconds": entry.get('actual_time_seconds'),
                                 "actual_time_formatted": entry.get('actual_time_formatted'),
                                 "speed": entry.get('speed'),
-                                "timestamp": entry.get('timestamp')
+                                "timestamp": entry.get('timestamp'),
+                                "play_count": play_counts[pattern_name],
+                                "last_played": entry.get('timestamp')
                             }
                 except json.JSONDecodeError:
                     continue

+ 116 - 33
modules/core/state.py

@@ -5,6 +5,7 @@ import json
 import os
 import logging
 import uuid
+import base64
 from typing import Optional, Literal
 
 logger = logging.getLogger(__name__)
@@ -84,6 +85,7 @@ class AppState:
         self.hard_reset_theta = False
 
         self.STATE_FILE = "state.json"
+        self.SETTINGS_FILE = "settings.json"
         self.mqtt_handler = None  # Will be set by the MQTT handler
         self.conn = None
         self.port = None
@@ -168,6 +170,10 @@ class AppState:
         self.mqtt_device_id = "dune_weaver"  # Device ID for Home Assistant
         self.mqtt_device_name = "Dune Weaver"  # Device display name
 
+        # Security settings
+        self.security_mode = "off"  # "off", "lockdown", "play_only"
+        self.security_password_hash = ""  # SHA-256 hex digest
+
         self.load()
 
     @property
@@ -440,21 +446,36 @@ class AppState:
     def shuffle(self, value):
         self._shuffle = value
 
-    def to_dict(self):
-        """Return a dictionary representation of the state."""
+    def to_state_dict(self):
+        """Return a dictionary of runtime/machine state (transient data)."""
         return {
             "stop_requested": self.stop_requested,
             "pause_requested": self._pause_requested,
             "current_playing_file": self._current_playing_file,
+            "current_playlist": self._current_playlist,
+            "current_playlist_name": self._current_playlist_name,
+            "current_playlist_index": self.current_playlist_index,
             "execution_progress": self.execution_progress,
             "is_clearing": self.is_clearing,
             "current_theta": self.current_theta,
             "current_rho": self.current_rho,
-            "speed": self._speed,
             "machine_x": self.machine_x,
             "machine_y": self.machine_y,
             "x_steps_per_mm": self.x_steps_per_mm,
             "y_steps_per_mm": self.y_steps_per_mm,
+            "port": self.port,
+            "patterns_since_last_home": self.patterns_since_last_home,
+        }
+
+    def to_settings_dict(self):
+        """Return a dictionary of user-configured settings (persisted intentionally)."""
+        # Base64-encode MQTT password for storage
+        mqtt_password_encoded = ""
+        if self.mqtt_password:
+            mqtt_password_encoded = base64.b64encode(self.mqtt_password.encode('utf-8')).decode('ascii')
+
+        return {
+            "speed": self._speed,
             "gear_ratio": self.gear_ratio,
             "homing": self.homing,
             "homing_user_override": self.homing_user_override,
@@ -462,9 +483,6 @@ class AppState:
             "auto_home_enabled": self.auto_home_enabled,
             "auto_home_after_patterns": self.auto_home_after_patterns,
             "hard_reset_theta": self.hard_reset_theta,
-            "current_playlist": self._current_playlist,
-            "current_playlist_name": self._current_playlist_name,
-            "current_playlist_index": self.current_playlist_index,
             "playlist_mode": self._playlist_mode,
             "pause_time": self._pause_time,
             "clear_pattern": self._clear_pattern,
@@ -472,7 +490,6 @@ class AppState:
             "shuffle": self._shuffle,
             "custom_clear_from_in": self.custom_clear_from_in,
             "custom_clear_from_out": self.custom_clear_from_out,
-            "port": self.port,
             "preferred_port": self.preferred_port,
             "wled_ip": self.wled_ip,
             "led_provider": self.led_provider,
@@ -502,33 +519,66 @@ class AppState:
             "scheduled_pause_control_wled": self.scheduled_pause_control_wled,
             "scheduled_pause_finish_pattern": self.scheduled_pause_finish_pattern,
             "scheduled_pause_timezone": self.scheduled_pause_timezone,
+            "server_port": self.server_port,
             "timezone": self.timezone,
             "mqtt_enabled": self.mqtt_enabled,
             "mqtt_broker": self.mqtt_broker,
             "mqtt_port": self.mqtt_port,
             "mqtt_username": self.mqtt_username,
-            "mqtt_password": self.mqtt_password,
+            "mqtt_password": mqtt_password_encoded,
             "mqtt_client_id": self.mqtt_client_id,
             "mqtt_discovery_prefix": self.mqtt_discovery_prefix,
             "mqtt_device_id": self.mqtt_device_id,
             "mqtt_device_name": self.mqtt_device_name,
             "table_type_override": self.table_type_override,
+            "security_mode": self.security_mode,
+            "security_password_hash": self.security_password_hash,
         }
 
-    def from_dict(self, data):
-        """Update state from a dictionary."""
+    def to_dict(self):
+        """Return a combined dictionary (for backward compatibility)."""
+        combined = self.to_state_dict()
+        combined.update(self.to_settings_dict())
+        return combined
+
+    def from_state_dict(self, data):
+        """Update runtime state from a dictionary."""
         self.stop_requested = data.get("stop_requested", False)
         self._pause_requested = data.get("pause_requested", False)
         self._current_playing_file = data.get("current_playing_file", None)
+        self._current_playlist = data.get("current_playlist", None)
+        self._current_playlist_name = data.get("current_playlist_name", None)
+        self.current_playlist_index = data.get("current_playlist_index", None)
         self.execution_progress = data.get("execution_progress")
         self.is_clearing = data.get("is_clearing", False)
         self.current_theta = data.get("current_theta", 0)
         self.current_rho = data.get("current_rho", 0)
-        self._speed = data.get("speed", 150)
         self.machine_x = data.get("machine_x", 0.0)
         self.machine_y = data.get("machine_y", 0.0)
         self.x_steps_per_mm = data.get("x_steps_per_mm", 0.0)
         self.y_steps_per_mm = data.get("y_steps_per_mm", 0.0)
+        self.port = data.get("port", None)
+        self.patterns_since_last_home = data.get("patterns_since_last_home", 0)
+
+    @staticmethod
+    def _decode_mqtt_password(stored_value):
+        """Decode MQTT password from storage. Handles both base64-encoded and plain text (migration)."""
+        if not stored_value:
+            return ""
+        # Try base64 decode — valid base64 will decode cleanly
+        try:
+            decoded = base64.b64decode(stored_value, validate=True).decode('utf-8')
+            # Extra check: if the decoded string is printable, it was likely base64
+            if decoded.isprintable():
+                return decoded
+        except Exception:
+            pass
+        # Not valid base64 — treat as plain text (old format, will be re-encoded on next save)
+        return stored_value
+
+    def from_settings_dict(self, data):
+        """Update user settings from a dictionary."""
+        self._speed = data.get("speed", 150)
         self.gear_ratio = data.get('gear_ratio', 10)
         self.homing = data.get('homing', 0)
         self.homing_user_override = data.get('homing_user_override', False)
@@ -536,9 +586,6 @@ class AppState:
         self.auto_home_enabled = data.get('auto_home_enabled', False)
         self.auto_home_after_patterns = data.get('auto_home_after_patterns', 5)
         self.hard_reset_theta = data.get('hard_reset_theta', False)
-        self._current_playlist = data.get("current_playlist", None)
-        self._current_playlist_name = data.get("current_playlist_name", None)
-        self.current_playlist_index = data.get("current_playlist_index", None)
         self._playlist_mode = data.get("playlist_mode", "loop")
         self._pause_time = data.get("pause_time", 0)
         self._clear_pattern = data.get("clear_pattern", "none")
@@ -546,7 +593,6 @@ class AppState:
         self._shuffle = data.get("shuffle", False)
         self.custom_clear_from_in = data.get("custom_clear_from_in", None)
         self.custom_clear_from_out = data.get("custom_clear_from_out", None)
-        self.port = data.get("port", None)
         self.preferred_port = data.get("preferred_port", None)
         self.wled_ip = data.get('wled_ip', None)
         self.led_provider = data.get('led_provider', "none")
@@ -560,26 +606,20 @@ class AppState:
         # Load effect settings (handle both old string format and new dict format)
         idle_effect_data = data.get('dw_led_idle_effect', None)
         if isinstance(idle_effect_data, str):
-            # Old format: just effect name
             self.dw_led_idle_effect = None if idle_effect_data == "off" else {"effect_id": 0}
         else:
-            # New format: full dict or None
             self.dw_led_idle_effect = idle_effect_data
 
         playing_effect_data = data.get('dw_led_playing_effect', None)
         if isinstance(playing_effect_data, str):
-            # Old format: just effect name
             self.dw_led_playing_effect = None if playing_effect_data == "off" else {"effect_id": 0}
         else:
-            # New format: full dict or None
             self.dw_led_playing_effect = playing_effect_data
 
-        # Load idle timeout settings
         self.dw_led_idle_timeout_enabled = data.get('dw_led_idle_timeout_enabled', False)
         self.dw_led_idle_timeout_minutes = data.get('dw_led_idle_timeout_minutes', 30)
 
         self.app_name = data.get("app_name", "Dune Weaver")
-        # Load or generate table_id (UUID persisted once generated)
         self.table_id = data.get("table_id", None)
         if self.table_id is None:
             self.table_id = str(uuid.uuid4())
@@ -597,25 +637,38 @@ class AppState:
         self.scheduled_pause_control_wled = data.get("scheduled_pause_control_wled", False)
         self.scheduled_pause_finish_pattern = data.get("scheduled_pause_finish_pattern", False)
         self.scheduled_pause_timezone = data.get("scheduled_pause_timezone", None)
+        self.server_port = data.get("server_port", 8080)
         self.timezone = data.get("timezone", "UTC")
         self.mqtt_enabled = data.get("mqtt_enabled", False)
         self.mqtt_broker = data.get("mqtt_broker", "")
         self.mqtt_port = data.get("mqtt_port", 1883)
         self.mqtt_username = data.get("mqtt_username", "")
-        self.mqtt_password = data.get("mqtt_password", "")
+        self.mqtt_password = self._decode_mqtt_password(data.get("mqtt_password", ""))
         self.mqtt_client_id = data.get("mqtt_client_id", "dune_weaver")
         self.mqtt_discovery_prefix = data.get("mqtt_discovery_prefix", "homeassistant")
         self.mqtt_device_id = data.get("mqtt_device_id", "dune_weaver")
         self.mqtt_device_name = data.get("mqtt_device_name", "Dune Weaver")
         self.table_type_override = data.get("table_type_override", None)
+        self.security_mode = data.get("security_mode", "off")
+        self.security_password_hash = data.get("security_password_hash", "")
+
+    def from_dict(self, data):
+        """Update state from a combined dictionary (backward compatibility / migration)."""
+        self.from_state_dict(data)
+        self.from_settings_dict(data)
 
     def save(self):
-        """Save the current state to a JSON file."""
+        """Save current state and settings to their respective JSON files."""
         try:
             with open(self.STATE_FILE, "w") as f:
-                json.dump(self.to_dict(), f)
+                json.dump(self.to_state_dict(), f)
         except Exception as e:
             print(f"Error saving state to {self.STATE_FILE}: {e}")
+        try:
+            with open(self.SETTINGS_FILE, "w") as f:
+                json.dump(self.to_settings_dict(), f)
+        except Exception as e:
+            print(f"Error saving settings to {self.SETTINGS_FILE}: {e}")
 
     def save_debounced(self, delay: float = 2.0):
         """
@@ -644,17 +697,47 @@ class AppState:
         logger.debug("Debounced state save completed")
 
     def load(self):
-        """Load state from a JSON file. If the file doesn't exist, create it with default values."""
-        if not os.path.exists(self.STATE_FILE):
-            # File doesn't exist: create one with the current (default) state.
+        """Load state and settings from their JSON files, with migration from old single-file format."""
+        settings_exists = os.path.exists(self.SETTINGS_FILE)
+        state_exists = os.path.exists(self.STATE_FILE)
+
+        if not settings_exists and not state_exists:
+            # Fresh install: create both files with defaults
             self.save()
             return
-        try:
-            with open(self.STATE_FILE, "r") as f:
-                data = json.load(f)
-            self.from_dict(data)
-        except Exception as e:
-            print(f"Error loading state from {self.STATE_FILE}: {e}")
+
+        if not settings_exists and state_exists:
+            # Migration: old single-file format — read settings fields from state.json
+            try:
+                with open(self.STATE_FILE, "r") as f:
+                    old_data = json.load(f)
+                # Load everything from the combined old format
+                self.from_dict(old_data)
+                # Save to split into both files
+                self.save()
+                logger.info("Migrated settings from state.json to settings.json")
+                return
+            except Exception as e:
+                print(f"Error migrating state from {self.STATE_FILE}: {e}")
+                self.save()
+                return
+
+        # Normal load: read both files
+        if state_exists:
+            try:
+                with open(self.STATE_FILE, "r") as f:
+                    state_data = json.load(f)
+                self.from_state_dict(state_data)
+            except Exception as e:
+                print(f"Error loading state from {self.STATE_FILE}: {e}")
+
+        if settings_exists:
+            try:
+                with open(self.SETTINGS_FILE, "r") as f:
+                    settings_data = json.load(f)
+                self.from_settings_dict(settings_data)
+            except Exception as e:
+                print(f"Error loading settings from {self.SETTINGS_FILE}: {e}")
 
     def update_steps_per_mm(self, x_steps, y_steps):
         """Update and save steps per mm values."""