| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378 |
- import { useState, useEffect, useRef } from 'react'
- import { toast } from 'sonner'
- import { Button } from '@/components/ui/button'
- import { Progress } from '@/components/ui/progress'
- interface PlaybackStatus {
- current_file: string | null
- is_paused: boolean
- manual_pause: boolean
- scheduled_pause: boolean
- is_running: boolean
- progress: {
- current: number
- total: number
- remaining_time: number
- elapsed_time: number
- percentage: number
- } | null
- playlist: {
- current_index: number
- total_files: number
- mode: string
- next_file: string | null
- } | null
- speed: number
- pause_time_remaining: number
- original_pause_time: number | null
- connection_status: boolean
- current_theta: number
- current_rho: number
- }
- function formatTime(seconds: number): string {
- if (!seconds || seconds < 0) return '--:--'
- const mins = Math.floor(seconds / 60)
- const secs = Math.floor(seconds % 60)
- return `${mins}:${secs.toString().padStart(2, '0')}`
- }
- function formatPatternName(path: string | null): string {
- if (!path) return 'Unknown'
- // Extract filename without extension and path
- const name = path.split('/').pop()?.replace('.thr', '') || path
- return name
- }
- interface NowPlayingBarProps {
- isLogsOpen?: boolean
- isVisible: boolean
- onClose: () => void
- }
- export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPlayingBarProps) {
- const [status, setStatus] = useState<PlaybackStatus | null>(null)
- const [isExpanded, setIsExpanded] = useState(false)
- const [previewUrl, setPreviewUrl] = useState<string | null>(null)
- const wsRef = useRef<WebSocket | null>(null)
- // Connect to status WebSocket
- useEffect(() => {
- const connectWebSocket = () => {
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
- ws.onmessage = (event) => {
- try {
- const message = JSON.parse(event.data)
- if (message.type === 'status_update' && message.data) {
- setStatus(message.data)
- }
- } catch {
- // Ignore parse errors
- }
- }
- ws.onclose = () => {
- setTimeout(connectWebSocket, 3000)
- }
- wsRef.current = ws
- }
- connectWebSocket()
- return () => {
- if (wsRef.current) {
- wsRef.current.close()
- }
- }
- }, [])
- // Fetch preview image when current file changes
- useEffect(() => {
- const currentFile = status?.current_file
- if (currentFile) {
- fetch('/preview_thr_batch', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ file_names: [currentFile] }),
- })
- .then((r) => r.json())
- .then((data) => {
- if (data[currentFile]?.image_data) {
- setPreviewUrl(data[currentFile].image_data)
- }
- })
- .catch(() => {})
- } else {
- setPreviewUrl(null)
- }
- }, [status?.current_file])
- const handlePause = async () => {
- try {
- const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
- const response = await fetch(endpoint, { method: 'POST' })
- if (!response.ok) throw new Error()
- toast.success(status?.is_paused ? 'Resumed' : 'Paused')
- } catch {
- toast.error('Failed to toggle pause')
- }
- }
- const handleStop = async () => {
- try {
- const response = await fetch('/stop_execution', { method: 'POST' })
- if (!response.ok) throw new Error()
- toast.success('Stopped')
- } catch {
- toast.error('Failed to stop')
- }
- }
- const handleSkip = async () => {
- try {
- const response = await fetch('/skip_pattern', { method: 'POST' })
- if (!response.ok) throw new Error()
- toast.success('Skipping to next pattern')
- } catch {
- toast.error('Failed to skip')
- }
- }
- // Don't render if not visible
- if (!isVisible) {
- return null
- }
- const isPlaying = status?.is_running || status?.is_paused
- const patternName = formatPatternName(status?.current_file ?? null)
- const progressPercent = status?.progress?.percentage || 0
- const remainingTime = status?.progress?.remaining_time || 0
- const elapsedTime = status?.progress?.elapsed_time || 0
- return (
- <>
- {/* Backdrop when expanded */}
- {isExpanded && (
- <div
- className="fixed inset-0 bg-black/30 z-30"
- onClick={() => setIsExpanded(false)}
- />
- )}
- {/* Now Playing Bar */}
- <div
- className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${
- isExpanded ? 'rounded-t-xl' : ''
- } ${isLogsOpen ? 'bottom-80' : 'bottom-16'}`}
- >
- {/* Mini Bar (always visible) */}
- <div
- className="flex items-center gap-4 px-4 py-3 cursor-pointer"
- onClick={() => isPlaying && setIsExpanded(!isExpanded)}
- >
- {/* Pattern Preview - Large */}
- <div className="w-28 h-28 rounded-xl overflow-hidden bg-muted shrink-0 border">
- {previewUrl && isPlaying ? (
- <img
- src={previewUrl}
- alt={patternName}
- 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-4xl">
- {isPlaying ? 'image' : 'hourglass_empty'}
- </span>
- </div>
- )}
- </div>
- {/* Pattern Info */}
- <div className="flex-1 min-w-0">
- {isPlaying ? (
- <>
- <div className="flex items-center gap-2">
- <p className="text-lg font-medium truncate">{patternName}</p>
- {status?.is_paused && (
- <span className="text-sm text-muted-foreground">(Paused)</span>
- )}
- </div>
- <Progress value={progressPercent} className="h-2 mt-2" />
- </>
- ) : (
- <p className="text-lg text-muted-foreground">Not playing</p>
- )}
- </div>
- {/* Quick Controls */}
- {isPlaying && (
- <div className="flex items-center gap-1 shrink-0">
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={(e) => {
- e.stopPropagation()
- handlePause()
- }}
- >
- <span className="material-icons text-lg">
- {status?.is_paused ? 'play_arrow' : 'pause'}
- </span>
- </Button>
- {status?.playlist && (
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={(e) => {
- e.stopPropagation()
- handleSkip()
- }}
- >
- <span className="material-icons text-lg">skip_next</span>
- </Button>
- )}
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={(e) => {
- e.stopPropagation()
- handleStop()
- }}
- >
- <span className="material-icons text-lg">stop</span>
- </Button>
- </div>
- )}
- {/* Expand/Close Indicator */}
- {isPlaying ? (
- <span
- className={`material-icons-outlined text-muted-foreground transition-transform ${
- isExpanded ? 'rotate-180' : ''
- }`}
- >
- expand_less
- </span>
- ) : (
- <Button
- variant="ghost"
- size="icon"
- className="h-8 w-8"
- onClick={(e) => {
- e.stopPropagation()
- onClose()
- }}
- >
- <span className="material-icons-outlined text-lg">close</span>
- </Button>
- )}
- </div>
- {/* Expanded View */}
- {isExpanded && isPlaying && (
- <div className="px-4 pb-4 pt-2 border-t space-y-4">
- {/* Time Info */}
- <div className="flex items-center justify-between text-sm text-muted-foreground">
- <span>{formatTime(elapsedTime)}</span>
- <span>{progressPercent.toFixed(0)}%</span>
- <span>-{formatTime(remainingTime)}</span>
- </div>
- {/* Playback Controls */}
- <div className="flex items-center justify-center gap-4">
- {status.playlist && (
- <Button
- variant="outline"
- size="icon"
- className="h-10 w-10 rounded-full"
- onClick={handleSkip}
- title="Skip to next"
- >
- <span className="material-icons">skip_next</span>
- </Button>
- )}
- <Button
- variant="default"
- size="icon"
- className="h-12 w-12 rounded-full"
- onClick={handlePause}
- >
- <span className="material-icons text-xl">
- {status.is_paused ? 'play_arrow' : 'pause'}
- </span>
- </Button>
- <Button
- variant="outline"
- size="icon"
- className="h-10 w-10 rounded-full"
- onClick={handleStop}
- title="Stop"
- >
- <span className="material-icons">stop</span>
- </Button>
- </div>
- {/* Details Grid */}
- <div className="grid grid-cols-2 gap-3 text-sm">
- <div className="bg-muted/50 rounded-lg p-3">
- <p className="text-muted-foreground text-xs">Speed</p>
- <p className="font-medium">{status.speed} mm/s</p>
- </div>
- {status.playlist ? (
- <div className="bg-muted/50 rounded-lg p-3">
- <p className="text-muted-foreground text-xs">Playlist</p>
- <p className="font-medium">
- {status.playlist.current_index + 1} of {status.playlist.total_files}
- </p>
- </div>
- ) : (
- <div className="bg-muted/50 rounded-lg p-3">
- <p className="text-muted-foreground text-xs">Mode</p>
- <p className="font-medium">Single Pattern</p>
- </div>
- )}
- {status.playlist?.next_file && (
- <div className="bg-muted/50 rounded-lg p-3 col-span-2">
- <p className="text-muted-foreground text-xs">Next Pattern</p>
- <p className="font-medium truncate">
- {formatPatternName(status.playlist.next_file)}
- </p>
- </div>
- )}
- </div>
- {/* Pause Time Remaining (if in pause between patterns) */}
- {status.pause_time_remaining > 0 && (
- <div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 text-center">
- <p className="text-sm text-amber-600 dark:text-amber-400">
- <span className="material-icons-outlined text-base align-middle mr-1">
- schedule
- </span>
- Next pattern in {formatTime(status.pause_time_remaining)}
- </p>
- </div>
- )}
- {/* Scheduled Pause Indicator */}
- {status.scheduled_pause && (
- <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-center">
- <p className="text-sm text-blue-600 dark:text-blue-400">
- <span className="material-icons-outlined text-base align-middle mr-1">
- bedtime
- </span>
- Scheduled pause active
- </p>
- </div>
- )}
- </div>
- )}
- </div>
- </>
- )
- }
|