Jelajahi Sumber

simplify playlist playback

tuanchris 2 minggu lalu
induk
melakukan
8645183657

+ 53 - 86
frontend/src/components/NowPlayingBar.tsx

@@ -78,15 +78,11 @@ function formatPatternName(path: string | null): string {
   return name
   return name
 }
 }
 
 
-// Sortable queue item component for drag-and-drop
+// Sortable queue item component for drag-and-drop (upcoming patterns only)
 interface SortableQueueItemProps {
 interface SortableQueueItemProps {
   id: string
   id: string
   file: string
   file: string
   index: number
   index: number
-  currentIndex: number
-  isPaused: boolean
-  isWaiting: boolean
-  waitTimeRemaining: number
   previewUrl: string | null
   previewUrl: string | null
 }
 }
 
 
@@ -94,15 +90,8 @@ function SortableQueueItem({
   id,
   id,
   file,
   file,
   index,
   index,
-  currentIndex,
-  isPaused,
-  isWaiting,
-  waitTimeRemaining,
   previewUrl,
   previewUrl,
 }: SortableQueueItemProps) {
 }: SortableQueueItemProps) {
-  const isCurrent = index === currentIndex
-  const isPast = index < currentIndex
-
   const {
   const {
     attributes,
     attributes,
     listeners,
     listeners,
@@ -110,10 +99,7 @@ function SortableQueueItem({
     transform,
     transform,
     transition,
     transition,
     isDragging,
     isDragging,
-  } = useSortable({
-    id,
-    disabled: isPast || isCurrent,
-  })
+  } = useSortable({ id })
 
 
   const style = {
   const style = {
     transform: CSS.Transform.toString(transform),
     transform: CSS.Transform.toString(transform),
@@ -126,34 +112,16 @@ function SortableQueueItem({
     <div
     <div
       ref={setNodeRef}
       ref={setNodeRef}
       style={style}
       style={style}
-      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'
-      } ${isDragging ? 'shadow-lg bg-background' : ''}`}
+      className={`flex items-center gap-2 p-2 rounded-lg transition-colors hover:bg-muted/50 ${isDragging ? 'shadow-lg bg-background' : ''}`}
     >
     >
-      {/* Drag handle - only for future items */}
-      {!isPast && !isCurrent ? (
-        <div
-          {...attributes}
-          {...listeners}
-          className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
-        >
-          <span className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
-        </div>
-      ) : (
-        <div className="w-6 text-center shrink-0">
-          {isCurrent ? (
-            <span className="material-icons text-primary text-lg">
-              {isPaused ? 'pause' : 'play_arrow'}
-            </span>
-          ) : (
-            <span className="material-icons-outlined text-muted-foreground text-sm">check</span>
-          )}
-        </div>
-      )}
+      {/* Drag handle */}
+      <div
+        {...attributes}
+        {...listeners}
+        className="w-6 flex items-center justify-center shrink-0 cursor-grab active:cursor-grabbing touch-none"
+      >
+        <span className="material-icons-outlined text-muted-foreground text-sm">drag_indicator</span>
+      </div>
 
 
       {/* Preview thumbnail */}
       {/* Preview thumbnail */}
       <div className="w-14 h-14 rounded-full overflow-hidden bg-muted border shrink-0">
       <div className="w-14 h-14 rounded-full overflow-hidden bg-muted border shrink-0">
@@ -161,32 +129,21 @@ function SortableQueueItem({
           <img
           <img
             src={previewUrl}
             src={previewUrl}
             alt=""
             alt=""
+            loading="lazy"
             className="w-full h-full object-cover pattern-preview"
             className="w-full h-full object-cover pattern-preview"
           />
           />
         ) : (
         ) : (
           <div className="w-full h-full flex items-center justify-center">
           <div className="w-full h-full flex items-center justify-center">
-            <span className="material-icons-outlined text-muted-foreground text-base">image</span>
+            <span className="material-icons-outlined text-muted-foreground text-2xl">image</span>
           </div>
           </div>
         )}
         )}
       </div>
       </div>
 
 
       {/* Pattern name */}
       {/* Pattern name */}
       <div className="flex-1 min-w-0">
       <div className="flex-1 min-w-0">
-        <p className={`text-sm truncate ${isCurrent ? 'font-medium' : ''}`}>
-          {formatPatternName(file)}
-        </p>
-        {!isPast && !isCurrent && (
-          <p className="text-xs text-muted-foreground">#{index + 1}</p>
-        )}
+        <p className="text-sm truncate">{formatPatternName(file)}</p>
+        <p className="text-xs text-muted-foreground">#{index + 1}</p>
       </div>
       </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>
-      )}
     </div>
     </div>
   )
   )
 }
 }
@@ -962,7 +919,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                           <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                           <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                           <Progress value={progressPercent} className="h-2 flex-1" />
                           <Progress value={progressPercent} className="h-2 flex-1" />
                           <span
                           <span
-                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 ${usingHistoricalEta ? 'w-20' : 'w-12'}`}
+                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}
                             title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
                             title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
                           >
                           >
                             {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
                             {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
@@ -1069,7 +1026,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                   <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                     <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                     <span className="text-sm text-muted-foreground w-12 font-mono">{formatTime(elapsedTime)}</span>
                     <Progress value={progressPercent} className="h-2 flex-1" />
                     <Progress value={progressPercent} className="h-2 flex-1" />
-                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 ${usingHistoricalEta ? 'w-20' : 'w-12'}`}>
+                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1.5 shrink-0 ${usingHistoricalEta ? 'w-24' : 'w-14'}`}>
                       {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
                       {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
                       -{formatTime(remainingTime)}
                       -{formatTime(remainingTime)}
                     </span>
                     </span>
@@ -1251,32 +1208,42 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
 
           <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2">
           <div className="flex-1 overflow-y-auto -mx-6 px-6 py-2">
             {status?.playlist?.files && status.playlist.files.length > 0 ? (
             {status?.playlist?.files && status.playlist.files.length > 0 ? (
-              <DndContext
-                sensors={sensors}
-                collisionDetection={closestCenter}
-                onDragEnd={handleDragEnd}
-              >
-                <SortableContext
-                  items={status.playlist.files.map((_, index) => `queue-item-${index}`)}
-                  strategy={verticalListSortingStrategy}
-                >
-                  <div className="space-y-1">
-                    {status.playlist.files.map((file, index) => (
-                      <SortableQueueItem
-                        key={`queue-item-${index}`}
-                        id={`queue-item-${index}`}
-                        file={file}
-                        index={index}
-                        currentIndex={status.playlist!.current_index}
-                        isPaused={status.is_paused}
-                        isWaiting={isWaiting}
-                        waitTimeRemaining={waitTimeRemaining}
-                        previewUrl={queuePreviews[file] || null}
-                      />
-                    ))}
-                  </div>
-                </SortableContext>
-              </DndContext>
+              (() => {
+                // Only show upcoming patterns (after current)
+                const currentIndex = status.playlist!.current_index
+                const upcomingFiles = status.playlist!.files
+                  .map((file, index) => ({ file, index }))
+                  .filter(({ index }) => index > currentIndex)
+
+                if (upcomingFiles.length === 0) {
+                  return <p className="text-center text-muted-foreground py-8">No upcoming patterns</p>
+                }
+
+                return (
+                  <DndContext
+                    sensors={sensors}
+                    collisionDetection={closestCenter}
+                    onDragEnd={handleDragEnd}
+                  >
+                    <SortableContext
+                      items={upcomingFiles.map(({ index }) => `queue-item-${index}`)}
+                      strategy={verticalListSortingStrategy}
+                    >
+                      <div className="space-y-1">
+                        {upcomingFiles.map(({ file, index }) => (
+                          <SortableQueueItem
+                            key={`queue-item-${index}`}
+                            id={`queue-item-${index}`}
+                            file={file}
+                            index={index}
+                            previewUrl={queuePreviews[file] || null}
+                          />
+                        ))}
+                      </div>
+                    </SortableContext>
+                  </DndContext>
+                )
+              })()
             ) : (
             ) : (
               <p className="text-center text-muted-foreground py-8">No queue</p>
               <p className="text-center text-muted-foreground py-8">No queue</p>
             )}
             )}

+ 13 - 0
frontend/src/components/layout/Layout.tsx

@@ -154,6 +154,19 @@ export function Layout() {
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [isNowPlayingOpen, setIsNowPlayingOpen] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
   const [openNowPlayingExpanded, setOpenNowPlayingExpanded] = useState(false)
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
   const wasPlayingRef = useRef<boolean | null>(null) // Track previous playing state (null = first message)
+
+  // Listen for playback-started event (dispatched when user starts a pattern)
+  useEffect(() => {
+    const handlePlaybackStarted = () => {
+      setIsNowPlayingOpen(true)
+      setOpenNowPlayingExpanded(true)
+      setIsLogsOpen(false)
+      // Reset expanded flag after animation
+      setTimeout(() => setOpenNowPlayingExpanded(false), 500)
+    }
+    window.addEventListener('playback-started', handlePlaybackStarted)
+    return () => window.removeEventListener('playback-started', handlePlaybackStarted)
+  }, [])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logs, setLogs] = useState<Array<{ timestamp: string; level: string; logger: string; message: string }>>([])
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
   const [logLevelFilter, setLogLevelFilter] = useState<string>('ALL')
   const logsWsRef = useRef<WebSocket | null>(null)
   const logsWsRef = useRef<WebSocket | null>(null)

+ 3 - 0
frontend/src/pages/BrowsePage.tsx

@@ -660,6 +660,9 @@ export function BrowsePage() {
         pre_execution: preExecution,
         pre_execution: preExecution,
       })
       })
       toast.success(`Running ${selectedPattern.name}`)
       toast.success(`Running ${selectedPattern.name}`)
+      // Close the preview panel and trigger Now Playing bar to open
+      setIsPanelOpen(false)
+      window.dispatchEvent(new CustomEvent('playback-started'))
     } catch (error) {
     } catch (error) {
       const message = error instanceof Error ? error.message : 'Failed to run pattern'
       const message = error instanceof Error ? error.message : 'Failed to run pattern'
       if (message.includes('409') || message.includes('already running')) {
       if (message.includes('409') || message.includes('already running')) {

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

@@ -417,6 +417,8 @@ export function PlaylistsPage() {
         shuffle: shuffle,
         shuffle: shuffle,
       })
       })
       toast.success(`Started playlist: ${selectedPlaylist}`)
       toast.success(`Started playlist: ${selectedPlaylist}`)
+      // Trigger Now Playing bar to open
+      window.dispatchEvent(new CustomEvent('playback-started'))
     } catch (error) {
     } catch (error) {
       toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
       toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
     } finally {
     } finally {

+ 15 - 8
main.py

@@ -2180,7 +2180,11 @@ async def skip_pattern():
 
 
 @app.post("/reorder_playlist")
 @app.post("/reorder_playlist")
 async def reorder_playlist(request: dict):
 async def reorder_playlist(request: dict):
-    """Reorder a pattern in the current playlist queue."""
+    """Reorder a pattern in the current playlist queue.
+
+    Since the playlist now contains only main patterns (clear patterns are executed
+    dynamically at runtime), this simply moves the pattern from one position to another.
+    """
     if not state.current_playlist:
     if not state.current_playlist:
         raise HTTPException(status_code=400, detail="No playlist is currently running")
         raise HTTPException(status_code=400, detail="No playlist is currently running")
 
 
@@ -2190,7 +2194,7 @@ async def reorder_playlist(request: dict):
     if from_index is None or to_index is None:
     if from_index is None or to_index is None:
         raise HTTPException(status_code=400, detail="from_index and to_index are required")
         raise HTTPException(status_code=400, detail="from_index and to_index are required")
 
 
-    playlist = state.current_playlist
+    playlist = list(state.current_playlist)  # Make a copy to work with
     current_index = state.current_playlist_index
     current_index = state.current_playlist_index
 
 
     # Validate indices
     # Validate indices
@@ -2199,15 +2203,18 @@ async def reorder_playlist(request: dict):
     if to_index < 0 or to_index >= len(playlist):
     if to_index < 0 or to_index >= len(playlist):
         raise HTTPException(status_code=400, detail="to_index out of range")
         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")
+    # Can't move patterns that have already played (before current_index)
+    # But CAN move the current pattern or swap with it (allows live reordering)
+    if from_index < current_index:
+        raise HTTPException(status_code=400, detail="Cannot move completed pattern")
+    if to_index < current_index:
+        raise HTTPException(status_code=400, detail="Cannot move to completed position")
 
 
     # Perform the reorder
     # Perform the reorder
     item = playlist.pop(from_index)
     item = playlist.pop(from_index)
-    playlist.insert(to_index, item)
+    # Adjust to_index if moving forward (since we removed an item before it)
+    adjusted_to_index = to_index if to_index < from_index else to_index - 1
+    playlist.insert(adjusted_to_index, item)
 
 
     # Update state (this triggers the property setter)
     # Update state (this triggers the property setter)
     state.current_playlist = playlist
     state.current_playlist = playlist

+ 3 - 3
modules/core/pattern_manager.py

@@ -1030,10 +1030,10 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
             # Execute main patterns using index-based access
             # Execute main patterns using index-based access
             # This allows the playlist to be reordered during execution
             # This allows the playlist to be reordered during execution
             idx = 0
             idx = 0
-            while idx < len(state.current_playlist):
+            while state.current_playlist and idx < len(state.current_playlist):
                 state.current_playlist_index = idx
                 state.current_playlist_index = idx
 
 
-                if state.stop_requested:
+                if state.stop_requested or not state.current_playlist:
                     logger.info("Execution stopped")
                     logger.info("Execution stopped")
                     return
                     return
 
 
@@ -1092,7 +1092,7 @@ async def run_theta_rho_files(file_paths, pause_time=0, clear_pattern=None, run_
                             idle_timeout_manager.cancel_timeout()
                             idle_timeout_manager.cancel_timeout()
 
 
                 # Handle pause between patterns
                 # Handle pause between patterns
-                if idx < len(state.current_playlist) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
+                if state.current_playlist and idx < len(state.current_playlist) - 1 and not state.stop_requested and pause_time > 0 and not state.skip_requested:
                     logger.info(f"Pausing for {pause_time} seconds")
                     logger.info(f"Pausing for {pause_time} seconds")
                     state.original_pause_time = pause_time
                     state.original_pause_time = pause_time
                     pause_start = time.time()
                     pause_start = time.time()

+ 1 - 0
modules/core/state.py

@@ -30,6 +30,7 @@ class AppState:
         self.current_playlist_index = 0
         self.current_playlist_index = 0
         self.playlist_mode = "loop"
         self.playlist_mode = "loop"
         self.pause_time_remaining = 0
         self.pause_time_remaining = 0
+        self.active_clear_pattern = None  # Runtime: clear pattern mode for current playlist (not persisted)
         
         
         # Machine position variables
         # Machine position variables
         self.machine_x = 0.0
         self.machine_x = 0.0