diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1a2f77b Binary files /dev/null and b/.DS_Store differ diff --git a/aiia_browser_node.py b/aiia_browser_node.py new file mode 100644 index 0000000..d6f9a07 --- /dev/null +++ b/aiia_browser_node.py @@ -0,0 +1,263 @@ +# --- START OF FILE aiia_browser_node.py (V19 - 健壮性终极修复) --- + +import os +import server +from aiohttp import web +import json +from pathlib import Path +import time +from PIL import Image, ImageOps +import asyncio +import shutil +import traceback +from io import BytesIO +import re +import tempfile + +print("--- [AIIA] Loading Media Browser API Endpoints (V19 - Robustness Fixes) ---") + +# --- START OF FIX: Path and Cache Logic Refinement --- +try: + # Attempt to find the ComfyUI root directory robustly + comfyui_root_path = Path(__file__).resolve().parents[2] + if not (comfyui_root_path / "main.py").exists(): + current_dir = Path(__file__).resolve().parent + while not (current_dir / "main.py").exists() and current_dir != current_dir.parent: + current_dir = current_dir.parent + if (current_dir / "main.py").exists(): + comfyui_root_path = current_dir + else: + raise FileNotFoundError("Could not find ComfyUI root directory.") + output_dir = comfyui_root_path / "output" + print(f"--- [AIIA] Successfully located ComfyUI root. Output directory set to: {output_dir}") + +except Exception as e: + # Fallback path if auto-detection fails + print(f"--- [AIIA] Warning: Could not auto-detect ComfyUI root path ({e}). Falling back to default './ComfyUI/output'.") + output_dir = Path("./ComfyUI/output") + +# Define cache directories based on the final output_dir. This ensures consistency. +cache_main_dir = output_dir / ".aiia_cache" +image_thumb_dir = cache_main_dir / "thumbnails" +video_poster_dir = cache_main_dir / "posters" + +# Create all necessary directories +try: + output_dir.mkdir(parents=True, exist_ok=True) + cache_main_dir.mkdir(exist_ok=True) + image_thumb_dir.mkdir(exist_ok=True) + video_poster_dir.mkdir(exist_ok=True) + print(f"--- [AIIA] Image thumbnail cache: {image_thumb_dir}") + print(f"--- [AIIA] Video poster cache: {video_poster_dir}") +except Exception as e: + print(f"--- [AIIA] CRITICAL ERROR: Could not create output or cache directories at {output_dir}. Please check permissions. Error: {e}") +# --- END OF FIX: Path and Cache Logic Refinement --- + + +CONCURRENT_LIMIT = 16 +semaphore = asyncio.Semaphore(CONCURRENT_LIMIT) + +def get_safe_path(base_dir, relative_path_str): + try: + safe_base = base_dir.resolve() + clean_path = os.path.normpath(relative_path_str) + if '..' in clean_path.split(os.sep): + raise PermissionError("Path traversal attempt detected.") + target_path = (safe_base / Path(clean_path)).resolve() + target_path.relative_to(safe_base) + return target_path + except ValueError: + raise PermissionError("Forbidden path, not within the base directory.") + + +async def _get_file_metadata(file_path: Path): + async with semaphore: + metadata = {} + try: + if not file_path.exists() or file_path.stat().st_size == 0: + return metadata + except FileNotFoundError: + return metadata + + ext = file_path.suffix.lower() + try: + if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp'}: + with Image.open(file_path) as img: + metadata["width"], metadata["height"] = img.size + 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)] + proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + if proc.returncode == 0: + ffprobe_data = json.loads(stdout) + if 'format' in ffprobe_data and 'duration' in ffprobe_data['format']: + metadata['duration'] = float(ffprobe_data['format']['duration']) + video_stream = next((s for s in ffprobe_data.get('streams', []) if s.get('codec_type') == 'video'), None) + if video_stream: + metadata['width'] = video_stream.get('width') + metadata['height'] = video_stream.get('height') + except Exception as e: + pass + return metadata + +async def list_items(request): + relative_path_str = request.query.get("path", "") + try: + target_path = get_safe_path(output_dir, relative_path_str) + if not target_path.is_dir(): return web.Response(status=404, text="Directory not found") + + directories, files = [], [] + for item in os.scandir(target_path): + # --- START OF FIX: Precise filtering --- + # Only filter our specific cache directory, not all dotfiles. + if item.name == '.aiia_cache': + continue + # --- END OF FIX: Precise filtering --- + try: + if item.is_dir(): + try: item_count = len([name for name in os.listdir(item.path) if name != '.aiia_cache']) # Also exclude from count + except OSError: item_count = 0 + directories.append({"name": item.name, "type": "directory", "mtime": item.stat().st_mtime, "item_count": item_count}) + elif item.is_file(): + ext = Path(item.name).suffix.lower() + if ext in {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.mp4', '.mov', '.avi', '.wav', '.mp3', '.ogg'}: + stat_info = item.stat() + file_data = {"name": item.name, "type": "file", "size": stat_info.st_size, "mtime": stat_info.st_mtime, "extension": ext} + files.append(file_data) + except Exception: + continue + + def natural_sort_key(s, _re=re.compile(r'([0-9]+)')): + return [int(text) if text.isdigit() else text.lower() for text in _re.split(s['name'])] + + directories.sort(key=natural_sort_key) + files.sort(key=natural_sort_key) + + return web.json_response({"directories": directories, "files": files}) + except Exception as e: + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_thumbnail(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: + 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") + + cache_sub_dir = get_safe_path(image_thumb_dir, relative_path_str) + cache_sub_dir.mkdir(parents=True, exist_ok=True) + 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'}) + + with Image.open(original_file_path) as img: + img = ImageOps.exif_transpose(img) + if img.mode in ("RGBA", "P"): img = img.convert("RGB") + img.thumbnail((256, 256), Image.Resampling.LANCZOS) + + buffer = BytesIO() + img.save(buffer, format="JPEG", quality=85, optimize=True) + buffer.seek(0) + + async def write_cache(): + try: + with open(cached_thumb_path, 'wb') as f: f.write(buffer.getvalue()) + except Exception as e: print(f"[AIIA] Error writing thumbnail cache for {filename}: {e}") + + asyncio.create_task(write_cache()) + + return web.Response(body=buffer.getvalue(), content_type='image/jpeg') + + except Exception as e: + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_video_poster(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.") + + if not shutil.which("ffmpeg"): + return web.Response(status=501, text="ffmpeg not found on server.") + + 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") + + 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'}) + + # 使用临时文件确保 ffmpeg 操作的原子性 + with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmpfile: + tmp_poster_path = tmpfile.name + + command = [ + "ffmpeg", + "-ss", "00:00:01.00", + "-i", str(original_file_path), + "-frames:v", "1", + "-vf", "thumbnail,scale=256:-1", + "-y", # Overwrite output file if it exists + str(tmp_poster_path) + ] + proc = await asyncio.create_subprocess_exec(*command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) + stdout, stderr = await proc.communicate() + + if proc.returncode != 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.") + + # 检查生成的文件是否有效 + 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'}) + except Exception as e: + if 'tmp_poster_path' in locals() and os.path.exists(tmp_poster_path): + os.remove(tmp_poster_path) + traceback.print_exc() + return web.Response(status=500, text=str(e)) + +async def get_batch_metadata(request): + data = await request.json() + path = data.get("path", "") + filenames = data.get("filenames", []) + try: + safe_base = output_dir.resolve() + tasks = [] + valid_filenames = [] + for filename in filenames: + file_path = (safe_base / Path(path) / filename).resolve() + if safe_base in file_path.parents and file_path.is_file(): + tasks.append(_get_file_metadata(file_path)) + valid_filenames.append(filename) + results = await asyncio.gather(*tasks) + response_data = {valid_filenames[i]: results[i] for i in range(len(valid_filenames))} + return web.json_response(response_data) + 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) + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +print("--- [AIIA] Media Browser API Endpoints (V19) loaded successfully. ---") \ No newline at end of file diff --git a/js/aiia_browser.js b/js/aiia_browser.js new file mode 100644 index 0000000..25ce4cb --- /dev/null +++ b/js/aiia_browser.js @@ -0,0 +1,474 @@ +// 文件: aiia_browser.js (V75 - 设置面板终极修复) + +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:"; +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.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.loadQueue = new Set(); + this.isProcessingQueue = false; + this.scrollDebounceHandle = null; + this.isScrolling = 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, rowHeight: 31, buffer: 10, lastRendered: { start: -1, end: -1 }, scrollDebounce: null }; + this.applyFocusRaf = 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: this.contentArea, rootMargin: '300px 0px 300px 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.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]); + + 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(); if(this.viewMode === 'list') this.renderVisibleRows(); }); + 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.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`); }; + + 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.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 }); }); } } + 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; } } + + 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'); + + 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; 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; } } + 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); } if (this.viewMode === 'list') this.startMetadataBatchLoading(); } + 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(); } + 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'); 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' }); } } } + + 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') { + const grid = this.contentArea.querySelector('.aiia-grid-inner-wrapper'); + if (grid) columns = window.getComputedStyle(grid).getPropertyValue('grid-template-columns').split(' ').length; + } + + 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]; 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); } } + + 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' }; + if (keyMap[e.key]) { + e.preventDefault(); e.stopPropagation(); + this.isKeyboardNavigating = true; + if(keyMap[e.key] === 'moveFocus') this.moveFocus(e.key); + else this[keyMap[e.key]](); + } + } + + show() { this.currentPath = null; this.history = []; this.navigateTo(""); super.show(); this.updateTooltipSizeLimits(); this.element.focus({ preventScroll: true }); } +} + +app.registerExtension({ + name: "Comfy.AIIA.Browser", + setup() { + 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.style.display === 'none') return; + if (browserDialog.fullscreenViewer && browserDialog.fullscreenViewer.element.style.display !== 'none') 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); + } +}); \ No newline at end of file diff --git a/js/aiia_browser_styles.js b/js/aiia_browser_styles.js new file mode 100644 index 0000000..71ffd7a --- /dev/null +++ b/js/aiia_browser_styles.js @@ -0,0 +1,176 @@ +// aiia_browser_styles.js (V67 - 设置面板样式修复) +export const browserStyles = ` + /* Main Browser Styles */ + #aiia-browser-menu-button { margin-left: 10px; } + .comfy-modal.aiia-browser-dialog-root { top: 50%; left: 50%; transform: translate(-50%, -50%); width: 80vw; height: 85vh; max-width: 1400px; max-height: 1000px; min-width: 800px; min-height: 500px; display: flex; flex-direction: column; padding: 0; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); resize: both; overflow: hidden; } + .aiia-browser-titlebar { background: var(--comfy-box-bg); padding: 4px 8px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); flex-shrink: 0; color: #F9FAFB; cursor: default; } + .aiia-browser-main-container { display: flex; flex-direction: column; flex-grow: 1; padding: 8px; overflow: hidden; } + .aiia-browser-header-controls { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; flex-shrink: 0; gap: 8px; flex-wrap: wrap; } + .aiia-browser-nav-container { display: flex; flex-grow: 1; align-items: center; gap: 4px; border: 1px solid var(--border-color); border-radius: 4px; padding-right: 4px; } + .aiia-browser-nav-button { background: none; border: none; font-size: 18px; cursor: pointer; color: #E5E7EB; padding: 4px 8px; line-height: 1; } + .aiia-browser-nav-button:disabled { color: #555; cursor: not-allowed; } + .aiia-browser-nav-button:hover:not(:disabled) { background-color: var(--comfy-hover-bg); border-radius: 2px; } + .aiia-browser-breadcrumbs { flex-grow: 1; padding: 4px 8px; border: none; display: flex; flex-wrap: nowrap; gap: 4px; color: #D1D5DB; overflow: hidden; cursor: text; } + .aiia-browser-breadcrumb-link { cursor: pointer; color: var(--link-color); white-space: nowrap; } + .aiia-browser-path-input-container { flex-grow: 1; display: none; align-items: center; background-color: var(--comfy-input-bg); border: 1px solid var(--input-active-border); border-radius: 4px; padding: 0 8px; } + .aiia-browser-path-prefix { color: var(--descrip-text); margin-right: 4px; white-space: nowrap; } + .aiia-browser-path-input { flex-grow: 1; background: transparent; border: none; outline: none; color: var(--input-text); padding: 4px 0; font-family: monospace; } + + .aiia-browser-view-controls { display: flex; align-items: center; gap: 4px; } + .aiia-browser-view-controls button, .aiia-browser-view-controls select, .aiia-browser-view-controls input { padding: 4px 8px; border: 1px solid var(--border-color); background: var(--comfy-input-bg); color: #E5E7EB; border-radius: 4px; cursor: pointer; vertical-align: middle; } + .aiia-browser-view-controls button.active { border-color: var(--input-active-border); background: var(--comfy-menu-bg); } + .aiia-browser-sort-controls { display: flex; align-items: center; gap: 4px; } + .aiia-browser-sort-controls button { font-size: 12px; padding: 4px 6px; } + + /* --- Settings Panel Styles --- */ + .aiia-settings-container { position: relative; } + .aiia-browser-settings-button { font-size: 16px; padding: 4px 6px; line-height: 1; } + .aiia-settings-panel { + display: none; + position: absolute; + top: 100%; + left: 0; + background-color: var(--comfy-menu-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 8px 12px; + z-index: 1003; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + color: #E5E7EB; + font-size: 13px; + min-width: 220px; + } + .aiia-settings-panel > div:first-of-type { + border-bottom: 1px solid var(--border-color); + padding-bottom: 6px; + margin-bottom: 4px; + } + .aiia-settings-label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + white-space: nowrap; + padding: 6px 4px; + border-radius: 3px; + } + .aiia-settings-label:hover { + background-color: var(--comfy-hover-bg); + color: var(--link-color); + } + + .aiia-split-view-container { display: flex; flex-grow: 1; gap: 8px; overflow: hidden; position: relative; } + .aiia-directory-panel { width: 220px; min-width: 150px; max-width: 50%; flex-shrink: 0; display: flex; flex-direction: column; border: 1px solid var(--border-color); resize: horizontal; overflow: hidden; background-color: var(--comfy-box-bg); } + .aiia-directory-panel.hidden { display: none; } + .aiia-directory-panel-header { padding: 8px; font-weight: bold; border-bottom: 1px solid var(--border-color); flex-shrink: 0; text-align: center; color: #E5E7EB; } + .aiia-directory-list { overflow-y: auto; padding: 4px; } + .aiia-directory-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 4px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #D1D5DB; } + .aiia-directory-item:before { content: '📁'; font-size: 16px; } + .aiia-directory-item:hover { background-color: var(--comfy-hover-bg); color: #F9FAFB; } + .aiia-directory-item.focused, .aiia-item-icon.focused, .aiia-list-row.focused { outline: 2px solid var(--accent-color, #4a90e2) !important; outline-offset: -2px; background-color: var(--comfy-hover-bg); } + + .aiia-content-panel { flex-grow: 1; border: 1px solid var(--border-color); background-color: var(--comfy-box-bg); overflow: hidden; display: flex; } + .aiia-browser-content { width: 100%; height: 100%; position: relative; overflow: hidden; display: flex; } + + .aiia-grid-wrapper::-webkit-scrollbar, .aiia-list-body::-webkit-scrollbar, .aiia-custom-tooltip::-webkit-scrollbar, .aiia-filmstrip-container::-webkit-scrollbar, .aiia-directory-list::-webkit-scrollbar { width: 16px; height: 16px; } + .aiia-grid-wrapper::-webkit-scrollbar-track, .aiia-list-body::-webkit-scrollbar-track, .aiia-custom-tooltip::-webkit-scrollbar-track, .aiia-filmstrip-container::-webkit-scrollbar-track, .aiia-directory-list::-webkit-scrollbar-track { background: transparent; } + .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-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-video-poster-container::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 30%; + height: 30%; + max-width: 48px; + max-height: 48px; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 50%; + -webkit-mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black"%3E%3Cpath d="M8 5v14l11-7z"/%3E%3C/svg%3E'); + mask-image: url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="black"%3E%3Cpath d="M8 5v14l11-7z"/%3E%3C/svg%3E'); + -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; + pointer-events: none; + } + .aiia-item-icon:hover .aiia-video-poster-container::after { + background-color: white; + transform: translate(-50%, -50%) scale(1.1); + } + + .aiia-custom-tooltip { position: absolute; display: none; padding: 12px; background-color: rgba(30, 30, 30, 0.95); border: 1px solid var(--border-color); color: white; border-radius: 8px; z-index: 1002; font-size: 13px; pointer-events: none; backdrop-filter: blur(4px); box-sizing: border-box; flex-direction: column; align-items: flex-start; } + .aiia-tooltip-media { width: 100%; margin-bottom: 10px; max-width: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-metadata { display: block; max-width: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-media:not(:has(> :not([style*="display: none"]))) { display: none; margin-bottom: 0; } + .aiia-tooltip-image, .aiia-tooltip-video { width: 100%; height: auto; object-fit: contain; display: none; border-radius: 4px; max-width: var(--aiia-tooltip-media-max-size, 250px); max-height: var(--aiia-tooltip-media-max-size, 250px); } + .aiia-tooltip-row { display: grid; grid-template-columns: auto 1fr; gap: 8px; margin-top: 4px; } + .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-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-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-icon { font-size: 16px; text-align: center; } + + .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-progress-container { display: none; flex-direction: column; gap: 4px; margin-bottom: 8px; } + .aiia-progress-text { font-size: 12px; color: var(--descrip-text); } + .aiia-progress-bar { width: 100%; height: 4px; background-color: var(--comfy-menu-bg); border-radius: 2px; } + .aiia-progress-bar > div { width: 0%; height: 100%; background-color: var(--accent-color); border-radius: 2px; transition: width 0.2s ease-out; } + + .aiia-fullscreen-viewer { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(10, 10, 10, 0.9); backdrop-filter: blur(8px); z-index: 1001; display: none; flex-direction: column; } + .aiia-fullscreen-main-content { display: flex; flex-grow: 1; align-items: center; position: relative; width: 100%; overflow: hidden; } + .aiia-fullscreen-media-container { flex-grow: 1; display: flex; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; width: 100%; height: 100%; } + .aiia-fullscreen-media-container > * { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 4px; } + .aiia-fullscreen-audio-wrapper { display: flex; flex-direction: column; align-items: center; gap: 20px; color: white; } + .aiia-fullscreen-audio-icon { font-size: 128px; } + .aiia-fullscreen-close-button, .aiia-fullscreen-nav { background: rgba(0,0,0,0.4); color: white; border: none; border-radius: 50%; cursor: pointer; font-size: 24px; width: 44px; height: 44px; line-height: 44px; text-align: center; transition: all 0.2s; z-index: 12; } + .aiia-fullscreen-close-button:hover, .aiia-fullscreen-nav:hover { background: rgba(0,0,0,0.7); } + .aiia-fullscreen-close-button { position: absolute; top: 20px; right: 20px; } + .aiia-fullscreen-nav { position: absolute; top: 50%; transform: translateY(-50%); } + .aiia-fullscreen-nav.prev { left: 20px; } + .aiia-fullscreen-nav.next { right: 20px; } + .aiia-fullscreen-nav:disabled { opacity: 0.3; cursor: not-allowed; } + .aiia-filmstrip-container { z-index: 11; flex-shrink: 0; background: linear-gradient(to top, rgba(0,0,0,0.7), transparent); padding: 15px; display: flex; gap: 10px; overflow-x: auto; width: 100%; box-sizing: border-box; } + .aiia-filmstrip-thumb { height: 80px; width: 80px; flex-shrink: 0; background: #222; border-radius: 4px; cursor: pointer; border: 2px solid transparent; transition: border-color 0.2s, opacity 0.2s; overflow: hidden; display: flex; align-items: center; justify-content: center; } + .aiia-filmstrip-thumb:hover { border-color: #555; } + .aiia-filmstrip-thumb.active { border-color: var(--accent-color, #007BFF); } + .aiia-filmstrip-thumb-content { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } + .aiia-filmstrip-thumb img { width: 100%; height: 100%; object-fit: cover; } + .aiia-filmstrip-thumb video { width: 100%; height: 100%; object-fit: contain; background-color: #000; } + .aiia-filmstrip-placeholder, .aiia-filmstrip-placeholder-icon { font-size: 32px; color: #888; background-color: #2a2a2a; width:100%; height:100%; display:flex; align-items:center; justify-content:center; } + .aiia-info-panel { position: absolute; bottom: 120px; right: 20px; width: auto; max-width: 350px; height: auto; background: rgba(15,15,15,0.8); padding: 10px 15px; box-sizing: border-box; color: #eee; border-radius: 6px; transition: all 0.2s ease-in-out; z-index: 11; pointer-events: none; backdrop-filter: blur(4px); } + .aiia-info-panel:hover { pointer-events: auto; } + .aiia-info-panel-row { display: grid; grid-template-columns: 80px 1fr; gap: 8px; margin-bottom: 8px; font-size: 13px; } + .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 diff --git a/js/aiia_fullscreen_viewer.js b/js/aiia_fullscreen_viewer.js new file mode 100644 index 0000000..449e3a8 --- /dev/null +++ b/js/aiia_fullscreen_viewer.js @@ -0,0 +1,374 @@ +// 文件: aiia_fullscreen_viewer.js (V2 - 增加滑动和滚动导航) + +import { $el } from "/scripts/ui.js"; +import { api } from "/scripts/api.js"; + +// Helper functions +function getFileUrl(filename, path = "") { const subfolder = path || ""; return api.apiURL(`/view?type=output&subfolder=${encodeURIComponent(subfolder)}&filename=${encodeURIComponent(filename)}`); } +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)) return '00:00'; const min = Math.floor(seconds / 60); const sec = Math.floor(seconds % 60); return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } +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'; } + +export class AIIAFullscreenViewer { + constructor() { + this.items = []; + this.currentIndex = -1; + this.currentPath = ""; + this.onCloseCallback = null; + this.filmstripObserver = null; + this.loadMediaTimeout = null; + + // V2 优化: 增加滑动和滚动相关的状态 + this.longPressTimeout = null; + this.longPressInterval = null; + this.swipeState = { + isDragging: false, + startX: 0, + threshold: 50, // 至少滑动50px才算有效 + }; + this.scrollDebounce = { + timeout: null, + delay: 150, // 滚动事件的防抖延迟 + }; + + this.element = $el("div.aiia-fullscreen-viewer"); + this.element.setAttribute('tabindex', -1); + + this.mediaContainer = $el("div.aiia-fullscreen-media-container"); + this.closeButton = $el("button.aiia-fullscreen-close-button", { textContent: "✖" }); + this.prevButton = $el("button.aiia-fullscreen-nav.prev", { textContent: "❮" }); + this.nextButton = $el("button.aiia-fullscreen-nav.next", { textContent: "❯" }); + this.filmstripContainer = $el("div.aiia-filmstrip-container"); + this.infoPanel = $el("div.aiia-info-panel"); + + const mainContent = $el("div.aiia-fullscreen-main-content", [ this.prevButton, this.mediaContainer, this.nextButton ]); + this.element.append(mainContent, this.infoPanel, this.filmstripContainer, this.closeButton); + document.body.appendChild(this.element); + + this.bindEvents(); + } + + bindEvents() { + this.closeButton.onclick = () => this.hide(); + + const setupNavButtonEvents = (button, direction) => { + const clearTimers = () => { + clearTimeout(this.longPressTimeout); + clearInterval(this.longPressInterval); + this.longPressTimeout = null; + this.longPressInterval = null; + }; + + button.addEventListener("mousedown", (e) => { + e.preventDefault(); + + if (direction === 'prev') this.prev(); + else if (direction === 'next') this.next(); + + this.longPressTimeout = setTimeout(() => { + this.longPressInterval = setInterval(() => { + if (direction === 'prev') this.prev(); + else if (direction === 'next') this.next(); + }, 150); + }, 500); + }); + + button.addEventListener("mouseup", clearTimers); + button.addEventListener("mouseleave", clearTimers); + + window.addEventListener("blur", clearTimers); + }; + + setupNavButtonEvents(this.prevButton, 'prev'); + setupNavButtonEvents(this.nextButton, 'next'); + + // V2 优化: 增加滑动事件监听 + const handleSwipeStart = (e) => { + if (e.target.tagName === 'VIDEO' || e.target.tagName === 'AUDIO') return; // 不在视频/音频控件上触发滑动 + e.preventDefault(); + this.swipeState.isDragging = true; + this.swipeState.startX = e.clientX; + this.mediaContainer.style.cursor = 'grabbing'; + }; + + const handleSwipeEnd = (e) => { + if (!this.swipeState.isDragging) return; + this.swipeState.isDragging = false; + this.mediaContainer.style.cursor = 'grab'; + + const deltaX = e.clientX - this.swipeState.startX; + if (Math.abs(deltaX) > this.swipeState.threshold) { + if (deltaX > 0) { + this.prev(); + } else { + this.next(); + } + } + }; + + this.mediaContainer.addEventListener('mousedown', handleSwipeStart); + this.mediaContainer.addEventListener('mouseup', handleSwipeEnd); + this.mediaContainer.addEventListener('mouseleave', () => { + if (this.swipeState.isDragging) { + this.swipeState.isDragging = false; + this.mediaContainer.style.cursor = 'grab'; + } + }); + + // V2 优化: 增加滚轮事件监听 + this.element.addEventListener('wheel', (e) => { + e.preventDefault(); + e.stopPropagation(); + + if (this.scrollDebounce.timeout) return; + + this.scrollDebounce.timeout = setTimeout(() => { + this.scrollDebounce.timeout = null; + }, this.scrollDebounce.delay); + + if (e.deltaY < 0) { + this.navigateVertical('up'); + } else if (e.deltaY > 0) { + this.navigateVertical('down'); + } + }); + + this.handleKeyDown = (e) => { + if (this.element.style.display === 'none') return; + switch (e.key) { + case "Escape": e.preventDefault(); e.stopPropagation(); this.hide(); break; + case "ArrowLeft": e.preventDefault(); this.prev(); break; + case "ArrowRight": e.preventDefault(); this.next(); break; + // V2 优化: 增加上下方向键导航 + case "ArrowUp": e.preventDefault(); this.navigateVertical('up'); break; + case "ArrowDown": e.preventDefault(); this.navigateVertical('down'); break; + case " ": e.preventDefault(); this.infoPanel.style.display = this.infoPanel.style.display === 'none' ? '' : 'none'; break; + } + }; + this.element.addEventListener('keydown', this.handleKeyDown); + } + + // V2 优化: 新增垂直导航方法 + navigateVertical(direction) { + const thumb = this.filmstripContainer.querySelector('.aiia-filmstrip-thumb'); + if (!thumb) return; + + // 计算filmstrip一行可以显示多少个缩略图 + const thumbStyle = getComputedStyle(thumb); + const containerStyle = getComputedStyle(this.filmstripContainer); + const thumbWidth = thumb.offsetWidth + parseFloat(thumbStyle.marginLeft) + parseFloat(thumbStyle.marginRight); + const gap = parseFloat(containerStyle.gap) || 10; + const totalThumbWidth = thumbWidth + gap; + const columns = Math.max(1, Math.floor(this.filmstripContainer.clientWidth / totalThumbWidth)); + + let newIndex = this.currentIndex; + if (direction === 'up') { + newIndex -= columns; + } else if (direction === 'down') { + newIndex += columns; + } + + // 确保新索引在有效范围内 + newIndex = Math.max(0, Math.min(this.items.length - 1, newIndex)); + + if (newIndex !== this.currentIndex) { + this.jumpTo(newIndex); + } + } + + show(items, currentIndex, currentPath, onCloseCallback = null) { + this.items = items; + this.currentIndex = currentIndex; + this.currentPath = currentPath; + this.onCloseCallback = onCloseCallback; + if (this.currentIndex === -1) return; + + this.mediaContainer.style.cursor = 'grab'; // V2 优化: 设置初始鼠标手势 + + this.renderFilmstrip(); + this.element.style.display = "flex"; + this.element.focus({ preventScroll: true }); + + this.loadMedia(); + } + + hide() { + if (this.onCloseCallback) { + this.onCloseCallback(); + } + this.element.style.display = "none"; + this.mediaContainer.innerHTML = ""; + this.filmstripContainer.innerHTML = ""; + if (this.filmstripObserver) this.filmstripObserver.disconnect(); + clearTimeout(this.loadMediaTimeout); + clearTimeout(this.longPressTimeout); + clearInterval(this.longPressInterval); + clearTimeout(this.scrollDebounce.timeout); // V2 优化: 清除滚动计时器 + } + + renderFilmstrip() { + this.filmstripContainer.innerHTML = ""; + if(this.filmstripObserver) this.filmstripObserver.disconnect(); + this.filmstripObserver = new IntersectionObserver(this.handleFilmstripIntersection.bind(this), { + root: this.filmstripContainer, + threshold: 0.1 + }); + + this.items.forEach((item, index) => { + const thumb = $el("div.aiia-filmstrip-thumb", { + dataset: { index, filename: item.name }, + onclick: () => this.jumpTo(index) + }); + const mime = getMimeType(item.name); + const thumbContent = $el("div.aiia-filmstrip-thumb-content"); + thumb.appendChild(thumbContent); + + if (mime === 'video') { + thumbContent.innerHTML = '
🎬
'; + thumb.dataset.mediaType = 'video'; + } else if (mime === 'image') { + thumbContent.innerHTML = '
'; + thumb.dataset.mediaType = 'image'; + } else { + thumbContent.innerHTML = '
🎵
'; + thumb.dataset.mediaType = 'audio'; + } + + this.filmstripContainer.appendChild(thumb); + if (mime === 'image' || mime === 'video') { + this.filmstripObserver.observe(thumb); + } + }); + } + + handleFilmstripIntersection(entries) { + for (const entry of entries) { + if (entry.isIntersecting) { + const thumb = entry.target; + const mediaType = thumb.dataset.mediaType; + const filename = thumb.dataset.filename; + const thumbContent = thumb.querySelector('.aiia-filmstrip-thumb-content'); + const url = getFileUrl(filename, this.currentPath); + let mediaElement; + + if (mediaType === 'image') { + mediaElement = $el("img", { src: url }); + } else if (mediaType === 'video') { + mediaElement = $el("video", { + src: url, autoplay: true, muted: true, loop: true, playsinline: true + }); + } + + if (mediaElement) { + thumbContent.replaceChildren(mediaElement); + this.filmstripObserver.unobserve(thumb); + } + } + } + } + + updateInfoPanel(item, extra = {}) { + this.infoPanel.innerHTML = ""; + const createMetaRow = (label, value) => { + if (value === undefined || value === null || value === '—' || value === '') return; + this.infoPanel.appendChild($el("div.aiia-info-panel-row", [ + $el("span.aiia-info-panel-label", { textContent: label }), + $el("span.aiia-info-panel-value", { textContent: value }) + ])); + }; + + createMetaRow("Name", item.name); + createMetaRow("Date", formatDate(item.mtime)); + createMetaRow("Type", item.extension.replace('.', '').toUpperCase()); + createMetaRow("Size", formatBytes(item.size)); + + const dimensions = extra.dimensions || (item.width && item.height ? `${item.width} x ${item.height}` : null); + if (dimensions) createMetaRow("Dimensions", dimensions); + + if (extra.duration) createMetaRow("Duration", extra.duration); + } + + loadMedia() { + if (this.currentIndex < 0 || this.currentIndex >= this.items.length) return; + const item = this.items[this.currentIndex]; + this.mediaContainer.innerHTML = ""; + + const url = getFileUrl(item.name, this.currentPath); + const mime = getMimeType(item.name); + + this.updateInfoPanel(item); + + let mediaElement; + if (mime === 'image') { + mediaElement = $el("img", { src: url }); + } else if (mime === 'video') { + mediaElement = $el("video", { src: url, controls: true, autoplay: true, loop: true }); + mediaElement.onloadedmetadata = () => { + const dimensions = `${mediaElement.videoWidth} x ${mediaElement.videoHeight}`; + this.updateInfoPanel(item, { dimensions, duration: formatDuration(mediaElement.duration) }); + }; + } else if (mime === 'audio') { + mediaElement = $el("div.aiia-fullscreen-audio-wrapper", [ + $el("div.aiia-fullscreen-audio-icon", { textContent: "🎵" }), + $el("audio", { + src: url, controls: true, autoplay: true, + onloadedmetadata: (e) => this.updateInfoPanel(item, { duration: formatDuration(e.target.duration) }) + }) + ]); + } else { + mediaElement = $el("div", { textContent: `Unsupported file type: ${item.name}` }); + } + + this.mediaContainer.appendChild(mediaElement); + this.updateNavButtons(); + this.updateFilmstripHighlight(); + } + + updateFilmstripHighlight() { + const thumbs = this.filmstripContainer.children; + for (let i = 0; i < thumbs.length; i++) { + thumbs[i].classList.toggle("active", i === this.currentIndex); + } + const activeThumb = this.filmstripContainer.querySelector('.active'); + if (activeThumb) { + activeThumb.scrollIntoView({ behavior: 'auto', block: 'nearest', inline: 'center' }); + } + } + + jumpTo(index) { + if (index >= 0 && index < this.items.length) { + this.currentIndex = index; + clearTimeout(this.loadMediaTimeout); + this.loadMedia(); + } + } + + navigate(direction) { + let newIndex = this.currentIndex; + if (direction === 'prev' && this.currentIndex > 0) newIndex--; + else if (direction === 'next' && this.currentIndex < this.items.length - 1) newIndex++; + else return; + + if (newIndex === this.currentIndex) return; + this.currentIndex = newIndex; + + this.updateNavButtons(); + this.updateFilmstripHighlight(); + clearTimeout(this.loadMediaTimeout); + this.loadMediaTimeout = setTimeout(() => this.loadMedia(), 150); + } + + prev() { this.navigate('prev'); } + next() { this.navigate('next'); } + + updateNavButtons() { + this.prevButton.disabled = this.currentIndex <= 0; + this.nextButton.disabled = this.currentIndex >= this.items.length - 1; + } + + destroy() { + this.element.removeEventListener('keydown', this.handleKeyDown); + this.element.remove(); + } +} \ No newline at end of file diff --git a/node.zip b/node.zip new file mode 100644 index 0000000..07a655b Binary files /dev/null and b/node.zip differ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..57fd5d4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "ComfyUI_AIIA", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pyproject.toml b/pyproject.toml index f5edda9..8f45ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "aiia" -description = "AIIA Nodes for ComfyUI is a comprehensive suite of advanced nodes designed to streamline and supercharge your video and audio generation workflows. It centrally addresses the critical Out-Of-Memory (OOM) issues encountered when processing long sequences by intelligently handling frames on disk. The suite includes: a powerful Video Combiner with an extensible format system; memory-efficient FLOAT Talking Head Animator nodes with both in-memory (for speed) and on-disk (for stability) modes; and a sophisticated Speaker Diarization toolkit powered by NeMo for identifying who spoke when. Utility nodes like the OOM-safe Image Sequence Concatenator are also included to create comparison or multi-panel videos effortlessly." -version = "1.0.4" +description = "Tired of OOM errors and tedious file management? The AIIA Node Suite delivers the ultimate workflow overhaul, headlined by the AIIA Media Browser: a high-performance file hub fully integrated into ComfyUI. Instantly preview images, videos, and audio, and experience ultimate smoothness thanks to smart caching and virtual scrolling, even with tens of thousands of files. It also features a powerful fullscreen viewer, rich tooltip previews, and flexible sorting. Alongside the browser, our Video Combine node fundamentally solves memory bottlenecks in long video generation. By streaming frames from disk, you can create videos of virtually unlimited length, making OOM errors a thing of the past. The suite also includes OOM-safe FLOAT video generation, advanced audio processing, and other utilities, providing a complete end-to-end solution from content creation to asset management." +version = "1.1.0" license = {file = "LICENSE"} [project.urls]