From 57ea2674542915f51da312eeeb4fcc036b66e7de Mon Sep 17 00:00:00 2001 From: Hawk Lee Date: Mon, 7 Jul 2025 19:16:46 +0800 Subject: [PATCH] feat(browser): Enhance UI/UX and fix multiple bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces a major overhaul to the AIIA Browser, addressing several UI/UX issues and bugs to create a more robust and intuitive experience. Key changes include: 1. **Fix List View Button Rendering**: Resolved a race condition where the "Load Workflow" button wouldn't appear in list view. The browser now waits for all metadata to be loaded before the initial render, ensuring UI consistency. 2. **Fix Workflow Name on Load**: Correctly sets the workflow name in the ComfyUI title bar upon loading by using the appropriate API for both standard and API-formatted workflows. 3. **Fix Icon Sizing Slider**: The icon size slider now updates the view instantly and correctly by triggering a full, clean re-render of the icon view, preventing display artifacts and state conflicts. 4. **Implement Directory Refresh**: - Added a manual refresh button to the navigation bar. - The browser now automatically refreshes the directory content when switching between icon and list views. This ensures that newly created or deleted files are correctly reflected in the UI. 5. **Refactor Icon Button Layout**: The download and load workflow buttons in the icon view are now a hover-activated overlay at the top of the thumbnail, improving the visual layout. 6. **Backend Enhancements**: Includes various improvements to the Python backend for more robust thumbnail generation and metadata extraction. --- feat(浏览器): 全面优化UI/UX并修复多个Bug 本次提交对AIIA浏览器进行了一次重大的更新,解决了一系列UI/UX问题和Bug,旨在提供一个更稳定、更直观的用户体验。 主要变更包括: 1. **修复列表视图按钮渲染问题**: 解决了一个因竞态条件导致的“加载工作流”按钮在列表视图中不显示的Bug。浏览器现在会等待所有元数据加载完毕后才进行初次渲染,确保了UI的一致性。 2. **修复加载工作流时的名称设置**: 通过为标准格式和API格式的工作流调用正确的API,现在加载工作流后能正确地在ComfyUI标题栏设置其名称。 3. **修复图标缩���滑块**: 图标大小滑块现在可以通过触发一次完整的、干净的视图重绘来即时、正确地更新视图,避免了显示错位和状态冲突。 4. **实现目录刷新功能**: - 在导航栏添加了手动刷新按钮。 - 现在当在图标和列表视图之间切换时,浏览器会自动刷新目录内容,确保新建或删除的文件能被正确地反映在界面上。 5. **重构图标按钮布局**: 图标视图中的下载和加载工作流按钮现在改为在鼠标悬停时,在缩略图顶部显示为一个浮层,优化了视觉布局。 6. **后端增强**: 包含对Python后端的若干改进,使缩略图生成和元数据提取更加健壮。 --- aiia_browser_node.py | 93 +++- js/aiia_browser.js | 972 ++++++++++++++++++++++++++++++++++++-- js/aiia_browser_styles.js | 159 ++++++- 3 files changed, 1152 insertions(+), 72 deletions(-) diff --git a/aiia_browser_node.py b/aiia_browser_node.py index 5b27a99..d2c0330 100644 --- a/aiia_browser_node.py +++ b/aiia_browser_node.py @@ -75,7 +75,10 @@ async def _get_file_metadata(file_path: Path): try: if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}: with Image.open(file_path) as img: + img.load() # Force loading of metadata chunks metadata["width"], metadata["height"] = img.size + if 'prompt' in img.info or 'workflow' in img.info: + metadata['has_workflow'] = True elif ext in {'.mp4', '.mov', '.avi', '.wav', '.mp3', '.ogg'}: if not shutil.which("ffprobe"): return metadata command = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", str(file_path)] @@ -146,7 +149,7 @@ async def get_thumbnail(request): cached_thumb_path = cache_sub_dir / f"{Path(filename).stem}.jpg" if cached_thumb_path.exists() and cached_thumb_path.stat().st_mtime > original_file_path.stat().st_mtime: - return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg'}) + return web.FileResponse(cached_thumb_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'}) with Image.open(original_file_path) as img: img = ImageOps.exif_transpose(img) @@ -164,7 +167,7 @@ async def get_thumbnail(request): asyncio.create_task(write_cache()) - return web.Response(body=buffer.getvalue(), content_type='image/jpeg') + return web.Response(body=buffer.getvalue(), content_type='image/jpeg', headers={'Cache-Control': 'public, must-revalidate'}) except Exception as e: traceback.print_exc() @@ -180,45 +183,49 @@ async def get_video_poster(request): try: original_file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename)) - if not original_file_path.is_file(): return web.Response(status=404, text="File not found") + if not original_file_path.is_file() or original_file_path.stat().st_size == 0: + return web.Response(status=404, text="File not found or is empty") cache_sub_dir = get_safe_path(video_poster_dir, relative_path_str) cache_sub_dir.mkdir(parents=True, exist_ok=True) cached_poster_path = cache_sub_dir / f"{Path(filename).stem}.jpg" - if cached_poster_path.exists() and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime: - return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'}) + if cached_poster_path.exists(): + # If the cached file is valid and up-to-date, serve it. + if cached_poster_path.stat().st_size > 0 and cached_poster_path.stat().st_mtime > original_file_path.stat().st_mtime: + return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'}) + else: + # Delete invalid (0-byte) or outdated cache files. + print(f"--- [AIIA Browser Debug] Deleting invalid or outdated cache file: {cached_poster_path}") + os.remove(cached_poster_path) - # 使用临时文件确保 ffmpeg 操作的原子性 with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmpfile: tmp_poster_path = tmpfile.name - + + # Use the 'thumbnail' filter with the 'update' flag, which is robust for both + # normal videos and special cases like single-frame videos. command = [ "ffmpeg", - "-ss", "00:00:01.00", "-i", str(original_file_path), + "-vf", "thumbnail,scale=256:-1:force_original_aspect_ratio=decrease", "-frames:v", "1", - "-vf", "thumbnail,scale=256:-1", - "-y", # Overwrite output file if it exists + "-update", "1", + "-q:v", "3", + "-y", str(tmp_poster_path) ] + proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) - stdout, stderr = await proc.communicate() + _, stderr = await proc.communicate() - if proc.returncode != 0: + if proc.returncode != 0 or not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0: if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path) - print(f"[AIIA] FFmpeg error for {filename}: {stderr.decode()}") - return web.Response(status=500, text="Failed to extract frame from video.") + print(f"[AIIA] FFmpeg failed to extract thumbnail for {filename}. Error: {stderr.decode()}") + return web.Response(status=500, text="Failed to extract thumbnail from video.") - # 检查生成的文件是否有效 - if not os.path.exists(tmp_poster_path) or os.path.getsize(tmp_poster_path) == 0: - if os.path.exists(tmp_poster_path): os.remove(tmp_poster_path) - return web.Response(status=500, text="FFmpeg produced an empty file.") - - # 原子性地移动/重命名临时文件到缓存位置 shutil.move(tmp_poster_path, cached_poster_path) + return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg', 'Cache-Control': 'public, must-revalidate'}) - return web.FileResponse(cached_poster_path, headers={'Content-Type': 'image/jpeg'}) except Exception as e: if 'tmp_poster_path' in locals() and os.path.exists(tmp_poster_path): os.remove(tmp_poster_path) @@ -245,10 +252,54 @@ async def get_batch_metadata(request): traceback.print_exc() return web.Response(status=500, text=str(e)) +import math + +def replace_nan_with_null(obj): + """ + Recursively traverses a Python object (dicts, lists) and replaces + float('nan') with None, which serializes to JSON 'null'. + """ + if isinstance(obj, dict): + return {k: replace_nan_with_null(v) for k, v in obj.items()} + elif isinstance(obj, list): + return [replace_nan_with_null(elem) for elem in obj] + # The core of the fix: only replace actual float NaN values + elif isinstance(obj, float) and math.isnan(obj): + return None + else: + return obj + +async def get_workflow(request): + relative_path_str = request.query.get("path", "") + filename = request.query.get("filename", "") + if not filename: + return web.Response(status=400, text="Filename is required.") + + try: + file_path = get_safe_path(output_dir, os.path.join(relative_path_str, filename)) + if not file_path.is_file(): + return web.Response(status=404, text="File not found") + + with Image.open(file_path) as img: + # Prioritize the full 'workflow' data if it exists, otherwise fall back to 'prompt'. + workflow_data_str = img.info.get('workflow') or img.info.get('prompt') + + if workflow_data_str: + workflow_obj = json.loads(workflow_data_str) + sanitized_workflow_obj = replace_nan_with_null(workflow_obj) + return web.json_response(sanitized_workflow_obj) + else: + return web.Response(status=404, text="No workflow data found in the image.") + + except Exception as e: + traceback.print_exc() + return web.Response(status=500, text=str(e)) + server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/list_items', list_items) server.PromptServer.instance.app.router.add_post('/api/aiia/v1/browser/get_batch_metadata', get_batch_metadata) server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/thumbnail', get_thumbnail) server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/poster', get_video_poster) +server.PromptServer.instance.app.router.add_get('/api/aiia/v1/browser/get_workflow', get_workflow) NODE_CLASS_MAPPINGS = {} NODE_DISPLAY_NAME_MAPPINGS = {} diff --git a/js/aiia_browser.js b/js/aiia_browser.js index 087cce3..70f9e82 100644 --- a/js/aiia_browser.js +++ b/js/aiia_browser.js @@ -1,4 +1,4 @@ -// 文件: aiia_browser.js (V75 - 设置面板终极修复) +// 文件: aiia_browser.js (V82 - 虚拟滚动引擎重构) import { app } from "/scripts/app.js"; import { api } from "/scripts/api.js"; @@ -24,7 +24,7 @@ function setSetting(key, value) { } catch (e) { console.error(`[AIIA] Error setting localStorage for key ${key}:`, e); } } -const CACHE_PREFIX = "aiia-cache:"; +const CACHE_PREFIX = "aiia-cache:v2:"; function getCacheKey(path, filename) { return `${CACHE_PREFIX}${path ? `${path}/` : ''}${filename}`; } function getCache(key) { try { const data = localStorage.getItem(key); return data ? JSON.parse(data) : null; } catch (e) { console.error(`[AIIA] Error parsing cache for key ${key}:`, e); return null; } } function setCache(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.error(`[AIIA] Error setting cache for key ${key}:`, e); } } @@ -46,6 +46,8 @@ class AIIABrowserDialog extends ComfyDialog { this.itemsData = []; this.itemsDataMap = new Map(); this.isNavigating = false; + this.lastActionClickTimestamp = 0; + this.downloadLock = new Set(); this.viewMode = getSetting('viewMode', 'icons'); this.iconSize = getSetting('iconSize', 120); @@ -55,10 +57,6 @@ class AIIABrowserDialog extends ComfyDialog { this.enableVideoPreviews = getSetting('enableVideoPreviews', false); this.metadataLoader = { controller: null, isLoading: false, path: null, isPaused: false }; - this.loadQueue = new Set(); - this.isProcessingQueue = false; - this.scrollDebounceHandle = null; - this.isScrolling = false; this.lastMouseEvent = null; this.isTooltipVisible = false; @@ -72,8 +70,12 @@ class AIIABrowserDialog extends ComfyDialog { this.tooltipItemName = null; this.isKeyboardNavigating = false; this.lastFocus = { dir: -1, file: -1 }; - this.virtualList = { viewport: null, content: null, scroller: null, rowHeight: 31, buffer: 10, lastRendered: { start: -1, end: -1 }, scrollDebounce: null }; + + this.virtualList = { viewport: null, content: null, scroller: null, buffer: 10, lastRendered: { start: -1, end: -1 }, renderRaf: null }; + this.listView = { rowHeight: 31, activeNodes: new Map(), nodePool: [] }; + this.iconView = { resizeObserver: null, columns: 0, rowHeight: 0, activeNodes: new Map(), nodePool: [], mediaCache: new Map() }; this.applyFocusRaf = null; + this.iconLoadTimers = new Map(); this.tooltipImage = $el("img.aiia-tooltip-image"); this.tooltipVideo = $el("video.aiia-tooltip-video", { autoplay: true, muted: true, loop: true, controls: false, volume: 0.8 }); @@ -96,12 +98,13 @@ class AIIABrowserDialog extends ComfyDialog { const titleBar = $el("div.aiia-browser-titlebar", [this.titleElement, this.closeButton]); this.backButton = $el("button.aiia-browser-nav-button", { textContent: "◀️", disabled: true, title: "Back" }); this.upButton = $el("button.aiia-browser-nav-button", { textContent: "🔼", disabled: true, title: "Up to parent directory" }); + this.refreshButton = $el("button.aiia-browser-nav-button", { textContent: "🔄", title: "Refresh current directory" }); this.breadcrumbs = $el("div.aiia-browser-breadcrumbs"); this.pathInputContainer = $el("div.aiia-browser-path-input-container"); this.pathPrefix = $el("span.aiia-browser-path-prefix", { textContent: "output / " }); this.pathInput = $el("input.aiia-browser-path-input", { type: "text", dataset: { escapeToList: "true" } }); this.pathInputContainer.append(this.pathPrefix, this.pathInput); - const navContainer = $el("div.aiia-browser-nav-container", [this.backButton, this.upButton, this.breadcrumbs, this.pathInputContainer]); + const navContainer = $el("div.aiia-browser-nav-container", [this.backButton, this.upButton, this.refreshButton, this.breadcrumbs, this.pathInputContainer]); this.hideFoldersCheckbox = $el("input", { type: "checkbox", id: "aiia-hide-folders-cb", checked: this.hideFolders }); const hideFoldersLabel = $el("label.aiia-settings-label", { htmlFor: "aiia-hide-folders-cb" }, [this.hideFoldersCheckbox, $el("span", {textContent: "Hide Folders"})]); @@ -137,7 +140,7 @@ class AIIABrowserDialog extends ComfyDialog { this.bindEvents(); this.applySettings(); this.updateSortControls(); - this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); if(this.viewMode === 'list') this.renderVisibleRows(); }); + this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); }); this.resizeObserver.observe(this.contentPanel); } @@ -153,6 +156,7 @@ class AIIABrowserDialog extends ComfyDialog { this.closeButton.onclick = () => this.close(); this.backButton.onclick = () => this.goBack(); this.upButton.onclick = () => this.goUp(); + this.refreshButton.onclick = () => this.refreshCurrentDirectory(); this.breadcrumbs.addEventListener('click', (e) => { this.showPathInput(); e.stopPropagation(); }); this.pathInput.onkeydown = (e) => { if (e.key === 'Enter') { this.navigateTo(this.pathInput.value.trim()); this.hidePathInput(); } else if (e.key === 'Escape') { this.hidePathInput(); } }; this.pathInput.onblur = () => this.hidePathInput(); @@ -199,25 +203,129 @@ class AIIABrowserDialog extends ComfyDialog { this.sortDirButton.onclick = () => this.handleSortChange(this.sortKey, this.sortDir === 'asc' ? 'desc' : 'asc'); this.iconViewButton.onclick = () => this.setViewMode('icons'); this.listViewButton.onclick = () => this.setViewMode('list'); - this.sizeSlider.oninput = (e) => { this.iconSize = e.target.value; setSetting('iconSize', this.iconSize); document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`); }; + this.sizeSlider.oninput = (e) => { + this.iconSize = e.target.value; + setSetting('iconSize', this.iconSize); + document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`); + if (this.viewMode === 'icons') { + // A full re-render is the most robust way to handle the size change, + // as it correctly re-initializes the virtual scroller and all its states, + // mimicking a view switch. + this.render(); + } + }; const splitViewContainer = this.element.querySelector('.aiia-split-view-container'); splitViewContainer.addEventListener('mouseover', this.handleTooltipMouseOver); splitViewContainer.addEventListener('mouseout', this.handleTooltipMouseOut); splitViewContainer.addEventListener('mousemove', (e) => { if (this.isKeyboardNavigating) this.isKeyboardNavigating = false; this.lastMouseEvent = e; if (this.isTooltipVisible) this.updateTooltipPosition(e); }); - this.element.addEventListener('dblclick', (e) => { const itemEl = e.target.closest('[data-item-name]'); if (itemEl) { const item = this.itemsDataMap.get(itemEl.dataset.itemName); if(item && item.type === 'file' && item.size > 0) this.openFullscreen(item.name); } }); + this.contentArea.addEventListener('click', (e) => { + const button = e.target.closest('.aiia-load-workflow-button, .aiia-download-button'); + if (button) { + e.stopPropagation(); + e.preventDefault(); + this.lastActionClickTimestamp = Date.now(); + const itemName = button.closest('[data-item-name]').dataset.itemName; + + if (button.classList.contains('aiia-load-workflow-button')) { + this.handleLoadWorkflow(itemName); + } else if (button.classList.contains('aiia-download-button')) { + if (this.downloadLock.has(itemName)) { + console.log(`[AIIA] Download for ${itemName} is already in progress or recently triggered. Ignoring.`); + return; + } + this.downloadLock.add(itemName); + const url = getFileUrl(itemName, this.currentPath); + const link = $el('a', { href: url, download: itemName }); + link.click(); + // Remove lock after 2 seconds + setTimeout(() => { + this.downloadLock.delete(itemName); + }, 2000); + } + } + }); + + this.element.addEventListener('dblclick', (e) => { + if (Date.now() - this.lastActionClickTimestamp < 300) { + return; + } + const itemEl = e.target.closest('[data-item-name]'); + if (itemEl) { + const item = this.itemsDataMap.get(itemEl.dataset.itemName); + if(item && item.type === 'file' && item.size > 0) { + this.openFullscreen(item.name); + } + } + }); this.element.addEventListener('keydown', this.handleKeyDown.bind(this)); } openFullscreen(itemName) { if (!this.fullscreenViewer) { this.fullscreenViewer = new AIIAFullscreenViewer(); } const filesOnly = this.itemsData.filter(i => i.type === 'file' && i.size > 0); this.sortItems(filesOnly); const currentIndex = filesOnly.findIndex(item => item.name === itemName); if (currentIndex !== -1) { this.fullscreenViewer.show(filesOnly, currentIndex, this.currentPath, () => { this.element.focus({ preventScroll: true }); }); } } + + async handleLoadWorkflow(itemName) { + if (confirm(`Load workflow from "${itemName}"? This will replace your current workflow.`)) { + try { + const res = await fetch(window.location.origin + `/api/aiia/v1/browser/get_workflow?path=${encodeURIComponent(this.currentPath)}&filename=${encodeURIComponent(itemName)}`); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${await res.text()}`); + } + const data = await res.json(); + + // The response can be in two formats: a direct workflow JSON, + // or an object containing the workflow under a 'workflow' key. + const workflowData = data.workflow || data; + + // The ComfyUI API can return a graph in two formats. We need to detect which one we have. + // The API format is a flat object where keys are node IDs and values have `class_type`. + // The standard format has a `nodes` array. + const isApiFormat = workflowData && !workflowData.nodes && Object.values(workflowData).every(v => v.class_type); + + if (isApiFormat) { + // Handle API format: loadApiJson(apiData, fileName) + app.loadApiJson(workflowData, itemName); + } else { + // Handle standard graph format: loadGraphData(graphData, clean, restore_view, workflowName) + app.loadGraphData(workflowData, true, true, itemName); + } + + this.close(); + } catch (e) { + alert(`Error loading workflow: ${e.message}`); + console.error(e); + } + } + } updateTooltipSizeLimits() { const rect = this.contentPanel.getBoundingClientRect(); const maxSize = Math.min(rect.width, rect.height) * 0.30; this.tooltipElement.style.setProperty('--aiia-tooltip-media-max-size', `${maxSize}px`); } updateSortControls() { this.sortKeySelect.value = this.sortKey; this.sortDirButton.textContent = this.sortDir === 'asc' ? '▲' : '▼'; this.sortDirButton.dataset.tooltipText = `Sort ${this.sortDir === 'asc' ? 'Descending' : 'Ascending'}`; } showPathInput() { this.breadcrumbs.style.display = 'none'; this.pathInputContainer.style.display = 'flex'; this.pathInput.value = this.currentPath; this.pathInput.focus(); this.pathInput.select(); } hidePathInput() { this.pathInputContainer.style.display = 'none'; this.breadcrumbs.style.display = 'flex'; if (!this.element.contains(document.activeElement)) { this.element.focus({ preventScroll: true }); } } - hideTooltip() { clearTimeout(this.tooltipTimeout); if (this.isTooltipVisible) { this.tooltipElement.style.display = 'none'; this.isTooltipVisible = false; this.tooltipItemName = null; this.tooltipImage.src = ""; this.tooltipVideo.src = ""; this.tooltipAudio.src = ""; if (this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; } } + hideTooltip() { + clearTimeout(this.tooltipTimeout); + if (this.isTooltipVisible) { + this.tooltipElement.style.display = 'none'; + this.isTooltipVisible = false; + this.tooltipItemName = null; + + this.tooltipVideo.pause(); + this.tooltipAudio.pause(); + this.tooltipImage.src = ""; + this.tooltipVideo.src = ""; + this.tooltipAudio.src = ""; + + // Detach the audio element if it was added + if (this.tooltipAudio.parentNode) { + this.tooltipAudio.parentNode.removeChild(this.tooltipAudio); + } + + if (this.metadataLoader.isPaused) { + this.metadataLoader.isPaused = false; + } + } + } setFocus(item, element, scrollIntoView = true, trigger = 'mouse') { if(this.applyFocusRaf) cancelAnimationFrame(this.applyFocusRaf); @@ -225,7 +333,10 @@ class AIIABrowserDialog extends ComfyDialog { this.hideTooltip(); const oldEl = this.element.querySelector('.focused'); - if (oldEl) oldEl.classList.remove('focused'); + if (oldEl) { + oldEl.classList.remove('focused'); + oldEl.classList.remove('actions-visible'); + } if (!item) { this.currentFocusIndex = -1; return; } @@ -272,29 +383,768 @@ class AIIABrowserDialog extends ComfyDialog { updateTooltipPosition(event) { if (!event) return; const xOffset = 20, yOffset = 20; const tooltipRect = this.tooltipElement.getBoundingClientRect(); const containerRect = this.element.querySelector('.aiia-split-view-container').getBoundingClientRect(); const mouseX_inContainer = event.clientX - containerRect.left, mouseY_inContainer = event.clientY - containerRect.top; let targetX = mouseX_inContainer + xOffset; if (targetX + tooltipRect.width > containerRect.width) targetX = mouseX_inContainer - tooltipRect.width - xOffset; if (targetX < 0) targetX = 0; let targetY = mouseY_inContainer + yOffset; if (targetY + tooltipRect.height > containerRect.height) targetY = mouseY_inContainer - tooltipRect.height - yOffset; if (targetY < 0) targetY = 0; this.tooltipElement.style.left = `${targetX}px`; this.tooltipElement.style.top = `${targetY}px`; } showTooltip(event) { this.tooltipElement.style.display = 'flex'; this.tooltipElement.style.visibility = 'hidden'; requestAnimationFrame(() => { this.updateTooltipPosition(event); this.tooltipElement.style.visibility = 'visible'; this.isTooltipVisible = true; }); } - updateTooltipContent(itemData, event) { this.tooltipItemName = itemData.name; this.tooltipMetadata.innerHTML = ''; const createMetaRow = (label, value) => { if (value === undefined || value === null || value === '—' || value === '') return; const row = $el("div.aiia-tooltip-row", [$el("span.aiia-tooltip-label", { textContent: `${label}:` }), $el("span.aiia-tooltip-value", { textContent: value })]); this.tooltipMetadata.appendChild(row); return row.querySelector('.aiia-tooltip-value'); }; createMetaRow("Name", itemData.name); const onMediaLoad = () => { if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; if (this.tooltipItemName !== itemData.name) return; this.showTooltip(event); this.tooltipImage.onload = null; this.tooltipImage.onerror = null; this.tooltipVideo.onloadedmetadata = null; this.tooltipVideo.onerror = null; this.tooltipAudio.onloadedmetadata = null; this.tooltipAudio.onerror = null; }; const onMediaError = (e) => { if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; onMediaLoad(); }; const mime = getMimeType(itemData.name); if (this.viewMode === 'list' && this.metadataLoader.isLoading && (mime === 'image' || mime === 'video')) this.metadataLoader.isPaused = true; if (itemData.type === 'directory') { this.tooltipMediaContainer.style.display = 'none'; createMetaRow("Date", formatDate(itemData.mtime)); createMetaRow("Type", "Folder"); createMetaRow("Contains", `${itemData.item_count} items`); this.showTooltip(event); } else { if (itemData.size === 0) { this.tooltipMediaContainer.style.display = 'none'; createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); createMetaRow("Size", formatBytes(0)); createMetaRow("Status", "Empty File 🚫"); this.showTooltip(event); return; } createMetaRow("Date", formatDate(itemData.mtime)); createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); createMetaRow("Size", formatBytes(itemData.size)); this.tooltipImage.style.display = 'none'; this.tooltipVideo.style.display = 'none'; if (mime === 'image') { this.tooltipMediaContainer.style.display = 'block'; const url = getThumbnailUrl(itemData.name, this.currentPath, itemData.mtime); if (itemData.width && itemData.height) createMetaRow("Dimensions", `${itemData.width} x ${itemData.height}`); this.tooltipImage.onload = onMediaLoad; this.tooltipImage.onerror = onMediaError; this.tooltipImage.src = url; this.tooltipImage.style.display = 'block'; } else if (mime === 'video') { this.tooltipMediaContainer.style.display = 'block'; const url = getFileUrl(itemData.name, this.currentPath); const dimText = itemData.width && itemData.height ? `${itemData.width} x ${itemData.height}` : '...'; const durText = itemData.duration != null ? formatDuration(itemData.duration) : '...'; const dimEl = createMetaRow("Dimensions", dimText); const durEl = createMetaRow("Duration", durText); this.tooltipVideo.onloadedmetadata = () => { if (dimEl) dimEl.textContent = `${this.tooltipVideo.videoWidth} x ${this.tooltipVideo.videoHeight}`; if (durEl) durEl.textContent = formatDuration(this.tooltipVideo.duration); onMediaLoad(); }; this.tooltipVideo.onerror = onMediaError; this.tooltipVideo.src = url; this.tooltipVideo.style.display = 'block'; this.tooltipImage.style.display = 'none'; } else if (mime === 'audio') { this.tooltipMediaContainer.style.display = 'none'; const url = getFileUrl(itemData.name, this.currentPath); const durText = itemData.duration != null ? formatDuration(itemData.duration) : '...'; const durationEl = createMetaRow("Duration", durText); this.tooltipAudio.onloadedmetadata = () => { if (durationEl) durationEl.textContent = formatDuration(this.tooltipAudio.duration); this.showTooltip(event); }; this.tooltipAudio.onerror = onMediaError; this.tooltipAudio.src = url; } else { this.tooltipMediaContainer.style.display = 'none'; this.showTooltip(event); } } } - async navigateTo(path) { if (this.isNavigating || this.currentPath === path) return; this.isNavigating = true; if (this.metadataLoader.isLoading) this.metadataLoader.controller.abort(); this.lastFocus = { dir: -1, file: -1 }; this.history.push(path); this.currentPath = path; this.contentArea.innerHTML = '
Loading...
'; this.directoryList.innerHTML = '
...
'; try { const response = await api.fetchApi(`/aiia/v1/browser/list_items?path=${encodeURIComponent(path)}`); if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`); const data = await response.json(); this.itemsData = [...data.directories, ...data.files]; this.render(); } catch (error) { this.contentArea.innerHTML = `
Error: ${error.message}
`; } finally { this.isNavigating = false; } } + updateTooltipContent(itemData, event) { + this.tooltipItemName = itemData.name; + this.tooltipMetadata.innerHTML = ''; + const createMetaRow = (label, value) => { if (value === undefined || value === null || value === '—' || value === '') return; const row = $el("div.aiia-tooltip-row", [$el("span.aiia-tooltip-label", { textContent: `${label}:` }), $el("span.aiia-tooltip-value", { textContent: value })]); this.tooltipMetadata.appendChild(row); return row.querySelector('.aiia-tooltip-value'); }; + createMetaRow("Name", itemData.name); + + const onMediaLoad = () => { + if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; + if (this.tooltipItemName !== itemData.name) return; + this.showTooltip(event); + this.tooltipImage.onload = null; + this.tooltipImage.onerror = null; + this.tooltipVideo.onloadedmetadata = null; + this.tooltipVideo.onerror = null; + this.tooltipAudio.onloadedmetadata = null; + this.tooltipAudio.onerror = null; + }; + + const onMediaError = (e) => { + if(this.metadataLoader.isPaused) this.metadataLoader.isPaused = false; + e.target.style.display = 'none'; // Hide the broken element + onMediaLoad(); // Still show the tooltip with metadata + }; + + const mime = getMimeType(itemData.name); + if (this.viewMode === 'list' && this.metadataLoader.isLoading && (mime === 'image' || mime === 'video')) this.metadataLoader.isPaused = true; + + // Reset media elements + this.tooltipMediaContainer.style.display = 'block'; + this.tooltipImage.style.display = 'none'; + this.tooltipVideo.style.display = 'none'; + if (this.tooltipAudio.parentNode) this.tooltipAudio.parentNode.removeChild(this.tooltipAudio); + this.tooltipAudio.removeAttribute('controls'); + + + if (itemData.type === 'directory') { + this.tooltipMediaContainer.style.display = 'none'; + createMetaRow("Date", formatDate(itemData.mtime)); + createMetaRow("Type", "Folder"); + createMetaRow("Contains", `${itemData.item_count} items`); + this.showTooltip(event); + } else { + if (itemData.size === 0) { + this.tooltipMediaContainer.style.display = 'none'; + createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); + createMetaRow("Size", formatBytes(0)); + createMetaRow("Status", "Empty File 🚫"); + this.showTooltip(event); + return; + } + createMetaRow("Date", formatDate(itemData.mtime)); + createMetaRow("Type", itemData.extension.replace('.', '').toUpperCase()); + createMetaRow("Size", formatBytes(itemData.size)); + + // Re-add the persistent elements to the container + this.tooltipMediaContainer.replaceChildren(this.tooltipImage, this.tooltipVideo); + + if (mime === 'image') { + this.tooltipImage.src = getFileUrl(itemData.name, this.currentPath); + this.tooltipImage.style.display = 'block'; + this.tooltipImage.onload = onMediaLoad; + this.tooltipImage.onerror = onMediaError; + } else if (mime === 'video') { + this.tooltipVideo.src = getFileUrl(itemData.name, this.currentPath); + this.tooltipVideo.style.display = 'block'; + this.tooltipVideo.onloadedmetadata = onMediaLoad; + this.tooltipVideo.onerror = onMediaError; + } else if (mime === 'audio') { + this.tooltipMediaContainer.style.display = 'none'; + this.tooltipAudio.src = getFileUrl(itemData.name, this.currentPath); + this.tooltipAudio.setAttribute('controls', 'true'); + this.tooltipMetadata.insertBefore(this.tooltipAudio, this.tooltipMetadata.firstChild); // Prepend audio player + this.tooltipAudio.onloadedmetadata = onMediaLoad; + this.tooltipAudio.onerror = onMediaError; + } else { + this.tooltipMediaContainer.style.display = 'none'; + this.showTooltip(event); + } + + const cachedMeta = getCache(getCacheKey(this.currentPath, itemData.name)); + if (cachedMeta && cachedMeta.metadata) { + if (cachedMeta.metadata.width) createMetaRow("Dimensions", `${cachedMeta.metadata.width} x ${cachedMeta.metadata.height}`); + if (cachedMeta.metadata.duration) createMetaRow("Duration", formatDuration(cachedMeta.metadata.duration)); + if (cachedMeta.metadata.has_workflow) createMetaRow("Workflow", "Yes"); + } else { + if (itemData.width) createMetaRow("Dimensions", `${itemData.width} x ${itemData.height}`); + if (itemData.duration) createMetaRow("Duration", formatDuration(itemData.duration)); + if (itemData.has_workflow) createMetaRow("Workflow", "Yes"); + } + } + } + + async navigateTo(path, isRefresh = false) { + if (this.isNavigating) return; + if (!isRefresh && this.currentPath === path) return; + + this.isNavigating = true; + if (this.metadataLoader.isLoading) this.metadataLoader.controller.abort(); + + if (!isRefresh) { + this.lastFocus = { dir: -1, file: -1 }; + this.history.push(path); + } + this.currentPath = path; + this.contentArea.innerHTML = '
Loading...
'; + this.directoryList.innerHTML = '
...
'; + + try { + const response = await api.fetchApi(`/aiia/v1/browser/list_items?path=${encodeURIComponent(path)}`); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`); + + this.itemsData = await response.json().then(data => [...data.directories, ...data.files]); + + // Wait for all metadata to load before the initial render. + // This prevents race conditions and ensures the view is always consistent. + await this.startMetadataBatchLoading(); + + this.render(); + + } catch (error) { + this.contentArea.innerHTML = `
Error: ${error.message}
`; + } finally { + this.isNavigating = false; + } + } + + refreshCurrentDirectory() { + if (this.isNavigating) return; + log(`Refreshing directory: ${this.currentPath}`); + this.navigateTo(this.currentPath, true); + } + goBack() { if (this.history.length > 1) { this.history.pop(); const path = this.history.pop(); this.navigateTo(path); } } + goUp() { if (this.currentPath) { const parentPath = this.currentPath.substring(0, this.currentPath.lastIndexOf('/')); this.navigateTo(parentPath); } } - render() { this.backButton.disabled = this.history.length <= 1; this.upButton.disabled = !this.currentPath; this.loadQueue.clear(); clearTimeout(this.scrollDebounceHandle); this.isProcessingQueue = false; this.isScrolling = false; this.iconViewObserver.disconnect(); this.applySettings(); this.updateSortControls(); this.itemsDataMap.clear(); for (const item of this.itemsData) this.itemsDataMap.set(item.name, item); this.updateBreadcrumbs(this.currentPath); const directories = this.itemsData.filter(i => i.type === 'directory'); const files = this.itemsData.filter(i => i.type !== 'directory'); if (this.hideFolders) { this.sortItems(files); this.directoryItems = []; this.renderDirectoryPanel([]); this.renderContentPanel(files); } else { this.sortItems(directories); this.sortItems(files); this.directoryItems = directories; this.renderDirectoryPanel(directories); this.renderContentPanel(files); } this.startMetadataBatchLoading(); } + + render() { + this.backButton.disabled = this.history.length <= 1; + this.upButton.disabled = !this.currentPath; + if (this.virtualList.renderRaf) { + cancelAnimationFrame(this.virtualList.renderRaf); + this.virtualList.renderRaf = null; + } + this.iconViewObserver.disconnect(); + this.applySettings(); + this.updateSortControls(); + + this.itemsDataMap.clear(); + for (const item of this.itemsData) this.itemsDataMap.set(item.name, item); + + this.updateBreadcrumbs(this.currentPath); + + const directories = this.itemsData.filter(i => i.type === 'directory'); + const files = this.itemsData.filter(i => i.type !== 'directory'); + + if (this.hideFolders) { + this.sortItems(files); + this.directoryItems = []; + this.renderDirectoryPanel([]); + this.renderContentPanel(files); + } else { + this.sortItems(directories); + this.sortItems(files); + this.directoryItems = directories; + this.renderDirectoryPanel(directories); + this.renderContentPanel(files); + } + } sortItems(items) { const key = this.sortKey; const dir = this.sortDir === 'asc' ? 1 : -1; items.sort((a, b) => { let valA, valB; if (key === 'type') { valA = a.extension || ''; valB = b.extension || ''; } else if (key === 'dimensions') { valA = (a.width || 0) * (a.height || 0); valB = (b.width || 0) * (b.height || 0); } else if (key === 'duration') { valA = a.duration ?? -1; valB = b.duration ?? -1; } else { valA = a[key]; valB = b[key]; } let comparison = 0; if (typeof valA === 'string' && typeof valB === 'string') { let strA = valA, strB = valB; if (key === 'name') { strA = valA.toLowerCase(); strB = valB.toLowerCase(); } comparison = strA.localeCompare(strB, undefined, { numeric: true }); } else if (typeof valA === 'number' && typeof valB === 'number') { comparison = valA - valB; } return comparison * dir; }); } handleSortChange(newSortKey, newSortDir) { this.sortKey = newSortKey; this.sortDir = newSortDir; setSetting('sortKey', this.sortKey); setSetting('sortDir', this.sortDir); this.render(); } updateBreadcrumbs(path) { this.breadcrumbs.innerHTML = ''; const rootLink = $el('span.aiia-browser-breadcrumb-link', { textContent: 'output', dataset: { path: '' } }); rootLink.onclick = (e) => { e.stopPropagation(); this.navigateTo(''); }; this.breadcrumbs.appendChild(rootLink); if (path) { let cumulativePath = ''; path.split('/').filter(p => p).forEach(part => { cumulativePath += (cumulativePath ? '/' : '') + part; this.breadcrumbs.appendChild($el('span', { textContent: ' / ' })); const link = $el('span.aiia-browser-breadcrumb-link', { textContent: part, dataset: { path: cumulativePath } }); link.onclick = (e) => { e.stopPropagation(); this.navigateTo(link.dataset.path); }; this.breadcrumbs.appendChild(link); }); } this.hidePathInput(); } - setViewMode(mode) { if (this.viewMode === mode) return; this.viewMode = mode; setSetting('viewMode', this.viewMode); this.render(); } + setViewMode(mode) { + if (this.viewMode === mode) return; + this.viewMode = mode; + setSetting('viewMode', this.viewMode); + this.refreshCurrentDirectory(); + } renderDirectoryPanel(directories) { this.directoryList.innerHTML = ""; if (directories.length === 0 && !this.hideFolders) { this.directoryList.appendChild($el("div.aiia-browser-placeholder", { style: { fontSize: "12px", padding: "10px" }, textContent: "No sub-folders." })); return; } directories.forEach(dir => { const dirEl = $el("div.aiia-directory-item", { dataset: { itemName: dir.name }, textContent: dir.name, onclick: () => this.navigateTo(this.currentPath ? `${this.currentPath}/${dir.name}` : dir.name) }); this.directoryList.appendChild(dirEl); }); } - renderContentPanel(files) { this.virtualList.lastRendered = { start: -1, end: -1 }; log("[Virtual List] State reset."); this.contentArea.innerHTML = ''; this.visibleItems = files; this.setFocus(null, null); if (this.viewMode === 'icons') this.renderIconsView(files); else this.renderListView(files); } - renderIconsView(files) { if (files.length === 0) { this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); return; } const gridInnerWrapper = $el('div.aiia-grid-inner-wrapper'); const gridWrapper = $el('div.aiia-grid-wrapper', [gridInnerWrapper]); gridWrapper.onscroll = () => { this.isScrolling = true; clearTimeout(this.scrollDebounceHandle); this.scrollDebounceHandle = setTimeout(() => { this.isScrolling = false; log("[Scroll] Scroll ended. Triggering queue processing."); this.processIconQueue(); }, 250); }; const createIcon = (item) => { const preview = $el('div.aiia-icon-preview.media-placeholder', { dataset: { filename: item.name, path: this.currentPath, mtime: item.mtime } }); const itemIcon = $el('div.aiia-item.aiia-item-icon', { dataset: { itemName: item.name } }, [ preview, $el('div.aiia-item-label', { textContent: item.name }) ]); return itemIcon; }; files.forEach(file => gridInnerWrapper.appendChild(createIcon(file))); this.contentArea.appendChild(gridWrapper); const placeholdersToObserve = gridWrapper.querySelectorAll('.media-placeholder'); for (const placeholder of placeholdersToObserve) this.iconViewObserver.observe(placeholder); } - renderListView(files) { if (files.length === 0) { this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); return; } const handleHeaderClick = (e) => { const cell = e.target.closest('.aiia-list-header-cell'); if (!cell || (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(cell.dataset.sort))) return; const key = cell.dataset.sort; if (!key) return; this.handleSortChange(key, this.sortKey === key ? (this.sortDir === 'asc' ? 'desc' : 'asc') : 'asc'); }; const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; const headerContainer = $el("div.aiia-list-header", { onclick: handleHeaderClick }); this.updateListHeader(headerContainer, headers); const totalHeight = files.length * this.virtualList.rowHeight; log(`[Virtual List] Initializing. Items: ${files.length}, RowHeight: ${this.virtualList.rowHeight}px, TotalHeight: ${totalHeight}px`); this.virtualList.scroller = $el("div", { style: { height: `${totalHeight}px`, position: 'relative', width: '100%' } }); this.virtualList.content = $el("div.aiia-list-body-content", { style: { position: 'absolute', top: 0, left: 0, width: '100%' }}); this.virtualList.viewport = $el("div.aiia-list-body", [this.virtualList.scroller, this.virtualList.content]); this.virtualList.viewport.onscroll = () => { clearTimeout(this.virtualList.scrollDebounce); this.virtualList.scrollDebounce = setTimeout(() => this.renderVisibleRows(), 16); }; const container = $el("div.aiia-list-container", [headerContainer, this.virtualList.viewport]); this.contentArea.innerHTML = ''; this.contentArea.appendChild(container); this.renderVisibleRows(); } - updateListHeader(headerContainer, headers) { headerContainer.replaceChildren($el("div.aiia-list-header-cell"), ...headers.map(h => { let headerText = h.text; if (h.key === this.sortKey && !(this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key))) headerText += this.sortDir === 'asc' ? ' ▲' : ' ▼'; const cell = $el("div.aiia-list-header-cell", { textContent: headerText, dataset: { sort: h.key } }); if (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key)) { cell.style.opacity = 0.5; cell.style.cursor = 'not-allowed'; } return cell; })); } - renderVisibleRows() { if (!this.virtualList.viewport || !this.visibleItems) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight, buffer } = this.virtualList; const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); const endIndex = Math.min(this.visibleItems.length - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); log(`[Virtual List] Rendering rows. ScrollTop: ${scrollTop.toFixed(0)}, Visible range: ${startIndex} - ${endIndex}`); const fragment = document.createDocumentFragment(); for(let i = startIndex; i <= endIndex; i++) { const item = this.visibleItems[i]; if (!item) continue; const row = $el("div.aiia-list-row", { dataset: { itemName: item.name }, style: { position: 'absolute', top: `${i * rowHeight}px`, height: `${rowHeight}px`, width: '100%', boxSizing: 'border-box', }}); const globalIndex = this.directoryItems.length + i; if(globalIndex === this.currentFocusIndex) row.classList.add('focused'); const mime = getMimeType(item.name); let icon = '📄'; if (mime === 'image') icon = '🖼️'; else if (mime === 'video') icon = '🎬'; else if (mime === 'audio') icon = '🎵'; let dimText = item.width && item.height ? `${item.width} x ${item.height}` : '—'; let durText = item.duration != null ? formatDuration(item.duration) : '—'; row.append( $el("div.aiia-list-cell-icon", { textContent: icon }), $el("div", { textContent: item.name }), $el("div", { textContent: formatDate(item.mtime) }), $el("div", { textContent: item.extension.replace('.','') }), $el("div", { textContent: formatBytes(item.size) }), $el("div", { textContent: dimText, dataset: { field: 'dimensions' } }), $el("div", { textContent: durText, dataset: { field: 'duration' } }) ); fragment.appendChild(row); } this.virtualList.content.replaceChildren(fragment); } - getAllNavigableItems() { const directories = this.hideFolders ? [] : this.directoryItems; return [...directories, ...this.visibleItems]; } - async startMetadataBatchLoading() { if (this.metadataLoader.isLoading) return; const filesToProcess = this.itemsData.filter(item => { const mime = getMimeType(item.name); const needsMeta = (mime === 'video' || mime === 'audio' || mime === 'image'); return item.type === 'file' && needsMeta && (!item.hasOwnProperty('width') || !item.hasOwnProperty('duration')); }); const filesToFetch = []; for (const item of filesToProcess) { const cacheKey = getCacheKey(this.currentPath, item.name); const cachedData = getCache(cacheKey); if (cachedData && cachedData.mtime === item.mtime && cachedData.size === item.size) Object.assign(item, cachedData.metadata); else filesToFetch.push(item); } this.renderVisibleRows(); if (filesToFetch.length === 0) return; this.metadataLoader.controller = new AbortController(); this.metadataLoader.isLoading = true; this.metadataLoader.path = this.currentPath; const headerContainer = this.element.querySelector('.aiia-list-header'); if (headerContainer) { const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; this.updateListHeader(headerContainer, headers); } const total = filesToFetch.length; let loaded = 0; this.updateProgressBar(loaded, total); const BATCH_SIZE = 50; for (let i = 0; i < filesToFetch.length; i += BATCH_SIZE) { if (this.metadataLoader.controller.signal.aborted) break; while (this.metadataLoader.isPaused) { await new Promise(resolve => setTimeout(resolve, 200)); if (this.metadataLoader.controller.signal.aborted) break; } if (this.metadataLoader.controller.signal.aborted) break; const batch = filesToFetch.slice(i, i + BATCH_SIZE); const filenames = batch.map(f => f.name); try { const response = await api.fetchApi('/aiia/v1/browser/get_batch_metadata', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: this.currentPath, filenames }), signal: this.metadataLoader.controller.signal }); if (!response.ok) continue; const metadataBatch = await response.json(); for (const filename in metadataBatch) { const item = this.itemsDataMap.get(filename); if (item) { Object.assign(item, metadataBatch[filename]); this.updateListItem(item); const cacheKey = getCacheKey(this.currentPath, item.name); setCache(cacheKey, { mtime: item.mtime, size: item.size, metadata: metadataBatch[filename] }); } } loaded += batch.length; this.updateProgressBar(loaded, total); } catch (err) { if (err.name === 'AbortError') break; console.error('[AIIA] Batch metadata fetch failed:', err); } } this.metadataLoader.isLoading = false; this.updateProgressBar(total, total, true); } - updateProgressBar(loaded, total, finished = false) { if (!this.progressContainer) return; if (total === 0 || loaded >= total || finished) { this.progressContainer.style.display = 'none'; if(finished) { this.metadataLoader.isLoading = false; const headerContainer = this.element.querySelector('.aiia-list-header'); if(headerContainer) { const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; this.updateListHeader(headerContainer, headers); } this.renderVisibleRows(); } return; } this.progressContainer.style.display = 'flex'; const percent = (loaded / total) * 100; this.progressBar.firstChild.style.width = `${percent}%`; this.progressText.textContent = `Analyzing metadata... (${loaded}/${total})`; } - updateListItem(item) { const row = this.element.querySelector(`.aiia-list-row[data-item-name="${CSS.escape(item.name)}"]`); if (!row) return; const dimCell = row.querySelector('[data-field="dimensions"]'); const durCell = row.querySelector('[data-field="duration"]'); if (dimCell && item.width && item.height) { dimCell.textContent = `${item.width} x ${item.height}`; } if (durCell && item.duration != null) { durCell.textContent = formatDuration(item.duration); } } - handleIconIntersection(entries) { let needsProcessing = false; for (const entry of entries) { const placeholder = entry.target; if (entry.isIntersecting) { if (!this.loadQueue.has(placeholder)) { this.loadQueue.add(placeholder); needsProcessing = true; } } else { if (this.loadQueue.has(placeholder)) { this.loadQueue.delete(placeholder); } } } if (needsProcessing && !this.isScrolling) this.processIconQueue(); } - processIconQueue() { if (this.isProcessingQueue || this.loadQueue.size === 0) return; const viewport = this.contentArea.querySelector('.aiia-grid-wrapper'); if (!viewport) { this.isProcessingQueue = false; this.loadQueue.clear(); return; } this.isProcessingQueue = true; const viewportRect = viewport.getBoundingClientRect(); const visiblePlaceholders = []; const preloadPlaceholders = []; for (const placeholder of this.loadQueue) { const rect = placeholder.getBoundingClientRect(); if (rect.top < viewportRect.bottom && rect.bottom > viewportRect.top) visiblePlaceholders.push(placeholder); else preloadPlaceholders.push(placeholder); } const sortedQueue = [...visiblePlaceholders, ...preloadPlaceholders]; const batchSize = 8; const itemsToLoad = sortedQueue.slice(0, batchSize); for(const placeholder of itemsToLoad) { this.loadQueue.delete(placeholder); this.iconViewObserver.unobserve(placeholder); const filename = placeholder.dataset.filename; if (!filename) continue; const fileData = this.itemsDataMap.get(filename); if (fileData && fileData.size === 0) { const emptyFileEl = $el("div.aiia-empty-file-placeholder", [ $el("span", { textContent: "🚫" }), $el("span", { textContent: "Empty File" }) ]); placeholder.replaceChildren(emptyFileEl); continue; } const path = placeholder.dataset.path; const mtime = placeholder.dataset.mtime; const mime = getMimeType(filename); let mediaElement; if (mime === 'image') { const url = getThumbnailUrl(filename, path, mtime); mediaElement = $el("img.aiia-image-preview", { src: url }); } else if (mime === 'video') { const url = this.enableVideoPreviews ? getFileUrl(filename, path) : getVideoPosterUrl(filename, path, mtime); if(this.enableVideoPreviews) { mediaElement = $el("video.aiia-video-preview", { src: url, autoplay: true, muted: true, loop: true, playsinline: true }); } else { mediaElement = $el("img.aiia-image-preview", { src: url }); placeholder.classList.add("aiia-video-poster-container"); } } else if (mime === 'audio') { const url = getFileUrl(filename, path); const audio = $el("audio", { src: url, controls: true }); const durationEl = $el("div.aiia-audio-duration", { textContent: "..." }); audio.onloadedmetadata = () => { durationEl.textContent = formatDuration(audio.duration); }; mediaElement = $el("div.aiia-audio-container", [audio, durationEl]); } else { mediaElement = $el("div.aiia-empty-file-placeholder", [ $el("span", { textContent: "?" }), $el("span", { textContent: "Unknown" }) ]); } /* --- START OF FIX: Use replaceChildren to keep the container --- */ placeholder.replaceChildren(mediaElement); /* --- END OF FIX --- */ } this.isProcessingQueue = false; if (this.loadQueue.size > 0 && !this.isScrolling) requestAnimationFrame(() => this.processIconQueue()); } - ensureFocusedItemVisible() { const item = this.getAllNavigableItems()[this.currentFocusIndex]; if (!item) return; if (item.type === 'directory') { const el = this.element.querySelector(`.aiia-directory-item[data-item-name="${CSS.escape(item.name)}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else { if (this.viewMode === 'list' && this.virtualList.viewport) { const fileIndex = this.visibleItems.findIndex(f => f.name === item.name); if (fileIndex === -1) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight } = this.virtualList; const itemTop = fileIndex * rowHeight, itemBottom = itemTop + rowHeight; if (itemTop < scrollTop) this.virtualList.viewport.scrollTop = itemTop; else if (itemBottom > scrollTop + clientHeight) this.virtualList.viewport.scrollTop = itemBottom - clientHeight; } else if (this.viewMode === 'icons') { const el = this.element.querySelector(`.aiia-item-icon[data-item-name="${CSS.escape(item.name)}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } } + renderContentPanel(files) { + this.virtualList.lastRendered = { start: -1, end: -1 }; + log("[Virtual List] State reset."); + this.contentArea.innerHTML = ''; + this.visibleItems = files; + this.setFocus(null, null); + + if (this.iconView.resizeObserver) { + this.iconView.resizeObserver.disconnect(); + this.iconView.resizeObserver = null; + } + + this.iconView.activeNodes.forEach(node => this.iconView.nodePool.push(node)); + this.iconView.activeNodes.clear(); + this.listView.activeNodes.forEach(node => this.listView.nodePool.push(node)); + this.listView.activeNodes.clear(); + + if (this.viewMode === 'icons') { + this.renderIconsView(files); + } else { + this.renderListView(files); + } + } + + renderIconsView(files) { + if (files.length === 0) { + this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); + return; + } + + // By wrapping the entire setup in a setTimeout, we push it to the next event + // loop cycle. This ensures the browser has finished painting the dialog and the + // container dimensions are stable before any layout calculations are performed. + // This is a robust way to solve race conditions. + setTimeout(() => { + if (this.viewMode !== 'icons') return; // In case the view changed while waiting + + this.virtualList.lastRendered = { start: -1, end: -1 }; + + const scroller = $el("div.aiia-virtual-scroller"); + this.virtualList.scroller = scroller; + + const content = $el("div.aiia-grid-inner-wrapper", { style: { position: 'relative' }}); + this.virtualList.content = content; + + const viewport = $el("div.aiia-grid-wrapper", [scroller, content]); + this.virtualList.viewport = viewport; + + this.iconView.resizeObserver = new ResizeObserver(() => { + this.updateIconViewGeometry(files); + this.renderVisibleIcons(files, true); + }); + this.iconView.resizeObserver.observe(viewport); + + viewport.onscroll = () => { + if (this.virtualList.renderRaf) cancelAnimationFrame(this.virtualList.renderRaf); + this.virtualList.renderRaf = requestAnimationFrame(() => this.renderVisibleIcons(files)); + }; + + this.contentArea.replaceChildren(viewport); // Use replaceChildren to be safe + }, 0); + } + + updateIconViewGeometry(files) { + if (!this.virtualList.viewport) return; + const { clientWidth } = this.virtualList.viewport; + + // Defensive guard against erroneous zero-width calculations during layout shifts. + if (clientWidth === 0) return; + + const iconSize = Number(this.iconSize); + const minMargin = 10; + this.iconView.columns = Math.max(1, Math.floor(clientWidth / (iconSize + minMargin))); + + this.iconView.rowHeight = iconSize + 40; + + const totalIconWidth = this.iconView.columns * iconSize; + const totalMarginWidth = clientWidth - totalIconWidth; + this.iconView.actualMargin = this.iconView.columns > 1 ? totalMarginWidth / (this.iconView.columns - 1) : 0; + + const totalRows = Math.ceil(files.length / this.iconView.columns); + const totalHeight = totalRows * this.iconView.rowHeight; + this.virtualList.scroller.style.height = `${totalHeight}px`; + } + + renderVisibleIcons(files, force = false) { + if (!this.virtualList.viewport || !files || !this.iconView.columns) return; + + const { scrollTop, clientHeight } = this.virtualList.viewport; + const { rowHeight, columns, colWidth } = this.iconView; + const { buffer } = this.virtualList; + + const totalRows = Math.ceil(files.length / columns); + const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); + const lastVisibleRow = Math.min(totalRows - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); + + if (!force && firstVisibleRow === this.virtualList.lastRendered.start && lastVisibleRow === this.virtualList.lastRendered.end) { + return; + } + + const firstItem = firstVisibleRow * columns; + const lastItem = Math.min(files.length - 1, ((lastVisibleRow + 1) * columns) - 1); + + const newVisibleNames = new Set(); + for(let i = firstItem; i <= lastItem; i++) { + if(files[i]) newVisibleNames.add(files[i].name); + } + + // Recycle nodes that are no longer visible + for (const [name, node] of this.iconView.activeNodes.entries()) { + if (!newVisibleNames.has(name)) { + if (this.iconLoadTimers.has(name)) { + clearTimeout(this.iconLoadTimers.get(name)); + this.iconLoadTimers.delete(name); + } + const mediaContainer = node.querySelector('.aiia-icon-preview'); + if(mediaContainer) this.iconViewObserver.unobserve(mediaContainer); + node.remove(); + this.iconView.nodePool.push(node); + this.iconView.activeNodes.delete(name); + } + } + + // Render new visible items + for (let i = firstItem; i <= lastItem; i++) { + const item = files[i]; + if (!item || this.iconView.activeNodes.has(item.name)) continue; + + const node = this.iconView.nodePool.pop() || (() => { + const newNode = $el('div.aiia-item-icon', { style: { position: 'absolute' }}); + const preview = $el('div.aiia-icon-preview'); + const buttonOverlay = $el('div.aiia-icon-button-overlay'); + const label = $el('div.aiia-item-label'); + newNode.replaceChildren(preview, buttonOverlay, label); + return newNode; + })(); + + // Cleanup and setup + const preview = node.querySelector('.aiia-icon-preview'); + preview.innerHTML = ''; + preview.className = 'aiia-icon-preview'; + + // Positioning + const row = Math.floor(i / columns); + const col = i % columns; + node.style.top = `${row * rowHeight}px`; + node.style.left = `${col * (Number(this.iconSize) + this.iconView.actualMargin)}px`; + node.style.width = `${this.iconSize}px`; + node.style.height = `${this.iconView.rowHeight}px`; + + // Content + node.dataset.itemName = item.name; + const label = node.querySelector('.aiia-item-label'); + label.textContent = item.name; + label.classList.toggle('has-workflow', !!item.has_workflow); + + let cachedEntry = this.iconView.mediaCache.get(item.name); + + // Invalidate cache if mtime is different + if (cachedEntry && cachedEntry.mtime !== item.mtime) { + cachedEntry = null; + this.iconView.mediaCache.delete(item.name); + } + + if (cachedEntry) { + const mediaElement = cachedEntry.element.cloneNode(true); + preview.appendChild(mediaElement); + if (mediaElement.classList.contains('aiia-video-poster-container-flag')) { + preview.classList.add("aiia-video-poster-container"); + } + } else { + preview.classList.add('media-placeholder'); + preview.dataset.filename = item.name; + preview.dataset.path = this.currentPath; + preview.dataset.mtime = item.mtime; + this.iconViewObserver.observe(preview); + } + + const buttonOverlay = node.querySelector('.aiia-icon-button-overlay'); + const downloadButton = $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' }); + + if (item.has_workflow) { + const loadButton = $el('button.aiia-load-workflow-button', { textContent: '🚀', title: 'Load Workflow' }); + buttonOverlay.replaceChildren(loadButton, downloadButton); + } else { + buttonOverlay.replaceChildren(downloadButton); + } + + this.virtualList.content.appendChild(node); + this.iconView.activeNodes.set(item.name, node); + } + + this.virtualList.lastRendered = { start: firstVisibleRow, end: lastVisibleRow }; + } + + renderListView(files) { + if (files.length === 0) { + this.contentArea.appendChild($el('div.aiia-browser-placeholder', {textContent: 'No files in this directory.'})); + return; + } + + const handleHeaderClick = (e) => { + const cell = e.target.closest('.aiia-list-header-cell'); + if (!cell || (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(cell.dataset.sort))) return; + const key = cell.dataset.sort; + if (!key) return; + this.handleSortChange(key, this.sortKey === key ? (this.sortDir === 'asc' ? 'desc' : 'asc') : 'asc'); + }; + + const headers = [ + { key: 'name', text: 'Name' }, + { key: 'mtime', text: 'Date modified' }, + { key: 'type', text: 'Type' }, + { key: 'size', text: 'Size' }, + { key: 'dimensions', text: 'Dimensions' }, + { key: 'duration', text: 'Duration' } + ]; + + const headerContainer = $el("div.aiia-list-header", { onclick: handleHeaderClick }); + this.updateListHeader(headerContainer, headers); + + const totalHeight = files.length * this.listView.rowHeight; + const scroller = $el("div.aiia-virtual-scroller", { style: { height: `${totalHeight}px` } }); + this.virtualList.scroller = scroller; + + const content = $el("div.aiia-list-body-content"); + this.virtualList.content = content; + + const viewport = $el("div.aiia-list-body", [scroller, content]); + this.virtualList.viewport = viewport; + + // --- START: Fixed Pool Implementation --- + this.listView.nodePool = []; + this.listView.activeNodes.clear(); // Clear any previous state + const poolSize = Math.ceil(viewport.clientHeight / this.listView.rowHeight) + (this.virtualList.buffer * 2); + + for (let i = 0; i < poolSize; i++) { + const row = $el("div.aiia-list-row"); + row.replaceChildren( + $el("div.aiia-list-cell.aiia-list-cell-icon"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell.aiia-list-cell-actions", [ + $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' }) + ]) + ); + this.listView.nodePool.push(row); + } + // --- END: Fixed Pool Implementation --- + + viewport.onscroll = () => { + if (this.virtualList.renderRaf) cancelAnimationFrame(this.virtualList.renderRaf); + this.virtualList.renderRaf = requestAnimationFrame(() => this.renderVisibleRows()); + }; + + const container = $el("div.aiia-list-container", [headerContainer, viewport]); + this.contentArea.innerHTML = ''; + this.contentArea.appendChild(container); + this.renderVisibleRows(); + } + + updateListHeader(headerContainer, headers) { + headerContainer.replaceChildren($el("div.aiia-list-header-cell"), ...headers.map(h => { + let headerText = h.text; + if (h.key === this.sortKey && !(this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key))) { + headerText += this.sortDir === 'asc' ? ' ▲' : ' ▼'; + } + const cell = $el("div.aiia-list-header-cell", { textContent: headerText, dataset: { sort: h.key } }); + if (this.metadataLoader.isLoading && ['dimensions', 'duration'].includes(h.key)) { + cell.style.opacity = 0.5; + cell.style.cursor = 'not-allowed'; + } + return cell; + })); + } + + renderVisibleRows(force = false) { + if (!this.virtualList.viewport || !this.visibleItems) return; + + const { scrollTop, clientHeight } = this.virtualList.viewport; + const { rowHeight, nodePool, activeNodes } = this.listView; + const { buffer } = this.virtualList; + + const totalRows = this.visibleItems.length; + const firstVisibleRow = Math.max(0, Math.floor(scrollTop / rowHeight) - buffer); + const lastVisibleRow = Math.min(totalRows - 1, Math.ceil((scrollTop + clientHeight) / rowHeight) + buffer); + + if (!force && firstVisibleRow === this.virtualList.lastRendered.start && lastVisibleRow === this.virtualList.lastRendered.end) { + return; + } + + const newActiveNodes = new Map(); + const fragment = document.createDocumentFragment(); + + for (let i = firstVisibleRow; i <= lastVisibleRow; i++) { + const item = this.visibleItems[i]; + if (!item) continue; + + let node = activeNodes.get(item.name); + if (node) { + activeNodes.delete(item.name); + } else { + node = nodePool.pop() || (() => { + const newNode = $el("div.aiia-list-row"); + newNode.replaceChildren( + $el("div.aiia-list-cell.aiia-list-cell-icon"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell"), + $el("div.aiia-list-cell.aiia-list-cell-actions", [ + $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' }) + ]) + ); + return newNode; + })(); + } + + node.dataset.itemName = item.name; + node.style.position = 'relative'; + node.style.transform = ''; + + const mime = getMimeType(item.name); + let icon = '📄'; + if (mime === 'image') icon = '🖼️'; + else if (mime === 'video') icon = '🎬'; + else if (mime === 'audio') icon = '🎵'; + + let dimText = item.width && item.height ? `${item.width} x ${item.height}` : '—'; + let durText = item.duration != null ? formatDuration(item.duration) : '—'; + + const cells = node.children; + cells[0].textContent = icon; + cells[1].textContent = item.name; + cells[2].textContent = formatDate(item.mtime); + cells[3].textContent = item.extension.replace('.', ''); + cells[4].textContent = formatBytes(item.size); + cells[5].textContent = dimText; + cells[6].textContent = durText; + + const actionsCell = cells[7]; + actionsCell.innerHTML = ''; // Clear previous buttons + + const downloadButton = $el('button.aiia-download-button', { textContent: '📥', title: 'Download File' }); + + if (item.has_workflow) { + const loadButton = $el('button.aiia-load-workflow-button', { textContent: '🚀', title: 'Load Workflow' }); + actionsCell.append(loadButton, downloadButton); + } else { + actionsCell.appendChild(downloadButton); + } + + const globalIndex = this.directoryItems.length + i; + if(globalIndex === this.currentFocusIndex) node.classList.add('focused'); + else node.classList.remove('focused'); + + fragment.appendChild(node); + newActiveNodes.set(item.name, node); + } + + for (const node of activeNodes.values()) { + nodePool.push(node); + } + + this.virtualList.content.replaceChildren(fragment); + this.listView.activeNodes = newActiveNodes; + + this.virtualList.content.style.transform = `translateY(${firstVisibleRow * rowHeight}px)`; + this.virtualList.lastRendered = { start: firstVisibleRow, end: lastVisibleRow }; + } + getAllNavigableItems() { const directories = this.hideFolders ? [] : this.directoryItems; return [...directories, ...this.visibleItems]; } + async startMetadataBatchLoading() { + return new Promise(async (resolve) => { + if (this.metadataLoader.isLoading) { + resolve(); + return; + } + + const filesToProcess = this.itemsData.filter(item => { + const mime = getMimeType(item.name); + let needsMeta = false; + if (mime === 'image') { + needsMeta = !item.hasOwnProperty('width'); + } else if (mime === 'video' || mime === 'audio') { + needsMeta = !item.hasOwnProperty('duration'); + } + return item.type === 'file' && needsMeta; + }); + + const filesToFetch = []; + for (const item of filesToProcess) { + const cacheKey = getCacheKey(this.currentPath, item.name); + const cachedData = getCache(cacheKey); + if (cachedData && cachedData.mtime === item.mtime && cachedData.size === item.size) { + const { mtime, size, ...rest } = cachedData.metadata; + Object.assign(item, rest); + } else { + filesToFetch.push(item); + } + } + + if (filesToFetch.length === 0) { + resolve(); + return; + } + + this.metadataLoader.controller = new AbortController(); + this.metadataLoader.isLoading = true; + this.metadataLoader.path = this.currentPath; + + const headerContainer = this.element.querySelector('.aiia-list-header'); + if (headerContainer) { + const headers = [ + { key: 'name', text: 'Name' }, + { key: 'mtime', text: 'Date modified' }, + { key: 'type', text: 'Type' }, + { key: 'size', text: 'Size' }, + { key: 'dimensions', text: 'Dimensions' }, + { key: 'duration', text: 'Duration' } + ]; + this.updateListHeader(headerContainer, headers); + } + + const total = filesToFetch.length; + let loaded = 0; + this.updateProgressBar(loaded, total); + + const BATCH_SIZE = 50; + for (let i = 0; i < filesToFetch.length; i += BATCH_SIZE) { + if (this.metadataLoader.controller.signal.aborted) break; + + while (this.metadataLoader.isPaused) { + await new Promise(resolveWait => setTimeout(resolveWait, 200)); + if (this.metadataLoader.controller.signal.aborted) break; + } + if (this.metadataLoader.controller.signal.aborted) break; + + const batch = filesToFetch.slice(i, i + BATCH_SIZE); + const filenames = batch.map(f => f.name); + + try { + const response = await fetch(window.location.origin + '/api/aiia/v1/browser/get_batch_metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: this.currentPath, filenames }), + signal: this.metadataLoader.controller.signal, + }); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const data = await response.json(); + for (const [filename, metadata] of Object.entries(data)) { + const f = this.itemsDataMap.get(filename); + if (f) { + const { mtime, size, ...rest } = metadata; + Object.assign(f, rest); + const cacheKey = getCacheKey(this.currentPath, f.name); + setCache(cacheKey, { mtime: f.mtime, size: f.size, metadata: rest }); + } + } + + } catch (error) { + if (error.name !== 'AbortError') { + console.error('[AIIA] Error fetching batch metadata:', error); + } + } + + loaded += batch.length; + this.updateProgressBar(loaded, total); + } + this.updateProgressBar(loaded, total, true); + resolve(); + }); + } + updateProgressBar(loaded, total, finished = false) { + if (!this.progressContainer) return; + if (total === 0 || loaded >= total || finished) { + this.progressContainer.style.display = 'none'; + if(finished) { + this.metadataLoader.isLoading = false; + const headerContainer = this.element.querySelector('.aiia-list-header'); + if(headerContainer) { + const headers = [ { key: 'name', text: 'Name' }, { key: 'mtime', text: 'Date modified' }, { key: 'type', text: 'Type' }, { key: 'size', text: 'Size' }, { key: 'dimensions', text: 'Dimensions' }, { key: 'duration', text: 'Duration' } ]; + this.updateListHeader(headerContainer, headers); + } + } + return; + } + this.progressContainer.style.display = 'flex'; + const percent = (loaded / total) * 100; + this.progressBar.firstChild.style.width = `${percent}%`; + this.progressText.textContent = `Analyzing metadata... (${loaded}/${total})`; + } + + handleIconIntersection(entries) { + const LOAD_DELAY = 100; + for (const entry of entries) { + const placeholder = entry.target; + const filename = placeholder.dataset.filename; + if (!filename) continue; + + if (entry.isIntersecting) { + if (this.iconLoadTimers.has(filename)) continue; + + const timer = setTimeout(() => { + this.iconLoadTimers.delete(filename); + + const fileData = this.itemsDataMap.get(filename); + if (!fileData) return; + + let cachedEntry = this.iconView.mediaCache.get(filename); + let mediaElement; + + // If the cached entry exists but the modification time is different, invalidate it. + if (cachedEntry && cachedEntry.mtime !== fileData.mtime) { + cachedEntry = null; + this.iconView.mediaCache.delete(filename); + } + + if (cachedEntry) { + mediaElement = cachedEntry.element; + } else { + const createPlaceholder = (icon, text) => $el(`div.${text === "Empty File" ? "aiia-empty-file-placeholder" : "aiia-no-preview-placeholder"}`, [ $el("span", { textContent: icon }), $el("span", { textContent: text }) ]); + + if (fileData.size === 0) { + mediaElement = createPlaceholder("🚫", "Empty File"); + } else { + const path = placeholder.dataset.path; + const mtime = placeholder.dataset.mtime; + const mime = getMimeType(filename); + + const handleMediaError = (e) => { + const newPlaceholder = createPlaceholder("🖼️🚫", "No Preview"); + if(e.target.parentNode) e.target.parentNode.replaceChild(newPlaceholder, e.target); + this.iconView.mediaCache.set(filename, { element: newPlaceholder.cloneNode(true), mtime: fileData.mtime }); + }; + + if (mime === 'image') { + const url = getThumbnailUrl(filename, path, mtime); + mediaElement = $el("img.aiia-image-preview", { src: url, onerror: handleMediaError }); + } else if (mime === 'video') { + const url = this.enableVideoPreviews ? getFileUrl(filename, path) : getVideoPosterUrl(filename, path, mtime); + if(this.enableVideoPreviews) { + mediaElement = $el("video.aiia-video-preview", { src: url, autoplay: true, muted: true, loop: true, playsinline: true, onerror: handleMediaError }); + } else { + mediaElement = $el("img.aiia-image-preview", { src: url, onerror: handleMediaError }); + mediaElement.classList.add("aiia-video-poster-container-flag"); + } + } else if (mime === 'audio') { + mediaElement = createPlaceholder("🎵", fileData.duration ? formatDuration(fileData.duration) : 'Audio'); + } else { + mediaElement = createPlaceholder("❓", "Unknown"); + } + } + this.iconView.mediaCache.set(filename, { element: mediaElement.cloneNode(true), mtime: fileData.mtime }); + } + + if (placeholder.parentNode) { + placeholder.className = 'aiia-icon-preview'; + if (mediaElement.classList.contains('aiia-video-poster-container-flag')) { + placeholder.classList.add("aiia-video-poster-container"); + } + placeholder.replaceChildren(mediaElement); + } + + }, LOAD_DELAY); + this.iconLoadTimers.set(filename, timer); + } else { + if (this.iconLoadTimers.has(filename)) { + clearTimeout(this.iconLoadTimers.get(filename)); + this.iconLoadTimers.delete(filename); + } + } + } + } + + ensureFocusedItemVisible() { const item = this.getAllNavigableItems()[this.currentFocusIndex]; if (!item) return; if (item.type === 'directory') { const el = this.element.querySelector(`.aiia-directory-item[data-item-name="${CSS.escape(item.name)}"]`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } else { if (this.viewMode === 'list' && this.virtualList.viewport) { const fileIndex = this.visibleItems.findIndex(f => f.name === item.name); if (fileIndex === -1) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight } = this.listView; const itemTop = fileIndex * rowHeight, itemBottom = itemTop + rowHeight; if (itemTop < scrollTop) this.virtualList.viewport.scrollTop = itemTop; else if (itemBottom > scrollTop + clientHeight) this.virtualList.viewport.scrollTop = itemBottom - clientHeight; } else if (this.viewMode === 'icons' && this.virtualList.viewport) { const fileIndex = this.visibleItems.findIndex(f => f.name === item.name); if (fileIndex === -1) return; const { scrollTop, clientHeight } = this.virtualList.viewport; const { rowHeight, columns } = this.iconView; const itemRow = Math.floor(fileIndex / columns); const itemTop = itemRow * rowHeight; const itemBottom = itemTop + rowHeight; if (itemTop < scrollTop) { this.virtualList.viewport.scrollTop = itemTop; } else if (itemBottom > scrollTop + clientHeight) { this.virtualList.viewport.scrollTop = itemBottom - clientHeight; } } } } moveFocus(key) { const allNavigableItems = this.getAllNavigableItems(); @@ -314,8 +1164,7 @@ class AIIABrowserDialog extends ComfyDialog { let columns = 1; if (this.viewMode === 'icons') { - const grid = this.contentArea.querySelector('.aiia-grid-inner-wrapper'); - if (grid) columns = window.getComputedStyle(grid).getPropertyValue('grid-template-columns').split(' ').length; + columns = this.iconView.columns || 1; } switch (key) { @@ -394,7 +1243,33 @@ class AIIABrowserDialog extends ComfyDialog { } activateFocusedItem() { const item = this.getAllNavigableItems()[this.currentFocusIndex]; if(!item) return; if(item.type === 'directory') { const el = this.element.querySelector('.focused'); if(el) el.click(); } else { const itemEl = this.element.querySelector('.focused'); if (itemEl) itemEl.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })); } } - toggleTooltipForFocusedItem() { if (this.currentFocusIndex === -1) return; const allNavigableItems = this.getAllNavigableItems(); const itemData = allNavigableItems[this.currentFocusIndex]; if (this.isTooltipVisible && this.tooltipItemName === itemData.name) { this.hideTooltip(); this.setFocus(null, null); return; } clearTimeout(this.tooltipTimeout); this.hideTooltip(); const itemEl = this.element.querySelector('.focused'); if (itemEl) { const rect = itemEl.getBoundingClientRect(); const fakeEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; this.updateTooltipContent(itemData, fakeEvent); } } + toggleTooltipForFocusedItem() { + if (this.currentFocusIndex === -1) return; + + const allNavigableItems = this.getAllNavigableItems(); + const itemData = allNavigableItems[this.currentFocusIndex]; + const itemEl = this.element.querySelector('.focused'); + + if (!itemEl) return; + + // Toggle the actions-visible class for keyboard users + const isActionsVisible = itemEl.classList.toggle('actions-visible'); + + if (this.isTooltipVisible && this.tooltipItemName === itemData.name) { + this.hideTooltip(); + // If actions are not visible, it means we are closing everything, so remove focus. + if (!isActionsVisible) { + itemEl.classList.remove('focused'); + this.currentFocusIndex = -1; + } + } else { + clearTimeout(this.tooltipTimeout); + this.hideTooltip(); + const rect = itemEl.getBoundingClientRect(); + const fakeEvent = { clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2 }; + this.updateTooltipContent(itemData, fakeEvent); + } + } handleKeyDown(e) { const target = e.target; @@ -442,12 +1317,43 @@ class AIIABrowserDialog extends ComfyDialog { } } - show() { this.currentPath = null; this.history = []; this.navigateTo(""); super.show(); this.updateTooltipSizeLimits(); this.element.focus({ preventScroll: true }); } + show() { + this.currentPath = null; + this.history = []; + this.navigateTo(""); + super.show(); + this.updateTooltipSizeLimits(); + this.element.focus({ preventScroll: true }); + } } app.registerExtension({ name: "Comfy.AIIA.Browser", setup() { + // --- START: One-time cache cleanup --- + try { + const migrationKey = "aiia-cache-v2-migrated"; + if (!localStorage.getItem(migrationKey)) { + console.log("[AIIA Browser] Checking for old cache entries to migrate..."); + const keysToRemove = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key.startsWith("aiia-cache:") && !key.startsWith("aiia-cache:v2:")) { + keysToRemove.push(key); + } + } + if (keysToRemove.length > 0) { + console.log(`[AIIA Browser] Removing ${keysToRemove.length} old cache entries...`); + keysToRemove.forEach(key => localStorage.removeItem(key)); + } + localStorage.setItem(migrationKey, "true"); + console.log("[AIIA Browser] Cache migration check complete."); + } + } catch (e) { + console.error("[AIIA Browser] Error during cache migration:", e); + } + // --- END: One-time cache cleanup --- + document.head.appendChild($el("style", { textContent: browserStyles })); let browserDialog = null; const createDialog = () => { if (!browserDialog) browserDialog = new AIIABrowserDialog(); browserDialog.show(); }; diff --git a/js/aiia_browser_styles.js b/js/aiia_browser_styles.js index 71ffd7a..6ec0a35 100644 --- a/js/aiia_browser_styles.js +++ b/js/aiia_browser_styles.js @@ -1,4 +1,4 @@ -// aiia_browser_styles.js (V67 - 设置面板样式修复) +// aiia_browser_styles.js (V82 - 虚拟滚动引擎重构) export const browserStyles = ` /* Main Browser Styles */ #aiia-browser-menu-button { margin-left: 10px; } @@ -77,16 +77,65 @@ export const browserStyles = ` .aiia-grid-wrapper::-webkit-scrollbar-thumb, .aiia-list-body::-webkit-scrollbar-thumb, .aiia-custom-tooltip::-webkit-scrollbar-thumb, .aiia-filmstrip-container::-webkit-scrollbar-thumb, .aiia-directory-list::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.5); border-radius: 8px; border: 4px solid transparent; background-clip: content-box; } .aiia-grid-wrapper:hover::-webkit-scrollbar-thumb, .aiia-list-container:hover .aiia-list-body::-webkit-scrollbar-thumb, .aiia-filmstrip-container:hover::-webkit-scrollbar-thumb, .aiia-directory-list:hover::-webkit-scrollbar-thumb { background-color: rgba(155, 155, 155, 0.8); } - .aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: auto; overflow-x: hidden; } - .aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; } - .aiia-item-icon { width: 100%; display: flex; flex-direction: column; border: 1px solid transparent; border-radius: 5px; cursor: pointer; } - .aiia-icon-preview { position: relative; height: var(--aiia-icon-size, 120px); min-height: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; overflow: hidden; } - .aiia-icon-preview.media-placeholder { background-color: var(--comfy-menu-bg); border: 1px solid var(--border-color); } - .aiia-image-preview, .aiia-video-preview { max-width: 100%; max-height: 100%; object-fit: contain; } + .aiia-grid-wrapper { height: 100%; width: 100%; overflow-y: scroll; overflow-x: hidden; position: relative; } + .aiia-virtual-scroller { position: absolute; top: 0; left: 0; width: 1px; opacity: 0; pointer-events: none; } + .aiia-grid-inner-wrapper { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--aiia-icon-size, 120px), 1fr)); gap: 10px; padding: 10px; position: absolute; top: 0; left: 0; right: 0; } + + .aiia-item-icon { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: 5px; + cursor: pointer; + position: relative; + background-color: var(--comfy-menu-bg); + overflow: hidden; + } + .aiia-item-icon:hover .aiia-icon-button-overlay { + opacity: 1; + transform: translateY(0); + } + .aiia-icon-preview { + position: relative; + flex-grow: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + overflow: hidden; + } + .aiia-icon-preview.media-placeholder { background-color: var(--comfy-box-bg); } + .aiia-image-preview, .aiia-video-preview { + max-width: 100%; + max-height: 100%; + object-fit: contain; + width: calc(100% - 16px); + height: calc(100% - 16px); + border-radius: 4px; + } .aiia-audio-container { display: flex; flex-direction: column; justify-content: center; align-items: center; width: 100%; height: 100%; gap: 8px; } .aiia-audio-container audio { width: 90%; } .aiia-audio-duration { font-size: 11px; color: var(--descrip-text); } - .aiia-item-label { flex-shrink: 0; width: 100%; text-align: center; font-size: 12px; padding: 4px; white-space: normal; word-break: break-all; color: #E5E7EB; } + .aiia-item-label { + flex-shrink: 0; + width: 100%; + text-align: center; + font-size: 12px; + padding: 8px 4px; + white-space: normal; + word-break: break-all; + color: #E5E7EB; + line-height: 1.3; + background-color: rgba(0,0,0,0.2); + } + .aiia-item-label.has-workflow { + background-color: rgba(59, 130, 246, 0.25); + border-top: 1px solid rgba(96, 165, 250, 0.3); + color: #eff6ff; + } .aiia-video-poster-container::after { content: ''; @@ -105,7 +154,6 @@ export const browserStyles = ` -webkit-mask-size: 60%; mask-size: 60%; -webkit-mask-repeat: no-repeat; - mask-repeat: no-repeat; -webkit-mask-position: center; mask-position: center; transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out; @@ -125,21 +173,95 @@ export const browserStyles = ` .aiia-tooltip-label { color: #aaa; text-align: right; white-space: nowrap; } .aiia-tooltip-value { color: #eee; font-weight: bold; white-space: normal; overflow-wrap: break-word; min-width: 0; } + .aiia-tooltip-metadata audio { + width: 100%; + margin-bottom: 8px; + } + .aiia-tooltip-metadata audio::-webkit-media-controls-play-button, + .aiia-tooltip-metadata audio::-webkit-media-controls-mute-button, + .aiia-tooltip-metadata audio::-webkit-media-controls-volume-slider { + display: none; + } + .aiia-list-container { display: flex; flex-direction: column; height: 100%; width: 100%; } - .aiia-list-body { flex-grow: 1; overflow-y: auto; position: relative; } - .aiia-list-body-content { } - .aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr); align-items: center; gap: 8px; padding: 0 8px; } - .aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; } + .aiia-list-body { flex-grow: 1; overflow-y: scroll; position: relative; } + .aiia-list-body-content { position: relative; width: 100%; height: 100%; } + .aiia-list-header, .aiia-list-row { display: grid; grid-template-columns: 28px minmax(150px, 3fr) minmax(140px, 1.5fr) minmax(70px, 1fr) minmax(80px, 1fr) minmax(100px, 1fr) minmax(80px, 1fr) 110px; align-items: center; gap: 8px; padding: 0 8px; } + .aiia-list-row { border-bottom: 1px solid var(--border-color); cursor: pointer; position: absolute; left: 0; right: 0; } .aiia-list-header { position: sticky; top: 0; z-index: 10; background: var(--comfy-box-bg); border-bottom: 2px solid var(--border-color); } .aiia-list-header-cell { padding: 8px 0; font-weight: bold; color: #F9FAFB; cursor: pointer; user-select: none; } .aiia-list-header-cell:hover { color: var(--link-color); } - .aiia-list-row > div { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; } + .aiia-list-cell { padding: 6px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #E5E7EB; } .aiia-list-cell-icon { font-size: 16px; text-align: center; } + .aiia-list-cell-actions { display: flex; justify-content: center; align-items: center; gap: 4px; } + + .aiia-icon-button-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + padding: 8px; + box-sizing: border-box; + display: flex; + justify-content: flex-end; + gap: 6px; + background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); + opacity: 0; + transform: translateY(-20px); + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; + pointer-events: none; + } + .aiia-item-icon:hover .aiia-icon-button-overlay, + .aiia-item-icon.actions-visible .aiia-icon-button-overlay { + opacity: 1; + transform: translateY(0); + pointer-events: all; + } + .aiia-icon-button-overlay > button { + pointer-events: all; + position: static; + padding: 4px 8px; + font-size: 14px; + line-height: 1; + background-color: rgba(30, 30, 30, 0.8); + color: white; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 4px; + cursor: pointer; + backdrop-filter: blur(2px); + transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out; + } + .aiia-icon-button-overlay > button:hover { + background-color: rgba(0, 123, 255, 0.9); + transform: scale(1.05); + } + .aiia-icon-button-overlay > button:active { + transform: scale(1); + } + + /* Hide old, now-unused absolute buttons in icon view */ + .aiia-item-icon > .aiia-load-workflow-button, + .aiia-item-icon > .aiia-download-button { + display: none; + } + + /* Keep original styles for list view */ + .aiia-list-row .aiia-load-workflow-button, .aiia-list-row .aiia-download-button { + position: static; + background-color: transparent; + border: none; + color: #E5E7EB; + font-size: 18px; + } + .aiia-list-row .aiia-load-workflow-button:hover, .aiia-list-row .aiia-download-button:hover { + color: var(--accent-color); + background-color: transparent; + } .aiia-browser-error, .aiia-browser-placeholder { padding: 20px; text-align: center; font-size: 16px; color: var(--descrip-text); width: 100%; } - .aiia-empty-file-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); } - .aiia-empty-file-placeholder span:first-child { font-size: 24px; } - .aiia-empty-file-placeholder span:last-child { font-size: 11px; margin-top: 4px; } + .aiia-empty-file-placeholder, .aiia-no-preview-placeholder { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; color: var(--descrip-text); } + .aiia-empty-file-placeholder span:first-child, .aiia-no-preview-placeholder span:first-child { font-size: 24px; } + .aiia-empty-file-placeholder span:last-child, .aiia-no-preview-placeholder span:last-child { font-size: 11px; margin-top: 4px; } .aiia-progress-container { display: none; flex-direction: column; gap: 4px; margin-bottom: 8px; } .aiia-progress-text { font-size: 12px; color: var(--descrip-text); } @@ -173,4 +295,5 @@ export const browserStyles = ` .aiia-info-panel-row:last-child { margin-bottom: 0; } .aiia-info-panel-label { color: #aaa; text-align: right; } .aiia-info-panel-value { font-weight: 500; overflow-wrap: break-word; min-width: 0; } -`; \ No newline at end of file +`; +