// 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
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 = `
No playlists found
`;
return;
}
allPlaylists.forEach(playlist => {
const playlistItem = document.createElement('a');
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';
playlistItem.innerHTML = `
queue_music
${playlist}
chevron_right
`;
playlistItem.addEventListener('click', () => selectPlaylist(playlist, playlistItem));
playlistsNav.appendChild(playlistItem);
});
}
// Select a playlist
async function selectPlaylist(playlistName, element) {
// Remove active state from all playlist items
document.querySelectorAll('#playlistsNav a').forEach(item => {
item.classList.remove('text-gray-900', 'dark:text-gray-100', 'bg-gray-100', 'dark:bg-gray-700', 'font-semibold');
item.classList.add('text-gray-700', 'dark:text-gray-300', 'font-medium');
});
// Add active state to selected item
element.classList.remove('text-gray-700', 'dark:text-gray-300', 'font-medium');
element.classList.add('text-gray-900', 'dark:text-gray-100', 'bg-gray-100', 'dark:bg-gray-700', 'font-semibold');
// Update current playlist
currentPlaylist = playlistName;
// Update header with playlist name and delete button
const header = document.getElementById('currentPlaylistTitle');
header.innerHTML = `
${playlistName}
`;
// Add delete button event listener
document.getElementById('deletePlaylistBtn').addEventListener('click', () => deletePlaylist(playlistName));
// Enable buttons
document.getElementById('addPatternsBtn').disabled = false;
document.getElementById('runPlaylistBtn').disabled = false;
// Save last selected
saveLastSelectedPlaylist(playlistName);
// Show playlist details on mobile
showPlaylistDetails();
// Load playlist patterns
await loadPlaylistPatterns(playlistName);
}
// Load patterns for selected playlist
async function loadPlaylistPatterns(playlistName) {
try {
const response = await fetch(`/get_playlist?name=${encodeURIComponent(playlistName)}`);
if (response.ok) {
const playlistData = await response.json();
displayPlaylistPatterns(playlistData.files || []);
// Show playback settings
document.getElementById('playbackSettings').classList.remove('hidden');
} else {
throw new Error('Failed to load playlist patterns');
}
} catch (error) {
logMessage(`Error loading playlist patterns: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to load playlist patterns', 'error');
}
}
// Display patterns in the current playlist
async function displayPlaylistPatterns(patterns) {
const patternsGrid = document.getElementById('patternsGrid');
if (patterns.length === 0) {
patternsGrid.innerHTML = `
No patterns in this playlist
`;
return;
}
// No more pre-loading - all patterns will use lazy loading
patternsGrid.innerHTML = '';
patterns.forEach(pattern => {
const patternCard = createPatternCard(pattern, true);
patternsGrid.appendChild(patternCard);
// Set up lazy loading for ALL patterns
patternCard.dataset.pattern = pattern;
intersectionObserver.observe(patternCard);
});
}
// Create a pattern card
function createPatternCard(pattern, showRemove = false) {
const card = document.createElement('div');
card.className = 'flex flex-col gap-3 group cursor-pointer relative';
const previewContainer = document.createElement('div');
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';
// Only set preview image if already available in memory cache
const previewData = previewCache.get(pattern);
if (previewData && !previewData.error && previewData.image_data) {
previewContainer.innerHTML = `
`;
}
const patternName = document.createElement('p');
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';
patternName.textContent = pattern.replace('.thr', '').split('/').pop();
card.appendChild(previewContainer);
card.appendChild(patternName);
if (showRemove) {
const removeBtn = document.createElement('button');
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';
removeBtn.innerHTML = 'close';
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
removePatternFromPlaylist(pattern);
});
card.appendChild(removeBtn);
}
return card;
}
// Search and filter patterns
function searchPatterns(query) {
const normalizedQuery = query.toLowerCase().trim();
if (!normalizedQuery) {
filteredPatterns = [...availablePatterns];
} else {
filteredPatterns = availablePatterns.filter(pattern => {
const patternName = pattern.replace('.thr', '').split('/').pop().toLowerCase();
return patternName.includes(normalizedQuery);
});
}
displayAvailablePatterns();
}
// Handle search input
function handleSearchInput() {
const searchInput = document.getElementById('patternSearchInput');
const clearBtn = document.getElementById('clearSearchBtn');
const query = searchInput.value;
// Show/hide clear button
if (query) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
// Debounce search
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
searchPatterns(query);
}, 300);
}
// Clear search
function clearSearch() {
const searchInput = document.getElementById('patternSearchInput');
const clearBtn = document.getElementById('clearSearchBtn');
searchInput.value = '';
clearBtn.classList.add('hidden');
searchPatterns('');
}
// Remove pattern from playlist
async function removePatternFromPlaylist(pattern) {
if (!currentPlaylist) return;
if (confirm(`Remove "${pattern.split('/').pop()}" from playlist?`)) {
try {
// Get current playlist data
const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
if (response.ok) {
const playlistData = await response.json();
const updatedFiles = playlistData.files.filter(file => file !== pattern);
// Update playlist
const updateResponse = await fetch('/modify_playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: currentPlaylist,
files: updatedFiles
})
});
if (updateResponse.ok) {
showStatusMessage('Pattern removed from playlist', 'success');
await loadPlaylistPatterns(currentPlaylist);
} else {
throw new Error('Failed to update playlist');
}
}
} catch (error) {
logMessage(`Error removing pattern: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to remove pattern', 'error');
}
}
}
// Load available patterns for adding (no caching)
async function loadAvailablePatterns(forceRefresh = false) {
const loadingIndicator = document.getElementById('patternsLoadingIndicator');
const grid = document.getElementById('availablePatternsGrid');
const noResultsMessage = document.getElementById('noResultsMessage');
// Always fetch from backend
loadingIndicator.classList.remove('hidden');
grid.classList.add('hidden');
noResultsMessage.classList.add('hidden');
try {
logMessage('Fetching fresh patterns list from server', LOG_TYPE.DEBUG);
const response = await fetch('/list_theta_rho_files');
if (response.ok) {
const patterns = await response.json();
const thrPatterns = patterns.filter(file => file.endsWith('.thr'));
availablePatterns = [...thrPatterns];
filteredPatterns = [...availablePatterns];
// Show patterns immediately - all lazy loading now
displayAvailablePatterns();
if (forceRefresh) {
showStatusMessage('Patterns list refreshed successfully', 'success');
}
} else {
throw new Error('Failed to load available patterns');
}
} catch (error) {
logMessage(`Error loading available patterns: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to load available patterns', 'error');
} finally {
loadingIndicator.classList.add('hidden');
}
}
// Display available patterns in modal
function displayAvailablePatterns() {
const grid = document.getElementById('availablePatternsGrid');
const noResultsMessage = document.getElementById('noResultsMessage');
grid.classList.remove('hidden');
noResultsMessage.classList.add('hidden');
grid.innerHTML = '';
if (filteredPatterns.length === 0) {
grid.classList.add('hidden');
noResultsMessage.classList.remove('hidden');
return;
}
filteredPatterns.forEach((pattern, index) => {
const card = document.createElement('div');
card.className = 'flex flex-col gap-2 cursor-pointer transition-all duration-150 hover:scale-105';
card.dataset.pattern = pattern;
card.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 = `
`;
// Re-add the add button
const addBtnContainer = document.createElement('div');
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';
addBtnContainer.innerHTML = 'add';
previewContainer.appendChild(addBtnContainer);
}
// Set up lazy loading for ALL patterns
intersectionObserver.observe(card);
// Handle selection
card.addEventListener('click', () => {
if (selectedPatterns.has(pattern)) {
selectedPatterns.delete(pattern);
card.classList.remove('ring-2', 'ring-blue-500');
addBtn.classList.remove('opacity-100', 'bg-blue-500', 'text-white');
addBtn.classList.add('opacity-0', 'bg-white', 'dark:bg-gray-700');
addBtn.querySelector('.material-icons').textContent = 'add';
} else {
selectedPatterns.add(pattern);
card.classList.add('ring-2', 'ring-blue-500');
addBtn.classList.remove('opacity-0', 'bg-white', 'dark:bg-gray-700');
addBtn.classList.add('opacity-100', 'bg-blue-500', 'text-white');
addBtn.querySelector('.material-icons').textContent = 'check';
}
});
// Show add button on hover
card.addEventListener('mouseenter', () => {
if (!selectedPatterns.has(pattern)) {
addBtn.classList.remove('opacity-0');
addBtn.classList.add('opacity-100');
}
});
card.addEventListener('mouseleave', () => {
if (!selectedPatterns.has(pattern)) {
addBtn.classList.remove('opacity-100');
addBtn.classList.add('opacity-0');
}
});
grid.appendChild(card);
});
// Trigger preview loading for visible patterns after displaying
triggerPreviewLoadingForVisible();
}
// Trigger preview loading for currently visible patterns
function triggerPreviewLoadingForVisible() {
// Get all pattern cards currently in the DOM
const patternCards = document.querySelectorAll('[data-pattern]');
patternCards.forEach(card => {
const pattern = card.dataset.pattern;
const previewContainer = card.querySelector('.pattern-preview');
// Check if this pattern needs preview loading
if (pattern && !previewCache.has(pattern) && !pendingPatterns.has(pattern)) {
// Add to batch for immediate loading
addPatternToBatch(pattern, previewContainer);
}
});
// Process any pending previews immediately
if (pendingPatterns.size > 0) {
processPendingBatch();
}
}
// Add pattern to pending batch for efficient loading
async function addPatternToBatch(pattern, element) {
// Check in-memory cache first
if (previewCache.has(pattern)) {
const previewData = previewCache.get(pattern);
if (previewData && !previewData.error) {
if (element) {
updatePreviewElement(element, previewData.image_data);
}
}
return;
}
// Check IndexedDB cache
const cachedData = await getPreviewFromCache(pattern);
if (cachedData && !cachedData.error) {
// Add to in-memory cache for faster access
previewCache.set(pattern, cachedData);
if (element) {
updatePreviewElement(element, cachedData.image_data);
}
return;
}
// Add loading indicator with better styling
if (element && !element.querySelector('img')) {
element.innerHTML = `
`;
}
// Add to pending batch
pendingPatterns.set(pattern, element);
// Process batch immediately if it's full
if (pendingPatterns.size >= BATCH_SIZE) {
processPendingBatch();
}
}
// Update preview element with image
function updatePreviewElement(element, imageData) {
if (element) {
element.innerHTML = `
`;
// Re-add the add button if it exists in the parent card
const card = element.closest('[data-pattern]');
if (card && !selectedPatterns.has(card.dataset.pattern)) {
const addBtnContainer = document.createElement('div');
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';
addBtnContainer.innerHTML = 'add';
element.appendChild(addBtnContainer);
}
}
}
// Add selected patterns to playlist
async function addSelectedPatternsToPlaylist() {
if (selectedPatterns.size === 0 || !currentPlaylist) return;
try {
// Get current playlist data
const response = await fetch(`/get_playlist?name=${encodeURIComponent(currentPlaylist)}`);
if (response.ok) {
const playlistData = await response.json();
const currentFiles = playlistData.files || [];
const newFiles = Array.from(selectedPatterns).filter(pattern => !currentFiles.includes(pattern));
const updatedFiles = [...currentFiles, ...newFiles];
// Update playlist
const updateResponse = await fetch('/modify_playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: currentPlaylist,
files: updatedFiles
})
});
if (updateResponse.ok) {
showStatusMessage(`Added ${newFiles.length} patterns to playlist`, 'success');
selectedPatterns.clear();
document.getElementById('addPatternsModal').classList.add('hidden');
await loadPlaylistPatterns(currentPlaylist);
} else {
throw new Error('Failed to update playlist');
}
}
} catch (error) {
logMessage(`Error adding patterns: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to add patterns', 'error');
}
}
// Run playlist
async function runPlaylist() {
if (!currentPlaylist) return;
const runMode = document.querySelector('input[name="run_playlist"]:checked')?.value || 'single';
const pauseTime = parseInt(document.getElementById('pauseTimeInput').value) || 0;
const clearPattern = document.getElementById('clearPatternSelect').value;
const shuffle = document.getElementById('shuffleCheckbox')?.checked || false;
try {
const response = await fetch('/run_playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: currentPlaylist,
run_mode: runMode,
pause_time: pauseTime,
clear_pattern: clearPattern === 'none' ? null : clearPattern,
shuffle: shuffle
})
});
if (response.ok) {
showStatusMessage(`Started playlist: ${currentPlaylist}`, 'success');
// Show the preview modal
try {
if (window.openPlayerPreviewModal) {
window.openPlayerPreviewModal();
}
} catch (e) {}
} else {
let errorMsg = 'Failed to run playlist';
let errorType = 'error';
try {
const data = await response.json();
if (data.detail) {
errorMsg = data.detail;
// Handle specific error cases with appropriate messaging
if (data.detail === 'Connection not established') {
errorMsg = 'Please connect to the device before running a playlist';
errorType = 'warning';
} else if (response.status === 409) {
errorMsg = 'Another pattern is already running. Please stop the current pattern first.';
errorType = 'warning';
} else if (response.status === 404) {
errorMsg = 'Playlist not found. Please refresh the page and try again.';
errorType = 'error';
}
}
} catch (e) {
// If we can't parse the JSON, use status-based messaging
if (response.status === 400) {
errorMsg = 'Invalid request. Please check your settings and try again.';
} else if (response.status === 500) {
errorMsg = 'Server error. Please try again later.';
}
}
showStatusMessage(errorMsg, errorType);
}
} catch (error) {
logMessage(`Error running playlist: ${error.message}`, LOG_TYPE.ERROR);
// Handle network errors specifically
if (error.name === 'TypeError' && error.message.includes('fetch')) {
showStatusMessage('Network error. Please check your connection and try again.', 'error');
} else {
showStatusMessage('Failed to run playlist', 'error');
}
}
}
// Create new playlist
async function createNewPlaylist() {
const playlistName = document.getElementById('newPlaylistName').value.trim();
if (!playlistName) {
showStatusMessage('Please enter a playlist name', 'warning');
return;
}
try {
const response = await fetch('/create_playlist', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName,
files: []
})
});
if (response.ok) {
showStatusMessage('Playlist created successfully', 'success');
document.getElementById('addPlaylistModal').classList.add('hidden');
document.getElementById('newPlaylistName').value = '';
await loadPlaylists();
} else {
const data = await response.json();
throw new Error(data.detail || 'Failed to create playlist');
}
} catch (error) {
logMessage(`Error creating playlist: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to create playlist', 'error');
}
}
// Delete playlist
async function deletePlaylist(playlistName) {
if (!confirm(`Are you sure you want to delete the playlist "${playlistName}"?`)) {
return;
}
try {
const response = await fetch('/delete_playlist', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
playlist_name: playlistName
})
});
if (response.ok) {
showStatusMessage(`Playlist "${playlistName}" deleted successfully`, 'success');
// If the deleted playlist was selected, clear the selection
if (currentPlaylist === playlistName) {
currentPlaylist = null;
const header = document.getElementById('currentPlaylistTitle');
header.innerHTML = 'Select a Playlist
';
document.getElementById('addPatternsBtn').disabled = true;
document.getElementById('runPlaylistBtn').disabled = true;
document.getElementById('playbackSettings').classList.add('hidden');
document.getElementById('patternsGrid').innerHTML = `
Select a playlist to view its patterns
`;
// Return to playlists list on mobile
showPlaylistsList();
}
// Reload playlists
await loadPlaylists();
} else {
const data = await response.json();
throw new Error(data.detail || 'Failed to delete playlist');
}
} catch (error) {
logMessage(`Error deleting playlist: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to delete playlist', 'error');
}
}
// Setup event listeners
function setupEventListeners() {
// Mobile back button event listeners
document.getElementById('mobileBackBtn').addEventListener('click', () => {
showPlaylistsList();
});
// Add playlist button
document.getElementById('addPlaylistBtn').addEventListener('click', () => {
const modal = document.getElementById('addPlaylistModal');
const input = document.getElementById('newPlaylistName');
// Show modal first
modal.classList.remove('hidden');
// Focus handling
const focusInput = () => {
if (input) {
input.focus();
input.select();
}
};
// Try multiple approaches to ensure focus
focusInput();
requestAnimationFrame(focusInput);
setTimeout(focusInput, 50);
setTimeout(focusInput, 100);
});
// Add patterns button
document.getElementById('addPatternsBtn').addEventListener('click', async () => {
await loadAvailablePatterns();
document.getElementById('addPatternsModal').classList.remove('hidden');
// Focus search input when modal opens
setTimeout(() => {
document.getElementById('patternSearchInput').focus();
}, 100);
});
// Search functionality
document.getElementById('patternSearchInput').addEventListener('input', handleSearchInput);
document.getElementById('clearSearchBtn').addEventListener('click', clearSearch);
// Handle Enter key in search input
document.getElementById('patternSearchInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
}
});
// Run playlist button
document.getElementById('runPlaylistBtn').addEventListener('click', runPlaylist);
// Modal controls
document.getElementById('cancelPlaylistBtn').addEventListener('click', () => {
document.getElementById('addPlaylistModal').classList.add('hidden');
});
document.getElementById('createPlaylistBtn').addEventListener('click', createNewPlaylist);
document.getElementById('cancelAddPatternsBtn').addEventListener('click', () => {
selectedPatterns.clear();
clearSearch();
document.getElementById('addPatternsModal').classList.add('hidden');
});
document.getElementById('confirmAddPatternsBtn').addEventListener('click', addSelectedPatternsToPlaylist);
// Handle Enter key in new playlist name input
document.getElementById('newPlaylistName').addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
createNewPlaylist();
}
});
// Close modals when clicking outside
document.getElementById('addPlaylistModal').addEventListener('click', (e) => {
if (e.target.id === 'addPlaylistModal') {
document.getElementById('addPlaylistModal').classList.add('hidden');
}
});
document.getElementById('addPatternsModal').addEventListener('click', (e) => {
if (e.target.id === 'addPatternsModal') {
selectedPatterns.clear();
clearSearch();
document.getElementById('addPatternsModal').classList.add('hidden');
}
});
}
// Initialize playlists page
document.addEventListener('DOMContentLoaded', async () => {
try {
// Initialize intersection observer for lazy loading
initializeIntersectionObserver();
// Initialize IndexedDB preview cache
await initPreviewCacheDB();
// Setup event listeners
setupEventListeners();
// Initialize mobile view state
isMobileView = isMobile();
if (isMobileView) {
initMobileLayout();
} else {
initDesktopLayout();
}
// Add window resize listener for responsive behavior
window.addEventListener('resize', updateMobileView);
// Restore playback settings
restorePlaybackSettings();
setupPlaybackSettingsPersistence();
// Load playlists
await loadPlaylists();
// Check serial connection status
await checkSerialStatus();
logMessage('Playlists page initialized successfully', LOG_TYPE.SUCCESS);
} catch (error) {
logMessage(`Error during initialization: ${error.message}`, LOG_TYPE.ERROR);
showStatusMessage('Failed to initialize playlists page', 'error');
}
});
// Check serial connection status
async function checkSerialStatus() {
try {
const response = await fetch('/serial_status');
if (response.ok) {
const data = await response.json();
const statusDot = document.getElementById('connectionStatusDot');
if (statusDot) {
statusDot.className = `inline-block size-2 rounded-full ml-2 align-middle ${
data.connected ? 'bg-green-500' : 'bg-red-500'
}`;
}
}
} catch (error) {
logMessage(`Error checking serial status: ${error.message}`, LOG_TYPE.ERROR);
}
}
// Mobile utility functions
function isMobile() {
return window.innerWidth <= 768;
}
function updateMobileView() {
const wasMobile = isMobileView;
isMobileView = isMobile();
if (wasMobile !== isMobileView) {
// Mobile state changed, update layout
if (isMobileView) {
initMobileLayout();
} else {
initDesktopLayout();
}
}
}
function initMobileLayout() {
const sidebar = document.getElementById('playlistsSidebar');
const details = document.getElementById('playlistDetails');
const mobileBackBtn = document.getElementById('mobileBackBtn');
if (!currentPlaylist) {
// Show playlists list, hide details
sidebar.classList.remove('mobile-hidden');
details.classList.add('mobile-hidden');
mobileBackBtn.classList.add('mobile-hidden');
} else {
// Show details, hide playlists list
sidebar.classList.add('mobile-hidden');
details.classList.remove('mobile-hidden');
mobileBackBtn.classList.remove('mobile-hidden');
mobileBackBtn.classList.add('mobile-flex');
}
}
function initDesktopLayout() {
const sidebar = document.getElementById('playlistsSidebar');
const details = document.getElementById('playlistDetails');
const mobileBackBtn = document.getElementById('mobileBackBtn');
// Show both sidebar and details on desktop
sidebar.classList.remove('mobile-hidden');
details.classList.remove('mobile-hidden');
mobileBackBtn.classList.add('mobile-hidden');
mobileBackBtn.classList.remove('mobile-flex');
}
function showPlaylistDetails() {
if (isMobileView) {
const sidebar = document.getElementById('playlistsSidebar');
const details = document.getElementById('playlistDetails');
const mobileBackBtn = document.getElementById('mobileBackBtn');
sidebar.classList.add('mobile-hidden');
details.classList.remove('mobile-hidden');
mobileBackBtn.classList.remove('mobile-hidden');
mobileBackBtn.classList.add('mobile-flex');
}
}
function showPlaylistsList() {
if (isMobileView) {
const sidebar = document.getElementById('playlistsSidebar');
const details = document.getElementById('playlistDetails');
const mobileBackBtn = document.getElementById('mobileBackBtn');
sidebar.classList.remove('mobile-hidden');
details.classList.add('mobile-hidden');
mobileBackBtn.classList.add('mobile-hidden');
mobileBackBtn.classList.remove('mobile-flex');
}
}