NowPlayingBar.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. import { useState, useEffect, useRef } from 'react'
  2. import { toast } from 'sonner'
  3. import { Button } from '@/components/ui/button'
  4. import { Progress } from '@/components/ui/progress'
  5. interface PlaybackStatus {
  6. current_file: string | null
  7. is_paused: boolean
  8. manual_pause: boolean
  9. scheduled_pause: boolean
  10. is_running: boolean
  11. progress: {
  12. current: number
  13. total: number
  14. remaining_time: number
  15. elapsed_time: number
  16. percentage: number
  17. } | null
  18. playlist: {
  19. current_index: number
  20. total_files: number
  21. mode: string
  22. next_file: string | null
  23. } | null
  24. speed: number
  25. pause_time_remaining: number
  26. original_pause_time: number | null
  27. connection_status: boolean
  28. current_theta: number
  29. current_rho: number
  30. }
  31. function formatTime(seconds: number): string {
  32. if (!seconds || seconds < 0) return '--:--'
  33. const mins = Math.floor(seconds / 60)
  34. const secs = Math.floor(seconds % 60)
  35. return `${mins}:${secs.toString().padStart(2, '0')}`
  36. }
  37. function formatPatternName(path: string | null): string {
  38. if (!path) return 'Unknown'
  39. // Extract filename without extension and path
  40. const name = path.split('/').pop()?.replace('.thr', '') || path
  41. return name
  42. }
  43. interface NowPlayingBarProps {
  44. isLogsOpen?: boolean
  45. isVisible: boolean
  46. onClose: () => void
  47. }
  48. export function NowPlayingBar({ isLogsOpen = false, isVisible, onClose }: NowPlayingBarProps) {
  49. const [status, setStatus] = useState<PlaybackStatus | null>(null)
  50. const [isExpanded, setIsExpanded] = useState(false)
  51. const [previewUrl, setPreviewUrl] = useState<string | null>(null)
  52. const wsRef = useRef<WebSocket | null>(null)
  53. // Connect to status WebSocket
  54. useEffect(() => {
  55. const connectWebSocket = () => {
  56. const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
  57. const ws = new WebSocket(`${protocol}//${window.location.host}/ws/status`)
  58. ws.onmessage = (event) => {
  59. try {
  60. const message = JSON.parse(event.data)
  61. if (message.type === 'status_update' && message.data) {
  62. setStatus(message.data)
  63. }
  64. } catch {
  65. // Ignore parse errors
  66. }
  67. }
  68. ws.onclose = () => {
  69. setTimeout(connectWebSocket, 3000)
  70. }
  71. wsRef.current = ws
  72. }
  73. connectWebSocket()
  74. return () => {
  75. if (wsRef.current) {
  76. wsRef.current.close()
  77. }
  78. }
  79. }, [])
  80. // Fetch preview image when current file changes
  81. useEffect(() => {
  82. const currentFile = status?.current_file
  83. if (currentFile) {
  84. fetch('/preview_thr_batch', {
  85. method: 'POST',
  86. headers: { 'Content-Type': 'application/json' },
  87. body: JSON.stringify({ file_names: [currentFile] }),
  88. })
  89. .then((r) => r.json())
  90. .then((data) => {
  91. if (data[currentFile]?.image_data) {
  92. setPreviewUrl(data[currentFile].image_data)
  93. }
  94. })
  95. .catch(() => {})
  96. } else {
  97. setPreviewUrl(null)
  98. }
  99. }, [status?.current_file])
  100. const handlePause = async () => {
  101. try {
  102. const endpoint = status?.is_paused ? '/resume_execution' : '/pause_execution'
  103. const response = await fetch(endpoint, { method: 'POST' })
  104. if (!response.ok) throw new Error()
  105. toast.success(status?.is_paused ? 'Resumed' : 'Paused')
  106. } catch {
  107. toast.error('Failed to toggle pause')
  108. }
  109. }
  110. const handleStop = async () => {
  111. try {
  112. const response = await fetch('/stop_execution', { method: 'POST' })
  113. if (!response.ok) throw new Error()
  114. toast.success('Stopped')
  115. } catch {
  116. toast.error('Failed to stop')
  117. }
  118. }
  119. const handleSkip = async () => {
  120. try {
  121. const response = await fetch('/skip_pattern', { method: 'POST' })
  122. if (!response.ok) throw new Error()
  123. toast.success('Skipping to next pattern')
  124. } catch {
  125. toast.error('Failed to skip')
  126. }
  127. }
  128. // Don't render if not visible
  129. if (!isVisible) {
  130. return null
  131. }
  132. const isPlaying = status?.is_running || status?.is_paused
  133. const patternName = formatPatternName(status?.current_file ?? null)
  134. const progressPercent = status?.progress?.percentage || 0
  135. const remainingTime = status?.progress?.remaining_time || 0
  136. const elapsedTime = status?.progress?.elapsed_time || 0
  137. return (
  138. <>
  139. {/* Backdrop when expanded */}
  140. {isExpanded && (
  141. <div
  142. className="fixed inset-0 bg-black/30 z-30"
  143. onClick={() => setIsExpanded(false)}
  144. />
  145. )}
  146. {/* Now Playing Bar */}
  147. <div
  148. className={`fixed left-0 right-0 z-40 bg-background border-t shadow-lg transition-all duration-300 ${
  149. isExpanded ? 'rounded-t-xl' : ''
  150. } ${isLogsOpen ? 'bottom-80' : 'bottom-16'}`}
  151. >
  152. {/* Mini Bar (always visible) */}
  153. <div
  154. className="flex items-center gap-4 px-4 py-3 cursor-pointer"
  155. onClick={() => isPlaying && setIsExpanded(!isExpanded)}
  156. >
  157. {/* Pattern Preview - Large */}
  158. <div className="w-28 h-28 rounded-xl overflow-hidden bg-muted shrink-0 border">
  159. {previewUrl && isPlaying ? (
  160. <img
  161. src={previewUrl}
  162. alt={patternName}
  163. className="w-full h-full object-cover pattern-preview"
  164. />
  165. ) : (
  166. <div className="w-full h-full flex items-center justify-center">
  167. <span className="material-icons-outlined text-muted-foreground text-4xl">
  168. {isPlaying ? 'image' : 'hourglass_empty'}
  169. </span>
  170. </div>
  171. )}
  172. </div>
  173. {/* Pattern Info */}
  174. <div className="flex-1 min-w-0">
  175. {isPlaying ? (
  176. <>
  177. <div className="flex items-center gap-2">
  178. <p className="text-lg font-medium truncate">{patternName}</p>
  179. {status?.is_paused && (
  180. <span className="text-sm text-muted-foreground">(Paused)</span>
  181. )}
  182. </div>
  183. <Progress value={progressPercent} className="h-2 mt-2" />
  184. </>
  185. ) : (
  186. <p className="text-lg text-muted-foreground">Not playing</p>
  187. )}
  188. </div>
  189. {/* Quick Controls */}
  190. {isPlaying && (
  191. <div className="flex items-center gap-1 shrink-0">
  192. <Button
  193. variant="ghost"
  194. size="icon"
  195. className="h-8 w-8"
  196. onClick={(e) => {
  197. e.stopPropagation()
  198. handlePause()
  199. }}
  200. >
  201. <span className="material-icons text-lg">
  202. {status?.is_paused ? 'play_arrow' : 'pause'}
  203. </span>
  204. </Button>
  205. {status?.playlist && (
  206. <Button
  207. variant="ghost"
  208. size="icon"
  209. className="h-8 w-8"
  210. onClick={(e) => {
  211. e.stopPropagation()
  212. handleSkip()
  213. }}
  214. >
  215. <span className="material-icons text-lg">skip_next</span>
  216. </Button>
  217. )}
  218. <Button
  219. variant="ghost"
  220. size="icon"
  221. className="h-8 w-8"
  222. onClick={(e) => {
  223. e.stopPropagation()
  224. handleStop()
  225. }}
  226. >
  227. <span className="material-icons text-lg">stop</span>
  228. </Button>
  229. </div>
  230. )}
  231. {/* Expand/Close Indicator */}
  232. {isPlaying ? (
  233. <span
  234. className={`material-icons-outlined text-muted-foreground transition-transform ${
  235. isExpanded ? 'rotate-180' : ''
  236. }`}
  237. >
  238. expand_less
  239. </span>
  240. ) : (
  241. <Button
  242. variant="ghost"
  243. size="icon"
  244. className="h-8 w-8"
  245. onClick={(e) => {
  246. e.stopPropagation()
  247. onClose()
  248. }}
  249. >
  250. <span className="material-icons-outlined text-lg">close</span>
  251. </Button>
  252. )}
  253. </div>
  254. {/* Expanded View */}
  255. {isExpanded && isPlaying && (
  256. <div className="px-4 pb-4 pt-2 border-t space-y-4">
  257. {/* Time Info */}
  258. <div className="flex items-center justify-between text-sm text-muted-foreground">
  259. <span>{formatTime(elapsedTime)}</span>
  260. <span>{progressPercent.toFixed(0)}%</span>
  261. <span>-{formatTime(remainingTime)}</span>
  262. </div>
  263. {/* Playback Controls */}
  264. <div className="flex items-center justify-center gap-4">
  265. {status.playlist && (
  266. <Button
  267. variant="outline"
  268. size="icon"
  269. className="h-10 w-10 rounded-full"
  270. onClick={handleSkip}
  271. title="Skip to next"
  272. >
  273. <span className="material-icons">skip_next</span>
  274. </Button>
  275. )}
  276. <Button
  277. variant="default"
  278. size="icon"
  279. className="h-12 w-12 rounded-full"
  280. onClick={handlePause}
  281. >
  282. <span className="material-icons text-xl">
  283. {status.is_paused ? 'play_arrow' : 'pause'}
  284. </span>
  285. </Button>
  286. <Button
  287. variant="outline"
  288. size="icon"
  289. className="h-10 w-10 rounded-full"
  290. onClick={handleStop}
  291. title="Stop"
  292. >
  293. <span className="material-icons">stop</span>
  294. </Button>
  295. </div>
  296. {/* Details Grid */}
  297. <div className="grid grid-cols-2 gap-3 text-sm">
  298. <div className="bg-muted/50 rounded-lg p-3">
  299. <p className="text-muted-foreground text-xs">Speed</p>
  300. <p className="font-medium">{status.speed} mm/s</p>
  301. </div>
  302. {status.playlist ? (
  303. <div className="bg-muted/50 rounded-lg p-3">
  304. <p className="text-muted-foreground text-xs">Playlist</p>
  305. <p className="font-medium">
  306. {status.playlist.current_index + 1} of {status.playlist.total_files}
  307. </p>
  308. </div>
  309. ) : (
  310. <div className="bg-muted/50 rounded-lg p-3">
  311. <p className="text-muted-foreground text-xs">Mode</p>
  312. <p className="font-medium">Single Pattern</p>
  313. </div>
  314. )}
  315. {status.playlist?.next_file && (
  316. <div className="bg-muted/50 rounded-lg p-3 col-span-2">
  317. <p className="text-muted-foreground text-xs">Next Pattern</p>
  318. <p className="font-medium truncate">
  319. {formatPatternName(status.playlist.next_file)}
  320. </p>
  321. </div>
  322. )}
  323. </div>
  324. {/* Pause Time Remaining (if in pause between patterns) */}
  325. {status.pause_time_remaining > 0 && (
  326. <div className="bg-amber-500/10 border border-amber-500/20 rounded-lg p-3 text-center">
  327. <p className="text-sm text-amber-600 dark:text-amber-400">
  328. <span className="material-icons-outlined text-base align-middle mr-1">
  329. schedule
  330. </span>
  331. Next pattern in {formatTime(status.pause_time_remaining)}
  332. </p>
  333. </div>
  334. )}
  335. {/* Scheduled Pause Indicator */}
  336. {status.scheduled_pause && (
  337. <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-center">
  338. <p className="text-sm text-blue-600 dark:text-blue-400">
  339. <span className="material-icons-outlined text-base align-middle mr-1">
  340. bedtime
  341. </span>
  342. Scheduled pause active
  343. </p>
  344. </div>
  345. )}
  346. </div>
  347. )}
  348. </div>
  349. </>
  350. )
  351. }