/** * 图片幻灯片 - 主逻辑 * * 关键设计: * - 两张 .slide 重叠,通过 .active 类切换显示,CSS 处理动画 * - 持久化:DirectoryHandle 存到 IndexedDB,普通配置存到 localStorage * - 动画时长通过 --anim-duration CSS 变量控制,slide 动画方向由 --enter-from / --exit-to 控制 * * 浏览器要求:基于 Chromium 的浏览器,且页面运行在 HTTPS 或 localhost 下。 */ (function () { 'use strict'; // ============================================================ // DOM 引用 // ============================================================ const $ = (id) => document.getElementById(id); const stage = $('stage'); const slideshow = $('slideshow'); const slideA = $('slideA'); const slideB = $('slideB'); const emptyHint = $('emptyHint'); const loadingHint = $('loadingHint'); const directoryListEl = $('directoryList'); const settingsModal = $('settingsModal'); // ============================================================ // 全局状态 // ============================================================ /** * 应用状态对象 * @type {{ * config: {interval: number, animation: string, duration: number, random: boolean}, * directories: Array<{handle: FileSystemDirectoryHandle, name: string, status: string, imageCount: number}>, * images: Array<{url: string, name: string, size: number}>, * currentIndex: number, * isPlaying: boolean, * timerId: number|null, * activeSlide: 'A' | 'B' * }} */ const state = { config: { ...DEFAULT_CONFIG }, directories: [], images: [], currentIndex: -1, isPlaying: true, timerId: null, activeSlide: 'A', }; // ============================================================ // IndexedDB:用于存储 DirectoryHandle // DirectoryHandle 不能被序列化到 localStorage,必须放入 IndexedDB // ============================================================ /** @type {Promise | null} */ let dbPromise = null; /** * 打开(首次访问时自动创建)IndexedDB 数据库。 * @returns {Promise} */ function openDB() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version); req.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(DB_CONFIG.store)) { db.createObjectStore(DB_CONFIG.store, { keyPath: 'id', autoIncrement: true, }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return dbPromise; } /** * 把当前所有目录句柄覆盖保存到 IndexedDB。 * @returns {Promise} */ async function persistHandles() { try { const db = await openDB(); await new Promise((resolve, reject) => { const tx = db.transaction(DB_CONFIG.store, 'readwrite'); const store = tx.objectStore(DB_CONFIG.store); store.clear(); state.directories.forEach((d) => store.add({ handle: d.handle })); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } catch (e) { console.warn('保存目录句柄失败:', e); } } /** * 从 IndexedDB 恢复 DirectoryHandle 列表。 * @returns {Promise} */ async function loadHandles() { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(DB_CONFIG.store, 'readonly'); const store = tx.objectStore(DB_CONFIG.store); const req = store.getAll(); req.onsuccess = () => resolve(req.result.map((r) => r.handle)); req.onerror = () => reject(req.error); }); } // ============================================================ // 普通配置持久化(localStorage) // ============================================================ /** 把当前 state.config 写入 localStorage。 */ function saveConfig() { try { localStorage.setItem( STORAGE_KEY_CONFIG, JSON.stringify(state.config), ); } catch (e) { console.warn('保存配置失败:', e); } } /** 启动时从 localStorage 读取配置。 */ function loadConfig() { try { const saved = localStorage.getItem(STORAGE_KEY_CONFIG); if (!saved) return; const parsed = JSON.parse(saved); // 只保留已知的合法字段,避免被异常数据污染 state.config = { interval: clampInt( parsed.interval, DEFAULT_CONFIG.interval, 500, 30000, ), animation: ANIMATION_TYPES.includes(parsed.animation) ? parsed.animation : DEFAULT_CONFIG.animation, duration: clampInt(parsed.duration, DEFAULT_CONFIG.duration, 0, 3000), random: Boolean(parsed.random), }; } catch (e) { console.warn('加载配置失败:', e); } } /** @param {*} v @param {number} fallback @param {number} min @param {number} max */ function clampInt(v, fallback, min, max) { const n = parseInt(v, 10); if (!Number.isFinite(n)) return fallback; return Math.max(min, Math.min(max, n)); } // ============================================================ // File System Access API // ============================================================ /** * 确保对某个 DirectoryHandle 拥有读权限。 * 首次选择目录时会直接被浏览器授权;之后重新打开页面需要重新请求权限。 * @param {FileSystemDirectoryHandle} handle * @returns {Promise<'granted' | 'prompt' | 'denied'>} */ async function ensurePermission(handle) { if (!handle || typeof handle.queryPermission !== 'function') { return 'granted'; } const opts = { mode: 'read' }; try { const cur = await handle.queryPermission(opts); if (cur === 'granted') return 'granted'; return await handle.requestPermission(opts); } catch (e) { console.warn('请求权限失败:', e); return 'denied'; } } /** * 递归读取目录中的图片到 out 数组。 * 会跳过以 "." 开头的隐藏目录。 * @param {FileSystemDirectoryHandle} handle * @param {Array<{url: string, name: string, size: number}>} out */ async function readDirectory(handle, out) { // entries() 在某些 Chromium 版本下更兼容;这里用异步迭代器 for await (const entry of handle.values()) { try { if (entry.kind === 'file') { const lower = entry.name.toLowerCase(); if (IMAGE_EXTENSIONS.some((ext) => lower.endsWith('.' + ext))) { const file = await entry.getFile(); out.push({ url: URL.createObjectURL(file), name: entry.name, size: file.size, }); } } else if (entry.kind === 'directory') { // 跳过隐藏目录,避免读取大量无用文件 if (entry.name && !entry.name.startsWith('.')) { await readDirectory(entry, out); } } } catch (e) { // 单个文件/子目录失败不影响整体 console.warn('读取条目失败:', entry.name, e); } } } // ============================================================ // 图片加载入口 // ============================================================ /** * 读取所有已添加目录中的图片,刷新 state.images 并触发首张显示。 */ async function loadAllImages() { // 加载期间隐藏空状态提示,避免与加载提示重叠 emptyHint.hidden = true; showLoading(true); // 释放旧的 blob URL,避免内存泄漏 state.images.forEach((img) => img.url && URL.revokeObjectURL(img.url)); state.images = []; const allImages = []; for (const dir of state.directories) { const perm = await ensurePermission(dir.handle); if (perm !== 'granted') { dir.status = 'denied'; dir.imageCount = 0; continue; } try { const list = []; await readDirectory(dir.handle, list); dir.imageCount = list.length; dir.status = 'ok'; allImages.push(...list); } catch (e) { console.error('读取目录失败:', dir.name, e); dir.status = 'error'; dir.imageCount = 0; } } state.images = allImages; state.currentIndex = -1; state.activeSlide = 'A'; slideA.classList.remove('active'); slideB.classList.remove('active'); slideA.style.backgroundImage = ''; slideB.style.backgroundImage = ''; showLoading(false); renderDirectoryList(); if (state.images.length > 0) { nextImage(); } else { emptyHint.hidden = false; stopTimer(); } } // ============================================================ // 幻灯片显示 // ============================================================ /** * 在两张 slide 中切换显示指定索引的图片。 * - 先把 nextSlide 用 .entering 类临时定位到进入位置(无过渡) * - 强制 reflow 后清除 .entering 并切换 .active,让 CSS 接管过渡 * @param {number} index */ function showImage(index) { if (state.images.length === 0) return; const url = state.images[index].url; const currentSlide = state.activeSlide === 'A' ? slideA : slideB; const nextSlide = state.activeSlide === 'A' ? slideB : slideA; nextSlide.style.backgroundImage = `url("${url}")`; // slide 动画方向交替(A→B 时下一张从右进,B→A 时从左进) const dir = state.activeSlide === 'A' ? 1 : -1; slideshow.style.setProperty('--enter-from', `${dir * 100}%`); slideshow.style.setProperty('--exit-to', `${-dir * 100}%`); // 把下一张临时定位到"进入位置",并关掉过渡 nextSlide.classList.remove('active'); nextSlide.classList.add('entering'); // 强制 reflow,让 .entering 的样式生效 void nextSlide.offsetWidth; // 启动过渡到 active 位置 nextSlide.classList.remove('entering'); nextSlide.classList.add('active'); currentSlide.classList.remove('active'); state.currentIndex = index; state.activeSlide = state.activeSlide === 'A' ? 'B' : 'A'; } /** 切换到下一张(根据 config.random 决定顺序)。 */ function nextImage() { if (state.images.length === 0) return; let next; if (state.config.random) { // 避免连续显示同一张 do { next = Math.floor(Math.random() * state.images.length); } while ( state.images.length > 1 && next === state.currentIndex ); } else { if (state.currentIndex === -1) { next = 0; } else { next = (state.currentIndex + 1) % state.images.length; } } showImage(next); } /** 切换到上一张。 */ function prevImage() { if (state.images.length === 0) return; const prev = (state.currentIndex - 1 + state.images.length) % state.images.length; showImage(prev); } // ============================================================ // 播放控制 // ============================================================ /** 启动自动切换定时器(如果条件允许)。 */ function startTimer() { stopTimer(); if (!state.isPlaying || state.images.length === 0) return; const interval = Math.max(state.config.interval, state.config.duration + 100); state.timerId = setInterval(nextImage, interval); } /** 停止自动切换定时器。 */ function stopTimer() { if (state.timerId !== null) { clearInterval(state.timerId); state.timerId = null; } } /** 切换播放/暂停。 */ function togglePlay() { state.isPlaying = !state.isPlaying; $('iconPlayPause').textContent = state.isPlaying ? '⏸' : '▶'; if (state.isPlaying) { startTimer(); } else { stopTimer(); } } // ============================================================ // 全屏 // ============================================================ /** 切换全屏状态。 */ function toggleFullscreen() { const el = stage; if (!document.fullscreenElement) { const fn = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen; if (fn) fn.call(el); } else { const fn = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen; if (fn) fn.call(document); } } // ============================================================ // 设置面板 // ============================================================ /** 把 state.config 同步到表单控件。 */ function syncSettingsForm() { $('inputInterval').value = state.config.interval; $('inputIntervalNum').value = state.config.interval; $('selectAnimation').value = state.config.animation; $('inputDuration').value = state.config.duration; $('inputDurationNum').value = state.config.duration; $('inputRandom').checked = state.config.random; } /** 从表单控件读取最新值写入 state.config。 */ function collectConfig() { state.config.interval = clampInt( $('inputIntervalNum').value, DEFAULT_CONFIG.interval, 500, 30000, ); state.config.animation = ANIMATION_TYPES.includes( $('selectAnimation').value, ) ? $('selectAnimation').value : DEFAULT_CONFIG.animation; state.config.duration = clampInt( $('inputDurationNum').value, DEFAULT_CONFIG.duration, 0, 3000, ); state.config.random = $('inputRandom').checked; } /** 应用 config 到 UI(动画类、CSS 变量)和计时器。 */ function applyConfig() { slideshow.style.setProperty( '--anim-duration', state.config.duration + 'ms', ); slideshow.className = 'slideshow anim-' + state.config.animation; // 配置变化后重启定时器(如果当前在播放) if (state.isPlaying) startTimer(); } /** 打开设置面板。 */ function openSettings() { syncSettingsForm(); renderDirectoryList(); settingsModal.hidden = false; } /** 关闭设置面板。 */ function closeSettings() { settingsModal.hidden = true; } // ============================================================ // 目录列表渲染 // ============================================================ /** @param {string} s */ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (ch) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', } )[ch], ); } /** * 根据 dir.status 给出简短显示文字。 * @param {{status: string, imageCount: number}} dir */ function getStatusText(dir) { switch (dir.status) { case 'ok': return `${dir.imageCount} 张图片`; case 'denied': return '未授权(可点击右侧按钮重新请求)'; case 'error': return '读取失败'; case 'pending': default: return '等待中…'; } } /** * @param {{status: string, imageCount: number}} dir */ function getStatusBadge(dir) { const meta = { ok: { cls: 'ok', text: '✓', title: '已加载' }, denied: { cls: 'warn', text: '⚠', title: '需要授权' }, error: { cls: 'err', text: '✕', title: '读取失败' }, pending: { cls: 'pending', text: '…', title: '等待' }, }[dir.status] || { cls: '', text: '', title: '' }; return `${meta.text}`; } /** 重新渲染设置面板中的目录列表。 */ function renderDirectoryList() { directoryListEl.innerHTML = ''; if (state.directories.length === 0) { const li = document.createElement('li'); li.className = 'empty-list'; li.textContent = '还没有添加任何目录。'; directoryListEl.appendChild(li); return; } state.directories.forEach((dir, idx) => { const li = document.createElement('li'); li.className = 'directory-item'; li.innerHTML = `
${escapeHtml(dir.name)}
${escapeHtml(getStatusText(dir))}
${getStatusBadge(dir)}
`; directoryListEl.appendChild(li); }); // 事件委托 directoryListEl.querySelectorAll('.icon-btn').forEach((btn) => { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.idx, 10); const action = btn.dataset.action; if (action === 'remove') removeDirectory(idx); else if (action === 'retry') retryDirectory(idx); }); }); } // ============================================================ // 目录操作 // ============================================================ /** * 通过 showDirectoryPicker 选择一个新目录并加入。 */ async function addDirectory() { if (typeof window.showDirectoryPicker !== 'function') { alert( '您的浏览器不支持 File System Access API。请使用基于 Chromium 内核的浏览器(如 Chrome、Edge、Opera)。', ); return; } try { const handle = await window.showDirectoryPicker({ id: 'slideshow-dir', mode: 'read', }); // 基于指针比较去重 if (state.directories.some((d) => d.handle === handle)) { alert('该目录已经添加。'); return; } state.directories.push({ handle, name: handle.name, status: 'pending', imageCount: 0, }); await persistHandles(); renderDirectoryList(); await loadAllImages(); } catch (e) { // 用户取消选择 if (e && e.name === 'AbortError') return; console.error(e); alert('添加目录失败:' + (e && e.message ? e.message : e)); } } /** * 重新请求某个目录的权限并读取其图片。 * 通常在恢复自 IndexedDB 的句柄,需要再次授权时使用。 * @param {number} idx */ async function retryDirectory(idx) { const dir = state.directories[idx]; if (!dir) return; const perm = await ensurePermission(dir.handle); if (perm !== 'granted') { alert('授权失败,仍无法访问该目录。'); renderDirectoryList(); return; } await loadAllImages(); } /** * 移除某个目录(同时更新 IndexedDB 与已加载的图片)。 * @param {number} idx */ async function removeDirectory(idx) { if (!confirm('确定要移除这个目录吗?')) return; state.directories.splice(idx, 1); await persistHandles(); await loadAllImages(); renderDirectoryList(); } // ============================================================ // 表单双向绑定(range 与 number 互相同步) // ============================================================ /** * @param {string} rangeId * @param {string} numId */ function bindRange(rangeId, numId) { const range = $(rangeId); const num = $(numId); range.addEventListener('input', () => { num.value = range.value; }); num.addEventListener('input', () => { const min = parseInt(num.min, 10); const max = parseInt(num.max, 10); const v = clamp(parseInt(num.value, 10), min, max); range.value = Number.isFinite(v) ? v : min; }); num.addEventListener('change', () => { const min = parseInt(num.min, 10); const max = parseInt(num.max, 10); const v = clamp(parseInt(num.value, 10), min, max); const finalV = Number.isFinite(v) ? v : min; num.value = finalV; range.value = finalV; }); } /** @param {number} v @param {number} min @param {number} max */ function clamp(v, min, max) { if (!Number.isFinite(v)) return min; return Math.max(min, Math.min(max, v)); } // ============================================================ // 键盘快捷键 // ============================================================ /** @param {KeyboardEvent} e */ function handleKey(e) { // 设置面板打开时只处理 Esc if (!settingsModal.hidden) { if (e.key === 'Escape') closeSettings(); return; } // 输入框中的按键不响应 const tag = e.target && e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; switch (e.key) { case ' ': case 'Spacebar': e.preventDefault(); togglePlay(); break; case 'ArrowLeft': prevImage(); break; case 'ArrowRight': nextImage(); break; case 'f': case 'F': toggleFullscreen(); break; case 'Escape': if (document.fullscreenElement) toggleFullscreen(); break; } } // ============================================================ // 加载状态提示 // ============================================================ /** @param {boolean} show */ function showLoading(show) { loadingHint.hidden = !show; } // ============================================================ // 事件绑定 // ============================================================ function bindEvents() { // 控制按钮 $('btnPlayPause').addEventListener('click', togglePlay); $('btnPrev').addEventListener('click', prevImage); $('btnNext').addEventListener('click', nextImage); $('btnFullscreen').addEventListener('click', toggleFullscreen); $('btnSettings').addEventListener('click', openSettings); // 设置面板 $('btnCloseSettings').addEventListener('click', closeSettings); $('btnAddDirectory').addEventListener('click', addDirectory); $('modalBackdrop').addEventListener('click', closeSettings); $('btnReloadImages').addEventListener('click', async () => { await loadAllImages(); }); $('btnSaveSettings').addEventListener('click', () => { collectConfig(); saveConfig(); applyConfig(); closeSettings(); }); // 表单控件双向绑定 bindRange('inputInterval', 'inputIntervalNum'); bindRange('inputDuration', 'inputDurationNum'); // 键盘快捷键 document.addEventListener('keydown', handleKey); // 全屏变化时无需特别处理,但保留扩展点 document.addEventListener('fullscreenchange', () => { // 某些全屏实现中需要重新计算尺寸 // 这里留给浏览器自动处理 }); } // ============================================================ // 启动 // ============================================================ async function init() { loadConfig(); // 尝试从 IndexedDB 恢复 DirectoryHandle try { const handles = await loadHandles(); if (handles && handles.length > 0) { state.directories = handles.map((h) => ({ handle: h, name: h.name || '(未命名目录)', status: 'pending', imageCount: 0, })); } } catch (e) { console.warn('加载目录句柄失败:', e); } bindEvents(); applyConfig(); // 加载图片(从 IndexedDB 恢复的目录可能需要用户授权) if (state.directories.length > 0) { await loadAllImages(); } else { emptyHint.hidden = false; } startTimer(); } init(); })();