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

Add historical ETA, persist pre-execution selection, fix mobile UI

- Display historical execution time as ETA when pattern was previously
  completed at the same speed (shows history icon indicator)
- Persist pre-execution action selection to localStorage (shared between
  Browse and Playlists pages)
- Fix delete pattern 404 by adding endpoint to Vite proxy config
- Fix mobile UI: circular preview containers, progress bar spacing
- Remove marquee effect from pattern name display

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

+ 36 - 15
frontend/src/components/NowPlayingBar.tsx

@@ -28,6 +28,11 @@ interface PlaybackStatus {
     remaining_time: number
     elapsed_time: number
     percentage: number
+    last_completed_time?: {
+      actual_time_seconds: number
+      actual_time_formatted: string
+      timestamp: string
+    }
   } | null
   playlist: {
     current_index: number
@@ -639,9 +644,16 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
 
   const patternName = formatPatternName(status?.current_file ?? null)
   const progressPercent = status?.progress?.percentage || 0
-  const remainingTime = status?.progress?.remaining_time || 0
+  const tqdmRemainingTime = status?.progress?.remaining_time || 0
   const elapsedTime = status?.progress?.elapsed_time || 0
 
+  // Use historical time if available, otherwise fall back to tqdm estimate
+  const historicalTime = status?.progress?.last_completed_time?.actual_time_seconds
+  const remainingTime = historicalTime
+    ? Math.max(0, historicalTime - elapsedTime)
+    : tqdmRemainingTime
+  const usingHistoricalEta = !!historicalTime
+
   // Detect waiting state between patterns
   const isWaiting = (status?.pause_time_remaining ?? 0) > 0
   const waitTimeRemaining = status?.pause_time_remaining ?? 0
@@ -750,11 +762,9 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                             </>
                           ) : (
                             <>
-                              <div className="marquee-container">
-                                <p className="text-sm md:text-base font-semibold whitespace-nowrap animate-marquee">
-                                  {patternName}
-                                </p>
-                              </div>
+                              <p className="text-sm md:text-base font-semibold truncate">
+                                {patternName}
+                              </p>
                               {status.playlist && (
                                 <p className="text-xs text-muted-foreground">
                                   Pattern {status.playlist.current_index + 1} of {status.playlist.total_files}
@@ -776,7 +786,13 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                         <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>
+                          <span
+                            className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-1 ${usingHistoricalEta ? 'w-16' : 'w-12'}`}
+                            title={usingHistoricalEta ? 'ETA based on last completed run' : 'Estimated time remaining'}
+                          >
+                            {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                            -{formatTime(remainingTime)}
+                          </span>
                         </div>
                       )}
 
@@ -869,16 +885,19 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
               {/* Progress Bar - Mobile only (full width at bottom) */}
               {isPlaying && status && (
                 isWaiting ? (
-                  <div className="flex md:hidden items-center gap-3 px-6 pb-3">
+                  <div className="flex md:hidden items-center gap-3 px-6 pb-16">
                     <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">
+                  <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>
                     <Progress value={progressPercent} className="h-2 flex-1" />
-                    <span className="text-sm text-muted-foreground w-12 text-right font-mono">-{formatTime(remainingTime)}</span>
+                    <span className={`text-sm text-muted-foreground text-right font-mono flex items-center justify-end gap-0.5 ${usingHistoricalEta ? 'w-16' : 'w-12'}`}>
+                      {usingHistoricalEta && <span className="material-icons-outlined text-sm">history</span>}
+                      -{formatTime(remainingTime)}
+                    </span>
                   </div>
                 )
               )}
@@ -891,7 +910,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
               <div className="w-full max-w-5xl mx-auto flex flex-col md:flex-row md:items-center md:justify-center gap-3 md:gap-6">
                 {/* Canvas - full width on mobile (click to collapse) */}
                 <div
-                  className="flex items-center justify-center flex-1 md:flex-none min-h-0 cursor-pointer"
+                  className="flex items-center justify-center cursor-pointer"
                   onClick={() => setIsExpanded(false)}
                   title="Click to collapse"
                 >
@@ -899,8 +918,7 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                     ref={canvasRef}
                     width={600}
                     height={600}
-                    className="w-full max-h-full rounded-full border-2 md:w-auto hover:border-primary transition-colors"
-                    style={{ aspectRatio: '1/1', maxHeight: '40vh' }}
+                    className="rounded-full border-2 hover:border-primary transition-colors max-h-[40vh] max-w-[40vh] w-[40vh] h-[40vh] md:w-[300px] md:h-[300px] md:max-w-none md:max-h-none"
                   />
                 </div>
 
@@ -944,9 +962,12 @@ export function NowPlayingBar({ isLogsOpen = false, isVisible, openExpanded = fa
                   <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 className="w-16">{formatTime(elapsedTime)}</span>
                       <span>{progressPercent.toFixed(0)}%</span>
-                      <span>-{formatTime(remainingTime)}</span>
+                      <span className="w-16 flex items-center justify-end gap-1">
+                        {usingHistoricalEta && <span className="material-icons-outlined text-xs">history</span>}
+                        -{formatTime(remainingTime)}
+                      </span>
                     </div>
                   </div>
                 )}

+ 12 - 4
frontend/src/pages/BrowsePage.tsx

@@ -75,7 +75,10 @@ export function BrowsePage() {
   // Selection and panel state
   const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
   const [isPanelOpen, setIsPanelOpen] = useState(false)
-  const [preExecution, setPreExecution] = useState<PreExecution>('adaptive')
+  const [preExecution, setPreExecution] = useState<PreExecution>(() => {
+    const cached = localStorage.getItem('preExecution')
+    return (cached as PreExecution) || 'adaptive'
+  })
   const [isRunning, setIsRunning] = useState(false)
 
   // Animated preview modal state
@@ -117,6 +120,11 @@ export function BrowsePage() {
     return () => window.removeEventListener('playback-started', handlePlaybackStarted)
   }, [])
 
+  // Persist pre-execution selection to localStorage
+  useEffect(() => {
+    localStorage.setItem('preExecution', preExecution)
+  }, [preExecution])
+
   // Initialize IndexedDB cache and fetch patterns on mount
   useEffect(() => {
     initPreviewCacheDB().then(() => {
@@ -1117,18 +1125,18 @@ export function BrowsePage() {
             {/* Modal Content */}
             <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
               {isLoadingCoordinates ? (
-                <div className="w-[400px] h-[400px] flex items-center justify-center rounded-full bg-muted">
+                <div className="w-full max-w-[400px] aspect-square flex items-center justify-center rounded-full bg-muted">
                   <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
                     sync
                   </span>
                 </div>
               ) : (
-                <div className="relative">
+                <div className="relative w-full max-w-[400px] aspect-square">
                   <canvas
                     ref={canvasRef}
                     width={400}
                     height={400}
-                    className="rounded-full"
+                    className="rounded-full w-full h-full"
                   />
                   {/* Play/Pause overlay */}
                   <div

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

@@ -74,7 +74,7 @@ export function PlaylistsPage() {
     return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
   })
   const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
-    const cached = localStorage.getItem('playlist-clearPattern')
+    const cached = localStorage.getItem('preExecution')
     return (cached as PreExecution) || 'adaptive'
   })
 
@@ -92,7 +92,7 @@ export function PlaylistsPage() {
     localStorage.setItem('playlist-pauseUnit', pauseUnit)
   }, [pauseUnit])
   useEffect(() => {
-    localStorage.setItem('playlist-clearPattern', clearPattern)
+    localStorage.setItem('preExecution', clearPattern)
   }, [clearPattern])
 
   // Persist selected playlist to localStorage

+ 1 - 0
frontend/vite.config.ts

@@ -90,6 +90,7 @@ export default defineConfig({
       '/preview_thr': 'http://localhost:8080',
       '/preview_thr_batch': 'http://localhost:8080',
       '/get_theta_rho_coordinates': 'http://localhost:8080',
+      '/delete_theta_rho_file': 'http://localhost:8080',
       // Playlists
       '/list_all_playlists': 'http://localhost:8080',
       '/get_playlist': 'http://localhost:8080',

+ 51 - 1
modules/core/pattern_manager.py

@@ -64,6 +64,49 @@ def log_execution_time(pattern_name: str, table_type: str, speed: int, actual_ti
     except Exception as e:
         logger.error(f"Failed to log execution time: {e}")
 
+def get_last_completed_execution_time(pattern_name: str, speed: float) -> Optional[dict]:
+    """Get the last completed execution time for a pattern at a specific speed.
+
+    Args:
+        pattern_name: Name of the pattern file (e.g., 'circle.thr')
+        speed: Speed setting to match
+
+    Returns:
+        Dict with execution time info if found, None otherwise.
+        Format: {"actual_time_seconds": float, "actual_time_formatted": str, "timestamp": str}
+    """
+    if not os.path.exists(EXECUTION_LOG_FILE):
+        return None
+
+    try:
+        matching_entry = None
+        with open(EXECUTION_LOG_FILE, 'r') as f:
+            for line in f:
+                line = line.strip()
+                if not line:
+                    continue
+                try:
+                    entry = json.loads(line)
+                    # Only consider fully completed patterns (100% finished)
+                    if (entry.get('completed', False) and
+                        entry.get('pattern_name') == pattern_name and
+                        entry.get('speed') == speed):
+                        # Keep the most recent match (last one in file)
+                        matching_entry = entry
+                except json.JSONDecodeError:
+                    continue
+
+        if matching_entry:
+            return {
+                "actual_time_seconds": matching_entry.get('actual_time_seconds'),
+                "actual_time_formatted": matching_entry.get('actual_time_formatted'),
+                "timestamp": matching_entry.get('timestamp')
+            }
+        return None
+    except Exception as e:
+        logger.error(f"Failed to read execution time log: {e}")
+        return None
+
 # Asyncio primitives - initialized lazily to avoid event loop issues
 # These must be created in the context of the running event loop
 pause_event: Optional[asyncio.Event] = None
@@ -1254,7 +1297,14 @@ def get_status():
             "elapsed_time": elapsed_time,
             "percentage": (current / total * 100) if total > 0 else 0
         }
-    
+
+        # Add historical execution time if available for this pattern at current speed
+        if state.current_playing_file:
+            pattern_name = os.path.basename(state.current_playing_file)
+            historical_time = get_last_completed_execution_time(pattern_name, state.speed)
+            if historical_time:
+                status["progress"]["last_completed_time"] = historical_time
+
     return status
 
 async def broadcast_progress():