playlists.js 56 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469
  1. // Constants for log message types
  2. const LOG_TYPE = {
  3. SUCCESS: 'success',
  4. WARNING: 'warning',
  5. ERROR: 'error',
  6. INFO: 'info',
  7. DEBUG: 'debug'
  8. };
  9. // Global variables
  10. let allPlaylists = [];
  11. let currentPlaylist = null;
  12. let availablePatterns = [];
  13. let filteredPatterns = [];
  14. let selectedPatterns = new Set();
  15. let previewCache = new Map();
  16. let intersectionObserver = null;
  17. let searchTimeout = null;
  18. // Mobile navigation state
  19. let isMobileView = false;
  20. // Global variables for batching lazy loading
  21. let pendingPatterns = new Map(); // pattern -> element mapping
  22. let batchTimeout = null;
  23. const BATCH_SIZE = 40; // Increased batch size for better performance
  24. const BATCH_DELAY = 150; // Wait 150ms to collect more patterns before batching
  25. // Shared caching for patterns list (persistent across sessions)
  26. const PATTERNS_CACHE_KEY = 'dune_weaver_patterns_cache';
  27. // IndexedDB cache for preview images with size management
  28. const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews';
  29. const PREVIEW_CACHE_DB_VERSION = 1;
  30. const PREVIEW_CACHE_STORE_NAME = 'previews';
  31. const MAX_CACHE_SIZE_MB = 200;
  32. const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024;
  33. let previewCacheDB = null;
  34. // --- Playback Settings Persistence ---
  35. const PLAYBACK_SETTINGS_KEY = 'dune_weaver_playback_settings';
  36. function savePlaybackSettings() {
  37. const runMode = document.querySelector('input[name="run_playlist"]:checked')?.value || 'single';
  38. const shuffle = document.getElementById('shuffleCheckbox')?.checked || false;
  39. const pauseTime = document.getElementById('pauseTimeInput')?.value || '5';
  40. const clearPattern = document.getElementById('clearPatternSelect')?.value || 'none';
  41. const settings = { runMode, shuffle, pauseTime, clearPattern };
  42. try {
  43. localStorage.setItem(PLAYBACK_SETTINGS_KEY, JSON.stringify(settings));
  44. } catch (e) {}
  45. }
  46. function restorePlaybackSettings() {
  47. try {
  48. const settings = JSON.parse(localStorage.getItem(PLAYBACK_SETTINGS_KEY));
  49. if (!settings) return;
  50. // Run mode
  51. if (settings.runMode) {
  52. const radio = document.querySelector(`input[name="run_playlist"][value="${settings.runMode}"]`);
  53. if (radio) radio.checked = true;
  54. }
  55. // Shuffle
  56. if (typeof settings.shuffle === 'boolean') {
  57. const shuffleBox = document.getElementById('shuffleCheckbox');
  58. if (shuffleBox) shuffleBox.checked = settings.shuffle;
  59. }
  60. // Pause time
  61. if (settings.pauseTime) {
  62. const pauseInput = document.getElementById('pauseTimeInput');
  63. if (pauseInput) pauseInput.value = settings.pauseTime;
  64. }
  65. // Clear pattern
  66. if (settings.clearPattern) {
  67. const clearSel = document.getElementById('clearPatternSelect');
  68. if (clearSel) clearSel.value = settings.clearPattern;
  69. }
  70. } catch (e) {}
  71. }
  72. // Attach listeners to save settings on change
  73. function setupPlaybackSettingsPersistence() {
  74. document.querySelectorAll('input[name="run_playlist"]').forEach(radio => {
  75. radio.addEventListener('change', savePlaybackSettings);
  76. });
  77. const shuffleBox = document.getElementById('shuffleCheckbox');
  78. if (shuffleBox) shuffleBox.addEventListener('change', savePlaybackSettings);
  79. const pauseInput = document.getElementById('pauseTimeInput');
  80. if (pauseInput) pauseInput.addEventListener('input', savePlaybackSettings);
  81. const clearSel = document.getElementById('clearPatternSelect');
  82. if (clearSel) clearSel.addEventListener('change', savePlaybackSettings);
  83. }
  84. // --- End Playback Settings Persistence ---
  85. // --- Playlist Selection Persistence ---
  86. const LAST_PLAYLIST_KEY = 'dune_weaver_last_playlist';
  87. function saveLastSelectedPlaylist(playlistName) {
  88. try {
  89. localStorage.setItem(LAST_PLAYLIST_KEY, playlistName);
  90. } catch (e) {}
  91. }
  92. function getLastSelectedPlaylist() {
  93. try {
  94. return localStorage.getItem(LAST_PLAYLIST_KEY);
  95. } catch (e) { return null; }
  96. }
  97. // --- End Playlist Selection Persistence ---
  98. // Initialize IndexedDB for preview caching
  99. async function initPreviewCacheDB() {
  100. if (previewCacheDB) return previewCacheDB;
  101. return new Promise((resolve, reject) => {
  102. const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION);
  103. request.onerror = () => {
  104. logMessage('Failed to open preview cache database', LOG_TYPE.ERROR);
  105. reject(request.error);
  106. };
  107. request.onsuccess = () => {
  108. previewCacheDB = request.result;
  109. logMessage('Preview cache database opened successfully', LOG_TYPE.DEBUG);
  110. resolve(previewCacheDB);
  111. };
  112. request.onupgradeneeded = (event) => {
  113. const db = event.target.result;
  114. // Create object store for preview cache
  115. const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' });
  116. store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
  117. store.createIndex('size', 'size', { unique: false });
  118. logMessage('Preview cache database schema created', LOG_TYPE.DEBUG);
  119. };
  120. });
  121. }
  122. // Get preview from IndexedDB cache
  123. async function getPreviewFromCache(pattern) {
  124. try {
  125. if (!previewCacheDB) await initPreviewCacheDB();
  126. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  127. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  128. return new Promise((resolve, reject) => {
  129. const request = store.get(pattern);
  130. request.onsuccess = () => {
  131. const result = request.result;
  132. if (result) {
  133. // Update last accessed time
  134. result.lastAccessed = Date.now();
  135. store.put(result);
  136. resolve(result.data);
  137. } else {
  138. resolve(null);
  139. }
  140. };
  141. request.onerror = () => reject(request.error);
  142. });
  143. } catch (error) {
  144. logMessage(`Error getting preview from cache: ${error.message}`, LOG_TYPE.WARNING);
  145. return null;
  146. }
  147. }
  148. // Save preview to IndexedDB cache with size management
  149. // Clear a specific pattern from IndexedDB cache
  150. async function clearPatternFromIndexedDB(pattern) {
  151. try {
  152. if (!previewCacheDB) await initPreviewCacheDB();
  153. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  154. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  155. await new Promise((resolve, reject) => {
  156. const deleteRequest = store.delete(pattern);
  157. deleteRequest.onsuccess = () => {
  158. logMessage(`Cleared ${pattern} from IndexedDB cache`, LOG_TYPE.DEBUG);
  159. resolve();
  160. };
  161. deleteRequest.onerror = () => {
  162. logMessage(`Failed to clear ${pattern} from IndexedDB: ${deleteRequest.error}`, LOG_TYPE.WARNING);
  163. reject(deleteRequest.error);
  164. };
  165. });
  166. } catch (error) {
  167. logMessage(`Error clearing pattern from IndexedDB: ${error.message}`, LOG_TYPE.WARNING);
  168. }
  169. }
  170. async function savePreviewToCache(pattern, previewData) {
  171. try {
  172. if (!previewCacheDB) await initPreviewCacheDB();
  173. // Validate preview data before attempting to fetch
  174. if (!previewData || !previewData.image_data) {
  175. logMessage(`Invalid preview data for ${pattern}, skipping cache save`, LOG_TYPE.WARNING);
  176. return;
  177. }
  178. // Convert preview URL to blob for size calculation
  179. const response = await fetch(previewData.image_data);
  180. const blob = await response.blob();
  181. const size = blob.size;
  182. // Check if we need to free up space
  183. await managePreviewCacheSize(size);
  184. const cacheEntry = {
  185. pattern: pattern,
  186. data: previewData,
  187. size: size,
  188. lastAccessed: Date.now(),
  189. created: Date.now()
  190. };
  191. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  192. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  193. return new Promise((resolve, reject) => {
  194. const request = store.put(cacheEntry);
  195. request.onsuccess = () => {
  196. logMessage(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
  197. resolve();
  198. };
  199. request.onerror = () => reject(request.error);
  200. });
  201. } catch (error) {
  202. logMessage(`Error saving preview to cache: ${error.message}`, LOG_TYPE.WARNING);
  203. }
  204. }
  205. // Manage cache size by removing least recently used items
  206. async function managePreviewCacheSize(newItemSize) {
  207. try {
  208. const currentSize = await getPreviewCacheSize();
  209. if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
  210. return; // No cleanup needed
  211. }
  212. logMessage(`Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`, LOG_TYPE.DEBUG);
  213. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  214. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  215. const index = store.index('lastAccessed');
  216. // Get all entries sorted by last accessed (oldest first)
  217. const entries = await new Promise((resolve, reject) => {
  218. const request = index.getAll();
  219. request.onsuccess = () => resolve(request.result);
  220. request.onerror = () => reject(request.error);
  221. });
  222. // Sort by last accessed time (oldest first)
  223. entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
  224. let freedSpace = 0;
  225. const targetSpace = newItemSize + (MAX_CACHE_SIZE_BYTES * 0.1); // Free 10% extra buffer
  226. for (const entry of entries) {
  227. if (freedSpace >= targetSpace) break;
  228. await new Promise((resolve, reject) => {
  229. const deleteRequest = store.delete(entry.pattern);
  230. deleteRequest.onsuccess = () => {
  231. freedSpace += entry.size;
  232. logMessage(`Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
  233. resolve();
  234. };
  235. deleteRequest.onerror = () => reject(deleteRequest.error);
  236. });
  237. }
  238. logMessage(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`, LOG_TYPE.DEBUG);
  239. } catch (error) {
  240. logMessage(`Error managing cache size: ${error.message}`, LOG_TYPE.WARNING);
  241. }
  242. }
  243. // Get current cache size
  244. async function getPreviewCacheSize() {
  245. try {
  246. if (!previewCacheDB) return 0;
  247. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
  248. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  249. return new Promise((resolve, reject) => {
  250. const request = store.getAll();
  251. request.onsuccess = () => {
  252. const totalSize = request.result.reduce((sum, entry) => sum + (entry.size || 0), 0);
  253. resolve(totalSize);
  254. };
  255. request.onerror = () => reject(request.error);
  256. });
  257. } catch (error) {
  258. logMessage(`Error getting cache size: ${error.message}`, LOG_TYPE.WARNING);
  259. return 0;
  260. }
  261. }
  262. // Clear preview cache
  263. async function clearPreviewCache() {
  264. try {
  265. if (!previewCacheDB) return;
  266. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
  267. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  268. return new Promise((resolve, reject) => {
  269. const request = store.clear();
  270. request.onsuccess = () => {
  271. logMessage('Preview cache cleared', LOG_TYPE.DEBUG);
  272. resolve();
  273. };
  274. request.onerror = () => reject(request.error);
  275. });
  276. } catch (error) {
  277. logMessage(`Error clearing preview cache: ${error.message}`, LOG_TYPE.WARNING);
  278. }
  279. }
  280. // Get cache statistics
  281. async function getPreviewCacheStats() {
  282. try {
  283. const size = await getPreviewCacheSize();
  284. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
  285. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
  286. const count = await new Promise((resolve, reject) => {
  287. const request = store.count();
  288. request.onsuccess = () => resolve(request.result);
  289. request.onerror = () => reject(request.error);
  290. });
  291. return {
  292. count,
  293. size,
  294. sizeMB: size / 1024 / 1024,
  295. maxSizeMB: MAX_CACHE_SIZE_MB,
  296. utilizationPercent: (size / MAX_CACHE_SIZE_BYTES) * 100
  297. };
  298. } catch (error) {
  299. logMessage(`Error getting cache stats: ${error.message}`, LOG_TYPE.WARNING);
  300. return { count: 0, size: 0, sizeMB: 0, maxSizeMB: MAX_CACHE_SIZE_MB, utilizationPercent: 0 };
  301. }
  302. }
  303. // Initialize Intersection Observer for lazy loading
  304. function initializeIntersectionObserver() {
  305. intersectionObserver = new IntersectionObserver((entries) => {
  306. // Get all visible elements
  307. const visibleElements = entries.filter(entry => entry.isIntersecting);
  308. if (visibleElements.length === 0) return;
  309. // Collect all visible patterns
  310. const visiblePatterns = new Map();
  311. visibleElements.forEach(entry => {
  312. const patternElement = entry.target;
  313. const pattern = patternElement.dataset.pattern;
  314. if (pattern && !previewCache.has(pattern)) {
  315. visiblePatterns.set(pattern, patternElement);
  316. intersectionObserver.unobserve(patternElement);
  317. }
  318. });
  319. // If we have visible patterns that need loading, add them to the batch
  320. if (visiblePatterns.size > 0) {
  321. // Add to pending batch
  322. for (const [pattern, element] of visiblePatterns) {
  323. pendingPatterns.set(pattern, element);
  324. }
  325. // Clear existing timeout and set new one
  326. if (batchTimeout) {
  327. clearTimeout(batchTimeout);
  328. }
  329. batchTimeout = setTimeout(() => {
  330. processPendingBatch();
  331. }, BATCH_DELAY);
  332. }
  333. }, {
  334. rootMargin: '0px 0px 600px 0px', // Large bottom margin to trigger early as element approaches from bottom
  335. threshold: 0.1
  336. });
  337. }
  338. // Function to get visible patterns that are still loading
  339. function getVisibleLoadingPatterns() {
  340. const visibleLoadingPatterns = new Map();
  341. // Get all pattern elements that are currently visible
  342. const patternElements = document.querySelectorAll('[data-pattern]');
  343. patternElements.forEach(element => {
  344. const pattern = element.dataset.pattern;
  345. if (pattern && !previewCache.has(pattern)) {
  346. // Check if element is visible (intersecting with viewport)
  347. const rect = element.getBoundingClientRect();
  348. const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
  349. if (isVisible) {
  350. visibleLoadingPatterns.set(pattern, element);
  351. }
  352. }
  353. });
  354. return visibleLoadingPatterns;
  355. }
  356. // Modified processPendingBatch to keep polling for loading previews
  357. async function processPendingBatch() {
  358. if (pendingPatterns.size === 0) return;
  359. // Create a copy of current pending patterns and clear the original
  360. const currentBatch = new Map(pendingPatterns);
  361. pendingPatterns.clear();
  362. batchTimeout = null;
  363. const patternsToLoad = Array.from(currentBatch.keys());
  364. try {
  365. logMessage(`Loading ${patternsToLoad.length} pattern previews`, LOG_TYPE.DEBUG);
  366. const response = await fetch('/preview_thr_batch', {
  367. method: 'POST',
  368. headers: { 'Content-Type': 'application/json' },
  369. body: JSON.stringify({ file_names: patternsToLoad })
  370. });
  371. if (response.ok) {
  372. const results = await response.json();
  373. // Process all results
  374. for (const [pattern, data] of Object.entries(results)) {
  375. const element = currentBatch.get(pattern);
  376. const previewContainer = element?.querySelector('.pattern-preview');
  377. if (data && !data.error && data.image_data) {
  378. // Cache both in memory and IndexedDB
  379. previewCache.set(pattern, data);
  380. await savePreviewToCache(pattern, data);
  381. if (previewContainer) {
  382. previewContainer.innerHTML = ''; // Remove loading indicator
  383. previewContainer.innerHTML = `<img src="${data.image_data}" alt="Pattern Preview" class="w-full h-full object-cover rounded-full" />`;
  384. }
  385. } else {
  386. previewCache.set(pattern, { error: true });
  387. }
  388. }
  389. } else {
  390. throw new Error(`HTTP error! status: ${response.status}`);
  391. }
  392. } catch (error) {
  393. logMessage(`Error loading pattern preview batch: ${error.message}`, LOG_TYPE.ERROR);
  394. // Mark as error in cache
  395. for (const pattern of patternsToLoad) {
  396. previewCache.set(pattern, { error: true });
  397. }
  398. }
  399. // After processing, check for any visible loading previews and request them
  400. const stillLoading = getVisibleLoadingPatterns();
  401. if (stillLoading.size > 0) {
  402. // Add to pendingPatterns and immediately process
  403. for (const [pattern, element] of stillLoading) {
  404. pendingPatterns.set(pattern, element);
  405. }
  406. await processPendingBatch();
  407. }
  408. }
  409. // Function to show status message
  410. function showStatusMessage(message, type = 'success') {
  411. const statusContainer = document.getElementById('status-message-container');
  412. const statusMessage = document.getElementById('status-message');
  413. if (!statusContainer || !statusMessage) return;
  414. // Set message and color based on type
  415. statusMessage.textContent = message;
  416. statusMessage.className = `text-base font-semibold opacity-0 transform -translate-y-2 transition-all duration-300 ease-in-out px-4 py-2 rounded-lg shadow-lg ${
  417. type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' :
  418. type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' :
  419. type === 'warning' ? 'bg-yellow-50 text-yellow-700 border border-yellow-200' :
  420. 'bg-blue-50 text-blue-700 border border-blue-200'
  421. }`;
  422. // Show message with animation
  423. requestAnimationFrame(() => {
  424. statusMessage.classList.remove('opacity-0', '-translate-y-2');
  425. statusMessage.classList.add('opacity-100', 'translate-y-0');
  426. });
  427. // Hide message after 5 seconds
  428. setTimeout(() => {
  429. statusMessage.classList.remove('opacity-100', 'translate-y-0');
  430. statusMessage.classList.add('opacity-0', '-translate-y-2');
  431. }, 5000);
  432. }
  433. // Function to log messages
  434. function logMessage(message, type = LOG_TYPE.DEBUG) {
  435. console.log(`[${type}] ${message}`);
  436. }
  437. // Load all playlists
  438. async function loadPlaylists() {
  439. try {
  440. const response = await fetch('/list_all_playlists');
  441. if (response.ok) {
  442. allPlaylists = await response.json();
  443. displayPlaylists();
  444. // Auto-select last selected
  445. const last = getLastSelectedPlaylist();
  446. if (last && allPlaylists.includes(last)) {
  447. setTimeout(() => {
  448. const nav = document.getElementById('playlistsNav');
  449. const el = Array.from(nav.querySelectorAll('a')).find(a => a.textContent.trim() === last);
  450. if (el) el.click();
  451. }, 0);
  452. }
  453. } else {
  454. throw new Error('Failed to load playlists');
  455. }
  456. } catch (error) {
  457. logMessage(`Error loading playlists: ${error.message}`, LOG_TYPE.ERROR);
  458. showStatusMessage('Failed to load playlists', 'error');
  459. }
  460. }
  461. // Display playlists in sidebar
  462. function displayPlaylists() {
  463. const playlistsNav = document.getElementById('playlistsNav');
  464. playlistsNav.innerHTML = '';
  465. if (allPlaylists.length === 0) {
  466. playlistsNav.innerHTML = `
  467. <div class="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
  468. <span class="text-sm">No playlists found</span>
  469. </div>
  470. `;
  471. return;
  472. }
  473. allPlaylists.forEach(playlist => {
  474. const playlistItem = document.createElement('a');
  475. playlistItem.className = 'flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-gray-100 transition-colors duration-150 cursor-pointer';
  476. playlistItem.innerHTML = `
  477. <span class="material-icons text-lg text-gray-500 dark:text-gray-400">queue_music</span>
  478. <span class="text-sm font-medium flex-1 truncate">${playlist}</span>
  479. <span class="material-icons text-lg text-gray-400 dark:text-gray-500">chevron_right</span>
  480. `;
  481. playlistItem.addEventListener('click', () => selectPlaylist(playlist, playlistItem));
  482. playlistsNav.appendChild(playlistItem);
  483. });
  484. }
  485. // Select a playlist
  486. async function selectPlaylist(playlistName, element) {
  487. // Remove active state from all playlist items
  488. document.querySelectorAll('#playlistsNav a').forEach(item => {
  489. item.classList.remove('text-gray-900', 'dark:text-gray-100', 'bg-gray-100', 'dark:bg-gray-700', 'font-semibold');
  490. item.classList.add('text-gray-700', 'dark:text-gray-300', 'font-medium');
  491. });
  492. // Add active state to selected item
  493. element.classList.remove('text-gray-700', 'dark:text-gray-300', 'font-medium');
  494. element.classList.add('text-gray-900', 'dark:text-gray-100', 'bg-gray-100', 'dark:bg-gray-700', 'font-semibold');
  495. // Update current playlist
  496. currentPlaylist = playlistName;
  497. // Update header with playlist name and delete button
  498. const header = document.getElementById('currentPlaylistTitle');
  499. header.innerHTML = `
  500. <h1 class="text-gray-900 dark:text-gray-100 text-2xl font-semibold leading-tight truncate">${playlistName}</h1>
  501. <button id="deletePlaylistBtn" class="p-1 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/20 text-gray-500 dark:text-gray-500 hover:text-red-500 dark:hover:text-red-400 transition-all duration-150 flex-shrink-0" title="Delete playlist">
  502. <span class="material-icons text-lg">delete</span>
  503. </button>
  504. `;
  505. // Add delete button event listener
  506. document.getElementById('deletePlaylistBtn').addEventListener('click', () => deletePlaylist(playlistName));
  507. // Enable buttons
  508. document.getElementById('addPatternsBtn').disabled = false;
  509. document.getElementById('runPlaylistBtn').disabled = false;
  510. // Save last selected
  511. saveLastSelectedPlaylist(playlistName);
  512. // Show playlist details on mobile
  513. showPlaylistDetails();
  514. // Load playlist patterns
  515. await loadPlaylistPatterns(playlistName);
  516. }
  517. // Load patterns for selected playlist
  518. async function loadPlaylistPatterns(playlistName) {
  519. try {
  520. const response = await fetch(`/get_playlist?name=${encodeURIComponent(playlistName)}`);
  521. if (response.ok) {
  522. const playlistData = await response.json();
  523. displayPlaylistPatterns(playlistData.files || []);
  524. // Show playback settings
  525. document.getElementById('playbackSettings').classList.remove('hidden');
  526. } else {
  527. throw new Error('Failed to load playlist patterns');
  528. }
  529. } catch (error) {
  530. logMessage(`Error loading playlist patterns: ${error.message}`, LOG_TYPE.ERROR);
  531. showStatusMessage('Failed to load playlist patterns', 'error');
  532. }
  533. }
  534. // Display patterns in the current playlist
  535. async function displayPlaylistPatterns(patterns) {
  536. const patternsGrid = document.getElementById('patternsGrid');
  537. if (patterns.length === 0) {
  538. patternsGrid.innerHTML = `
  539. <div class="flex items-center justify-center col-span-full py-12 text-gray-500 dark:text-gray-400">
  540. <span class="text-sm">No patterns in this playlist</span>
  541. </div>
  542. `;
  543. return;
  544. }
  545. // No more pre-loading - all patterns will use lazy loading
  546. patternsGrid.innerHTML = '';
  547. patterns.forEach(pattern => {
  548. const patternCard = createPatternCard(pattern, true);
  549. patternsGrid.appendChild(patternCard);
  550. // Set up lazy loading for ALL patterns
  551. patternCard.dataset.pattern = pattern;
  552. intersectionObserver.observe(patternCard);
  553. });
  554. }
  555. // Create a pattern card
  556. function createPatternCard(pattern, showRemove = false) {
  557. const card = document.createElement('div');
  558. card.className = 'flex flex-col gap-3 group cursor-pointer relative';
  559. const previewContainer = document.createElement('div');
  560. previewContainer.className = 'w-full aspect-square bg-cover rounded-full shadow-sm group-hover:shadow-md transition-shadow duration-150 border border-gray-200 dark:border-gray-700 pattern-preview relative';
  561. // Only set preview image if already available in memory cache
  562. const previewData = previewCache.get(pattern);
  563. if (previewData && !previewData.error && previewData.image_data) {
  564. previewContainer.innerHTML = `<img src="${previewData.image_data}" alt="Pattern Preview" class="w-full h-full object-cover rounded-full" />`;
  565. }
  566. const patternName = document.createElement('p');
  567. patternName.className = 'text-sm text-gray-800 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 font-medium truncate text-center';
  568. patternName.textContent = pattern.replace('.thr', '').split('/').pop();
  569. card.appendChild(previewContainer);
  570. card.appendChild(patternName);
  571. if (showRemove) {
  572. const removeBtn = document.createElement('button');
  573. removeBtn.className = 'absolute top-2 right-2 size-6 rounded-full bg-red-500 hover:bg-red-600 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex items-center justify-center text-xs';
  574. removeBtn.innerHTML = '<span class="material-icons text-sm">close</span>';
  575. removeBtn.addEventListener('click', (e) => {
  576. e.stopPropagation();
  577. removePatternFromPlaylist(pattern);
  578. });
  579. card.appendChild(removeBtn);
  580. }
  581. return card;
  582. }
  583. // Search and filter patterns
  584. function searchPatterns(query) {
  585. const normalizedQuery = query.toLowerCase().trim();
  586. if (!normalizedQuery) {
  587. filteredPatterns = [...availablePatterns];
  588. } else {
  589. filteredPatterns = availablePatterns.filter(pattern => {
  590. const patternName = pattern.replace('.thr', '').split('/').pop().toLowerCase();
  591. return patternName.includes(normalizedQuery);
  592. });
  593. }
  594. displayAvailablePatterns();
  595. }
  596. // Handle search input
  597. function handleSearchInput() {
  598. const searchInput = document.getElementById('patternSearchInput');
  599. const clearBtn = document.getElementById('clearSearchBtn');
  600. const query = searchInput.value;
  601. // Show/hide clear button
  602. if (query) {
  603. clearBtn.classList.remove('hidden');
  604. } else {
  605. clearBtn.classList.add('hidden');
  606. }
  607. // Debounce search
  608. if (searchTimeout) {
  609. clearTimeout(searchTimeout);
  610. }
  611. searchTimeout = setTimeout(() => {
  612. searchPatterns(query);
  613. }, 300);
  614. }
  615. // Clear search
  616. function clearSearch() {
  617. const searchInput = document.getElementById('patternSearchInput');
  618. const clearBtn = document.getElementById('clearSearchBtn');
  619. searchInput.value = '';
  620. clearBtn.classList.add('hidden');
  621. searchPatterns('');
  622. }
  623. // Remove pattern from playlist
  624. async function removePatternFromPlaylist(pattern) {
  625. if (!currentPlaylist) return;
  626. if (confirm(`Remove "${pattern.split('/').pop()}" from playlist?`)) {
  627. try {
  628. // Get current playlist data
  629. const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
  630. if (response.ok) {
  631. const playlistData = await response.json();
  632. const updatedFiles = playlistData.files.filter(file => file !== pattern);
  633. // Update playlist
  634. const updateResponse = await fetch('/modify_playlist', {
  635. method: 'POST',
  636. headers: { 'Content-Type': 'application/json' },
  637. body: JSON.stringify({
  638. playlist_name: currentPlaylist,
  639. files: updatedFiles
  640. })
  641. });
  642. if (updateResponse.ok) {
  643. showStatusMessage('Pattern removed from playlist', 'success');
  644. await loadPlaylistPatterns(currentPlaylist);
  645. } else {
  646. throw new Error('Failed to update playlist');
  647. }
  648. }
  649. } catch (error) {
  650. logMessage(`Error removing pattern: ${error.message}`, LOG_TYPE.ERROR);
  651. showStatusMessage('Failed to remove pattern', 'error');
  652. }
  653. }
  654. }
  655. // Load available patterns for adding (no caching)
  656. async function loadAvailablePatterns(forceRefresh = false) {
  657. const loadingIndicator = document.getElementById('patternsLoadingIndicator');
  658. const grid = document.getElementById('availablePatternsGrid');
  659. const noResultsMessage = document.getElementById('noResultsMessage');
  660. // Always fetch from backend
  661. loadingIndicator.classList.remove('hidden');
  662. grid.classList.add('hidden');
  663. noResultsMessage.classList.add('hidden');
  664. try {
  665. logMessage('Fetching fresh patterns list from server', LOG_TYPE.DEBUG);
  666. const response = await fetch('/list_theta_rho_files');
  667. if (response.ok) {
  668. const patterns = await response.json();
  669. const thrPatterns = patterns.filter(file => file.endsWith('.thr'));
  670. availablePatterns = [...thrPatterns];
  671. filteredPatterns = [...availablePatterns];
  672. // Show patterns immediately - all lazy loading now
  673. displayAvailablePatterns();
  674. if (forceRefresh) {
  675. showStatusMessage('Patterns list refreshed successfully', 'success');
  676. }
  677. } else {
  678. throw new Error('Failed to load available patterns');
  679. }
  680. } catch (error) {
  681. logMessage(`Error loading available patterns: ${error.message}`, LOG_TYPE.ERROR);
  682. showStatusMessage('Failed to load available patterns', 'error');
  683. } finally {
  684. loadingIndicator.classList.add('hidden');
  685. }
  686. }
  687. // Update selection count display
  688. function updateSelectionCount() {
  689. const countElement = document.getElementById('selectionCount');
  690. if (countElement) {
  691. const count = selectedPatterns.size;
  692. countElement.textContent = `${count} selected`;
  693. }
  694. }
  695. // Select all visible patterns
  696. function selectAllPatterns() {
  697. const patterns = filteredPatterns.length > 0 ? filteredPatterns : availablePatterns;
  698. patterns.forEach(pattern => {
  699. selectedPatterns.add(pattern);
  700. });
  701. updatePatternSelection();
  702. updateSelectionCount();
  703. }
  704. // Deselect all patterns
  705. function deselectAllPatterns() {
  706. selectedPatterns.clear();
  707. updatePatternSelection();
  708. updateSelectionCount();
  709. }
  710. // Update visual selection state for all pattern cards
  711. function updatePatternSelection() {
  712. const cards = document.querySelectorAll('#availablePatternsGrid .group');
  713. cards.forEach(card => {
  714. const patternName = card.dataset.pattern;
  715. const addBtn = card.querySelector('.absolute.top-2.right-2');
  716. if (selectedPatterns.has(patternName)) {
  717. card.classList.add('ring-2', 'ring-blue-500');
  718. if (addBtn) {
  719. addBtn.classList.remove('opacity-0', 'bg-white', 'dark:bg-gray-700');
  720. addBtn.classList.add('opacity-100', 'bg-blue-500', 'text-white');
  721. addBtn.querySelector('.material-icons').textContent = 'check';
  722. }
  723. } else {
  724. card.classList.remove('ring-2', 'ring-blue-500');
  725. if (addBtn) {
  726. addBtn.classList.remove('opacity-100', 'bg-blue-500', 'text-white');
  727. addBtn.classList.add('opacity-0', 'bg-white', 'dark:bg-gray-700');
  728. addBtn.querySelector('.material-icons').textContent = 'add';
  729. }
  730. }
  731. });
  732. }
  733. // Display available patterns in modal
  734. function displayAvailablePatterns() {
  735. const grid = document.getElementById('availablePatternsGrid');
  736. const noResultsMessage = document.getElementById('noResultsMessage');
  737. grid.classList.remove('hidden');
  738. noResultsMessage.classList.add('hidden');
  739. grid.innerHTML = '';
  740. if (filteredPatterns.length === 0) {
  741. grid.classList.add('hidden');
  742. noResultsMessage.classList.remove('hidden');
  743. return;
  744. }
  745. filteredPatterns.forEach((pattern, index) => {
  746. const card = document.createElement('div');
  747. card.className = 'flex flex-col gap-2 cursor-pointer transition-all duration-150 hover:scale-105';
  748. card.dataset.pattern = pattern;
  749. card.innerHTML = `
  750. <div class="w-full bg-center aspect-square bg-cover rounded-full border border-gray-200 dark:border-gray-700 relative pattern-preview">
  751. <div class="absolute top-2 right-2 size-6 rounded-full shadow-md opacity-0 transition-opacity duration-150 flex items-center justify-center">
  752. <span class="material-icons text-sm text-gray-600 dark:text-gray-300">add</span>
  753. </div>
  754. </div>
  755. <p class="text-xs text-gray-700 dark:text-gray-300 font-medium truncate text-center">${pattern.replace('.thr', '').split('/').pop()}</p>
  756. `;
  757. const previewContainer = card.querySelector('.pattern-preview');
  758. const addBtn = card.querySelector('.absolute.top-2');
  759. // Only set preview image if already available in memory cache
  760. const previewData = previewCache.get(pattern);
  761. if (previewData && !previewData.error && previewData.image_data) {
  762. previewContainer.innerHTML = `<img src="${previewData.image_data}" alt="Pattern Preview" class="w-full h-full object-cover rounded-full" />`;
  763. // Re-add the add button
  764. const addBtnContainer = document.createElement('div');
  765. addBtnContainer.className = 'absolute top-2 right-2 size-6 rounded-full bg-white dark:bg-gray-700 shadow-md opacity-0 transition-opacity duration-150 flex items-center justify-center';
  766. addBtnContainer.innerHTML = '<span class="material-icons text-sm text-gray-600 dark:text-gray-300">add</span>';
  767. previewContainer.appendChild(addBtnContainer);
  768. }
  769. // Set up lazy loading for ALL patterns
  770. intersectionObserver.observe(card);
  771. // Handle selection
  772. card.addEventListener('click', () => {
  773. if (selectedPatterns.has(pattern)) {
  774. selectedPatterns.delete(pattern);
  775. card.classList.remove('ring-2', 'ring-blue-500');
  776. addBtn.classList.remove('opacity-100', 'bg-blue-500', 'text-white');
  777. addBtn.classList.add('opacity-0', 'bg-white', 'dark:bg-gray-700');
  778. addBtn.querySelector('.material-icons').textContent = 'add';
  779. } else {
  780. selectedPatterns.add(pattern);
  781. card.classList.add('ring-2', 'ring-blue-500');
  782. addBtn.classList.remove('opacity-0', 'bg-white', 'dark:bg-gray-700');
  783. addBtn.classList.add('opacity-100', 'bg-blue-500', 'text-white');
  784. addBtn.querySelector('.material-icons').textContent = 'check';
  785. }
  786. updateSelectionCount();
  787. });
  788. // Show add button on hover
  789. card.addEventListener('mouseenter', () => {
  790. if (!selectedPatterns.has(pattern)) {
  791. addBtn.classList.remove('opacity-0');
  792. addBtn.classList.add('opacity-100');
  793. }
  794. });
  795. card.addEventListener('mouseleave', () => {
  796. if (!selectedPatterns.has(pattern)) {
  797. addBtn.classList.remove('opacity-100');
  798. addBtn.classList.add('opacity-0');
  799. }
  800. });
  801. grid.appendChild(card);
  802. });
  803. // Trigger preview loading for visible patterns after displaying
  804. triggerPreviewLoadingForVisible();
  805. }
  806. // Trigger preview loading for currently visible patterns
  807. function triggerPreviewLoadingForVisible() {
  808. // Get all pattern cards currently in the DOM
  809. const patternCards = document.querySelectorAll('[data-pattern]');
  810. patternCards.forEach(card => {
  811. const pattern = card.dataset.pattern;
  812. const previewContainer = card.querySelector('.pattern-preview');
  813. // Check if this pattern needs preview loading
  814. if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
  815. // Add to batch for immediate loading
  816. addPatternToBatch(pattern, previewContainer);
  817. }
  818. });
  819. // Process any pending previews immediately
  820. if (pendingPatterns.size > 0) {
  821. processPendingBatch();
  822. }
  823. }
  824. // Add pattern to pending batch for efficient loading
  825. async function addPatternToBatch(pattern, element) {
  826. // Check in-memory cache first
  827. if (previewCache.has(pattern)) {
  828. const previewData = previewCache.get(pattern);
  829. if (previewData && !previewData.error) {
  830. if (element) {
  831. updatePreviewElement(element, previewData.image_data);
  832. }
  833. }
  834. return;
  835. }
  836. // Check IndexedDB cache
  837. const cachedData = await getPreviewFromCache(pattern);
  838. if (cachedData && !cachedData.error) {
  839. // Add to in-memory cache for faster access
  840. previewCache.set(pattern, cachedData);
  841. if (element) {
  842. updatePreviewElement(element, cachedData.image_data);
  843. }
  844. return;
  845. }
  846. // Add loading indicator with better styling
  847. if (element && !element.querySelector('img')) {
  848. element.innerHTML = `
  849. <div class="absolute inset-0 flex items-center justify-center bg-slate-100 rounded-full">
  850. <div class="bg-slate-200 rounded-full h-8 w-8 flex items-center justify-center">
  851. <div class="bg-slate-500 rounded-full h-4 w-4"></div>
  852. </div>
  853. </div>
  854. <div class="absolute inset-0 flex items-center justify-center">
  855. <div class="text-xs text-slate-500 mt-12">Loading...</div>
  856. </div>
  857. `;
  858. }
  859. // Add to pending batch
  860. pendingPatterns.set(pattern, element);
  861. // Process batch immediately if it's full
  862. if (pendingPatterns.size >= BATCH_SIZE) {
  863. processPendingBatch();
  864. }
  865. }
  866. // Update preview element with image
  867. function updatePreviewElement(element, imageData) {
  868. if (element) {
  869. element.innerHTML = `<img src="${imageData}" alt="Pattern Preview" class="w-full h-full object-cover rounded-full" />`;
  870. // Re-add the add button if it exists in the parent card
  871. const card = element.closest('[data-pattern]');
  872. if (card && !selectedPatterns.has(card.dataset.pattern)) {
  873. const addBtnContainer = document.createElement('div');
  874. addBtnContainer.className = 'absolute top-2 right-2 size-6 rounded-full bg-white dark:bg-gray-700 shadow-md opacity-0 transition-opacity duration-150 flex items-center justify-center';
  875. addBtnContainer.innerHTML = '<span class="material-icons text-sm text-gray-600 dark:text-gray-300">add</span>';
  876. element.appendChild(addBtnContainer);
  877. }
  878. }
  879. }
  880. // Add selected patterns to playlist
  881. async function addSelectedPatternsToPlaylist() {
  882. if (selectedPatterns.size === 0 || !currentPlaylist) return;
  883. try {
  884. // Get current playlist data
  885. const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
  886. if (response.ok) {
  887. const playlistData = await response.json();
  888. const currentFiles = playlistData.files || [];
  889. const newFiles = Array.from(selectedPatterns).filter(pattern => !currentFiles.includes(pattern));
  890. const updatedFiles = [...currentFiles, ...newFiles];
  891. // Update playlist
  892. const updateResponse = await fetch('/modify_playlist', {
  893. method: 'POST',
  894. headers: { 'Content-Type': 'application/json' },
  895. body: JSON.stringify({
  896. playlist_name: currentPlaylist,
  897. files: updatedFiles
  898. })
  899. });
  900. if (updateResponse.ok) {
  901. showStatusMessage(`Added ${newFiles.length} patterns to playlist`, 'success');
  902. selectedPatterns.clear();
  903. document.getElementById('addPatternsModal').classList.add('hidden');
  904. await loadPlaylistPatterns(currentPlaylist);
  905. } else {
  906. throw new Error('Failed to update playlist');
  907. }
  908. }
  909. } catch (error) {
  910. logMessage(`Error adding patterns: ${error.message}`, LOG_TYPE.ERROR);
  911. showStatusMessage('Failed to add patterns', 'error');
  912. }
  913. }
  914. // Run playlist
  915. async function runPlaylist() {
  916. if (!currentPlaylist) return;
  917. const runMode = document.querySelector('input[name="run_playlist"]:checked')?.value || 'single';
  918. const pauseTime = parseInt(document.getElementById('pauseTimeInput').value) || 0;
  919. const clearPattern = document.getElementById('clearPatternSelect').value;
  920. const shuffle = document.getElementById('shuffleCheckbox')?.checked || false;
  921. try {
  922. const response = await fetch('/run_playlist', {
  923. method: 'POST',
  924. headers: { 'Content-Type': 'application/json' },
  925. body: JSON.stringify({
  926. playlist_name: currentPlaylist,
  927. run_mode: runMode,
  928. pause_time: pauseTime,
  929. clear_pattern: clearPattern === 'none' ? null : clearPattern,
  930. shuffle: shuffle
  931. })
  932. });
  933. if (response.ok) {
  934. showStatusMessage(`Started playlist: ${currentPlaylist}`, 'success');
  935. // Show the preview modal when a playlist starts
  936. if (typeof setModalVisibility === 'function') {
  937. setModalVisibility(true, false);
  938. } else if (window.openPlayerPreviewModal) {
  939. window.openPlayerPreviewModal();
  940. }
  941. } else {
  942. let errorMsg = 'Failed to run playlist';
  943. let errorType = 'error';
  944. try {
  945. const data = await response.json();
  946. if (data.detail) {
  947. errorMsg = data.detail;
  948. // Handle specific error cases with appropriate messaging
  949. if (data.detail === 'Connection not established') {
  950. errorMsg = 'Please connect to the device before running a playlist';
  951. errorType = 'warning';
  952. } else if (response.status === 409) {
  953. errorMsg = 'Another pattern is already running. Please stop the current pattern first.';
  954. errorType = 'warning';
  955. } else if (response.status === 404) {
  956. errorMsg = 'Playlist not found. Please refresh the page and try again.';
  957. errorType = 'error';
  958. }
  959. }
  960. } catch (e) {
  961. // If we can't parse the JSON, use status-based messaging
  962. if (response.status === 400) {
  963. errorMsg = 'Invalid request. Please check your settings and try again.';
  964. } else if (response.status === 500) {
  965. errorMsg = 'Server error. Please try again later.';
  966. }
  967. }
  968. showStatusMessage(errorMsg, errorType);
  969. }
  970. } catch (error) {
  971. logMessage(`Error running playlist: ${error.message}`, LOG_TYPE.ERROR);
  972. // Handle network errors specifically
  973. if (error.name === 'TypeError' && error.message.includes('fetch')) {
  974. showStatusMessage('Network error. Please check your connection and try again.', 'error');
  975. } else {
  976. showStatusMessage('Failed to run playlist', 'error');
  977. }
  978. }
  979. }
  980. // Create new playlist
  981. async function createNewPlaylist() {
  982. const playlistName = document.getElementById('newPlaylistName').value.trim();
  983. if (!playlistName) {
  984. showStatusMessage('Please enter a playlist name', 'warning');
  985. return;
  986. }
  987. try {
  988. const response = await fetch('/create_playlist', {
  989. method: 'POST',
  990. headers: { 'Content-Type': 'application/json' },
  991. body: JSON.stringify({
  992. playlist_name: playlistName,
  993. files: []
  994. })
  995. });
  996. if (response.ok) {
  997. showStatusMessage('Playlist created successfully', 'success');
  998. document.getElementById('addPlaylistModal').classList.add('hidden');
  999. document.getElementById('newPlaylistName').value = '';
  1000. await loadPlaylists();
  1001. } else {
  1002. const data = await response.json();
  1003. throw new Error(data.detail || 'Failed to create playlist');
  1004. }
  1005. } catch (error) {
  1006. logMessage(`Error creating playlist: ${error.message}`, LOG_TYPE.ERROR);
  1007. showStatusMessage('Failed to create playlist', 'error');
  1008. }
  1009. }
  1010. // Delete playlist
  1011. async function deletePlaylist(playlistName) {
  1012. if (!confirm(`Are you sure you want to delete the playlist "${playlistName}"?`)) {
  1013. return;
  1014. }
  1015. try {
  1016. const response = await fetch('/delete_playlist', {
  1017. method: 'DELETE',
  1018. headers: { 'Content-Type': 'application/json' },
  1019. body: JSON.stringify({
  1020. playlist_name: playlistName
  1021. })
  1022. });
  1023. if (response.ok) {
  1024. showStatusMessage(`Playlist "${playlistName}" deleted successfully`, 'success');
  1025. // If the deleted playlist was selected, clear the selection
  1026. if (currentPlaylist === playlistName) {
  1027. currentPlaylist = null;
  1028. const header = document.getElementById('currentPlaylistTitle');
  1029. header.innerHTML = '<h1 class="text-gray-900 text-2xl font-semibold leading-tight truncate">Select a Playlist</h1>';
  1030. document.getElementById('addPatternsBtn').disabled = true;
  1031. document.getElementById('runPlaylistBtn').disabled = true;
  1032. document.getElementById('playbackSettings').classList.add('hidden');
  1033. document.getElementById('patternsGrid').innerHTML = `
  1034. <div class="flex items-center justify-center col-span-full py-12 text-gray-500 dark:text-gray-400">
  1035. <span class="text-sm text-center">Select a playlist to view its patterns</span>
  1036. </div>
  1037. `;
  1038. // Return to playlists list on mobile
  1039. showPlaylistsList();
  1040. }
  1041. // Reload playlists
  1042. await loadPlaylists();
  1043. } else {
  1044. const data = await response.json();
  1045. throw new Error(data.detail || 'Failed to delete playlist');
  1046. }
  1047. } catch (error) {
  1048. logMessage(`Error deleting playlist: ${error.message}`, LOG_TYPE.ERROR);
  1049. showStatusMessage('Failed to delete playlist', 'error');
  1050. }
  1051. }
  1052. // Setup event listeners
  1053. function setupEventListeners() {
  1054. // Mobile back button event listeners
  1055. document.getElementById('mobileBackBtn').addEventListener('click', () => {
  1056. showPlaylistsList();
  1057. });
  1058. // Add playlist button
  1059. document.getElementById('addPlaylistBtn').addEventListener('click', () => {
  1060. const modal = document.getElementById('addPlaylistModal');
  1061. const input = document.getElementById('newPlaylistName');
  1062. // Show modal first
  1063. modal.classList.remove('hidden');
  1064. // Focus handling
  1065. const focusInput = () => {
  1066. if (input) {
  1067. input.focus();
  1068. input.select();
  1069. }
  1070. };
  1071. // Try multiple approaches to ensure focus
  1072. focusInput();
  1073. requestAnimationFrame(focusInput);
  1074. setTimeout(focusInput, 50);
  1075. setTimeout(focusInput, 100);
  1076. });
  1077. // Add patterns button
  1078. document.getElementById('addPatternsBtn').addEventListener('click', async () => {
  1079. await loadAvailablePatterns();
  1080. updateSelectionCount();
  1081. document.getElementById('addPatternsModal').classList.remove('hidden');
  1082. // Focus search input when modal opens
  1083. setTimeout(() => {
  1084. document.getElementById('patternSearchInput').focus();
  1085. }, 100);
  1086. });
  1087. // Search functionality
  1088. document.getElementById('patternSearchInput').addEventListener('input', handleSearchInput);
  1089. document.getElementById('clearSearchBtn').addEventListener('click', clearSearch);
  1090. // Handle Enter key in search input
  1091. document.getElementById('patternSearchInput').addEventListener('keypress', (e) => {
  1092. if (e.key === 'Enter') {
  1093. e.preventDefault();
  1094. }
  1095. });
  1096. // Run playlist button
  1097. document.getElementById('runPlaylistBtn').addEventListener('click', runPlaylist);
  1098. // Modal controls
  1099. document.getElementById('cancelPlaylistBtn').addEventListener('click', () => {
  1100. document.getElementById('addPlaylistModal').classList.add('hidden');
  1101. });
  1102. document.getElementById('createPlaylistBtn').addEventListener('click', createNewPlaylist);
  1103. document.getElementById('cancelAddPatternsBtn').addEventListener('click', () => {
  1104. selectedPatterns.clear();
  1105. clearSearch();
  1106. updateSelectionCount();
  1107. document.getElementById('addPatternsModal').classList.add('hidden');
  1108. });
  1109. document.getElementById('confirmAddPatternsBtn').addEventListener('click', addSelectedPatternsToPlaylist);
  1110. // Select All and Deselect All buttons
  1111. document.getElementById('selectAllBtn').addEventListener('click', selectAllPatterns);
  1112. document.getElementById('deselectAllBtn').addEventListener('click', deselectAllPatterns);
  1113. // Handle Enter key in new playlist name input
  1114. document.getElementById('newPlaylistName').addEventListener('keypress', (e) => {
  1115. if (e.key === 'Enter') {
  1116. createNewPlaylist();
  1117. }
  1118. });
  1119. // Close modals when clicking outside
  1120. document.getElementById('addPlaylistModal').addEventListener('click', (e) => {
  1121. if (e.target.id === 'addPlaylistModal') {
  1122. document.getElementById('addPlaylistModal').classList.add('hidden');
  1123. }
  1124. });
  1125. document.getElementById('addPatternsModal').addEventListener('click', (e) => {
  1126. if (e.target.id === 'addPatternsModal') {
  1127. selectedPatterns.clear();
  1128. clearSearch();
  1129. document.getElementById('addPatternsModal').classList.add('hidden');
  1130. }
  1131. });
  1132. }
  1133. // Initialize playlists page
  1134. document.addEventListener('DOMContentLoaded', async () => {
  1135. try {
  1136. // Initialize intersection observer for lazy loading
  1137. initializeIntersectionObserver();
  1138. // Initialize IndexedDB preview cache
  1139. await initPreviewCacheDB();
  1140. // Setup event listeners
  1141. setupEventListeners();
  1142. // Initialize mobile view state
  1143. isMobileView = isMobile();
  1144. if (isMobileView) {
  1145. initMobileLayout();
  1146. } else {
  1147. initDesktopLayout();
  1148. }
  1149. // Add window resize listener for responsive behavior
  1150. window.addEventListener('resize', updateMobileView);
  1151. // Restore playback settings
  1152. restorePlaybackSettings();
  1153. setupPlaybackSettingsPersistence();
  1154. // Load playlists
  1155. await loadPlaylists();
  1156. // Check serial connection status
  1157. await checkSerialStatus();
  1158. logMessage('Playlists page initialized successfully', LOG_TYPE.SUCCESS);
  1159. } catch (error) {
  1160. logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
  1161. showStatusMessage('Failed to initialize playlists page', 'error');
  1162. }
  1163. });
  1164. // Check serial connection status
  1165. async function checkSerialStatus() {
  1166. try {
  1167. const response = await fetch('/serial_status');
  1168. if (response.ok) {
  1169. const data = await response.json();
  1170. const statusDot = document.getElementById('connectionStatusDot');
  1171. if (statusDot) {
  1172. statusDot.className = `inline-block size-2 rounded-full ml-2 align-middle ${
  1173. data.connected ? 'bg-green-500' : 'bg-red-500'
  1174. }`;
  1175. }
  1176. }
  1177. } catch (error) {
  1178. logMessage(`Error checking serial status: ${error.message}`, LOG_TYPE.ERROR);
  1179. }
  1180. }
  1181. // Mobile utility functions
  1182. function isMobile() {
  1183. return window.innerWidth <= 768;
  1184. }
  1185. function updateMobileView() {
  1186. const wasMobile = isMobileView;
  1187. isMobileView = isMobile();
  1188. if (wasMobile !== isMobileView) {
  1189. // Mobile state changed, update layout
  1190. if (isMobileView) {
  1191. initMobileLayout();
  1192. } else {
  1193. initDesktopLayout();
  1194. }
  1195. }
  1196. }
  1197. function initMobileLayout() {
  1198. const sidebar = document.getElementById('playlistsSidebar');
  1199. const details = document.getElementById('playlistDetails');
  1200. const mobileBackBtn = document.getElementById('mobileBackBtn');
  1201. if (!currentPlaylist) {
  1202. // Show playlists list, hide details
  1203. sidebar.classList.remove('mobile-hidden');
  1204. details.classList.add('mobile-hidden');
  1205. mobileBackBtn.classList.add('mobile-hidden');
  1206. } else {
  1207. // Show details, hide playlists list
  1208. sidebar.classList.add('mobile-hidden');
  1209. details.classList.remove('mobile-hidden');
  1210. mobileBackBtn.classList.remove('mobile-hidden');
  1211. mobileBackBtn.classList.add('mobile-flex');
  1212. }
  1213. }
  1214. function initDesktopLayout() {
  1215. const sidebar = document.getElementById('playlistsSidebar');
  1216. const details = document.getElementById('playlistDetails');
  1217. const mobileBackBtn = document.getElementById('mobileBackBtn');
  1218. // Show both sidebar and details on desktop
  1219. sidebar.classList.remove('mobile-hidden');
  1220. details.classList.remove('mobile-hidden');
  1221. mobileBackBtn.classList.add('mobile-hidden');
  1222. mobileBackBtn.classList.remove('mobile-flex');
  1223. }
  1224. function showPlaylistDetails() {
  1225. if (isMobileView) {
  1226. const sidebar = document.getElementById('playlistsSidebar');
  1227. const details = document.getElementById('playlistDetails');
  1228. const mobileBackBtn = document.getElementById('mobileBackBtn');
  1229. sidebar.classList.add('mobile-hidden');
  1230. details.classList.remove('mobile-hidden');
  1231. mobileBackBtn.classList.remove('mobile-hidden');
  1232. mobileBackBtn.classList.add('mobile-flex');
  1233. }
  1234. }
  1235. function showPlaylistsList() {
  1236. if (isMobileView) {
  1237. const sidebar = document.getElementById('playlistsSidebar');
  1238. const details = document.getElementById('playlistDetails');
  1239. const mobileBackBtn = document.getElementById('mobileBackBtn');
  1240. sidebar.classList.remove('mobile-hidden');
  1241. details.classList.add('mobile-hidden');
  1242. mobileBackBtn.classList.add('mobile-hidden');
  1243. mobileBackBtn.classList.remove('mobile-flex');
  1244. }
  1245. }