Просмотр исходного кода

Clear pause time between patterns on stop

Always clear pause_time_remaining and original_pause_time when stopping,
not just when clearing the playlist. This ensures the UI doesn't show
stale countdown values after stopping.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
tuanchris 2 недель назад
Родитель
Сommit
2277a31c22

+ 56 - 0
frontend/package-lock.json

@@ -8,6 +8,9 @@
       "name": "frontend",
       "version": "0.0.0",
       "dependencies": {
+        "@dnd-kit/core": "^6.3.1",
+        "@dnd-kit/sortable": "^10.0.0",
+        "@dnd-kit/utilities": "^3.2.2",
         "@radix-ui/react-accordion": "^1.2.12",
         "@radix-ui/react-dialog": "^1.1.15",
         "@radix-ui/react-label": "^2.1.8",
@@ -351,6 +354,59 @@
         "node": ">=6.9.0"
       }
     },
+    "node_modules/@dnd-kit/accessibility": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+      "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/core": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+      "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/accessibility": "^3.1.1",
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0",
+        "react-dom": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/sortable": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+      "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+      "license": "MIT",
+      "dependencies": {
+        "@dnd-kit/utilities": "^3.2.2",
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "@dnd-kit/core": "^6.3.0",
+        "react": ">=16.8.0"
+      }
+    },
+    "node_modules/@dnd-kit/utilities": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+      "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8.0"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.27.2",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",

+ 3 - 0
frontend/package.json

@@ -10,6 +10,9 @@
     "preview": "vite preview --host"
   },
   "dependencies": {
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
     "@radix-ui/react-accordion": "^1.2.12",
     "@radix-ui/react-dialog": "^1.1.15",
     "@radix-ui/react-label": "^2.1.8",

+ 322 - 35
frontend/src/components/NowPlayingBar.tsx

@@ -3,6 +3,15 @@ import { toast } from 'sonner'
 import { Button } from '@/components/ui/button'
 import { Progress } from '@/components/ui/progress'
 import { Input } from '@/components/ui/input'
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from '@/components/ui/dialog'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
 import { apiClient } from '@/lib/apiClient'
 
 type Coordinate = [number, number]
@@ -25,6 +34,8 @@ interface PlaybackStatus {
     total_files: number
     mode: string
     next_file: string | null
+    files: string[]
+    name: string | null
   } | null
   speed: number
   pause_time_remaining: number
@@ -539,6 +550,9 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
   }
 
   const [speedInput, setSpeedInput] = useState('')
+  const [showQueue, setShowQueue] = useState(false)
+  const [hideClearPatterns, setHideClearPatterns] = useState(false)
+  const [queuePreviews, setQueuePreviews] = useState<Record<string, string>>({})
 
   const handleSpeedSubmit = async () => {
     const speed = parseInt(speedInput)
@@ -555,6 +569,69 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
     }
   }
 
