BrowsePage.tsx 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490
  1. import { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } from 'react'
  2. import { useOutletContext } from 'react-router-dom'
  3. import { toast } from 'sonner'
  4. import {
  5. initPreviewCacheDB,
  6. getPreviewsFromCache,
  7. savePreviewToCache,
  8. cacheAllPreviews,
  9. } from '@/lib/previewCache'
  10. import { fuzzyMatch } from '@/lib/utils'
  11. import { apiClient } from '@/lib/apiClient'
  12. import { useOnBackendConnected } from '@/hooks/useBackendConnection'
  13. import { Button } from '@/components/ui/button'
  14. import { Input } from '@/components/ui/input'
  15. import { Label } from '@/components/ui/label'
  16. import { Slider } from '@/components/ui/slider'
  17. import {
  18. Select,
  19. SelectContent,
  20. SelectItem,
  21. SelectTrigger,
  22. SelectValue,
  23. } from '@/components/ui/select'
  24. import {
  25. Sheet,
  26. SheetContent,
  27. SheetHeader,
  28. SheetTitle,
  29. } from '@/components/ui/sheet'
  30. import { preExecutionOptions } from '@/lib/types'
  31. // Types
  32. interface PatternMetadata {
  33. path: string
  34. name: string
  35. category: string
  36. date_modified: number
  37. coordinates_count: number
  38. }
  39. interface PreviewData {
  40. image_data: string
  41. first_coordinate: { x: number; y: number } | null
  42. last_coordinate: { x: number; y: number } | null
  43. error?: string
  44. }
  45. // Coordinates come as [theta, rho] tuples from the backend
  46. type Coordinate = [number, number]
  47. type SortOption = 'name' | 'date' | 'size' | 'favorites' | 'plays' | 'last_played'
  48. type PreExecution = 'none' | 'adaptive' | 'clear_from_in' | 'clear_from_out' | 'clear_sideway'
  49. // Context for lazy loading previews
  50. interface PreviewContextType {
  51. requestPreview: (path: string) => void
  52. previews: Record<string, PreviewData>
  53. }
  54. const PreviewContext = createContext<PreviewContextType | null>(null)
  55. export function BrowsePage() {
  56. const { isPlayOnlyActive } = useOutletContext<{ isPlayOnlyActive?: boolean }>() || {}
  57. // Data state
  58. const [patterns, setPatterns] = useState<PatternMetadata[]>([])
  59. const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
  60. const [isLoading, setIsLoading] = useState(true)
  61. // Filter/sort state
  62. const [searchQuery, setSearchQuery] = useState('')
  63. const [selectedCategory, setSelectedCategory] = useState<string>('all')
  64. const [sortBy, setSortBy] = useState<SortOption>('name')
  65. const [sortAsc, setSortAsc] = useState(true)
  66. // Selection and panel state
  67. const [selectedPattern, setSelectedPattern] = useState<PatternMetadata | null>(null)
  68. const [isPanelOpen, setIsPanelOpen] = useState(false)
  69. const [preExecution, setPreExecution] = useState<PreExecution>(() => {
  70. const cached = localStorage.getItem('preExecution')
  71. return (cached as PreExecution) || 'adaptive'
  72. })
  73. const [isRunning, setIsRunning] = useState(false)
  74. // Animated preview modal state
  75. const [isAnimatedPreviewOpen, setIsAnimatedPreviewOpen] = useState(false)
  76. const [coordinates, setCoordinates] = useState<Coordinate[]>([])
  77. const [isLoadingCoordinates, setIsLoadingCoordinates] = useState(false)
  78. const [isPlaying, setIsPlaying] = useState(false)
  79. const [speed, setSpeed] = useState(1)
  80. const [progress, setProgress] = useState(0)
  81. // Pattern execution history state
  82. const [patternHistory, setPatternHistory] = useState<{
  83. actual_time_formatted: string | null
  84. speed: number | null
  85. } | null>(null)
  86. // All pattern histories for badges
  87. const [allPatternHistories, setAllPatternHistories] = useState<Record<string, {
  88. actual_time_formatted: string | null
  89. timestamp: string | null
  90. play_count: number
  91. last_played: string | null
  92. }>>({})
  93. // Canvas and animation refs
  94. const canvasRef = useRef<HTMLCanvasElement>(null)
  95. const animationRef = useRef<number | null>(null)
  96. const currentIndexRef = useRef(0)
  97. // Lazy loading queue for previews
  98. const pendingPreviewsRef = useRef<Set<string>>(new Set())
  99. const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  100. const abortControllerRef = useRef<AbortController | null>(null)
  101. // Cache all previews state
  102. const [isCaching, setIsCaching] = useState(false)
  103. const [cacheProgress, setCacheProgress] = useState(0)
  104. const [allCached, setAllCached] = useState(false)
  105. // Favorites state
  106. const [favorites, setFavorites] = useState<Set<string>>(new Set())
  107. // Upload state
  108. const fileInputRef = useRef<HTMLInputElement>(null)
  109. const [isUploading, setIsUploading] = useState(false)
  110. // Swipe to dismiss sheet on mobile
  111. const sheetTouchStartRef = useRef<{ x: number; y: number } | null>(null)
  112. const handleSheetTouchStart = (e: React.TouchEvent) => {
  113. sheetTouchStartRef.current = {
  114. x: e.touches[0].clientX,
  115. y: e.touches[0].clientY,
  116. }
  117. }
  118. const handleSheetTouchEnd = (e: React.TouchEvent) => {
  119. if (!sheetTouchStartRef.current) return
  120. const deltaX = e.changedTouches[0].clientX - sheetTouchStartRef.current.x
  121. const deltaY = e.changedTouches[0].clientY - sheetTouchStartRef.current.y
  122. // Swipe right (positive X) or swipe down (positive Y) to dismiss
  123. // Require at least 80px movement and more horizontal/vertical than the other direction
  124. if (deltaX > 80 && deltaX > Math.abs(deltaY)) {
  125. setIsPanelOpen(false)
  126. } else if (deltaY > 80 && deltaY > Math.abs(deltaX)) {
  127. setIsPanelOpen(false)
  128. }
  129. sheetTouchStartRef.current = null
  130. }
  131. // Close panel when playback starts
  132. useEffect(() => {
  133. const handlePlaybackStarted = () => {
  134. setIsPanelOpen(false)
  135. }
  136. window.addEventListener('playback-started', handlePlaybackStarted)
  137. return () => window.removeEventListener('playback-started', handlePlaybackStarted)
  138. }, [])
  139. // Persist pre-execution selection to localStorage
  140. useEffect(() => {
  141. localStorage.setItem('preExecution', preExecution)
  142. }, [preExecution])
  143. // Initialize IndexedDB cache and fetch patterns on mount
  144. useEffect(() => {
  145. initPreviewCacheDB().then(() => {
  146. fetchPatterns()
  147. }).catch(() => {
  148. // Continue even if IndexedDB fails - just won't have persistent cache
  149. fetchPatterns()
  150. })
  151. loadFavorites()
  152. // Cleanup on unmount: abort in-flight requests and clear pending queue
  153. return () => {
  154. if (batchTimeoutRef.current) {
  155. clearTimeout(batchTimeoutRef.current)
  156. }
  157. if (abortControllerRef.current) {
  158. abortControllerRef.current.abort()
  159. }
  160. pendingPreviewsRef.current.clear()
  161. }
  162. }, [])
  163. // Refetch when backend reconnects
  164. useOnBackendConnected(() => {
  165. fetchPatterns()
  166. loadFavorites()
  167. })
  168. // Load favorites from "Favorites" playlist
  169. const loadFavorites = async () => {
  170. try {
  171. const playlist = await apiClient.get<{ files?: string[] }>('/get_playlist?name=Favorites')
  172. setFavorites(new Set(playlist.files || []))
  173. } catch {
  174. // Favorites playlist doesn't exist yet - that's OK
  175. }
  176. }
  177. // Toggle favorite status for a pattern
  178. const toggleFavorite = async (path: string, e: React.MouseEvent) => {
  179. e.stopPropagation() // Don't trigger card click
  180. const isFavorite = favorites.has(path)
  181. const newFavorites = new Set(favorites)
  182. try {
  183. if (isFavorite) {
  184. // Remove from favorites
  185. newFavorites.delete(path)
  186. await apiClient.post('/modify_playlist', { playlist_name: 'Favorites', files: Array.from(newFavorites) })
  187. setFavorites(newFavorites)
  188. toast.success('Removed from favorites')
  189. } else {
  190. // Add to favorites - first check if playlist exists
  191. newFavorites.add(path)
  192. try {
  193. await apiClient.get('/get_playlist?name=Favorites')
  194. // Playlist exists, add to it
  195. await apiClient.post('/add_to_playlist', { playlist_name: 'Favorites', pattern: path })
  196. } catch {
  197. // Create playlist with this pattern
  198. await apiClient.post('/create_playlist', { playlist_name: 'Favorites', files: [path] })
  199. }
  200. setFavorites(newFavorites)
  201. toast.success('Added to favorites')
  202. }
  203. } catch {
  204. toast.error('Failed to update favorites')
  205. }
  206. }
  207. const fetchPatterns = async () => {
  208. setIsLoading(true)
  209. try {
  210. // Fetch patterns and history in parallel
  211. const [data, historyData] = await Promise.all([
  212. apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata'),
  213. apiClient.get<Record<string, { actual_time_formatted: string | null; timestamp: string | null; play_count: number; last_played: string | null }>>('/api/pattern_history_all')
  214. ])
  215. setPatterns(data)
  216. setAllPatternHistories(historyData)
  217. if (data.length > 0) {
  218. // Sort patterns by name (default sort) before preloading
  219. const sortedPatterns = [...data].sort((a: PatternMetadata, b: PatternMetadata) =>
  220. a.name.localeCompare(b.name)
  221. )
  222. const allPaths = data.map((p: PatternMetadata) => p.path)
  223. // Preload first 30 patterns in sorted order (fills most viewports)
  224. const initialBatch = sortedPatterns.slice(0, 30).map((p: PatternMetadata) => p.path)
  225. const cachedPreviews = await getPreviewsFromCache(initialBatch)
  226. // Immediately display cached previews
  227. if (cachedPreviews.size > 0) {
  228. const cachedData: Record<string, PreviewData> = {}
  229. cachedPreviews.forEach((previewData, path) => {
  230. cachedData[path] = previewData
  231. })
  232. setPreviews(cachedData)
  233. }
  234. // Fetch any uncached patterns in the initial batch
  235. const uncachedInitial = initialBatch.filter((p: string) => !cachedPreviews.has(p))
  236. if (uncachedInitial.length > 0) {
  237. fetchPreviewsBatch(uncachedInitial)
  238. }
  239. // Check if ALL patterns are cached (for Cache All button)
  240. const allCachedPreviews = await getPreviewsFromCache(allPaths)
  241. setAllCached(allCachedPreviews.size === allPaths.length)
  242. }
  243. } catch (error) {
  244. console.error('Error fetching patterns:', error)
  245. toast.error('Failed to load patterns')
  246. } finally {
  247. setIsLoading(false)
  248. }
  249. }
  250. const fetchPreviewsBatch = async (filePaths: string[]) => {
  251. const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
  252. // Create new AbortController for this batch of requests
  253. abortControllerRef.current = new AbortController()
  254. const signal = abortControllerRef.current.signal
  255. try {
  256. // First check IndexedDB cache for all patterns
  257. const cachedPreviews = await getPreviewsFromCache(filePaths)
  258. // Update state with cached previews immediately
  259. if (cachedPreviews.size > 0) {
  260. const cachedData: Record<string, PreviewData> = {}
  261. cachedPreviews.forEach((data, path) => {
  262. cachedData[path] = data
  263. })
  264. setPreviews((prev) => ({ ...prev, ...cachedData }))
  265. }
  266. // Find patterns not in cache
  267. const uncachedPaths = filePaths.filter((path) => !cachedPreviews.has(path))
  268. // Fetch uncached patterns in batches to avoid overwhelming the backend
  269. if (uncachedPaths.length > 0) {
  270. for (let i = 0; i < uncachedPaths.length; i += BATCH_SIZE) {
  271. // Check if aborted before each batch
  272. if (signal.aborted) break
  273. const batch = uncachedPaths.slice(i, i + BATCH_SIZE)
  274. try {
  275. const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
  276. // Save fetched previews to IndexedDB cache
  277. for (const [path, previewData] of Object.entries(data)) {
  278. if (previewData && !(previewData as PreviewData).error) {
  279. savePreviewToCache(path, previewData as PreviewData)
  280. }
  281. }
  282. setPreviews((prev) => ({ ...prev, ...data }))
  283. } catch (err) {
  284. // Stop processing if aborted, otherwise continue with next batch
  285. if (err instanceof Error && err.name === 'AbortError') break
  286. }
  287. // Small delay between batches to reduce backend load
  288. if (i + BATCH_SIZE < uncachedPaths.length) {
  289. await new Promise((resolve) => setTimeout(resolve, 100))
  290. }
  291. }
  292. }
  293. } catch (error) {
  294. // Silently ignore abort errors
  295. if (error instanceof Error && error.name === 'AbortError') return
  296. console.error('Error fetching previews:', error)
  297. }
  298. }
  299. const fetchCoordinates = async (filePath: string) => {
  300. setIsLoadingCoordinates(true)
  301. try {
  302. const data = await apiClient.post<{ coordinates?: Coordinate[] }>('/get_theta_rho_coordinates', { file_name: filePath })
  303. setCoordinates(data.coordinates || [])
  304. } catch (error) {
  305. console.error('Error fetching coordinates:', error)
  306. toast.error('Failed to load pattern coordinates')
  307. } finally {
  308. setIsLoadingCoordinates(false)
  309. }
  310. }
  311. // Get unique categories
  312. const categories = useMemo(() => {
  313. const cats = new Set(patterns.map((p) => p.category))
  314. return ['all', ...Array.from(cats).sort()]
  315. }, [patterns])
  316. // Filter and sort patterns
  317. const filteredPatterns = useMemo(() => {
  318. let result = patterns
  319. if (selectedCategory !== 'all') {
  320. result = result.filter((p) => p.category === selectedCategory)
  321. }
  322. if (searchQuery) {
  323. result = result.filter(
  324. (p) =>
  325. fuzzyMatch(p.name, searchQuery) ||
  326. fuzzyMatch(p.category, searchQuery)
  327. )
  328. }
  329. result = [...result].sort((a, b) => {
  330. let comparison = 0
  331. switch (sortBy) {
  332. case 'name':
  333. comparison = a.name.localeCompare(b.name)
  334. break
  335. case 'date':
  336. comparison = a.date_modified - b.date_modified
  337. break
  338. case 'size':
  339. comparison = a.coordinates_count - b.coordinates_count
  340. break
  341. case 'favorites': {
  342. const aFav = favorites.has(a.path) ? 1 : 0
  343. const bFav = favorites.has(b.path) ? 1 : 0
  344. comparison = bFav - aFav // Favorites first
  345. if (comparison === 0) {
  346. comparison = a.name.localeCompare(b.name) // Then by name
  347. }
  348. break
  349. }
  350. case 'plays': {
  351. const aKey = a.path.split('/').pop() || ''
  352. const bKey = b.path.split('/').pop() || ''
  353. const aPlays = allPatternHistories[aKey]?.play_count ?? 0
  354. const bPlays = allPatternHistories[bKey]?.play_count ?? 0
  355. comparison = aPlays - bPlays
  356. if (comparison === 0) {
  357. comparison = a.name.localeCompare(b.name)
  358. }
  359. break
  360. }
  361. case 'last_played': {
  362. const aKey = a.path.split('/').pop() || ''
  363. const bKey = b.path.split('/').pop() || ''
  364. const aTime = allPatternHistories[aKey]?.last_played || ''
  365. const bTime = allPatternHistories[bKey]?.last_played || ''
  366. comparison = aTime.localeCompare(bTime)
  367. if (comparison === 0) {
  368. comparison = a.name.localeCompare(b.name)
  369. }
  370. break
  371. }
  372. default:
  373. return 0
  374. }
  375. return sortAsc ? comparison : -comparison
  376. })
  377. return result
  378. }, [patterns, selectedCategory, searchQuery, sortBy, sortAsc, favorites, allPatternHistories])
  379. // Batched preview loading - collects requests and fetches in batches
  380. const requestPreview = useCallback((path: string) => {
  381. // Skip if already loaded or pending
  382. if (previews[path] || pendingPreviewsRef.current.has(path)) return
  383. pendingPreviewsRef.current.add(path)
  384. // Clear existing timeout and set a new one to batch requests
  385. if (batchTimeoutRef.current) {
  386. clearTimeout(batchTimeoutRef.current)
  387. }
  388. batchTimeoutRef.current = setTimeout(() => {
  389. const pathsToFetch = Array.from(pendingPreviewsRef.current)
  390. if (pathsToFetch.length > 0) {
  391. pendingPreviewsRef.current.clear()
  392. fetchPreviewsBatch(pathsToFetch)
  393. }
  394. }, 50) // Batch requests within 50ms window
  395. }, [previews])
  396. // Canvas drawing functions
  397. const polarToCartesian = useCallback((theta: number, rho: number, size: number) => {
  398. const centerX = size / 2
  399. const centerY = size / 2
  400. const radius = (size / 2) * 0.9 * rho
  401. const x = centerX + radius * Math.cos(theta)
  402. const y = centerY + radius * Math.sin(theta)
  403. return { x, y }
  404. }, [])
  405. // Offscreen canvas for the pattern path (performance optimization)
  406. const offscreenCanvasRef = useRef<HTMLCanvasElement | null>(null)
  407. const lastDrawnIndexRef = useRef<number>(-1)
  408. const lastThemeRef = useRef<boolean | null>(null)
  409. // Get theme colors
  410. const getThemeColors = useCallback(() => {
  411. const isDark = document.documentElement.classList.contains('dark')
  412. return {
  413. isDark,
  414. bgOuter: isDark ? '#1a1a1a' : '#f5f5f5',
  415. bgInner: isDark ? '#262626' : '#ffffff',
  416. borderColor: isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(128, 128, 128, 0.3)',
  417. lineColor: isDark ? '#e5e5e5' : '#333333',
  418. markerBorder: isDark ? '#333333' : '#ffffff',
  419. }
  420. }, [])
  421. // Initialize or reset offscreen canvas
  422. const initOffscreenCanvas = useCallback((size: number, coords: Coordinate[]) => {
  423. const colors = getThemeColors()
  424. // Create offscreen canvas if needed
  425. if (!offscreenCanvasRef.current) {
  426. offscreenCanvasRef.current = document.createElement('canvas')
  427. }
  428. const offscreen = offscreenCanvasRef.current
  429. offscreen.width = size
  430. offscreen.height = size
  431. const ctx = offscreen.getContext('2d')
  432. if (!ctx) return
  433. // Draw background
  434. ctx.fillStyle = colors.bgOuter
  435. ctx.fillRect(0, 0, size, size)
  436. // Draw background circle
  437. ctx.beginPath()
  438. ctx.arc(size / 2, size / 2, (size / 2) * 0.95, 0, Math.PI * 2)
  439. ctx.fillStyle = colors.bgInner
  440. ctx.fill()
  441. ctx.strokeStyle = colors.borderColor
  442. ctx.lineWidth = 1
  443. ctx.stroke()
  444. // Setup line style for incremental drawing
  445. ctx.strokeStyle = colors.lineColor
  446. ctx.lineWidth = 1
  447. ctx.lineCap = 'round'
  448. ctx.lineJoin = 'round'
  449. // Draw initial point if we have coordinates
  450. if (coords.length > 0) {
  451. const firstPoint = polarToCartesian(coords[0][0], coords[0][1], size)
  452. ctx.beginPath()
  453. ctx.moveTo(firstPoint.x, firstPoint.y)
  454. ctx.stroke()
  455. }
  456. lastDrawnIndexRef.current = 0
  457. lastThemeRef.current = colors.isDark
  458. }, [getThemeColors, polarToCartesian])
  459. // Draw pattern incrementally for performance
  460. const drawPattern = useCallback((ctx: CanvasRenderingContext2D, coords: Coordinate[], upToIndex: number, forceRedraw = false) => {
  461. const canvas = ctx.canvas
  462. const size = canvas.width
  463. const colors = getThemeColors()
  464. // Check if we need to reinitialize (theme change or reset)
  465. const needsReinit = forceRedraw ||
  466. !offscreenCanvasRef.current ||
  467. lastThemeRef.current !== colors.isDark ||
  468. upToIndex < lastDrawnIndexRef.current
  469. if (needsReinit) {
  470. initOffscreenCanvas(size, coords)
  471. }
  472. const offscreen = offscreenCanvasRef.current
  473. if (!offscreen) return
  474. const offCtx = offscreen.getContext('2d')
  475. if (!offCtx) return
  476. // Draw new segments incrementally on offscreen canvas
  477. if (coords.length > 0 && upToIndex > lastDrawnIndexRef.current) {
  478. offCtx.strokeStyle = colors.lineColor
  479. offCtx.lineWidth = 1
  480. offCtx.lineCap = 'round'
  481. offCtx.lineJoin = 'round'
  482. offCtx.beginPath()
  483. const startPoint = polarToCartesian(
  484. coords[lastDrawnIndexRef.current][0],
  485. coords[lastDrawnIndexRef.current][1],
  486. size
  487. )
  488. offCtx.moveTo(startPoint.x, startPoint.y)
  489. for (let i = lastDrawnIndexRef.current + 1; i <= upToIndex && i < coords.length; i++) {
  490. const point = polarToCartesian(coords[i][0], coords[i][1], size)
  491. offCtx.lineTo(point.x, point.y)
  492. }
  493. offCtx.stroke()
  494. lastDrawnIndexRef.current = upToIndex
  495. }
  496. // Copy offscreen canvas to main canvas
  497. ctx.drawImage(offscreen, 0, 0)
  498. // Draw current position marker on main canvas
  499. if (upToIndex < coords.length && coords.length > 0) {
  500. const currentPoint = polarToCartesian(coords[upToIndex][0], coords[upToIndex][1], size)
  501. ctx.beginPath()
  502. ctx.arc(currentPoint.x, currentPoint.y, 5, 0, Math.PI * 2)
  503. ctx.fillStyle = '#0b80ee'
  504. ctx.fill()
  505. ctx.strokeStyle = colors.markerBorder
  506. ctx.lineWidth = 1
  507. ctx.stroke()
  508. }
  509. }, [getThemeColors, initOffscreenCanvas, polarToCartesian])
  510. // Animation loop
  511. useEffect(() => {
  512. if (!isPlaying || coordinates.length === 0 || !canvasRef.current) return
  513. const ctx = canvasRef.current.getContext('2d')
  514. if (!ctx) return
  515. let lastTime = performance.now()
  516. const coordsPerSecond = 100 * speed
  517. const animate = (currentTime: number) => {
  518. const deltaTime = (currentTime - lastTime) / 1000
  519. lastTime = currentTime
  520. const coordsToAdvance = Math.floor(deltaTime * coordsPerSecond)
  521. currentIndexRef.current = Math.min(
  522. currentIndexRef.current + Math.max(1, coordsToAdvance),
  523. coordinates.length - 1
  524. )
  525. drawPattern(ctx, coordinates, currentIndexRef.current)
  526. setProgress((currentIndexRef.current / (coordinates.length - 1)) * 100)
  527. if (currentIndexRef.current < coordinates.length - 1) {
  528. animationRef.current = requestAnimationFrame(animate)
  529. } else {
  530. setIsPlaying(false)
  531. }
  532. }
  533. animationRef.current = requestAnimationFrame(animate)
  534. return () => {
  535. if (animationRef.current) {
  536. cancelAnimationFrame(animationRef.current)
  537. }
  538. }
  539. }, [isPlaying, coordinates, speed, drawPattern])
  540. // Draw initial state when coordinates load
  541. useEffect(() => {
  542. if (coordinates.length > 0 && canvasRef.current) {
  543. const ctx = canvasRef.current.getContext('2d')
  544. if (ctx) {
  545. currentIndexRef.current = 0
  546. setProgress(0)
  547. drawPattern(ctx, coordinates, 0, true) // Force redraw on new pattern
  548. }
  549. }
  550. }, [coordinates, drawPattern])
  551. const handlePatternClick = async (pattern: PatternMetadata) => {
  552. setSelectedPattern(pattern)
  553. setIsPanelOpen(true)
  554. setPreExecution('adaptive')
  555. setPatternHistory(null) // Reset while loading
  556. // Fetch pattern execution history
  557. try {
  558. const history = await apiClient.get<{
  559. actual_time_formatted: string | null
  560. speed: number | null
  561. }>(`/api/pattern_history/${encodeURIComponent(pattern.path)}`)
  562. setPatternHistory(history)
  563. } catch {
  564. // Silently ignore - history is optional
  565. }
  566. }
  567. const handleOpenAnimatedPreview = async () => {
  568. if (!selectedPattern) return
  569. setIsPanelOpen(false) // Close sheet before opening preview
  570. setIsAnimatedPreviewOpen(true)
  571. setIsPlaying(false)
  572. setProgress(0)
  573. currentIndexRef.current = 0
  574. await fetchCoordinates(selectedPattern.path)
  575. // Auto-play after coordinates load
  576. setIsPlaying(true)
  577. }
  578. const handleCloseAnimatedPreview = () => {
  579. setIsAnimatedPreviewOpen(false)
  580. setIsPlaying(false)
  581. if (animationRef.current) {
  582. cancelAnimationFrame(animationRef.current)
  583. }
  584. setCoordinates([])
  585. }
  586. const handlePlayPause = () => {
  587. if (isPlaying) {
  588. setIsPlaying(false)
  589. } else {
  590. if (currentIndexRef.current >= coordinates.length - 1) {
  591. currentIndexRef.current = 0
  592. setProgress(0)
  593. }
  594. setIsPlaying(true)
  595. }
  596. }
  597. const handleReset = () => {
  598. setIsPlaying(false)
  599. currentIndexRef.current = 0
  600. setProgress(0)
  601. if (canvasRef.current && coordinates.length > 0) {
  602. const ctx = canvasRef.current.getContext('2d')
  603. if (ctx) {
  604. drawPattern(ctx, coordinates, 0, true) // Force redraw on reset
  605. }
  606. }
  607. }
  608. const handleProgressChange = (value: number[]) => {
  609. const newProgress = value[0]
  610. setProgress(newProgress)
  611. currentIndexRef.current = Math.floor((newProgress / 100) * (coordinates.length - 1))
  612. if (canvasRef.current && coordinates.length > 0) {
  613. const ctx = canvasRef.current.getContext('2d')
  614. if (ctx) {
  615. drawPattern(ctx, coordinates, currentIndexRef.current)
  616. }
  617. }
  618. }
  619. const handleRunPattern = async () => {
  620. if (!selectedPattern) return
  621. setIsRunning(true)
  622. try {
  623. await apiClient.post('/run_theta_rho', {
  624. file_name: selectedPattern.path,
  625. pre_execution: preExecution,
  626. })
  627. toast.success(`Running ${selectedPattern.name}`)
  628. // Close the preview panel and trigger Now Playing bar to open
  629. setIsPanelOpen(false)
  630. window.dispatchEvent(new CustomEvent('playback-started'))
  631. } catch (error) {
  632. const message = error instanceof Error ? error.message : 'Failed to run pattern'
  633. if (message.includes('409') || message.includes('already running')) {
  634. toast.error('Another pattern is already running')
  635. } else {
  636. toast.error(message)
  637. }
  638. } finally {
  639. setIsRunning(false)
  640. }
  641. }
  642. const handleDeletePattern = async () => {
  643. if (!selectedPattern) return
  644. if (!selectedPattern.path.startsWith('custom_patterns/')) {
  645. toast.error('Only custom patterns can be deleted')
  646. return
  647. }
  648. if (!confirm(`Delete "${selectedPattern.name}"? This cannot be undone.`)) {
  649. return
  650. }
  651. try {
  652. await apiClient.post('/delete_theta_rho_file', { file_name: selectedPattern.path })
  653. toast.success(`Deleted ${selectedPattern.name}`)
  654. setIsPanelOpen(false)
  655. setSelectedPattern(null)
  656. fetchPatterns()
  657. } catch {
  658. toast.error('Failed to delete pattern')
  659. }
  660. }
  661. const getPreviewUrl = (path: string) => {
  662. const preview = previews[path]
  663. return preview?.image_data || null
  664. }
  665. const formatCoordinate = (coord: { x: number; y: number } | null) => {
  666. if (!coord) return '(-, -)'
  667. return `(${coord.x.toFixed(1)}, ${coord.y.toFixed(1)})`
  668. }
  669. const canDelete = selectedPattern?.path.startsWith('custom_patterns/')
  670. // Cache all previews handler
  671. const handleCacheAllPreviews = async () => {
  672. if (isCaching) return
  673. setIsCaching(true)
  674. setCacheProgress(0)
  675. const result = await cacheAllPreviews((progress) => {
  676. const percentage = progress.total > 0
  677. ? Math.round((progress.completed / progress.total) * 100)
  678. : 0
  679. setCacheProgress(percentage)
  680. })
  681. if (result.success) {
  682. setAllCached(true)
  683. if (result.cached === 0) {
  684. toast.success('All patterns are already cached!')
  685. } else {
  686. toast.success('All pattern previews have been cached!')
  687. }
  688. } else {
  689. toast.error('Failed to cache previews')
  690. }
  691. setIsCaching(false)
  692. setCacheProgress(0)
  693. }
  694. // Handle pattern file upload (supports multiple files)
  695. const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
  696. const files = e.target.files
  697. if (!files || files.length === 0) return
  698. // Validate all files have .thr extension
  699. const invalidFiles = Array.from(files).filter(f => !f.name.endsWith('.thr'))
  700. if (invalidFiles.length > 0) {
  701. toast.error(`Invalid file${invalidFiles.length > 1 ? 's' : ''}: ${invalidFiles.map(f => f.name).join(', ')}. Only .thr files are accepted.`)
  702. return
  703. }
  704. setIsUploading(true)
  705. let successCount = 0
  706. let failCount = 0
  707. for (const file of Array.from(files)) {
  708. try {
  709. await apiClient.uploadFile('/upload_theta_rho', file)
  710. successCount++
  711. } catch (error) {
  712. console.error(`Upload error for ${file.name}:`, error)
  713. failCount++
  714. toast.error(`Failed to upload "${file.name}"`)
  715. }
  716. }
  717. if (successCount > 0) {
  718. toast.success(
  719. successCount === 1
  720. ? `Pattern "${files[0].name}" uploaded successfully`
  721. : `${successCount} pattern${successCount > 1 ? 's' : ''} uploaded successfully`
  722. )
  723. await fetchPatterns()
  724. }
  725. setIsUploading(false)
  726. // Reset file input
  727. if (fileInputRef.current) {
  728. fileInputRef.current.value = ''
  729. }
  730. }
  731. if (isLoading) {
  732. return (
  733. <div className="flex items-center justify-center min-h-[60vh]">
  734. <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
  735. sync
  736. </span>
  737. </div>
  738. )
  739. }
  740. return (
  741. <div className="flex flex-col w-full max-w-5xl mx-auto gap-3 sm:gap-6 py-3 sm:py-6 px-0 sm:px-4">
  742. {/* Hidden file input for pattern upload */}
  743. {!isPlayOnlyActive && (
  744. <input
  745. ref={fileInputRef}
  746. type="file"
  747. accept=".thr"
  748. multiple
  749. onChange={handleFileUpload}
  750. className="hidden"
  751. />
  752. )}
  753. {/* Page Header */}
  754. <div className="flex items-start justify-between gap-4 pl-1">
  755. <div className="space-y-0.5">
  756. <h1 className="text-xl font-semibold tracking-tight">Browse Patterns</h1>
  757. <p className="text-xs text-muted-foreground">
  758. {patterns.length} patterns available
  759. </p>
  760. </div>
  761. {!isPlayOnlyActive && (
  762. <Button
  763. variant="ghost"
  764. onClick={() => fileInputRef.current?.click()}
  765. disabled={isUploading}
  766. className="gap-2 shrink-0 h-9 w-9 sm:h-11 sm:w-auto rounded-full px-0 sm:px-4 justify-center bg-card border border-border shadow-sm hover:bg-accent"
  767. >
  768. {isUploading ? (
  769. <span className="material-icons-outlined animate-spin text-lg">sync</span>
  770. ) : (
  771. <span className="material-icons-outlined text-lg">add</span>
  772. )}
  773. <span className="hidden sm:inline">Add Pattern</span>
  774. </Button>
  775. )}
  776. </div>
  777. {/* Filter Bar */}
  778. <div
  779. className="sticky z-30 py-3 -mx-0 sm:-mx-4 px-0 sm:px-4 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
  780. style={{ top: 'calc(4.5rem + env(safe-area-inset-top, 0px))' }}
  781. >
  782. <div className="flex items-center gap-2 sm:gap-3">
  783. {/* Search - Pill shaped, white background */}
  784. <div className="relative flex-1 min-w-0">
  785. <span className="material-icons-outlined absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 text-muted-foreground text-lg sm:text-xl">
  786. search
  787. </span>
  788. <Input
  789. value={searchQuery}
  790. onChange={(e) => setSearchQuery(e.target.value)}
  791. placeholder="Search..."
  792. className="pl-9 sm:pl-11 pr-10 h-9 sm:h-11 rounded-full bg-card border-border shadow-sm text-xs sm:text-sm focus:ring-2 focus:ring-ring"
  793. />
  794. {searchQuery && (
  795. <Button
  796. variant="ghost"
  797. size="icon"
  798. onClick={() => setSearchQuery('')}
  799. className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground h-7 w-7 rounded-full"
  800. >
  801. <span className="material-icons-outlined text-lg">close</span>
  802. </Button>
  803. )}
  804. </div>
  805. {/* Category - Icon on mobile, text on desktop */}
  806. <Select value={selectedCategory} onValueChange={setSelectedCategory}>
  807. <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
  808. <span className="material-icons-outlined text-lg shrink-0 sm:hidden">folder</span>
  809. <SelectValue placeholder="All" />
  810. </SelectTrigger>
  811. <SelectContent>
  812. {categories.map((cat) => (
  813. <SelectItem key={cat} value={cat}>
  814. {cat === 'all' ? 'All' : cat === 'root' ? 'Default' : cat}
  815. </SelectItem>
  816. ))}
  817. </SelectContent>
  818. </Select>
  819. {/* Sort - Icon on mobile, text on desktop */}
  820. <Select value={sortBy} onValueChange={(v) => {
  821. const option = v as SortOption
  822. setSortBy(option)
  823. // Most Played and Last Played should default to descending (highest first)
  824. setSortAsc(option !== 'plays' && option !== 'last_played')
  825. }}>
  826. <SelectTrigger className="h-9 w-9 sm:h-11 sm:w-auto rounded-full bg-card border-border shadow-sm text-xs sm:text-sm shrink-0 [&>svg]:hidden sm:[&>svg]:block px-0 sm:px-3 justify-center sm:justify-between [&>span:last-of-type]:hidden sm:[&>span:last-of-type]:inline gap-2">
  827. <span className="material-icons-outlined text-lg shrink-0 sm:hidden">sort</span>
  828. <SelectValue placeholder="Sort" />
  829. </SelectTrigger>
  830. <SelectContent>
  831. <SelectItem value="favorites">Favorites</SelectItem>
  832. <SelectItem value="name">Name</SelectItem>
  833. <SelectItem value="date">Modified</SelectItem>
  834. <SelectItem value="size">Size</SelectItem>
  835. <SelectItem value="plays">Most Played</SelectItem>
  836. <SelectItem value="last_played">Last Played</SelectItem>
  837. </SelectContent>
  838. </Select>
  839. {/* Sort direction - Pill shaped, white background */}
  840. <Button
  841. variant="outline"
  842. size="icon"
  843. onClick={() => setSortAsc(!sortAsc)}
  844. className="shrink-0 h-9 w-9 sm:h-11 sm:w-11 rounded-full bg-card shadow-sm"
  845. title={sortAsc ? 'Ascending' : 'Descending'}
  846. >
  847. <span className="material-icons-outlined text-lg sm:text-xl">
  848. {sortAsc ? 'arrow_upward' : 'arrow_downward'}
  849. </span>
  850. </Button>
  851. {/* Cache button - Pill shaped, white background */}
  852. {!allCached && (
  853. <Button
  854. variant="outline"
  855. onClick={handleCacheAllPreviews}
  856. className={`shrink-0 rounded-full bg-card shadow-sm gap-2 ${
  857. isCaching
  858. ? 'h-9 sm:h-11 w-auto px-3 sm:px-4'
  859. : 'h-9 w-9 sm:h-11 sm:w-auto px-0 sm:px-4 justify-center sm:justify-start'
  860. }`}
  861. title="Cache All Previews"
  862. >
  863. {isCaching ? (
  864. <>
  865. <span className="material-icons-outlined animate-spin text-lg">sync</span>
  866. <span className="text-sm">{cacheProgress}%</span>
  867. </>
  868. ) : (
  869. <>
  870. <span className="material-icons-outlined text-lg">cached</span>
  871. <span className="hidden sm:inline text-sm">Cache</span>
  872. </>
  873. )}
  874. </Button>
  875. )}
  876. </div>
  877. </div>
  878. {(searchQuery || selectedCategory !== 'all') && (
  879. <p className="text-sm text-muted-foreground">
  880. Showing {filteredPatterns.length} of {patterns.length} patterns
  881. </p>
  882. )}
  883. {/* Pattern Grid */}
  884. {filteredPatterns.length === 0 ? (
  885. <div className="flex flex-col items-center justify-center min-h-[40vh] gap-4 text-center">
  886. <div className="p-4 rounded-full bg-muted">
  887. <span className="material-icons-outlined text-5xl text-muted-foreground">
  888. search_off
  889. </span>
  890. </div>
  891. <div className="space-y-1">
  892. <h2 className="text-xl font-semibold">No patterns found</h2>
  893. <p className="text-muted-foreground">Try adjusting your search or filters</p>
  894. </div>
  895. {(searchQuery || selectedCategory !== 'all') && (
  896. <Button
  897. variant="secondary"
  898. onClick={() => {
  899. setSearchQuery('')
  900. setSelectedCategory('all')
  901. }}
  902. >
  903. Clear Filters
  904. </Button>
  905. )}
  906. </div>
  907. ) : (
  908. <PreviewContext.Provider value={{ requestPreview, previews }}>
  909. <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-2 sm:gap-4">
  910. {filteredPatterns.map((pattern) => (
  911. <PatternCard
  912. key={pattern.path}
  913. pattern={pattern}
  914. isSelected={selectedPattern?.path === pattern.path}
  915. isFavorite={favorites.has(pattern.path)}
  916. playTime={allPatternHistories[pattern.path.split('/').pop() || '']?.actual_time_formatted || null}
  917. playCount={allPatternHistories[pattern.path.split('/').pop() || '']?.play_count ?? 0}
  918. onToggleFavorite={toggleFavorite}
  919. onClick={() => handlePatternClick(pattern)}
  920. />
  921. ))}
  922. </div>
  923. </PreviewContext.Provider>
  924. )}
  925. <div className="h-48" />
  926. {/* Pattern Details Sheet */}
  927. <Sheet open={isPanelOpen} onOpenChange={setIsPanelOpen}>
  928. <SheetContent
  929. className="flex flex-col p-0 overflow-hidden pt-safe"
  930. onTouchStart={handleSheetTouchStart}
  931. onTouchEnd={handleSheetTouchEnd}
  932. >
  933. <SheetHeader className="px-6 py-4 shrink-0">
  934. <SheetTitle className="flex items-center gap-2 pr-8">
  935. {selectedPattern && (
  936. <span
  937. role="button"
  938. tabIndex={0}
  939. className={`shrink-0 transition-colors cursor-pointer flex items-center ${
  940. favorites.has(selectedPattern.path) ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
  941. }`}
  942. onClick={(e) => toggleFavorite(selectedPattern.path, e)}
  943. onKeyDown={(e) => {
  944. if (e.key === 'Enter' || e.key === ' ') {
  945. e.preventDefault()
  946. toggleFavorite(selectedPattern.path, e as unknown as React.MouseEvent)
  947. }
  948. }}
  949. title={favorites.has(selectedPattern.path) ? 'Remove from favorites' : 'Add to favorites'}
  950. >
  951. <span className="material-icons" style={{ fontSize: '20px' }}>
  952. {favorites.has(selectedPattern.path) ? 'favorite' : 'favorite_border'}
  953. </span>
  954. </span>
  955. )}
  956. <span className="truncate">{selectedPattern?.name || 'Pattern Details'}</span>
  957. </SheetTitle>
  958. </SheetHeader>
  959. {selectedPattern && (
  960. <div className="p-6 overflow-y-auto flex-1">
  961. {/* Clickable Round Preview Image */}
  962. <div className="mb-6">
  963. <div
  964. className="aspect-square w-full max-w-[280px] mx-auto overflow-hidden rounded-full border bg-muted relative group cursor-pointer"
  965. onClick={handleOpenAnimatedPreview}
  966. >
  967. {getPreviewUrl(selectedPattern.path) ? (
  968. <img
  969. src={getPreviewUrl(selectedPattern.path)!}
  970. alt={selectedPattern.name}
  971. className="w-full h-full object-cover pattern-preview"
  972. />
  973. ) : (
  974. <div className="w-full h-full flex items-center justify-center">
  975. <span className="material-icons-outlined text-4xl text-muted-foreground">
  976. image
  977. </span>
  978. </div>
  979. )}
  980. {/* Play badge - always visible */}
  981. <div className="absolute bottom-2 right-2 bg-background/90 backdrop-blur-sm rounded-full w-10 h-10 flex items-center justify-center shadow-md border group-hover:scale-110 transition-transform">
  982. <span className="material-icons text-xl">play_arrow</span>
  983. </div>
  984. {/* Hover overlay */}
  985. <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-black/20 rounded-full" />
  986. </div>
  987. <p className="text-xs text-muted-foreground text-center mt-2">Tap to preview animation</p>
  988. </div>
  989. {/* Coordinates */}
  990. <div className="mb-4 flex justify-between text-sm">
  991. <div className="flex items-center gap-2">
  992. <span className="material-icons-outlined text-muted-foreground text-base">flag</span>
  993. <span className="text-muted-foreground">First:</span>
  994. <span className="font-semibold">
  995. {formatCoordinate(previews[selectedPattern.path]?.first_coordinate)}
  996. </span>
  997. </div>
  998. <div className="flex items-center gap-2">
  999. <span className="material-icons-outlined text-muted-foreground text-base">check</span>
  1000. <span className="text-muted-foreground">Last:</span>
  1001. <span className="font-semibold">
  1002. {formatCoordinate(previews[selectedPattern.path]?.last_coordinate)}
  1003. </span>
  1004. </div>
  1005. </div>
  1006. {/* Play History Info */}
  1007. {(() => {
  1008. const historyKey = selectedPattern.path.split('/').pop() || ''
  1009. const playCount = allPatternHistories[historyKey]?.play_count ?? 0
  1010. return (
  1011. <>
  1012. {(patternHistory?.actual_time_formatted || playCount > 0) && (
  1013. <div className="mb-4 flex justify-between text-sm">
  1014. {patternHistory?.actual_time_formatted && (
  1015. <div className="flex items-center gap-2">
  1016. <span className="material-icons-outlined text-muted-foreground text-base">schedule</span>
  1017. <span className="text-muted-foreground">Last run:</span>
  1018. <span className="font-semibold">{patternHistory.actual_time_formatted}</span>
  1019. </div>
  1020. )}
  1021. {playCount > 0 && (
  1022. <div className="flex items-center gap-2">
  1023. <span className="material-icons-outlined text-muted-foreground text-base">play_circle</span>
  1024. <span className="text-muted-foreground">Plays:</span>
  1025. <span className="font-semibold">{playCount}</span>
  1026. </div>
  1027. )}
  1028. </div>
  1029. )}
  1030. </>
  1031. )
  1032. })()}
  1033. {/* Clear Options */}
  1034. <div className="mb-6">
  1035. <Label className="text-sm font-semibold mb-3 block">Clear</Label>
  1036. <div className="grid grid-cols-2 gap-2">
  1037. {preExecutionOptions.map((option) => (
  1038. <label
  1039. key={option.value}
  1040. className={`relative flex cursor-pointer items-center justify-center rounded-lg border p-2.5 text-center text-sm font-medium transition-all hover:border-primary ${
  1041. preExecution === option.value
  1042. ? 'border-primary bg-primary text-primary-foreground ring-2 ring-primary ring-offset-2 ring-offset-background'
  1043. : 'border-border text-muted-foreground hover:text-foreground'
  1044. }`}
  1045. >
  1046. {option.label}
  1047. <input
  1048. type="radio"
  1049. name="preExecutionAction"
  1050. value={option.value}
  1051. checked={preExecution === option.value}
  1052. onChange={() => setPreExecution(option.value)}
  1053. className="sr-only"
  1054. />
  1055. </label>
  1056. ))}
  1057. </div>
  1058. <p className="text-xs text-muted-foreground mt-2">
  1059. {preExecutionOptions.find(o => o.value === preExecution)?.description}
  1060. </p>
  1061. </div>
  1062. {/* Action Buttons */}
  1063. <div className="space-y-3">
  1064. {/* Play + Delete row */}
  1065. <div className="flex gap-2">
  1066. <Button
  1067. onClick={handleRunPattern}
  1068. disabled={isRunning}
  1069. className="flex-1 gap-2"
  1070. size="lg"
  1071. >
  1072. {isRunning ? (
  1073. <span className="material-icons-outlined animate-spin text-lg">sync</span>
  1074. ) : (
  1075. <span className="material-icons text-lg">play_arrow</span>
  1076. )}
  1077. Play
  1078. </Button>
  1079. {canDelete && (
  1080. <Button
  1081. variant="outline"
  1082. onClick={handleDeletePattern}
  1083. className="text-destructive hover:bg-destructive/10 hover:border-destructive px-3"
  1084. size="lg"
  1085. >
  1086. <span className="material-icons text-lg">delete</span>
  1087. </Button>
  1088. )}
  1089. </div>
  1090. </div>
  1091. </div>
  1092. )}
  1093. </SheetContent>
  1094. </Sheet>
  1095. {/* Animated Preview Modal */}
  1096. {isAnimatedPreviewOpen && (
  1097. <div
  1098. className="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4"
  1099. onClick={handleCloseAnimatedPreview}
  1100. >
  1101. <div
  1102. className="bg-background rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] flex flex-col overflow-hidden"
  1103. onClick={(e) => e.stopPropagation()}
  1104. >
  1105. {/* Modal Header */}
  1106. <div className="flex items-center justify-between p-6 border-b shrink-0">
  1107. <h3 className="text-xl font-semibold">
  1108. {selectedPattern?.name || 'Animated Preview'}
  1109. </h3>
  1110. <Button
  1111. variant="ghost"
  1112. size="icon"
  1113. onClick={handleCloseAnimatedPreview}
  1114. className="rounded-full"
  1115. >
  1116. <span className="material-icons text-2xl">close</span>
  1117. </Button>
  1118. </div>
  1119. {/* Modal Content */}
  1120. <div className="p-6 overflow-y-auto flex-1 flex justify-center items-center">
  1121. {isLoadingCoordinates ? (
  1122. <div className="w-full max-w-[400px] aspect-square flex items-center justify-center rounded-full bg-muted">
  1123. <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
  1124. sync
  1125. </span>
  1126. </div>
  1127. ) : (
  1128. <div className="relative w-full max-w-[400px] aspect-square">
  1129. <canvas
  1130. ref={canvasRef}
  1131. width={400}
  1132. height={400}
  1133. className="rounded-full w-full h-full"
  1134. />
  1135. {/* Play/Pause overlay */}
  1136. <div
  1137. className="absolute inset-0 flex items-center justify-center cursor-pointer rounded-full opacity-0 hover:opacity-100 transition-opacity bg-black/10"
  1138. onClick={handlePlayPause}
  1139. >
  1140. <div className="bg-background rounded-full w-16 h-16 flex items-center justify-center shadow-lg">
  1141. <span className="material-icons text-3xl">
  1142. {isPlaying ? 'pause' : 'play_arrow'}
  1143. </span>
  1144. </div>
  1145. </div>
  1146. </div>
  1147. )}
  1148. </div>
  1149. {/* Controls */}
  1150. <div className="p-6 space-y-4 shrink-0 border-t">
  1151. {/* Speed Control */}
  1152. <div>
  1153. <div className="flex justify-between mb-2">
  1154. <Label className="text-sm font-medium">Speed</Label>
  1155. <span className="text-sm text-muted-foreground">{speed}x</span>
  1156. </div>
  1157. <Slider
  1158. value={[speed]}
  1159. onValueChange={(v) => setSpeed(v[0])}
  1160. min={0.1}
  1161. max={5}
  1162. step={0.1}
  1163. className="py-2"
  1164. />
  1165. </div>
  1166. {/* Progress Control */}
  1167. <div>
  1168. <div className="flex justify-between mb-2">
  1169. <Label className="text-sm font-medium">Progress</Label>
  1170. <span className="text-sm text-muted-foreground">{progress.toFixed(0)}%</span>
  1171. </div>
  1172. <Slider
  1173. value={[progress]}
  1174. onValueChange={handleProgressChange}
  1175. min={0}
  1176. max={100}
  1177. step={0.1}
  1178. className="py-2"
  1179. />
  1180. </div>
  1181. {/* Control Buttons */}
  1182. <div className="flex items-center justify-center gap-4">
  1183. <Button onClick={handlePlayPause} className="gap-2">
  1184. <span className="material-icons">
  1185. {isPlaying ? 'pause' : 'play_arrow'}
  1186. </span>
  1187. {isPlaying ? 'Pause' : 'Play'}
  1188. </Button>
  1189. <Button variant="secondary" onClick={handleReset} className="gap-2">
  1190. <span className="material-icons">replay</span>
  1191. Reset
  1192. </Button>
  1193. </div>
  1194. </div>
  1195. </div>
  1196. </div>
  1197. )}
  1198. </div>
  1199. )
  1200. }
  1201. // Pattern Card Component
  1202. interface PatternCardProps {
  1203. pattern: PatternMetadata
  1204. isSelected: boolean
  1205. isFavorite: boolean
  1206. playTime: string | null
  1207. playCount: number
  1208. onToggleFavorite: (path: string, e: React.MouseEvent) => void
  1209. onClick: () => void
  1210. }
  1211. function PatternCard({ pattern, isSelected, isFavorite, playTime, playCount, onToggleFavorite, onClick }: PatternCardProps) {
  1212. const [imageLoaded, setImageLoaded] = useState(false)
  1213. const [imageError, setImageError] = useState(false)
  1214. const cardRef = useRef<HTMLButtonElement>(null)
  1215. const context = useContext(PreviewContext)
  1216. // Request preview when card becomes visible
  1217. useEffect(() => {
  1218. if (!context || !cardRef.current) return
  1219. const observer = new IntersectionObserver(
  1220. (entries) => {
  1221. entries.forEach((entry) => {
  1222. if (entry.isIntersecting) {
  1223. context.requestPreview(pattern.path)
  1224. observer.disconnect() // Only need to load once
  1225. }
  1226. })
  1227. },
  1228. { rootMargin: '100px' } // Start loading slightly before visible
  1229. )
  1230. observer.observe(cardRef.current)
  1231. return () => observer.disconnect()
  1232. }, [pattern.path, context])
  1233. const previewUrl = context?.previews[pattern.path]?.image_data || null
  1234. return (
  1235. <button
  1236. ref={cardRef}
  1237. onClick={onClick}
  1238. className={`group flex flex-col items-center gap-2 p-2.5 rounded-xl bg-card border border-border transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-md active:scale-95 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 ${
  1239. isSelected ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : ''
  1240. }`}
  1241. >
  1242. <div className="relative w-full aspect-square">
  1243. <div className="w-full h-full rounded-full overflow-hidden border border-border bg-muted">
  1244. {previewUrl && !imageError ? (
  1245. <>
  1246. {!imageLoaded && (
  1247. <div className="absolute inset-0 flex items-center justify-center">
  1248. <span className="material-icons-outlined animate-spin text-xl text-muted-foreground">
  1249. sync
  1250. </span>
  1251. </div>
  1252. )}
  1253. <img
  1254. src={previewUrl}
  1255. alt={pattern.name}
  1256. className={`w-full h-full object-cover pattern-preview transition-opacity ${
  1257. imageLoaded ? 'opacity-100' : 'opacity-0'
  1258. }`}
  1259. loading="lazy"
  1260. onLoad={() => setImageLoaded(true)}
  1261. onError={() => setImageError(true)}
  1262. />
  1263. </>
  1264. ) : (
  1265. <div className="w-full h-full flex items-center justify-center">
  1266. <span className="material-icons-outlined text-2xl text-muted-foreground">
  1267. {imageError ? 'broken_image' : 'image'}
  1268. </span>
  1269. </div>
  1270. )}
  1271. </div>
  1272. </div>
  1273. {/* Stats row */}
  1274. {(playCount > 0 || playTime) && (
  1275. <div className="flex items-center w-full px-0.5 -mb-1 justify-between">
  1276. {playCount > 0 && (
  1277. <span className="flex items-center gap-0.5 text-xs text-muted-foreground" title={`Played ${playCount} time${playCount !== 1 ? 's' : ''}`}>
  1278. <span className="material-icons-outlined" style={{ fontSize: '13px' }}>play_circle</span>
  1279. {playCount}x
  1280. </span>
  1281. )}
  1282. {playTime && (
  1283. <span className="flex items-center gap-0.5 text-xs text-muted-foreground ml-auto" title={`Last run: ${playTime}`}>
  1284. <span className="material-icons-outlined" style={{ fontSize: '13px' }}>schedule</span>
  1285. {(() => {
  1286. const colonMatch = playTime.match(/^(?:(\d+):)?(\d+):(\d+)$/)
  1287. if (colonMatch) {
  1288. const hours = colonMatch[1] ? parseInt(colonMatch[1]) : 0
  1289. const minutes = parseInt(colonMatch[2])
  1290. const seconds = parseInt(colonMatch[3])
  1291. const totalMins = hours * 60 + minutes + (seconds >= 30 ? 1 : 0)
  1292. return totalMins > 0 ? `${totalMins}m` : '<1m'
  1293. }
  1294. const match = playTime.match(/(\d+)h\s*(\d+)m|(\d+)\s*min|(\d+)m\s*(\d+)s|(\d+)\s*sec/)
  1295. if (match) {
  1296. if (match[1] && match[2]) return `${parseInt(match[1]) * 60 + parseInt(match[2])}m`
  1297. else if (match[3]) return `${match[3]}m`
  1298. else if (match[4] && match[5]) { const mins = parseInt(match[4]); return mins > 0 ? `${mins}m` : '<1m' }
  1299. else if (match[6]) return '<1m'
  1300. }
  1301. return playTime
  1302. })()}
  1303. </span>
  1304. )}
  1305. </div>
  1306. )}
  1307. {/* Name and favorite row */}
  1308. <div className="flex items-center justify-between w-full gap-1 px-0.5">
  1309. <span className="text-xs font-bold text-foreground truncate" title={pattern.name}>
  1310. {pattern.name}
  1311. </span>
  1312. <span
  1313. role="button"
  1314. tabIndex={0}
  1315. className={`shrink-0 transition-colors cursor-pointer ${
  1316. isFavorite ? 'text-red-500 hover:text-red-600' : 'text-muted-foreground hover:text-red-500'
  1317. }`}
  1318. onClick={(e) => {
  1319. e.stopPropagation()
  1320. onToggleFavorite(pattern.path, e)
  1321. }}
  1322. onKeyDown={(e) => {
  1323. if (e.key === 'Enter' || e.key === ' ') {
  1324. e.preventDefault()
  1325. e.stopPropagation()
  1326. onToggleFavorite(pattern.path, e as unknown as React.MouseEvent)
  1327. }
  1328. }}
  1329. title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
  1330. >
  1331. <span className="material-icons" style={{ fontSize: '16px' }}>
  1332. {isFavorite ? 'favorite' : 'favorite_border'}
  1333. </span>
  1334. </span>
  1335. </div>
  1336. </button>
  1337. )
  1338. }