PlaylistsPage.tsx 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999
  1. import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
  2. import { toast } from 'sonner'
  3. import { apiClient } from '@/lib/apiClient'
  4. import {
  5. initPreviewCacheDB,
  6. getPreviewsFromCache,
  7. savePreviewToCache,
  8. } from '@/lib/previewCache'
  9. import { fuzzyMatch } from '@/lib/utils'
  10. import { useOnBackendConnected } from '@/hooks/useBackendConnection'
  11. import type { PatternMetadata, PreviewData, SortOption, PreExecution, RunMode } from '@/lib/types'
  12. import { preExecutionOptions } from '@/lib/types'
  13. import { Button } from '@/components/ui/button'
  14. import { Input } from '@/components/ui/input'
  15. import { Label } from '@/components/ui/label'
  16. import { Separator } from '@/components/ui/separator'
  17. import {
  18. Select,
  19. SelectContent,
  20. SelectItem,
  21. SelectTrigger,
  22. SelectValue,
  23. } from '@/components/ui/select'
  24. import {
  25. Dialog,
  26. DialogContent,
  27. DialogHeader,
  28. DialogTitle,
  29. DialogFooter,
  30. } from '@/components/ui/dialog'
  31. export function PlaylistsPage() {
  32. // Playlists state
  33. const [playlists, setPlaylists] = useState<string[]>([])
  34. const [selectedPlaylist, setSelectedPlaylist] = useState<string | null>(() => {
  35. return localStorage.getItem('playlist-selected')
  36. })
  37. const [playlistPatterns, setPlaylistPatterns] = useState<string[]>([])
  38. const [isLoadingPlaylists, setIsLoadingPlaylists] = useState(true)
  39. // All patterns for the picker modal
  40. const [allPatterns, setAllPatterns] = useState<PatternMetadata[]>([])
  41. const [previews, setPreviews] = useState<Record<string, PreviewData>>({})
  42. // Pattern picker modal state
  43. const [isPickerOpen, setIsPickerOpen] = useState(false)
  44. const [selectedPatternPaths, setSelectedPatternPaths] = useState<Set<string>>(new Set())
  45. const [searchQuery, setSearchQuery] = useState('')
  46. const [selectedCategory, setSelectedCategory] = useState<string>('all')
  47. const [sortBy, setSortBy] = useState<SortOption>('name')
  48. const [sortAsc, setSortAsc] = useState(true)
  49. // Create/Rename playlist modal
  50. const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
  51. const [isRenameModalOpen, setIsRenameModalOpen] = useState(false)
  52. const [newPlaylistName, setNewPlaylistName] = useState('')
  53. const [playlistToRename, setPlaylistToRename] = useState<string | null>(null)
  54. // Playback settings - initialized from localStorage
  55. const [runMode, setRunMode] = useState<RunMode>(() => {
  56. const cached = localStorage.getItem('playlist-runMode')
  57. return (cached === 'single' || cached === 'indefinite') ? cached : 'single'
  58. })
  59. const [shuffle, setShuffle] = useState(() => {
  60. return localStorage.getItem('playlist-shuffle') === 'true'
  61. })
  62. const [pauseTime, setPauseTime] = useState(() => {
  63. const cached = localStorage.getItem('playlist-pauseTime')
  64. return cached ? Number(cached) : 5
  65. })
  66. const [pauseUnit, setPauseUnit] = useState<'sec' | 'min' | 'hr'>(() => {
  67. const cached = localStorage.getItem('playlist-pauseUnit')
  68. return (cached === 'sec' || cached === 'min' || cached === 'hr') ? cached : 'min'
  69. })
  70. const [clearPattern, setClearPattern] = useState<PreExecution>(() => {
  71. const cached = localStorage.getItem('preExecution')
  72. return (cached as PreExecution) || 'adaptive'
  73. })
  74. // Persist playback settings to localStorage
  75. useEffect(() => {
  76. localStorage.setItem('playlist-runMode', runMode)
  77. }, [runMode])
  78. useEffect(() => {
  79. localStorage.setItem('playlist-shuffle', String(shuffle))
  80. }, [shuffle])
  81. useEffect(() => {
  82. localStorage.setItem('playlist-pauseTime', String(pauseTime))
  83. }, [pauseTime])
  84. useEffect(() => {
  85. localStorage.setItem('playlist-pauseUnit', pauseUnit)
  86. }, [pauseUnit])
  87. useEffect(() => {
  88. localStorage.setItem('preExecution', clearPattern)
  89. }, [clearPattern])
  90. // Persist selected playlist to localStorage
  91. useEffect(() => {
  92. if (selectedPlaylist) {
  93. localStorage.setItem('playlist-selected', selectedPlaylist)
  94. } else {
  95. localStorage.removeItem('playlist-selected')
  96. }
  97. }, [selectedPlaylist])
  98. // Validate cached playlist exists and load its patterns after playlists load
  99. const initialLoadDoneRef = useRef(false)
  100. useEffect(() => {
  101. if (isLoadingPlaylists) return
  102. if (selectedPlaylist) {
  103. if (playlists.includes(selectedPlaylist)) {
  104. // Load patterns for cached playlist on initial load only
  105. if (!initialLoadDoneRef.current) {
  106. initialLoadDoneRef.current = true
  107. fetchPlaylistPatterns(selectedPlaylist)
  108. }
  109. } else {
  110. // Cached playlist no longer exists
  111. setSelectedPlaylist(null)
  112. }
  113. }
  114. }, [isLoadingPlaylists, playlists, selectedPlaylist])
  115. // Close modals when playback starts
  116. useEffect(() => {
  117. const handlePlaybackStarted = () => {
  118. setIsPickerOpen(false)
  119. setIsCreateModalOpen(false)
  120. setIsRenameModalOpen(false)
  121. }
  122. window.addEventListener('playback-started', handlePlaybackStarted)
  123. return () => window.removeEventListener('playback-started', handlePlaybackStarted)
  124. }, [])
  125. const [isRunning, setIsRunning] = useState(false)
  126. // Convert pause time to seconds based on unit
  127. const getPauseTimeInSeconds = () => {
  128. switch (pauseUnit) {
  129. case 'hr':
  130. return pauseTime * 3600
  131. case 'min':
  132. return pauseTime * 60
  133. default:
  134. return pauseTime
  135. }
  136. }
  137. // Preview loading
  138. const pendingPreviewsRef = useRef<Set<string>>(new Set())
  139. const batchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
  140. const abortControllerRef = useRef<AbortController | null>(null)
  141. // Initialize and fetch data
  142. useEffect(() => {
  143. initPreviewCacheDB().catch(() => {})
  144. fetchPlaylists()
  145. fetchAllPatterns()
  146. // Cleanup on unmount: abort in-flight requests and clear pending queue
  147. return () => {
  148. if (batchTimeoutRef.current) {
  149. clearTimeout(batchTimeoutRef.current)
  150. }
  151. if (abortControllerRef.current) {
  152. abortControllerRef.current.abort()
  153. }
  154. pendingPreviewsRef.current.clear()
  155. }
  156. }, [])
  157. // Refetch when backend reconnects
  158. useOnBackendConnected(() => {
  159. fetchPlaylists()
  160. fetchAllPatterns()
  161. })
  162. const fetchPlaylists = async () => {
  163. setIsLoadingPlaylists(true)
  164. try {
  165. const data = await apiClient.get<string[]>('/list_all_playlists')
  166. // Backend returns array directly, not { playlists: [...] }
  167. setPlaylists(Array.isArray(data) ? data : [])
  168. } catch (error) {
  169. console.error('Error fetching playlists:', error)
  170. toast.error('Failed to load playlists')
  171. } finally {
  172. setIsLoadingPlaylists(false)
  173. }
  174. }
  175. const fetchPlaylistPatterns = async (name: string) => {
  176. try {
  177. const data = await apiClient.get<{ files: string[] }>(`/get_playlist?name=${encodeURIComponent(name)}`)
  178. setPlaylistPatterns(data.files || [])
  179. // Load previews for playlist patterns
  180. if (data.files?.length > 0) {
  181. loadPreviewsForPaths(data.files)
  182. }
  183. } catch (error) {
  184. console.error('Error fetching playlist:', error)
  185. toast.error('Failed to load playlist')
  186. setPlaylistPatterns([])
  187. }
  188. }
  189. const fetchAllPatterns = async () => {
  190. try {
  191. const data = await apiClient.get<PatternMetadata[]>('/list_theta_rho_files_with_metadata')
  192. setAllPatterns(data)
  193. } catch (error) {
  194. console.error('Error fetching patterns:', error)
  195. }
  196. }
  197. // Preview loading functions (similar to BrowsePage)
  198. const loadPreviewsForPaths = async (paths: string[]) => {
  199. const cachedPreviews = await getPreviewsFromCache(paths)
  200. if (cachedPreviews.size > 0) {
  201. const cachedData: Record<string, PreviewData> = {}
  202. cachedPreviews.forEach((previewData, path) => {
  203. cachedData[path] = previewData
  204. })
  205. setPreviews(prev => ({ ...prev, ...cachedData }))
  206. }
  207. const uncached = paths.filter(p => !cachedPreviews.has(p))
  208. if (uncached.length > 0) {
  209. fetchPreviewsBatch(uncached)
  210. }
  211. }
  212. const fetchPreviewsBatch = async (paths: string[]) => {
  213. const BATCH_SIZE = 10 // Process 10 patterns at a time to avoid overwhelming the backend
  214. // Create new AbortController for this batch of requests
  215. abortControllerRef.current = new AbortController()
  216. const signal = abortControllerRef.current.signal
  217. // Process in batches
  218. for (let i = 0; i < paths.length; i += BATCH_SIZE) {
  219. // Check if aborted before each batch
  220. if (signal.aborted) break
  221. const batch = paths.slice(i, i + BATCH_SIZE)
  222. try {
  223. const data = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batch }, signal)
  224. const newPreviews: Record<string, PreviewData> = {}
  225. for (const [path, previewData] of Object.entries(data)) {
  226. newPreviews[path] = previewData as PreviewData
  227. // Only cache valid previews (with image_data and no error)
  228. if (previewData && !(previewData as PreviewData).error) {
  229. savePreviewToCache(path, previewData as PreviewData)
  230. }
  231. }
  232. setPreviews(prev => ({ ...prev, ...newPreviews }))
  233. } catch (error) {
  234. // Stop processing if aborted, otherwise continue with next batch
  235. if (error instanceof Error && error.name === 'AbortError') break
  236. console.error('Error fetching previews batch:', error)
  237. }
  238. // Small delay between batches to reduce backend load
  239. if (i + BATCH_SIZE < paths.length) {
  240. await new Promise((resolve) => setTimeout(resolve, 100))
  241. }
  242. }
  243. }
  244. const requestPreview = useCallback((path: string) => {
  245. if (previews[path] || pendingPreviewsRef.current.has(path)) return
  246. pendingPreviewsRef.current.add(path)
  247. if (batchTimeoutRef.current) {
  248. clearTimeout(batchTimeoutRef.current)
  249. }
  250. batchTimeoutRef.current = setTimeout(() => {
  251. const pathsToFetch = Array.from(pendingPreviewsRef.current)
  252. pendingPreviewsRef.current.clear()
  253. if (pathsToFetch.length > 0) {
  254. loadPreviewsForPaths(pathsToFetch)
  255. }
  256. }, 100)
  257. }, [previews])
  258. // Playlist CRUD operations
  259. const handleSelectPlaylist = (name: string) => {
  260. setSelectedPlaylist(name)
  261. fetchPlaylistPatterns(name)
  262. }
  263. const handleCreatePlaylist = async () => {
  264. if (!newPlaylistName.trim()) {
  265. toast.error('Please enter a playlist name')
  266. return
  267. }
  268. const name = newPlaylistName.trim()
  269. try {
  270. await apiClient.post('/create_playlist', { playlist_name: name, files: [] })
  271. toast.success('Playlist created')
  272. setIsCreateModalOpen(false)
  273. setNewPlaylistName('')
  274. await fetchPlaylists()
  275. handleSelectPlaylist(name)
  276. } catch (error) {
  277. console.error('Create playlist error:', error)
  278. toast.error(error instanceof Error ? error.message : 'Failed to create playlist')
  279. }
  280. }
  281. const handleRenamePlaylist = async () => {
  282. if (!playlistToRename || !newPlaylistName.trim()) return
  283. try {
  284. await apiClient.post('/rename_playlist', { old_name: playlistToRename, new_name: newPlaylistName.trim() })
  285. toast.success('Playlist renamed')
  286. setIsRenameModalOpen(false)
  287. setNewPlaylistName('')
  288. setPlaylistToRename(null)
  289. fetchPlaylists()
  290. if (selectedPlaylist === playlistToRename) {
  291. setSelectedPlaylist(newPlaylistName.trim())
  292. }
  293. } catch (error) {
  294. toast.error('Failed to rename playlist')
  295. }
  296. }
  297. const handleDeletePlaylist = async (name: string) => {
  298. if (!confirm(`Delete playlist "${name}"?`)) return
  299. try {
  300. await apiClient.delete('/delete_playlist', { playlist_name: name })
  301. toast.success('Playlist deleted')
  302. fetchPlaylists()
  303. if (selectedPlaylist === name) {
  304. setSelectedPlaylist(null)
  305. setPlaylistPatterns([])
  306. }
  307. } catch (error) {
  308. toast.error('Failed to delete playlist')
  309. }
  310. }
  311. const handleRemovePattern = async (patternPath: string) => {
  312. if (!selectedPlaylist) return
  313. const newPatterns = playlistPatterns.filter(p => p !== patternPath)
  314. try {
  315. await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
  316. setPlaylistPatterns(newPatterns)
  317. toast.success('Pattern removed')
  318. } catch (error) {
  319. toast.error('Failed to remove pattern')
  320. }
  321. }
  322. // Pattern picker modal
  323. const openPatternPicker = () => {
  324. setSelectedPatternPaths(new Set(playlistPatterns))
  325. setSearchQuery('')
  326. setIsPickerOpen(true)
  327. // Load previews for all patterns
  328. if (allPatterns.length > 0) {
  329. const paths = allPatterns.slice(0, 50).map(p => p.path)
  330. loadPreviewsForPaths(paths)
  331. }
  332. }
  333. const handleSavePatterns = async () => {
  334. if (!selectedPlaylist) return
  335. const newPatterns = Array.from(selectedPatternPaths)
  336. try {
  337. await apiClient.post('/modify_playlist', { playlist_name: selectedPlaylist, files: newPatterns })
  338. setPlaylistPatterns(newPatterns)
  339. setIsPickerOpen(false)
  340. toast.success('Playlist updated')
  341. loadPreviewsForPaths(newPatterns)
  342. } catch (error) {
  343. toast.error('Failed to update playlist')
  344. }
  345. }
  346. const togglePatternSelection = (path: string) => {
  347. setSelectedPatternPaths(prev => {
  348. const next = new Set(prev)
  349. if (next.has(path)) {
  350. next.delete(path)
  351. } else {
  352. next.add(path)
  353. }
  354. return next
  355. })
  356. }
  357. // Run playlist
  358. const handleRunPlaylist = async () => {
  359. if (!selectedPlaylist || playlistPatterns.length === 0) return
  360. setIsRunning(true)
  361. try {
  362. await apiClient.post('/run_playlist', {
  363. playlist_name: selectedPlaylist,
  364. run_mode: runMode === 'indefinite' ? 'indefinite' : 'single',
  365. pause_time: getPauseTimeInSeconds(),
  366. clear_pattern: clearPattern,
  367. shuffle: shuffle,
  368. })
  369. toast.success(`Started playlist: ${selectedPlaylist}`)
  370. // Trigger Now Playing bar to open
  371. window.dispatchEvent(new CustomEvent('playback-started'))
  372. } catch (error) {
  373. toast.error(error instanceof Error ? error.message : 'Failed to run playlist')
  374. } finally {
  375. setIsRunning(false)
  376. }
  377. }
  378. // Filter and sort patterns for picker
  379. const categories = useMemo(() => {
  380. const cats = new Set(allPatterns.map(p => p.category))
  381. return ['all', ...Array.from(cats).sort()]
  382. }, [allPatterns])
  383. const filteredPatterns = useMemo(() => {
  384. let filtered = allPatterns
  385. if (searchQuery) {
  386. filtered = filtered.filter(p => fuzzyMatch(p.name, searchQuery))
  387. }
  388. if (selectedCategory !== 'all') {
  389. filtered = filtered.filter(p => p.category === selectedCategory)
  390. }
  391. filtered = [...filtered].sort((a, b) => {
  392. let cmp = 0
  393. switch (sortBy) {
  394. case 'name':
  395. cmp = a.name.localeCompare(b.name)
  396. break
  397. case 'date':
  398. cmp = a.date_modified - b.date_modified
  399. break
  400. case 'category':
  401. cmp = a.category.localeCompare(b.category)
  402. break
  403. }
  404. return sortAsc ? cmp : -cmp
  405. })
  406. return filtered
  407. }, [allPatterns, searchQuery, selectedCategory, sortBy, sortAsc])
  408. // Get pattern name from path
  409. const getPatternName = (path: string) => {
  410. const pattern = allPatterns.find(p => p.path === path)
  411. return pattern?.name || path.split('/').pop()?.replace('.thr', '') || path
  412. }
  413. // Get preview URL (backend already returns full data URL)
  414. const getPreviewUrl = (path: string) => {
  415. const preview = previews[path]
  416. return preview?.image_data || null
  417. }
  418. return (
  419. <div className="flex flex-col w-full max-w-5xl mx-auto gap-4 sm:gap-6 py-3 sm:py-6 px-3 sm:px-4 h-[calc(100dvh-7rem)] overflow-hidden">
  420. {/* Page Header */}
  421. <div className="space-y-0.5 sm:space-y-1 shrink-0 pl-1">
  422. <h1 className="text-xl font-semibold tracking-tight">Playlists</h1>
  423. <p className="text-xs text-muted-foreground">
  424. Create and manage pattern playlists
  425. </p>
  426. </div>
  427. <Separator className="shrink-0" />
  428. {/* Main Content Area */}
  429. <div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0">
  430. {/* Playlists Sidebar */}
  431. <aside className="w-full lg:w-64 shrink-0 bg-card border rounded-lg flex flex-col max-h-48 lg:max-h-none">
  432. <div className="flex items-center justify-between px-3 py-2.5 border-b shrink-0">
  433. <h2 className="text-lg font-semibold">My Playlists</h2>
  434. <Button
  435. variant="ghost"
  436. size="icon"
  437. className="h-8 w-8"
  438. onClick={() => {
  439. setNewPlaylistName('')
  440. setIsCreateModalOpen(true)
  441. }}
  442. >
  443. <span className="material-icons-outlined text-xl">add</span>
  444. </Button>
  445. </div>
  446. <nav className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
  447. {isLoadingPlaylists ? (
  448. <div className="flex items-center justify-center py-8 text-muted-foreground">
  449. <span className="text-sm">Loading...</span>
  450. </div>
  451. ) : playlists.length === 0 ? (
  452. <div className="flex flex-col items-center justify-center py-8 text-muted-foreground gap-2">
  453. <span className="material-icons-outlined text-3xl">playlist_add</span>
  454. <span className="text-sm">No playlists yet</span>
  455. </div>
  456. ) : (
  457. playlists.map(name => (
  458. <div
  459. key={name}
  460. className={`group flex items-center justify-between rounded-lg px-3 py-2 cursor-pointer transition-colors ${
  461. selectedPlaylist === name
  462. ? 'bg-accent text-accent-foreground'
  463. : 'hover:bg-muted text-muted-foreground'
  464. }`}
  465. onClick={() => handleSelectPlaylist(name)}
  466. >
  467. <div className="flex items-center gap-2 min-w-0">
  468. <span className="material-icons-outlined text-lg">playlist_play</span>
  469. <span className="truncate text-sm font-medium">{name}</span>
  470. </div>
  471. <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
  472. <Button
  473. variant="ghost"
  474. size="icon-sm"
  475. className="h-7 w-7"
  476. onClick={(e) => {
  477. e.stopPropagation()
  478. setPlaylistToRename(name)
  479. setNewPlaylistName(name)
  480. setIsRenameModalOpen(true)
  481. }}
  482. >
  483. <span className="material-icons-outlined text-base">edit</span>
  484. </Button>
  485. <Button
  486. variant="ghost"
  487. size="icon-sm"
  488. className="h-7 w-7 text-destructive hover:text-destructive hover:bg-destructive/20"
  489. onClick={(e) => {
  490. e.stopPropagation()
  491. handleDeletePlaylist(name)
  492. }}
  493. >
  494. <span className="material-icons-outlined text-base">delete</span>
  495. </Button>
  496. </div>
  497. </div>
  498. ))
  499. )}
  500. </nav>
  501. </aside>
  502. {/* Main Content */}
  503. <main className="flex-1 bg-card border rounded-lg flex flex-col overflow-hidden min-h-0 relative">
  504. {/* Header */}
  505. <header className="flex items-center justify-between px-4 py-3 border-b shrink-0">
  506. <div className="flex items-center gap-3 min-w-0">
  507. <div className="min-w-0">
  508. <h2 className="text-lg font-semibold truncate">
  509. {selectedPlaylist || 'Select a Playlist'}
  510. </h2>
  511. {selectedPlaylist && playlistPatterns.length > 0 && (
  512. <p className="text-sm text-muted-foreground">
  513. {playlistPatterns.length} pattern{playlistPatterns.length !== 1 ? 's' : ''}
  514. </p>
  515. )}
  516. </div>
  517. </div>
  518. <Button
  519. onClick={openPatternPicker}
  520. disabled={!selectedPlaylist}
  521. size="sm"
  522. className="gap-2"
  523. >
  524. <span className="material-icons-outlined text-base">add</span>
  525. <span className="hidden sm:inline">Add Patterns</span>
  526. </Button>
  527. </header>
  528. {/* Patterns List */}
  529. <div className={`flex-1 overflow-y-auto p-4 min-h-0 ${selectedPlaylist ? 'pb-28 sm:pb-24' : ''}`}>
  530. {!selectedPlaylist ? (
  531. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  532. <div className="p-4 rounded-full bg-muted">
  533. <span className="material-icons-outlined text-5xl">touch_app</span>
  534. </div>
  535. <div className="text-center">
  536. <p className="font-medium">No playlist selected</p>
  537. <p className="text-sm">Select a playlist from the sidebar to view its patterns</p>
  538. </div>
  539. </div>
  540. ) : playlistPatterns.length === 0 ? (
  541. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  542. <div className="p-4 rounded-full bg-muted">
  543. <span className="material-icons-outlined text-5xl">library_music</span>
  544. </div>
  545. <div className="text-center">
  546. <p className="font-medium">Empty playlist</p>
  547. <p className="text-sm">Add patterns to get started</p>
  548. </div>
  549. <Button variant="secondary" className="mt-2 gap-2" onClick={openPatternPicker}>
  550. <span className="material-icons-outlined text-base">add</span>
  551. Add Patterns
  552. </Button>
  553. </div>
  554. ) : (
  555. <div className="grid grid-cols-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-3 sm:gap-4">
  556. {playlistPatterns.map((path, index) => {
  557. const previewUrl = getPreviewUrl(path)
  558. if (!previewUrl && !previews[path]) {
  559. requestPreview(path)
  560. }
  561. return (
  562. <div
  563. key={`${path}-${index}`}
  564. className="flex flex-col items-center gap-1.5 sm:gap-2 group"
  565. >
  566. <div className="relative w-full aspect-square">
  567. <div className="w-full h-full rounded-full overflow-hidden border bg-muted hover:ring-2 hover:ring-primary hover:ring-offset-2 hover:ring-offset-background transition-all cursor-pointer">
  568. {previewUrl ? (
  569. <img
  570. src={previewUrl}
  571. alt={getPatternName(path)}
  572. className="w-full h-full object-cover pattern-preview"
  573. />
  574. ) : (
  575. <div className="w-full h-full flex items-center justify-center">
  576. <span className="material-icons-outlined text-muted-foreground text-sm sm:text-base">
  577. image
  578. </span>
  579. </div>
  580. )}
  581. </div>
  582. <button
  583. className="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-5 h-5 rounded-full bg-destructive hover:bg-destructive/90 text-destructive-foreground flex items-center justify-center opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shadow-sm z-10"
  584. onClick={() => handleRemovePattern(path)}
  585. title="Remove from playlist"
  586. >
  587. <span className="material-icons" style={{ fontSize: '12px' }}>close</span>
  588. </button>
  589. </div>
  590. <p className="text-[10px] sm:text-xs truncate font-medium w-full text-center">{getPatternName(path)}</p>
  591. </div>
  592. )
  593. })}
  594. </div>
  595. )}
  596. </div>
  597. {/* Floating Playback Controls */}
  598. {selectedPlaylist && (
  599. <div className="absolute bottom-0 left-0 right-0 pointer-events-none z-20">
  600. {/* Blur backdrop */}
  601. <div className="h-20 bg-gradient-to-t backdrop-blur-sm" />
  602. {/* Controls container */}
  603. <div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-3 px-4 pointer-events-auto">
  604. {/* Control pill */}
  605. <div className="flex items-center h-12 sm:h-14 bg-card rounded-full shadow-xl border px-1.5 sm:px-2">
  606. {/* Shuffle & Loop */}
  607. <div className="flex items-center px-1 sm:px-2 border-r border-border gap-0.5 sm:gap-1">
  608. <button
  609. onClick={() => setShuffle(!shuffle)}
  610. className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
  611. shuffle
  612. ? 'text-primary bg-primary/10'
  613. : 'text-muted-foreground hover:bg-muted'
  614. }`}
  615. title="Shuffle"
  616. >
  617. <span className="material-icons-outlined text-lg sm:text-xl">shuffle</span>
  618. </button>
  619. <button
  620. onClick={() => setRunMode(runMode === 'indefinite' ? 'single' : 'indefinite')}
  621. className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition ${
  622. runMode === 'indefinite'
  623. ? 'text-primary bg-primary/10'
  624. : 'text-muted-foreground hover:bg-muted'
  625. }`}
  626. title={runMode === 'indefinite' ? 'Loop mode' : 'Play once mode'}
  627. >
  628. <span className="material-icons-outlined text-lg sm:text-xl">repeat</span>
  629. </button>
  630. </div>
  631. {/* Pause Time */}
  632. <div className="flex items-center px-2 sm:px-3 gap-2 sm:gap-3 border-r border-border">
  633. <span className="text-[10px] sm:text-xs font-semibold text-muted-foreground tracking-wider hidden sm:block">Pause</span>
  634. <div className="flex items-center gap-1">
  635. <Button
  636. variant="secondary"
  637. size="icon"
  638. className="w-7 h-7 sm:w-8 sm:h-8"
  639. onClick={() => setPauseTime(Math.max(0, pauseTime - 1))}
  640. >
  641. <span className="material-icons-outlined text-sm">remove</span>
  642. </Button>
  643. <button
  644. onClick={() => {
  645. const units: ('sec' | 'min' | 'hr')[] = ['sec', 'min', 'hr']
  646. const currentIndex = units.indexOf(pauseUnit)
  647. setPauseUnit(units[(currentIndex + 1) % units.length])
  648. }}
  649. className="relative flex items-center justify-center w-10 sm:w-12 text-xs sm:text-sm font-bold hover:text-primary transition"
  650. title="Click to change unit"
  651. >
  652. {pauseTime}{pauseUnit === 'sec' ? 's' : pauseUnit === 'min' ? 'm' : 'h'}
  653. <span className="material-icons-outlined text-xs opacity-50 scale-75 absolute -right-1">swap_vert</span>
  654. </button>
  655. <Button
  656. variant="secondary"
  657. size="icon"
  658. className="w-7 h-7 sm:w-8 sm:h-8"
  659. onClick={() => setPauseTime(pauseTime + 1)}
  660. >
  661. <span className="material-icons-outlined text-sm">add</span>
  662. </Button>
  663. </div>
  664. </div>
  665. {/* Clear Pattern Dropdown */}
  666. <div className="flex items-center px-1 sm:px-2">
  667. <Select value={clearPattern} onValueChange={(v) => setClearPattern(v as PreExecution)}>
  668. <SelectTrigger className={`w-9 h-9 sm:w-10 sm:h-10 rounded-full border-0 p-0 shadow-none focus:ring-0 justify-center [&>svg]:hidden transition ${
  669. clearPattern !== 'none' ? '!bg-primary/10' : '!bg-transparent hover:!bg-muted'
  670. }`}>
  671. <span className={`material-icons-outlined text-lg sm:text-xl ${
  672. clearPattern !== 'none' ? 'text-primary' : 'text-muted-foreground'
  673. }`}>cleaning_services</span>
  674. </SelectTrigger>
  675. <SelectContent>
  676. {preExecutionOptions.map(opt => (
  677. <SelectItem key={opt.value} value={opt.value}>
  678. {opt.label}
  679. </SelectItem>
  680. ))}
  681. </SelectContent>
  682. </Select>
  683. </div>
  684. </div>
  685. {/* Play Button */}
  686. <button
  687. onClick={handleRunPlaylist}
  688. disabled={isRunning || playlistPatterns.length === 0}
  689. className="w-10 h-10 sm:w-12 sm:h-12 rounded-full bg-primary hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground text-primary-foreground shadow-lg shadow-primary/30 hover:shadow-primary/50 hover:scale-105 disabled:shadow-none disabled:hover:scale-100 transition-all duration-200 flex items-center justify-center"
  690. title="Run Playlist"
  691. >
  692. {isRunning ? (
  693. <span className="material-icons-outlined text-xl sm:text-2xl animate-spin">sync</span>
  694. ) : (
  695. <span className="material-icons text-xl sm:text-2xl ml-0.5">play_arrow</span>
  696. )}
  697. </button>
  698. </div>
  699. </div>
  700. )}
  701. </main>
  702. </div>
  703. {/* Create Playlist Modal */}
  704. <Dialog open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
  705. <DialogContent className="sm:max-w-md">
  706. <DialogHeader>
  707. <DialogTitle className="flex items-center gap-2">
  708. <span className="material-icons-outlined text-primary">playlist_add</span>
  709. Create New Playlist
  710. </DialogTitle>
  711. </DialogHeader>
  712. <div className="space-y-4 py-4">
  713. <div className="space-y-2">
  714. <Label htmlFor="playlistName">Playlist Name</Label>
  715. <Input
  716. id="playlistName"
  717. value={newPlaylistName}
  718. onChange={(e) => setNewPlaylistName(e.target.value)}
  719. placeholder="e.g., Favorites, Morning Patterns..."
  720. onKeyDown={(e) => e.key === 'Enter' && handleCreatePlaylist()}
  721. autoFocus
  722. />
  723. </div>
  724. </div>
  725. <DialogFooter className="gap-2 sm:gap-0">
  726. <Button variant="secondary" onClick={() => setIsCreateModalOpen(false)}>
  727. Cancel
  728. </Button>
  729. <Button onClick={handleCreatePlaylist} className="gap-2">
  730. <span className="material-icons-outlined text-base">add</span>
  731. Create Playlist
  732. </Button>
  733. </DialogFooter>
  734. </DialogContent>
  735. </Dialog>
  736. {/* Rename Playlist Modal */}
  737. <Dialog open={isRenameModalOpen} onOpenChange={setIsRenameModalOpen}>
  738. <DialogContent className="sm:max-w-md">
  739. <DialogHeader>
  740. <DialogTitle className="flex items-center gap-2">
  741. <span className="material-icons-outlined text-primary">edit</span>
  742. Rename Playlist
  743. </DialogTitle>
  744. </DialogHeader>
  745. <div className="space-y-4 py-4">
  746. <div className="space-y-2">
  747. <Label htmlFor="renamePlaylist">New Name</Label>
  748. <Input
  749. id="renamePlaylist"
  750. value={newPlaylistName}
  751. onChange={(e) => setNewPlaylistName(e.target.value)}
  752. placeholder="Enter new name"
  753. onKeyDown={(e) => e.key === 'Enter' && handleRenamePlaylist()}
  754. autoFocus
  755. />
  756. </div>
  757. </div>
  758. <DialogFooter className="gap-2 sm:gap-0">
  759. <Button variant="secondary" onClick={() => setIsRenameModalOpen(false)}>
  760. Cancel
  761. </Button>
  762. <Button onClick={handleRenamePlaylist} className="gap-2">
  763. <span className="material-icons-outlined text-base">save</span>
  764. Save Name
  765. </Button>
  766. </DialogFooter>
  767. </DialogContent>
  768. </Dialog>
  769. {/* Pattern Picker Modal */}
  770. <Dialog open={isPickerOpen} onOpenChange={setIsPickerOpen}>
  771. <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
  772. <DialogHeader>
  773. <DialogTitle className="flex items-center gap-2">
  774. <span className="material-icons-outlined text-primary">playlist_add</span>
  775. Add Patterns to {selectedPlaylist}
  776. </DialogTitle>
  777. </DialogHeader>
  778. {/* Search and Filters */}
  779. <div className="space-y-3 py-2">
  780. <div className="relative">
  781. <span className="material-icons-outlined absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground text-lg">
  782. search
  783. </span>
  784. <Input
  785. value={searchQuery}
  786. onChange={(e) => setSearchQuery(e.target.value)}
  787. placeholder="Search patterns..."
  788. className="pl-10 pr-10 h-10"
  789. />
  790. {searchQuery && (
  791. <button
  792. className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
  793. onClick={() => setSearchQuery('')}
  794. >
  795. <span className="material-icons-outlined text-lg">close</span>
  796. </button>
  797. )}
  798. </div>
  799. <div className="flex flex-wrap gap-3 items-center p-3 rounded-lg bg-muted/50">
  800. <div className="flex items-center gap-2">
  801. <Label className="text-xs text-muted-foreground">Sort:</Label>
  802. <Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOption)}>
  803. <SelectTrigger className="h-8 w-28">
  804. <SelectValue />
  805. </SelectTrigger>
  806. <SelectContent>
  807. <SelectItem value="name">Name</SelectItem>
  808. <SelectItem value="date">Date</SelectItem>
  809. <SelectItem value="category">Category</SelectItem>
  810. </SelectContent>
  811. </Select>
  812. <Button
  813. variant="ghost"
  814. size="icon"
  815. className="h-8 w-8"
  816. onClick={() => setSortAsc(!sortAsc)}
  817. >
  818. <span className="material-icons-outlined text-lg">
  819. {sortAsc ? 'arrow_upward' : 'arrow_downward'}
  820. </span>
  821. </Button>
  822. </div>
  823. <Separator orientation="vertical" className="h-6" />
  824. <div className="flex items-center gap-2">
  825. <Label className="text-xs text-muted-foreground">Folder:</Label>
  826. <Select value={selectedCategory} onValueChange={setSelectedCategory}>
  827. <SelectTrigger className="h-8 w-32">
  828. <SelectValue />
  829. </SelectTrigger>
  830. <SelectContent>
  831. {categories.map(cat => (
  832. <SelectItem key={cat} value={cat}>
  833. {cat === 'all' ? 'All Folders' : cat}
  834. </SelectItem>
  835. ))}
  836. </SelectContent>
  837. </Select>
  838. </div>
  839. <div className="flex-1" />
  840. <div className="flex items-center gap-2 text-sm">
  841. <span className="material-icons-outlined text-base text-primary">check_circle</span>
  842. <span className="font-medium">{selectedPatternPaths.size}</span>
  843. <span className="text-muted-foreground">selected</span>
  844. </div>
  845. </div>
  846. </div>
  847. {/* Patterns Grid */}
  848. <div className="flex-1 overflow-y-auto border rounded-lg p-4 min-h-[300px] bg-muted/20">
  849. {filteredPatterns.length === 0 ? (
  850. <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-3">
  851. <div className="p-4 rounded-full bg-muted">
  852. <span className="material-icons-outlined text-5xl">search_off</span>
  853. </div>
  854. <span className="text-sm">No patterns found</span>
  855. </div>
  856. ) : (
  857. <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 gap-4">
  858. {filteredPatterns.map(pattern => {
  859. const isSelected = selectedPatternPaths.has(pattern.path)
  860. const previewUrl = getPreviewUrl(pattern.path)
  861. if (!previewUrl && !previews[pattern.path]) {
  862. requestPreview(pattern.path)
  863. }
  864. return (
  865. <div
  866. key={pattern.path}
  867. className="flex flex-col items-center gap-2 cursor-pointer"
  868. onClick={() => togglePatternSelection(pattern.path)}
  869. >
  870. <div
  871. className={`relative w-full aspect-square rounded-full overflow-hidden border-2 bg-muted transition-all ${
  872. isSelected
  873. ? 'border-primary ring-2 ring-primary/20'
  874. : 'border-transparent hover:border-muted-foreground/30'
  875. }`}
  876. >
  877. {previewUrl ? (
  878. <img
  879. src={previewUrl}
  880. alt={pattern.name}
  881. className="w-full h-full object-cover pattern-preview"
  882. />
  883. ) : (
  884. <div className="w-full h-full flex items-center justify-center">
  885. <span className="material-icons-outlined text-muted-foreground">
  886. image
  887. </span>
  888. </div>
  889. )}
  890. {isSelected && (
  891. <div className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-primary flex items-center justify-center shadow-md">
  892. <span className="material-icons text-primary-foreground" style={{ fontSize: '14px' }}>
  893. check
  894. </span>
  895. </div>
  896. )}
  897. </div>
  898. <p className={`text-xs truncate font-medium w-full text-center ${isSelected ? 'text-primary' : ''}`}>
  899. {pattern.name}
  900. </p>
  901. </div>
  902. )
  903. })}
  904. </div>
  905. )}
  906. </div>
  907. <DialogFooter className="gap-2 sm:gap-0">
  908. <Button variant="secondary" onClick={() => setIsPickerOpen(false)}>
  909. Cancel
  910. </Button>
  911. <Button onClick={handleSavePatterns} className="gap-2">
  912. <span className="material-icons-outlined text-base">save</span>
  913. Save Selection
  914. </Button>
  915. </DialogFooter>
  916. </DialogContent>
  917. </Dialog>
  918. </div>
  919. )
  920. }