+  // Track which files we've already requested previews for
+  const requestedPreviewsRef = useRef<Set<string>>(new Set())
+
+  // Fetch queue previews when dialog opens
+  useEffect(() => {
+    if (!showQueue || !status?.playlist?.files) {
+      return
+    }
+
+    // Filter out files we've already requested
+    const filesToFetch = status.playlist.files.filter(f => !requestedPreviewsRef.current.has(f))
+    if (filesToFetch.length === 0) return
+
+    // Mark these as requested immediately to prevent duplicate requests
+    filesToFetch.forEach(f => requestedPreviewsRef.current.add(f))
+
+    // Fetch in batches of 20 to avoid overwhelming the server
+    const batchSize = 20
+    const fetchBatch = async (batch: string[]) => {
+      try {
+        const data = await apiClient.post<Record<string, { image_data?: string }>>('/preview_thr_batch', { file_names: batch })
+        const newPreviews: Record<string, string> = {}
+        for (const [file, result] of Object.entries(data)) {
+          if (result.image_data) {
+            newPreviews[file] = result.image_data
+          }
+        }
+        if (Object.keys(newPreviews).length > 0) {
+          setQueuePreviews(prev => ({ ...prev, ...newPreviews }))
+        }
+      } catch (err) {
+        console.error('Failed to fetch queue previews:', err)
+      }
+    }
+
+    // Fetch first batch immediately, then stagger the rest
+    for (let i = 0; i < filesToFetch.length; i += batchSize) {
+      const batch = filesToFetch.slice(i, i + batchSize)
+      setTimeout(() => fetchBatch(batch), (i / batchSize) * 200)
+    }
+  }, [showQueue, status?.playlist?.files])
+
+  // Reorder pattern in queue
+  const handleReorder = async (fromIndex: number, toIndex: number) => {
+    if (!status?.playlist?.files) return
+
+    // Can't move past items or the current item
+    if (toIndex <= status.playlist.current_index) {
+      toast.error("Can't move before current pattern")
+      return
+    }
+    if (fromIndex <= status.playlist.current_index) {
+      toast.error("Can't move completed or current pattern")
+      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
@@ -565,6 +642,12 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
   const remainingTime = status?.progress?.remaining_time || 0
   const elapsedTime = status?.progress?.elapsed_time || 0
 
+  // Detect waiting state between patterns
+  const isWaiting = (status?.pause_time_remaining ?? 0) > 0
+  const waitTimeRemaining = status?.pause_time_remaining ?? 0
+  const originalWaitTime = status?.original_pause_time ?? 0
+  const waitProgress = originalWaitTime > 0 ? ((originalWaitTime - waitTimeRemaining) / originalWaitTime) * 100 : 0
+
   return (
     <>
       {/* Backdrop when expanded */}
@@ -654,25 +737,48 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                       {/* Title Row */}
                       <div className="flex items-center gap-3 pr-12 md:pr-16">
                         <div className="flex-1 min-w-0">
-                          <div className="marquee-container">
-                            <p className="text-sm md:text-base font-semibold whitespace-nowrap animate-marquee">
-                              {patternName}
-                            </p>
-                          </div>
-                          {status.playlist && (
-                            <p className="text-xs text-muted-foreground">
-                              Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                            </p>
+                          {isWaiting ? (
+                            <>
+                              <p className="text-sm md:text-base font-semibold text-muted-foreground">
+                                Waiting for next pattern...
+                              </p>
+                              {status.playlist?.next_file && (
+                                <p className="text-xs text-muted-foreground">
+                                  Up next: {formatPatternName(status.playlist.next_file)}
+                                </p>
+                              )}
+                            </>
+                          ) : (
+                            <>
+                              <div className="marquee-container">
+                                <p className="text-sm md:text-base font-semibold whitespace-nowrap animate-marquee">
+                                  {patternName}
+                                </p>
+                              </div>
+                              {status.playlist && (
+                                <p className="text-xs text-muted-foreground">
+                                  Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                                </p>
+                              )}
+                            </>
                           )}
                         </div>
                       </div>
 
                       {/* Progress Bar - Desktop only (inline, above controls) */}
-                      <div className="hidden md:flex items-center gap-3">
-                        <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                        <Progress value={progressPercent} className="h-2 flex-1" />
-                        <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
-                      </div>
+                      {isWaiting ? (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                          <Progress value={waitProgress} className="h-2 flex-1" />
+                          <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                        </div>
+                      ) : (
+                        <div className="hidden md:flex items-center gap-3">
+                          <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                          <Progress value={progressPercent} className="h-2 flex-1" />
+                          <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
+                        </div>
+                      )}
 
                       {/* Playback Controls - Centered */}
                       <div className="flex items-center justify-center gap-3">
@@ -725,8 +831,15 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
                     {/* Next Pattern Preview - hidden on mobile */}
                     {status.playlist?.next_file && (
-                      <div className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16">
-                        <p className="text-xs text-muted-foreground font-medium">Up Next</p>
+                      <div
+                        className="hidden md:flex shrink-0 flex-col items-center gap-1 mr-16 cursor-pointer hover:opacity-80 transition-opacity"
+                        onClick={() => setShowQueue(true)}
+                        title="View queue"
+                      >
+                        <p className="text-xs text-muted-foreground font-medium flex items-center gap-1">
+                          Up Next
+                          <span className="material-icons-outlined text-xs">queue_music</span>
+                        </p>
                         <div className="w-24 h-24 rounded-full overflow-hidden bg-muted border-2">
                           {nextPreviewUrl ? (
                             <img
@@ -755,11 +868,19 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
               {/* Progress Bar - Mobile only (full width at bottom) */}
               {isPlaying && status && (
-                <div className="flex md:hidden items-center gap-3 px-6 pb-3">
-                  <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
-                  <Progress value={progressPercent} className="h-2 flex-1" />
-                  <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
-                </div>
+                isWaiting ? (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-3">
+                    <span className="material-icons-outlined text-muted-foreground text-lg">hourglass_top</span>
+                    <Progress value={waitProgress} className="h-2 flex-1" />
+                    <span className="text-sm text-muted-foreground font-mono">{formatTime(waitTimeRemaining)}</span>
+                  </div>
+                ) : (
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-3">
+                    <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
+                    <Progress value={progressPercent} className="h-2 flex-1" />
+                    <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
+                  </div>
+                )
               )}
             </div>
           )}
@@ -787,23 +908,48 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                 <div className="md:w-80 shrink-0 flex flex-col justify-start md:justify-center gap-2 md:gap-4">
                 {/* Pattern Info */}
                 <div className="text-center">
-                  <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
-                  {status?.playlist && (
-                    <p className="text-sm text-muted-foreground">
-                      Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
-                    </p>
+                  {isWaiting ? (
+                    <>
+                      <h2 className="text-lg md:text-xl font-semibold text-muted-foreground">
+                        Waiting for next pattern...
+                      </h2>
+                      {status?.playlist?.next_file && (
+                        <p className="text-sm text-muted-foreground">
+                          Up next: {formatPatternName(status.playlist.next_file)}
+                        </p>
+                      )}
+                    </>
+                  ) : (
+                    <>
+                      <h2 className="text-lg md:text-xl font-semibold truncate">{patternName}</h2>
+                      {status?.playlist && (
+                        <p className="text-sm text-muted-foreground">
+                          Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
+                        </p>
+                      )}
+                    </>
                   )}
                 </div>
 
                 {/* Progress */}
-                <div className="space-y-1 md:space-y-2">
-                  <Progress value={progressPercent} className="h-1.5 md:h-2" />
-                  <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
-                    <span>{formatTime(elapsedTime)}</span>
-                    <span>{progressPercent.toFixed(0)}%</span>
-                    <span>-{formatTime(remainingTime)}</span>
+                {isWaiting ? (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={waitProgress} className="h-1.5 md:h-2" />
+                    <div className="flex justify-center items-center gap-2 text-xs md:text-sm text-muted-foreground font-mono">
+                      <span className="material-icons-outlined text-base">hourglass_top</span>
+                      <span>{formatTime(waitTimeRemaining)} remaining</span>
+                    </div>
                   </div>
-                </div>
+                ) : (
+                  <div className="space-y-1 md:space-y-2">
+                    <Progress value={progressPercent} className="h-1.5 md:h-2" />
+                    <div className="flex justify-between text-xs md:text-sm text-muted-foreground font-mono">
+                      <span>{formatTime(elapsedTime)}</span>
+                      <span>{progressPercent.toFixed(0)}%</span>
+                      <span>-{formatTime(remainingTime)}</span>
+                    </div>
+                  </div>
+                )}
 
                 {/* Playback Controls */}
                 <div className="flex items-center justify-center gap-2 md:gap-3">
@@ -855,7 +1001,11 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
                 {/* Next Pattern */}
                 {status?.playlist?.next_file && (
-                  <div className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3">
+                  <div
+                    className="flex items-center gap-3 bg-muted/50 rounded-lg p-2 md:p-3 cursor-pointer hover:bg-muted/70 transition-colors"
+                    onClick={() => setShowQueue(true)}
+                    title="View queue"
+                  >
                     <div className="w-10 h-10 md:w-12 md:h-12 rounded-full overflow-hidden bg-muted border shrink-0">
                       {nextPreviewUrl ? (
                         <img
@@ -869,12 +1019,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                         </div>
                       )}
                     </div>
-                    <div className="min-w-0">
+                    <div className="min-w-0 flex-1">
                       <p className="text-xs text-muted-foreground">Up Next</p>
                       <p className="text-sm font-medium truncate">
                         {formatPatternName(status.playlist.next_file)}
                       </p>
                     </div>
+                    <span className="material-icons-outlined text-muted-foreground text-lg">queue_music</span>
                   </div>
                 )}
               </div>
@@ -883,6 +1034,142 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
           )}
         </div>
       </div>
+
+      {/* Queue Dialog */}
+      <Dialog open={showQueue} onOpenChange={setShowQueue}>
+        <DialogContent className="max-w-md max-h-[80vh] flex flex-col">
+          <DialogHeader>
+            <DialogTitle className="flex items-center gap-2">
+              <span className="material-icons-outlined">queue_music</span>
+              Queue
+              {status?.playlist?.name && (
+                <span className="text-sm font-normal text-muted-foreground">
+                  — {status.playlist.name}
+                </span>
+              )}
+            </DialogTitle>
+            <DialogDescription className="sr-only">
+              List of patterns in the current playlist queue
+            </DialogDescription>
+          </DialogHeader>
+
+          {/* Filter toggle */}
+          <div className="flex items-center justify-between py-2 border-b">
+            <Label htmlFor="hide-clear" className="text-sm text-muted-foreground cursor-pointer">
+              Hide clear patterns
+            </Label>
+            <Switch
+              id="hide-clear"
+              checked={hideClearPatterns}
+              onCheckedChange={setHideClearPatterns}
+            />
+          </div>
+
+          <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2">
+            {status?.playlist?.files && status.playlist.files.length > 0 ? (
+              <div className="space-y-1">
+                {status.playlist.files.map((file, index) => {
+                  const isCurrent = index === status.playlist!.current_index
+                  const isPast = index < status.playlist!.current_index
+                  const isClearPattern = formatPatternName(file).toLowerCase().includes('clear')
+                  const canReorder = !isPast && !isCurrent
+
+                  // Filter out clear patterns if enabled
+                  if (hideClearPatterns && isClearPattern) return null
+
+                  return (
+                    <div
+                      key={`${file}-${index}`}
+                      className={`flex items-center gap-2 p-2 rounded-lg transition-colors ${
+                        isCurrent
+                          ? 'bg-primary/10 border border-primary/30'
+                          : isPast
+                          ? 'opacity-50'
+                          : 'hover:bg-muted/50'
+                      }`}
+                    >
+                      {/* Index/Status indicator */}
+                      <div className="w-6 text-center shrink-0">
+                        {isCurrent ? (
+                          <span className="material-icons text-primary text-lg">
+                            {status.is_paused ? 'pause' : 'play_arrow'}
+                          </span>
+                        ) : isPast ? (
+                          <span className="material-icons-outlined text-muted-foreground text-sm">check</span>
+                        ) : (
+                          <span className="text-xs text-muted-foreground">{index + 1}</span>
+                        )}
+                      </div>
+
+                      {/* Preview thumbnail */}
+                      <div className="w-14 h-14 rounded-full overflow-hidden bg-muted border shrink-0">
+                        {queuePreviews[file] ? (
+                          <img
+                            src={queuePreviews[file]}
+                            alt=""
+                            className="w-full h-full object-cover pattern-preview"
+                          />
+                        ) : (
+                          <div className="w-full h-full flex items-center justify-center">
+                            <span className="material-icons-outlined text-muted-foreground text-base">image</span>
+                          </div>
+                        )}
+                      </div>
+
+                      {/* Pattern name */}
+                      <div className="flex-1 min-w-0">
+                        <p className={`text-sm truncate ${isCurrent ? 'font-medium' : ''} ${isClearPattern ? 'text-muted-foreground italic' : ''}`}>
+                          {formatPatternName(file)}
+                        </p>
+                      </div>
+
+                      {/* Waiting indicator */}
+                      {isCurrent && isWaiting && (
+                        <span className="text-xs text-muted-foreground flex items-center gap-1">
+                          <span className="material-icons-outlined text-sm">hourglass_top</span>
+                          {formatTime(waitTimeRemaining)}
+                        </span>
+                      )}
+
+                      {/* Reorder buttons */}
+                      {canReorder && (
+                        <div className="flex flex-col shrink-0">
+                          <button
+                            onClick={() => handleReorder(index, index - 1)}
+                            disabled={index <= status.playlist!.current_index + 1}
+                            className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
+                            title="Move up"
+                          >
+                            <span className="material-icons text-sm">keyboard_arrow_up</span>
+                          </button>
+                          <button
+                            onClick={() => handleReorder(index, index + 1)}
+                            disabled={index >= status.playlist!.files.length - 1}
+                            className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed"
+                            title="Move down"
+                          >
+                            <span className="material-icons text-sm">keyboard_arrow_down</span>
+                          </button>
+                        </div>
+                      )}
+                    </div>
+                  )
+                })}
+              </div>
+            ) : (
+              <p className="text-center text-muted-foreground py-8">No queue</p>
+            )}
+          </div>
+          {status?.playlist && (
+            <div className="pt-3 border-t text-xs text-muted-foreground flex justify-between">
+              <span>Mode: {status.playlist.mode}</span>
+              <span>
+                {status.playlist.current_index + 1} of {status.playlist.total_files}
+              </span>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
     </>
   )
 }

+ 2 - 2
frontend/src/pages/PlaylistsPage.tsx

@@ -615,14 +615,14 @@ export function PlaylistsPage() {
               </div>
             ) : (
               <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
-                {playlistPatterns.map((path) => {
+                {playlistPatterns.map((path, index) => {
                   const previewUrl = getPreviewUrl(path)
                   if (!previewUrl && !previews[path]) {
                     requestPreview(path)
                   }
                   return (
                     <div
-                      key={path}
+                      key={`${path}-${index}`}
                       className="flex flex-col items-center gap-1.5 sm:gap-2 group"
                     >
                       <div className="relative w-full aspect-square">

+ 1 - 0
frontend/vite.config.ts

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

+ 42 - 2
main.py

@@ -1120,15 +1120,19 @@ async def list_ports():
 async def connect(request: ConnectRequest):
     if not request.port:
         state.conn = connection_manager.WebSocketConnection('ws://fluidnc.local:81')
-        connection_manager.device_init()
+        if not connection_manager.device_init():
+            raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
         logger.info('Successfully connected to websocket ws://fluidnc.local:81')
         return {"success": True}
 
     try:
         state.conn = connection_manager.SerialConnection(request.port)
-        connection_manager.device_init()
+        if not connection_manager.device_init():
+            raise HTTPException(status_code=500, detail="Failed to initialize device - could not get machine parameters")
         logger.info(f'Successfully connected to serial port {request.port}')
         return {"success": True}
+    except HTTPException:
+        raise
     except Exception as e:
         logger.error(f'Failed to connect to serial port {request.port}: {str(e)}')
         raise HTTPException(status_code=500, detail=str(e))
@@ -2174,6 +2178,42 @@ async def skip_pattern():
     state.skip_requested = True
     return {"success": True}
 
+@app.post("/reorder_playlist")
+async def reorder_playlist(request: dict):
+    """Reorder a pattern in the current playlist queue."""
+    if not state.current_playlist:
+        raise HTTPException(status_code=400, detail="No playlist is currently running")
+
+    from_index = request.get("from_index")
+    to_index = request.get("to_index")
+
+    if from_index is None or to_index is None:
+        raise HTTPException(status_code=400, detail="from_index and to_index are required")
+
+    playlist = state.current_playlist
+    current_index = state.current_playlist_index
+
+    # Validate indices
+    if from_index < 0 or from_index >= len(playlist):
+        raise HTTPException(status_code=400, detail="from_index out of range")
+    if to_index < 0 or to_index >= len(playlist):
+        raise HTTPException(status_code=400, detail="to_index out of range")
+
+    # Can't move items that are already played or currently playing
+    if from_index <= current_index:
+        raise HTTPException(status_code=400, detail="Cannot move completed or currently playing pattern")
+    if to_index <= current_index:
+        raise HTTPException(status_code=400, detail="Cannot move pattern before current position")
+
+    # Perform the reorder
+    item = playlist.pop(from_index)
+    playlist.insert(to_index, item)
+
+    # Update state (this triggers the property setter)
+    state.current_playlist = playlist
+
+    return {"success": True}
+
 @app.get("/api/custom_clear_patterns", deprecated=True, tags=["settings-deprecated"])
 async def get_custom_clear_patterns():
     """Get the currently configured custom clear patterns."""

+ 1 - 0
modules/connection/connection_manager.py

@@ -219,6 +219,7 @@ def device_init(homing=True):
         logger.info(f'State x, y; {state.machine_x}, {state.machine_y}')
 
     time.sleep(2)  # Allow time for the connection to establish
+    return True
 
 
 def connect_device(homing=True):

+ 4 - 1
modules/core/pattern_manager.py

@@ -1099,12 +1099,15 @@ async def stop_actions(clear_playlist = True, wait_for_lock = True):
             state.stop_requested = True
             state.is_clearing = False
 
+            # Always clear pause time between patterns on stop
+            state.pause_time_remaining = 0
+            state.original_pause_time = None
+
             if clear_playlist:
                 # Clear playlist state
                 state.current_playlist = None
                 state.current_playlist_index = None
                 state.playlist_mode = None
-                state.pause_time_remaining = 0
 
                 # Cancel progress update task if we're clearing the playlist
                 global progress_update_task