Files
havvk-ComfyUI_AIIA/js/aiia_browser.js
T

1448 lines
75 KiB
JavaScript
Executable File

// 文件: aiia_browser.js (V82 - 虚拟滚动引擎重构)
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
import { AIIAFullscreenViewer } from "./aiia_fullscreen_viewer.js";
import { browserStyles } from "./aiia_browser_styles.js";
const DEBUG = false;
const log = (...args) => DEBUG && console.log("[AIIA Browser Debug]", ...args);
const SETTINGS_PREFIX = "aiia-browser-settings:";
function getSetting(key, defaultValue) {
try {
const data = localStorage.getItem(SETTINGS_PREFIX + key);
return data !== null ? JSON.parse(data) : defaultValue;
} catch (e) {
return defaultValue;
}
}
function setSetting(key, value) {
try {
localStorage.setItem(SETTINGS_PREFIX + key, JSON.stringify(value));
} catch (e) { console.error(`[AIIA] Error setting localStorage for key ${key}:`, e); }
}
const CACHE_PREFIX = "aiia-cache:v3:";
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); } }
function getFileUrl(filename, path = "") { const subfolder = path || ""; return api.apiURL(`/view?type=output&subfolder=${encodeURIComponent(subfolder)}&filename=${encodeURIComponent(filename)}`); }
function getThumbnailUrl(filename, path = "", mtime = 0) { return `/api/aiia/v1/browser/thumbnail?path=${encodeURIComponent(path)}&filename=${encodeURIComponent(filename)}&mtime=${mtime}`; }
function getVideoPosterUrl(filename, path = "", mtime = 0) { return `/api/aiia/v1/browser/poster?path=${encodeURIComponent(path)}&filename=${encodeURIComponent(filename)}&mtime=${mtime}`; }
function getMimeType(filename) { if (typeof filename !== 'string') return 'unknown'; const extension = filename.split('.').pop().toLowerCase(); if (['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) return 'image'; if (['mp4', 'webm', 'mov', 'avi'].includes(extension)) return 'video'; if (['mp3', 'wav', 'ogg'].includes(extension)) return 'audio'; return 'unknown'; }
function formatBytes(bytes, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; }
function formatDate(timestamp) { const date = new Date(timestamp * 1000); return date.toLocaleString(); }
function formatDuration(seconds) { if (isNaN(seconds) || seconds < 0) return '—'; const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; }
class AIIABrowserDialog extends ComfyDialog {
constructor() {
super();
this.currentPath = null;
this.history = [];
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);
this.sortKey = getSetting('sortKey', 'name');
this.sortDir = getSetting('sortDir', 'asc');
this.hideFolders = getSetting('hideFolders', false);
this.enableVideoPreviews = getSetting('enableVideoPreviews', false);
this.metadataLoader = { controller: null, isLoading: false, path: null, isPaused: false };
this.lastMouseEvent = null;
this.isTooltipVisible = false;
this.tooltipTimeout = null;
this.isPointerDownOnSettings = false;
this.fullscreenViewer = null;
this.visibleItems = [];
this.directoryItems = [];
this.currentFocusIndex = -1;
this.tooltipItemName = null;
this.isKeyboardNavigating = false;
this.lastFocus = { dir: -1, file: -1 };
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.outsideClickListener = null;
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 });
this.tooltipAudio = $el("audio", { autoplay: true });
this.tooltipMediaContainer = $el("div.aiia-tooltip-media", [this.tooltipImage, this.tooltipVideo]);
this.tooltipMetadata = $el("div.aiia-tooltip-metadata");
this.tooltipElement = $el("div.aiia-custom-tooltip", [this.tooltipMediaContainer, this.tooltipMetadata]);
this.directoryList = $el("div.aiia-directory-list");
this.directoryPanel = $el("div.aiia-directory-panel", [$el("div.aiia-directory-panel-header", { textContent: "Folders" }), this.directoryList]);
this.contentArea = $el("div.aiia-browser-content");
this.contentPanel = $el("div.aiia-content-panel", [this.contentArea]);
const splitViewContainer = $el("div.aiia-split-view-container", [this.directoryPanel, this.contentPanel, this.tooltipElement]);
this.iconViewObserver = new IntersectionObserver(this.handleIconIntersection.bind(this), {
root: null, // Use root: null to observe relative to browser viewport
rootMargin: '500px 0px 500px 0px'
});
this.titleElement = $el("span", { textContent: "AIIA Media Browser" });
this.closeButton = $el("button.close", { textContent: "✖", title: "Close" });
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.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" })]);
this.videoPreviewsCheckbox = $el("input", { type: "checkbox", id: "aiia-video-previews-cb", checked: this.enableVideoPreviews });
const videoPreviewsLabel = $el("label.aiia-settings-label", { htmlFor: "aiia-video-previews-cb" }, [this.videoPreviewsCheckbox, $el("span", { textContent: "Enable Video Previews" })]);
this.settingsPanel = $el("div.aiia-settings-panel", { style: { display: "none" }, tabindex: -1 }, [
$el("div", [$el("strong", { textContent: "Display" })]),
hideFoldersLabel,
videoPreviewsLabel,
]);
this.settingsButton = $el("button.aiia-browser-settings-button", { textContent: "⚙️" });
this.settingsContainer = $el("div.aiia-settings-container", [this.settingsButton, this.settingsPanel]);
this.sortKeySelect = $el("select", [$el("option", { value: "name", textContent: "Sort by Name" }), $el("option", { value: "mtime", textContent: "Sort by Date" }), $el("option", { value: "type", textContent: "Sort by Type" }), $el("option", { value: "size", textContent: "Sort by Size" }), $el("option", { value: "dimensions", textContent: "Sort by Dimensions" }), $el("option", { value: "duration", textContent: "Sort by Duration" })]);
this.sortDirButton = $el("button", { textContent: "▲" });
const sortControls = $el("div.aiia-browser-sort-controls", [this.sortKeySelect, this.sortDirButton]);
this.iconViewButton = $el("button", { textContent: "🖼️", dataset: { tooltipText: "Icon View" } });
this.listViewButton = $el("button", { textContent: "📄", dataset: { tooltipText: "List View" } });
this.sizeSlider = $el("input", { type: "range", min: 64, max: 256, value: this.iconSize, step: 8, dataset: { tooltipText: "Change icon size", escapeToList: "true" } });
const viewControls = $el("div.aiia-browser-view-controls", [this.settingsContainer, sortControls, this.iconViewButton, this.listViewButton, this.sizeSlider]);
this.progressBar = $el("div.aiia-progress-bar", [$el("div")]);
this.progressText = $el("div.aiia-progress-text");
this.progressContainer = $el("div.aiia-progress-container", [this.progressText, this.progressBar]);
this.progressContainer.style.display = 'none';
const headerControls = $el("div.aiia-browser-header-controls", [navContainer, viewControls]);
const mainContainer = $el("div.aiia-browser-main-container", [headerControls, this.progressContainer, splitViewContainer]);
this.element.replaceChildren(titleBar, mainContainer);
this.element.classList.add("aiia-browser-dialog-root");
this.element.setAttribute('tabindex', -1);
this.bindEvents();
this.applySettings();
this.updateSortControls();
this.resizeObserver = new ResizeObserver(() => { this.updateTooltipSizeLimits(); });
this.resizeObserver.observe(this.contentPanel);
}
applySettings() {
this.iconViewButton.classList.toggle('active', this.viewMode === 'icons');
this.listViewButton.classList.toggle('active', this.viewMode === 'list');
this.sizeSlider.style.display = this.viewMode === 'icons' ? 'inline-block' : 'none';
document.documentElement.style.setProperty('--aiia-icon-size', `${this.iconSize}px`);
this.directoryPanel.classList.toggle('hidden', this.hideFolders);
}
bindEvents() {
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();
this.element.addEventListener('mousedown', (e) => {
if (this.settingsContainer.contains(e.target)) {
this.isPointerDownOnSettings = true;
}
});
this.element.addEventListener('mouseup', (e) => {
this.isPointerDownOnSettings = false;
});
this.element.addEventListener('click', (e) => {
if (!this.isPointerDownOnSettings && !this.settingsContainer.contains(e.target)) {
this.settingsPanel.style.display = 'none';
}
if (!e.target.closest('input, select, button, .aiia-item-icon, .aiia-list-row, .aiia-browser-breadcrumb-link, .aiia-directory-item, .aiia-settings-container')) {
this.element.focus({ preventScroll: true });
}
});
this.element.addEventListener('focusout', (e) => {
if (!this.element.contains(e.relatedTarget)) this.hideTooltip();
});
this.element.addEventListener("dragover", (e) => { e.preventDefault(); });
this.element.addEventListener("drop", (e) => { e.preventDefault(); e.stopPropagation(); });
this.settingsButton.onclick = (e) => {
e.stopPropagation();
const newDisplay = this.settingsPanel.style.display === 'block' ? 'none' : 'block';
this.settingsPanel.style.display = newDisplay;
if (newDisplay === 'block') {
this.settingsPanel.focus();
}
};
this.hideFoldersCheckbox.onchange = (e) => { this.hideFolders = e.target.checked; setSetting('hideFolders', this.hideFolders); this.render(); };
this.videoPreviewsCheckbox.onchange = (e) => { this.enableVideoPreviews = e.target.checked; setSetting('enableVideoPreviews', this.enableVideoPreviews); this.render(); };
this.sortKeySelect.onchange = (e) => this.handleSortChange(e.target.value, this.sortDir);
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`);
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.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);
}
}
}
async deleteItem(itemName) {
if (!itemName) return;
if (!confirm(`Are you sure you want to delete "${itemName}"? This action cannot be undone.`)) return;
try {
const res = await api.fetchApi('/aiia/v1/browser/delete_item', {
method: 'POST',
body: JSON.stringify({ path: this.currentPath, filename: itemName })
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const result = await res.json();
if (result.status === 'success') {
this.setFocus(null, null);
this.refreshCurrentDirectory();
}
} catch (e) {
alert(`Error deleting item: ${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.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);
clearTimeout(this.tooltipTimeout);
this.hideTooltip();
const oldEl = this.element.querySelector('.focused');
if (oldEl) {
oldEl.classList.remove('focused');
oldEl.classList.remove('actions-visible');
}
if (!item) { this.currentFocusIndex = -1; return; }
this.currentFocusIndex = this.getAllNavigableItems().findIndex(i => i.name === item.name);
if (this.currentFocusIndex > -1) {
if (item.type === 'directory') this.lastFocus.dir = this.currentFocusIndex;
else this.lastFocus.file = this.currentFocusIndex;
}
if (scrollIntoView) this.ensureFocusedItemVisible();
const applyFocus = () => {
let elToFocus = element || this.element.querySelector(`[data-item-name="${CSS.escape(item.name)}"]`);
if (this.viewMode === 'list' && !elToFocus) {
this.renderVisibleRows();
elToFocus = this.element.querySelector(`[data-item-name="${CSS.escape(item.name)}"]`);
}
if (elToFocus) {
const currentlyFocused = this.element.querySelector('.focused');
if (currentlyFocused) currentlyFocused.classList.remove('focused');
elToFocus.classList.add('focused');
this.tooltipTimeout = setTimeout(() => {
const currentFocusedEl = this.element.querySelector('.focused');
if (currentFocusedEl && currentFocusedEl.dataset.itemName === item.name) {
const eventForPositioning = (trigger === 'keyboard' || !this.lastMouseEvent) ? { clientX: currentFocusedEl.getBoundingClientRect().right, clientY: currentFocusedEl.getBoundingClientRect().top } : this.lastMouseEvent;
this.updateTooltipContent(item, eventForPositioning);
}
}, 200);
}
this.applyFocusRaf = null;
};
if ((this.viewMode === 'list' || !element) && trigger === 'keyboard') {
this.applyFocusRaf = requestAnimationFrame(applyFocus);
} else {
applyFocus();
}
}
handleTooltipMouseOver = (e) => { if (this.isKeyboardNavigating) return; const itemEl = e.target.closest('[data-item-name]'); if (itemEl) { const itemData = this.itemsDataMap.get(itemEl.dataset.itemName); if (itemData) this.setFocus(itemData, itemEl, false, 'mouse'); } }
handleTooltipMouseOut = (e) => { if (!e.currentTarget.contains(e.relatedTarget)) this.setFocus(null, null); }
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;
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 = '<div class="aiia-browser-placeholder">Loading...</div>';
this.directoryList.innerHTML = '<div class="aiia-browser-placeholder" style="padding:10px;font-size:12px;">...</div>';
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]);
this.render();
// Fetch metadata in the background after rendering placeholders
this.startMetadataBatchLoading();
} catch (error) {
this.contentArea.innerHTML = `<div class="aiia-browser-error">Error: ${error.message}</div>`;
} 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;
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.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.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);
}
// Use setTimeout to push the final render to the next event loop.
// This is the most robust way to ensure the UI correctly reflects all
// loaded metadata after the initial asynchronous display.
setTimeout(() => this.render(), 0);
}
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();
if (allNavigableItems.length === 0) return;
let newIndex = this.currentFocusIndex;
const originalIndex = newIndex;
const dirCount = this.directoryItems.length;
const fileCount = this.visibleItems.length;
if (newIndex === -1) {
newIndex = 0;
} else {
const isDir = newIndex < dirCount;
const fileIndex = isDir ? -1 : newIndex - dirCount;
let columns = 1;
if (this.viewMode === 'icons') {
columns = this.iconView.columns || 1;
}
switch (key) {
case 'ArrowUp':
if (isDir) newIndex = (newIndex > 0) ? newIndex - 1 : dirCount - 1;
else {
if (this.viewMode === 'list') newIndex = (fileIndex > 0) ? newIndex - 1 : allNavigableItems.length - 1;
else {
if (fileIndex >= columns) newIndex -= columns;
else {
const lastRowCol = fileIndex % columns;
const lastRowFileCount = fileCount % columns || columns;
const lastRowFirstFileIndex = fileCount - lastRowFileCount;
newIndex = dirCount + lastRowFirstFileIndex + Math.min(lastRowCol, lastRowFileCount - 1);
}
}
}
break;
case 'ArrowDown':
if (isDir) newIndex = (newIndex < dirCount - 1) ? newIndex + 1 : 0;
else {
if (this.viewMode === 'list') newIndex = (fileIndex < fileCount - 1) ? newIndex + 1 : dirCount;
else {
const targetIndex = newIndex + columns;
if (targetIndex < allNavigableItems.length) {
newIndex = targetIndex;
} else {
const currentRow = Math.floor(fileIndex / columns);
const totalRows = Math.ceil(fileCount / columns);
if (currentRow < totalRows - 1) {
newIndex = allNavigableItems.length - 1;
} else {
newIndex = dirCount + (fileIndex % columns);
if (newIndex >= allNavigableItems.length) newIndex = allNavigableItems.length - 1;
}
}
}
}
break;
case 'ArrowLeft':
if (isDir) newIndex = 0;
else {
if (this.viewMode === 'list') {
if (dirCount > 0) newIndex = this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0;
} else {
if (fileIndex > 0) newIndex--;
else if (dirCount > 0) newIndex = this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0;
else newIndex = allNavigableItems.length - 1;
}
}
break;
case 'ArrowRight':
if (isDir) {
if (fileCount > 0) newIndex = this.lastFocus.file !== -1 && this.lastFocus.file >= dirCount ? this.lastFocus.file : dirCount;
} else {
if (this.viewMode === 'list') {
newIndex = allNavigableItems.length - 1;
} else {
if (fileIndex < fileCount - 1) newIndex++;
else newIndex = dirCount;
}
}
break;
}
}
newIndex = Math.max(0, Math.min(allNavigableItems.length - 1, newIndex));
if (newIndex !== originalIndex) {
const newItem = allNavigableItems[newIndex];
this.setFocus(newItem, null, true, 'keyboard');
}
}
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];
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;
if (target.dataset.escapeToList) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
e.stopPropagation();
if (target === this.pathInput) this.hidePathInput();
const focusIndex = this.lastFocus.file !== -1 ? this.lastFocus.file : (this.directoryItems.length > 0 ? (this.lastFocus.dir !== -1 ? this.lastFocus.dir : 0) : -1);
if (focusIndex !== -1) {
const item = this.getAllNavigableItems()[focusIndex];
if (item) this.setFocus(item, null, true, 'keyboard');
} else if (this.getAllNavigableItems().length > 0) {
this.setFocus(this.getAllNavigableItems()[0], null, true, 'keyboard');
}
this.element.focus({ preventScroll: true });
return;
}
if (target === this.sizeSlider && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault();
e.stopPropagation();
const step = parseInt(this.sizeSlider.step) || 1;
const dir = e.key === 'ArrowLeft' ? -1 : 1;
this.sizeSlider.value = parseInt(this.sizeSlider.value) + (step * dir);
this.sizeSlider.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
return;
}
return;
}
if ((target.tagName === 'INPUT' && target !== this.pathInput) || target.tagName === 'SELECT') return;
const keyMap = { 'ArrowUp': 'moveFocus', 'ArrowDown': 'moveFocus', 'ArrowLeft': 'moveFocus', 'ArrowRight': 'moveFocus', 'Enter': 'activateFocusedItem', ' ': 'toggleTooltipForFocusedItem', 'Escape': 'close', 'Delete': 'deleteFocusedItem' };
if (keyMap[e.key]) {
e.preventDefault(); e.stopPropagation();
this.isKeyboardNavigating = true;
if (keyMap[e.key] === 'moveFocus') this.moveFocus(e.key);
else if (keyMap[e.key] === 'deleteFocusedItem') {
const item = this.getAllNavigableItems()[this.currentFocusIndex];
if (item) this.deleteItem(item.name);
}
else this[keyMap[e.key]]();
}
}
show() {
this.currentPath = null;
this.history = [];
this.navigateTo("");
super.show();
this.updateTooltipSizeLimits();
this.element.focus({ preventScroll: true });
// [v1.9.184] Click-outside to close - targeting the native ComfyUI modal backdrop
if (!this.outsideClickListener) {
this.outsideClickListener = (e) => {
if (this.element.style.display !== "none") {
// If the click is on the parent container (the backdrop) and NOT on the element itself
if (e.target === this.element.parentElement || e.target.classList.contains('comfy-modal')) {
// Safety check: Don't close if we are interacting with another aiia modal
if (this.fullscreenViewer && this.fullscreenViewer.element.style.display !== 'none') return;
this.close();
}
}
};
setTimeout(() => document.addEventListener("mousedown", this.outsideClickListener), 10);
}
// Force a resize/scroll event to trigger initial icon rendering
setTimeout(() => { this.render(); }, 100);
}
close() {
if (this.outsideClickListener) {
document.removeEventListener("mousedown", this.outsideClickListener);
this.outsideClickListener = null;
}
this.element.style.display = "none";
this.hideTooltip();
if (typeof super.close === "function") super.close();
else if (typeof super.hide === "function") super.hide();
}
}
app.registerExtension({
name: "Comfy.AIIA.Browser",
setup() {
// --- START: One-time cache cleanup ---
try {
const migrationKey = "aiia-cache-v3-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:v3:")) {
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(); };
document.addEventListener('keydown', (e) => {
if (!browserDialog || (browserDialog.element && browserDialog.element.style.display === 'none')) return;
if (browserDialog.fullscreenViewer && browserDialog.fullscreenViewer.element && browserDialog.fullscreenViewer.element.style.display !== 'none') return;
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
browserDialog.close();
return;
}
if (!browserDialog.element.contains(document.activeElement)) {
const isNavKey = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', ' '].includes(e.key);
if (isNavKey) {
e.preventDefault();
browserDialog.element.focus({ preventScroll: true });
if (browserDialog.currentFocusIndex === -1 && e.key !== ' ') {
const allItems = browserDialog.getAllNavigableItems();
if (allItems.length > 0) browserDialog.setFocus(allItems[0], browserDialog.element.querySelector('[data-item-name]'), true, 'keyboard');
} else {
browserDialog.handleKeyDown(e);
}
}
}
});
const menuButton = $el("button", { id: "aiia-browser-menu-button", textContent: "AIIA Browser", onclick: createDialog });
app.menu.element.appendChild(menuButton);
}
});