769 lines
24 KiB
JavaScript
769 lines
24 KiB
JavaScript
/**
|
||||
|
|
* 图片幻灯片 - 主逻辑
|
|||
|
|
*
|
|||
|
|
* 关键设计:
|
|||
|
|
* - 两张 .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<IDBDatabase> | null} */
|
|||
|
|
let dbPromise = null;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 打开(首次访问时自动创建)IndexedDB 数据库。
|
|||
|
|
* @returns {Promise<IDBDatabase>}
|
|||
|
|
*/
|
|||
|
|
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<void>}
|
|||
|
|
*/
|
|||
|
|
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<FileSystemDirectoryHandle[]>}
|
|||
|
|
*/
|
|||
|
|
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 `<span class="directory-status ${meta.cls}" title="${meta.title}">${meta.text}</span>`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 重新渲染设置面板中的目录列表。 */
|
|||
|
|
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 = `
|
|||
|
|
<div class="directory-info">
|
|||
|
|
<div class="directory-name" title="${escapeHtml(dir.name)}">${escapeHtml(dir.name)}</div>
|
|||
|
|
<div class="directory-meta">${escapeHtml(getStatusText(dir))}</div>
|
|||
|
|
</div>
|
|||
|
|
${getStatusBadge(dir)}
|
|||
|
|
<div class="directory-actions">
|
|||
|
|
<button class="icon-btn" data-action="retry" data-idx="${idx}" title="重新请求授权 / 重新读取" aria-label="重新读取">↻</button>
|
|||
|
|
<button class="icon-btn danger" data-action="remove" data-idx="${idx}" title="移除" aria-label="移除">×</button>
|
|||
|
|
</div>
|
|||
|
|
`;
|
|||
|
|
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();
|
|||
|
|
})();
|