playlists.js 53 KB

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