playlists.js 69 KB

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