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();
|
||
})();
|