// Constants for log message types
const LOG_TYPE = {
SUCCESS: 'success',
WARNING: 'warning',
ERROR: 'error',
INFO: 'info',
DEBUG: 'debug'
};
// Global variables
let allPlaylists = [];
let currentPlaylist = null;
let availablePatterns = [];
let filteredPatterns = [];
let selectedPatterns = new Set();
let previewCache = new Map();
let intersectionObserver = null;
let searchTimeout = null;
// Mobile navigation state
let isMobileView = false;
// Global variables for batching lazy loading
let pendingPatterns = new Map(); // pattern -> element mapping
let batchTimeout = null;
const BATCH_SIZE = 40; // Increased batch size for better performance
const BATCH_DELAY = 150; // Wait 150ms to collect more patterns before batching
// Shared caching for patterns list (persistent across sessions)
const PATTERNS_CACHE_KEY = 'dune_weaver_patterns_cache';
// IndexedDB cache for preview images with size management
const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews';
const PREVIEW_CACHE_DB_VERSION = 1;
const PREVIEW_CACHE_STORE_NAME = 'previews';
const MAX_CACHE_SIZE_MB = 200;
const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024;
let previewCacheDB = null;
// --- Playback Settings Persistence ---
const PLAYBACK_SETTINGS_KEY = 'dune_weaver_playback_settings';
function savePlaybackSettings() {
const runMode = document.querySelector('input[name="run_playlist"]:checked')?.value || 'single';
const shuffle = document.getElementById('shuffleCheckbox')?.checked || false;
const pauseTime = document.getElementById('pauseTimeInput')?.value || '5';
const clearPattern = document.getElementById('clearPatternSelect')?.value || 'none';
const settings = { runMode, shuffle, pauseTime, clearPattern };
try {
localStorage.setItem(PLAYBACK_SETTINGS_KEY, JSON.stringify(settings));
} catch (e) {}
}
function restorePlaybackSettings() {
try {
const settings = JSON.parse(localStorage.getItem(PLAYBACK_SETTINGS_KEY));
if (!settings) return;
// Run mode
if (settings.runMode) {
const radio = document.querySelector(`input[name="run_playlist"][value="${settings.runMode}"]`);
if (radio) radio.checked = true;
}
// Shuffle
if (typeof settings.shuffle === 'boolean') {
const shuffleBox = document.getElementById('shuffleCheckbox');
if (shuffleBox) shuffleBox.checked = settings.shuffle;
}
// Pause time
if (settings.pauseTime) {
const pauseInput = document.getElementById('pauseTimeInput');
if (pauseInput) pauseInput.value = settings.pauseTime;
}
// Clear pattern
if (settings.clearPattern) {
const clearSel = document.getElementById('clearPatternSelect');
if (clearSel) clearSel.value = settings.clearPattern;
}
} catch (e) {}
}
// Attach listeners to save settings on change
function setupPlaybackSettingsPersistence() {
document.querySelectorAll('input[name="run_playlist"]').forEach(radio => {
radio.addEventListener('change', savePlaybackSettings);
});
const shuffleBox = document.getElementById('shuffleCheckbox');
if (shuffleBox) shuffleBox.addEventListener('change', savePlaybackSettings);
const pauseInput = document.getElementById('pauseTimeInput');
if (pauseInput) pauseInput.addEventListener('input', savePlaybackSettings);
const clearSel = document.getElementById('clearPatternSelect');
if (clearSel) clearSel.addEventListener('change', savePlaybackSettings);
}
// --- End Playback Settings Persistence ---
// --- Playlist Selection Persistence ---
const LAST_PLAYLIST_KEY = 'dune_weaver_last_playlist';
function saveLastSelectedPlaylist(playlistName) {
try {
localStorage.setItem(LAST_PLAYLIST_KEY, playlistName);
} catch (e) {}
}
function getLastSelectedPlaylist() {
try {
return localStorage.getItem(LAST_PLAYLIST_KEY);
} catch (e) { return null; }
}
// --- End Playlist Selection Persistence ---
// Initialize IndexedDB for preview caching
async function initPreviewCacheDB() {
if (previewCacheDB) return previewCacheDB;
return new Promise((resolve, reject) => {
const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION);
request.onerror = () => {
logMessage('Failed to open preview cache database', LOG_TYPE.ERROR);
reject(request.error);
};
request.onsuccess = () => {
previewCacheDB = request.result;
logMessage('Preview cache database opened successfully', LOG_TYPE.DEBUG);
resolve(previewCacheDB);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// Create object store for preview cache
const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' });
store.createIndex('lastAccessed', 'lastAccessed', { unique: false });
store.createIndex('size', 'size', { unique: false });
logMessage('Preview cache database schema created', LOG_TYPE.DEBUG);
};
});
}
// Get preview from IndexedDB cache
async function getPreviewFromCache(pattern) {
try {
if (!previewCacheDB) await initPreviewCacheDB();
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.get(pattern);
request.onsuccess = () => {
const result = request.result;
if (result) {
// Update last accessed time
result.lastAccessed = Date.now();
store.put(result);
resolve(result.data);
} else {
resolve(null);
}
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error getting preview from cache: ${error.message}`, LOG_TYPE.WARNING);
return null;
}
}
// Save preview to IndexedDB cache with size management
// Clear a specific pattern from IndexedDB cache
async function clearPatternFromIndexedDB(pattern) {
try {
if (!previewCacheDB) await initPreviewCacheDB();
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
await new Promise((resolve, reject) => {
const deleteRequest = store.delete(pattern);
deleteRequest.onsuccess = () => {
logMessage(`Cleared ${pattern} from IndexedDB cache`, LOG_TYPE.DEBUG);
resolve();
};
deleteRequest.onerror = () => {
logMessage(`Failed to clear ${pattern} from IndexedDB: ${deleteRequest.error}`, LOG_TYPE.WARNING);
reject(deleteRequest.error);
};
});
} catch (error) {
logMessage(`Error clearing pattern from IndexedDB: ${error.message}`, LOG_TYPE.WARNING);
}
}
async function savePreviewToCache(pattern, previewData) {
try {
if (!previewCacheDB) await initPreviewCacheDB();
// Validate preview data before attempting to fetch
if (!previewData || !previewData.image_data) {
logMessage(`Invalid preview data for ${pattern}, skipping cache save`, LOG_TYPE.WARNING);
return;
}
// Convert preview URL to blob for size calculation
const response = await fetch(previewData.image_data);
const blob = await response.blob();
const size = blob.size;
// Check if we need to free up space
await managePreviewCacheSize(size);
const cacheEntry = {
pattern: pattern,
data: previewData,
size: size,
lastAccessed: Date.now(),
created: Date.now()
};
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.put(cacheEntry);
request.onsuccess = () => {
logMessage(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
resolve();
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error saving preview to cache: ${error.message}`, LOG_TYPE.WARNING);
}
}
// Manage cache size by removing least recently used items
async function managePreviewCacheSize(newItemSize) {
try {
const currentSize = await getPreviewCacheSize();
if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
return; // No cleanup needed
}
logMessage(`Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`, LOG_TYPE.DEBUG);
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
const index = store.index('lastAccessed');
// Get all entries sorted by last accessed (oldest first)
const entries = await new Promise((resolve, reject) => {
const request = index.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
// Sort by last accessed time (oldest first)
entries.sort((a, b) => a.lastAccessed - b.lastAccessed);
let freedSpace = 0;
const targetSpace = newItemSize + (MAX_CACHE_SIZE_BYTES * 0.1); // Free 10% extra buffer
for (const entry of entries) {
if (freedSpace >= targetSpace) break;
await new Promise((resolve, reject) => {
const deleteRequest = store.delete(entry.pattern);
deleteRequest.onsuccess = () => {
freedSpace += entry.size;
logMessage(`Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`, LOG_TYPE.DEBUG);
resolve();
};
deleteRequest.onerror = () => reject(deleteRequest.error);
});
}
logMessage(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`, LOG_TYPE.DEBUG);
} catch (error) {
logMessage(`Error managing cache size: ${error.message}`, LOG_TYPE.WARNING);
}
}
// Get current cache size
async function getPreviewCacheSize() {
try {
if (!previewCacheDB) return 0;
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => {
const totalSize = request.result.reduce((sum, entry) => sum + (entry.size || 0), 0);
resolve(totalSize);
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error getting cache size: ${error.message}`, LOG_TYPE.WARNING);
return 0;
}
}
// Clear preview cache
async function clearPreviewCache() {
try {
if (!previewCacheDB) return;
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onsuccess = () => {
logMessage('Preview cache cleared', LOG_TYPE.DEBUG);
resolve();
};
request.onerror = () => reject(request.error);
});
} catch (error) {
logMessage(`Error clearing preview cache: ${error.message}`, LOG_TYPE.WARNING);
}
}
// Get cache statistics
async function getPreviewCacheStats() {
try {
const size = await getPreviewCacheSize();
const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly');
const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME);
const count = await new Promise((resolve, reject) => {
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
return {
count,
size,
sizeMB: size / 1024 / 1024,
maxSizeMB: MAX_CACHE_SIZE_MB,
utilizationPercent: (size / MAX_CACHE_SIZE_BYTES) * 100
};
} catch (error) {
logMessage(`Error getting cache stats: ${error.message}`, LOG_TYPE.WARNING);
return { count: 0, size: 0, sizeMB: 0, maxSizeMB: MAX_CACHE_SIZE_MB, utilizationPercent: 0 };
}
}
// Initialize Intersection Observer for lazy loading
function initializeIntersectionObserver() {
intersectionObserver = new IntersectionObserver((entries) => {
// Get all visible elements
const visibleElements = entries.filter(entry => entry.isIntersecting);
if (visibleElements.length === 0) return;
// Collect all visible patterns
const visiblePatterns = new Map();
visibleElements.forEach(entry => {
const patternElement = entry.target;
const pattern = patternElement.dataset.pattern;
if (pattern && !previewCache.has(pattern)) {
visiblePatterns.set(pattern, patternElement);
intersectionObserver.unobserve(patternElement);
}
});
// If we have visible patterns that need loading, add them to the batch
if (visiblePatterns.size > 0) {
// Add to pending batch
for (const [pattern, element] of visiblePatterns) {
pendingPatterns.set(pattern, element);
}
// Clear existing timeout and set new one
if (batchTimeout) {
clearTimeout(batchTimeout);
}
batchTimeout = setTimeout(() => {
processPendingBatch();
}, BATCH_DELAY);
}
}, {
rootMargin: '0px 0px 600px 0px', // Large bottom margin to trigger early as element approaches from bottom
threshold: 0.1
});
}
// Function to get visible patterns that are still loading
function getVisibleLoadingPatterns() {
const visibleLoadingPatterns = new Map();
// Get all pattern elements that are currently visible
const patternElements = document.querySelectorAll('[data-pattern]');
patternElements.forEach(element => {
const pattern = element.dataset.pattern;
if (pattern && !previewCache.has(pattern)) {
// Check if element is visible (intersecting with viewport)
const rect = element.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible) {
visibleLoadingPatterns.set(pattern, element);
}
}
});
return visibleLoadingPatterns;
}
// Modified processPendingBatch to keep polling for loading previews
async function processPendingBatch() {
if (pendingPatterns.size === 0) return;
// Create a copy of current pending patterns and clear the original
const currentBatch = new Map(pendingPatterns);
pendingPatterns.clear();
batchTimeout = null;
const patternsToLoad = Array.from(currentBatch.keys());
try {
logMessage(`Loading ${patternsToLoad.length} pattern previews`, LOG_TYPE.DEBUG);
const response = await fetch('/preview_thr_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_names: patternsToLoad })
});
if (response.ok) {
const results = await response.json();
// Process all results
for (const [pattern, data] of Object.entries(results)) {
const element = currentBatch.get(pattern);
const previewContainer = element?.querySelector('.pattern-preview');
if (data && !data.error && data.image_data) {
// Cache both in memory and IndexedDB
previewCache.set(pattern, data);
await savePreviewToCache(pattern, data);
if (previewContainer) {
previewContainer.innerHTML = ''; // Remove loading indicator
previewContainer.innerHTML = ``;
}
} else {
previewCache.set(pattern, { error: true });
}
}
} else {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (error) {
logMessage(`Error loading pattern preview batch: ${error.message}`, LOG_TYPE.ERROR);
// Mark as error in cache
for (const pattern of patternsToLoad) {
previewCache.set(pattern, { error: true });
}
}
// After processing, check for any visible loading previews and request them
const stillLoading = getVisibleLoadingPatterns();
if (stillLoading.size > 0) {
// Add to pendingPatterns and immediately process
for (const [pattern, element] of stillLoading) {
pendingPatterns.set(pattern, element);
}
await processPendingBatch();
}
}
// Function to show status message
function showStatusMessage(message, type = 'success') {
const statusContainer = document.getElementById('status-message-container');
const statusMessage = document.getElementById('status-message');
if (!statusContainer || !statusMessage) return;
// Set message and color based on type
statusMessage.textContent = message;
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 ${
type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' :
type === 'error' ? 'bg-red-50 text-red-700 border border-red-200' :
type === 'warning' ? 'bg-yellow-50 text-yellow-700 border border-yellow-200' :
'bg-blue-50 text-blue-700 border border-blue-200'
}`;
// Show message with animation
requestAnimationFrame(() => {
statusMessage.classList.remove('opacity-0', '-translate-y-2');
statusMessage.classList.add('opacity-100', 'translate-y-0');
});
// Hide message after 5 seconds
setTimeout(() => {
statusMessage.classList.remove('opacity-100', 'translate-y-0');
statusMessage.classList.add('opacity-0', '-translate-y-2');
}, 5000);
}
// Function to log messages
function logMessage(message, type = LOG_TYPE.DEBUG) {
console.log(`[${type}] ${message}`);
}
// Load all playlists
async function loadPlaylists() {
try {
const response = await fetch('/list_all_playlists');
if (response.ok) {
allPlaylists = await response.json();
displayPlaylists();
// Auto-select last selected
const last = getLastSelectedPlaylist();
if (last && allPlaylists.includes(last)) {
setTimeout(() => {
const nav = document.getElementById('playlistsNav');
const el = Array.from(nav.querySelectorAll('a')).find(a => a.textContent.trim() === last);
if (el) el.click();
}, 0);
}
} else {
throw new Error('Failed to load playlists');
}
} catch (error) {
logMessage(`Error loading playlists: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to load playlists', 'error');
}
}
// Display playlists in sidebar
function displayPlaylists() {
const playlistsNav = document.getElementById('playlistsNav');
playlistsNav.innerHTML = '';
if (allPlaylists.length === 0) {
playlistsNav.innerHTML = `
${pattern.replace('.thr', '').split('/').pop()}
`; const previewContainer = card.querySelector('.pattern-preview'); const addBtn = card.querySelector('.absolute.top-2'); // Only set preview image if already available in memory cache const previewData = previewCache.get(pattern); if (previewData && !previewData.error && previewData.image_data) { previewContainer.innerHTML = `