diff --git a/HTML/image-slideshow-web/config.js b/HTML/image-slideshow-web/config.js
new file mode 100644
index 0000000..d47585e
--- /dev/null
+++ b/HTML/image-slideshow-web/config.js
@@ -0,0 +1,33 @@
+/**
+ * 图片幻灯片 - 默认配置和常量
+ */
+
+/** @type {{interval: number, animation: string, duration: number, random: boolean}} */
+const DEFAULT_CONFIG = Object.freeze({
+ // 切换间隔(毫秒)
+ interval: 5000,
+ // 切换动画类型:none / fade / slide / zoom / flip
+ animation: 'fade',
+ // 动画时长(毫秒)
+ duration: 1000,
+ // 是否随机显示图片
+ random: false,
+});
+
+/** 支持的图片扩展名(小写) */
+const IMAGE_EXTENSIONS = Object.freeze([
+ 'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'jxl', 'heic', 'heif',
+]);
+
+/** IndexedDB 数据库配置 */
+const DB_CONFIG = Object.freeze({
+ name: 'image-slideshow-web',
+ version: 1,
+ store: 'directory-handles',
+});
+
+/** localStorage 配置存储 key */
+const STORAGE_KEY_CONFIG = 'slideshow-config';
+
+/** 支持的动画类型 */
+const ANIMATION_TYPES = Object.freeze(['none', 'fade', 'slide', 'zoom', 'flip']);
diff --git a/HTML/image-slideshow-web/index.html b/HTML/image-slideshow-web/index.html
new file mode 100644
index 0000000..959a6ea
--- /dev/null
+++ b/HTML/image-slideshow-web/index.html
@@ -0,0 +1,129 @@
+
+
+
+
+
+ 图片幻灯片
+
+
+
+
+
+
+
+
+
+
+
图片幻灯片
+
+ 点击右下角的 ⚙ 按钮,在设置中添加至少一个图片目录,即可开始播放。
+
+
+ ⚠️ 请使用基于 Chromium 内核的浏览器(Chrome / Edge / Opera),并在 HTTPS 或 localhost 下访问本页面。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 💡 每次重新加载页面后,浏览器需要您重新授权访问已添加的目录。
+
+
+
+
+
+
+
+
+
+ 切换动画
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HTML/image-slideshow-web/main.css b/HTML/image-slideshow-web/main.css
new file mode 100644
index 0000000..46e91ca
--- /dev/null
+++ b/HTML/image-slideshow-web/main.css
@@ -0,0 +1,740 @@
+/* ============================================================
+ * 图片幻灯片 - 主样式
+ * 设计思路:
+ * - 舞台(.stage)始终占满视口,全屏时跟随全屏元素
+ * - .slideshow 中两张 .slide 重叠,使用 .active 类控制显示
+ * - 不同动画由 .anim-* 类驱动,使用 CSS 变量控制时长和方向
+ * ============================================================ */
+
+:root {
+ --primary: #4f8cff;
+ --primary-hover: #3a7bff;
+ --bg: #000;
+ --text: #fff;
+ --text-secondary: rgba(255, 255, 255, 0.7);
+ --panel-bg: rgba(20, 20, 20, 0.96);
+ --border: rgba(255, 255, 255, 0.12);
+ --danger: #ff5f56;
+ --warn: #ffb84f;
+ --success: #5cd97a;
+}
+
+/* ---------- Reset ---------- */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html,
+body {
+ height: 100%;
+ overflow: hidden;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
+ 'Microsoft YaHei', Roboto, sans-serif;
+ font-size: 14px;
+ -webkit-font-smoothing: antialiased;
+}
+
+button {
+ font: inherit;
+}
+
+/* ============================================================
+ * 舞台
+ * ============================================================ */
+.stage {
+ position: fixed;
+ inset: 0;
+ background: #000;
+ overflow: hidden;
+ cursor: default;
+}
+
+/* ============================================================
+ * 幻灯片
+ * ============================================================ */
+.slideshow {
+ position: absolute;
+ inset: 0;
+ /* 动画时长(JS 设置) */
+ --anim-duration: 1000ms;
+ --enter-from: 100%;
+ --exit-to: -100%;
+}
+
+.slide {
+ position: absolute;
+ inset: 0;
+ background-color: #000;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center center;
+ opacity: 0;
+ will-change: opacity, transform;
+}
+
+/* ---------- none:直接切换 ---------- */
+.anim-none .slide {
+ transition: none !important;
+}
+.anim-none .slide.active {
+ opacity: 1;
+}
+
+/* ---------- fade:仅透明度过渡 ---------- */
+.anim-fade .slide {
+ transition: opacity var(--anim-duration) ease;
+}
+.anim-fade .slide.active {
+ opacity: 1;
+}
+
+/* ---------- slide:平移(方向由 --enter-from / --exit-to 决定) ---------- */
+.anim-slide .slide {
+ transition:
+ transform var(--anim-duration) cubic-bezier(0.45, 0.05, 0.55, 0.95),
+ opacity var(--anim-duration) ease;
+}
+.anim-slide .slide:not(.active) {
+ opacity: 0;
+ transform: translateX(var(--exit-to, -100%));
+}
+.anim-slide .slide.active {
+ opacity: 1;
+ transform: translateX(0);
+}
+
+/* ---------- zoom:缩放 ---------- */
+.anim-zoom .slide {
+ transition:
+ transform var(--anim-duration) ease,
+ opacity var(--anim-duration) ease;
+ transform-origin: center center;
+}
+.anim-zoom .slide:not(.active) {
+ opacity: 0;
+ transform: scale(1.15);
+}
+.anim-zoom .slide.active {
+ opacity: 1;
+ transform: scale(1);
+}
+
+/* ---------- flip:3D 翻转 ---------- */
+.anim-flip .slide {
+ transition:
+ transform var(--anim-duration) ease,
+ opacity var(--anim-duration) ease;
+ transform-origin: center center;
+ backface-visibility: hidden;
+ perspective: 1200px;
+}
+.anim-flip .slide:not(.active) {
+ opacity: 0;
+ transform: rotateY(90deg);
+}
+.anim-flip .slide.active {
+ opacity: 1;
+ transform: rotateY(0deg);
+}
+
+/* 进入动画初始位置(无过渡)。
+ 仅在切换的瞬间使用 .entering 类,
+ 强制将下一张幻灯片定位到屏幕外的"进入位置"。
+ !important 是为了临时胜过各动画自己的 .active 规则。 */
+.slide.entering {
+ opacity: 0 !important;
+ transition: none !important;
+}
+
+.anim-slide .slide.entering {
+ transform: translateX(var(--enter-from, 100%)) !important;
+}
+.anim-zoom .slide.entering {
+ transform: scale(0.7) !important;
+}
+.anim-flip .slide.entering {
+ transform: rotateY(90deg) !important;
+}
+
+/* ============================================================
+ * 空状态 / 加载状态
+ * ============================================================ */
+.hint,
+.loading {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 24px;
+ z-index: 5;
+ pointer-events: none;
+}
+
+/* 让 HTML 的 [hidden] 属性真正生效。
+ 用户样式 .hint { display: flex } 的特异性为 (0,0,1,0),
+ 足以覆盖 UA 默认 [hidden] { display: none } —— 因此加了 hidden 属性也不隐藏。
+ 提升特异性到 (0,0,2,0) 后,hidden 属性重新生效。 */
+.hint[hidden],
+.loading[hidden] {
+ display: none;
+}
+
+.hint-title {
+ font-size: 36px;
+ font-weight: 600;
+ margin-bottom: 16px;
+ letter-spacing: 1px;
+}
+
+.hint-desc {
+ color: var(--text-secondary);
+ font-size: 16px;
+ max-width: 520px;
+ line-height: 1.6;
+}
+
+.hint-meta {
+ margin-top: 32px;
+ color: rgba(255, 255, 255, 0.45);
+ font-size: 13px;
+ max-width: 540px;
+ line-height: 1.6;
+}
+
+.kbd {
+ display: inline-block;
+ min-width: 24px;
+ padding: 2px 8px;
+ background: rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ font-family: inherit;
+}
+
+.loading p {
+ color: var(--text-secondary);
+ margin-top: 16px;
+}
+
+.spinner {
+ width: 48px;
+ height: 48px;
+ border: 3px solid rgba(255, 255, 255, 0.2);
+ border-top-color: var(--primary);
+ border-radius: 50%;
+ animation: spin 0.9s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ============================================================
+ * 控制按钮
+ * ============================================================ */
+.controls {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ display: flex;
+ gap: 8px;
+ z-index: 10;
+ opacity: 0.35;
+ transition: opacity 0.25s ease;
+}
+
+.controls:hover,
+.controls:focus-within {
+ opacity: 1;
+}
+
+.control-btn {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(0, 0, 0, 0.55);
+ color: var(--text);
+ font-size: 18px;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ transform 0.2s ease,
+ box-shadow 0.2s ease;
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.control-btn:hover {
+ background: rgba(0, 0, 0, 0.85);
+ transform: scale(1.08);
+}
+
+.control-btn:focus-visible {
+ outline: 2px solid var(--primary);
+ outline-offset: 2px;
+}
+
+.control-btn:active {
+ transform: scale(0.95);
+}
+
+/* ============================================================
+ * 模态框
+ * ============================================================ */
+.modal {
+ position: fixed;
+ inset: 0;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal[hidden] {
+ display: none;
+}
+
+.modal-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ cursor: pointer;
+}
+
+.modal-content {
+ position: relative;
+ background: var(--panel-bg);
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ width: min(640px, calc(100vw - 32px));
+ max-height: calc(100vh - 32px);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
+ animation: modalIn 0.2s ease;
+}
+
+@keyframes modalIn {
+ from { transform: translateY(20px); opacity: 0; }
+ to { transform: translateY(0); opacity: 1; }
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 18px 24px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.modal-header h2 {
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.close-btn {
+ width: 32px;
+ height: 32px;
+ border: none;
+ background: transparent;
+ color: var(--text);
+ font-size: 26px;
+ line-height: 1;
+ cursor: pointer;
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.2s ease;
+}
+
+.close-btn:hover {
+ background: rgba(255, 255, 255, 0.1);
+}
+
+.modal-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px 24px;
+}
+
+.modal-footer {
+ padding: 14px 24px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ flex-shrink: 0;
+}
+
+/* ---------- 设置分区 ---------- */
+.setting-section {
+ margin-bottom: 24px;
+}
+
+.setting-section:last-child {
+ margin-bottom: 0;
+}
+
+.setting-section h3 {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.6px;
+}
+
+.section-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 10px;
+}
+
+.section-header h3 {
+ margin: 0;
+}
+
+.setting-tip {
+ color: var(--warn);
+ font-size: 13px;
+ margin-bottom: 12px;
+ padding: 8px 12px;
+ background: rgba(255, 184, 79, 0.08);
+ border: 1px solid rgba(255, 184, 79, 0.25);
+ border-radius: 6px;
+ line-height: 1.5;
+}
+
+/* ---------- 表单 ---------- */
+.form-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.form-row input[type='range'] {
+ flex: 1;
+ min-width: 180px;
+ height: 4px;
+ -webkit-appearance: none;
+ appearance: none;
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 2px;
+ outline: none;
+}
+
+.form-row input[type='range']::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary);
+ cursor: pointer;
+ border: none;
+}
+
+.form-row input[type='range']::-moz-range-thumb {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: var(--primary);
+ cursor: pointer;
+ border: none;
+}
+
+.form-row input[type='number'] {
+ width: 100px;
+ padding: 7px 10px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.15s ease;
+}
+
+.form-row input[type='number']:focus {
+ border-color: var(--primary);
+}
+
+.form-row select {
+ padding: 8px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+ font-size: 14px;
+ cursor: pointer;
+ min-width: 180px;
+ outline: none;
+}
+
+.form-row select:focus {
+ border-color: var(--primary);
+}
+
+.form-row select option {
+ background: #1c1c1c;
+ color: #fff;
+}
+
+.unit {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+/* ---------- 切换开关 ---------- */
+.toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ cursor: pointer;
+ user-select: none;
+}
+
+.toggle input[type='checkbox'] {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+
+.toggle-slider {
+ position: relative;
+ display: inline-block;
+ width: 40px;
+ height: 22px;
+ background: rgba(255, 255, 255, 0.15);
+ border-radius: 999px;
+ transition: background 0.2s ease;
+ flex-shrink: 0;
+}
+
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ left: 3px;
+ top: 3px;
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: #fff;
+ transition: transform 0.2s ease;
+}
+
+.toggle input:checked + .toggle-slider {
+ background: var(--primary);
+}
+
+.toggle input:checked + .toggle-slider::before {
+ transform: translateX(18px);
+}
+
+.toggle input:focus-visible + .toggle-slider {
+ box-shadow: 0 0 0 2px rgba(79, 140, 255, 0.4);
+}
+
+.toggle-text {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+/* ---------- 目录列表 ---------- */
+.directory-list {
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.directory-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 12px 14px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.directory-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.directory-name {
+ font-size: 14px;
+ font-weight: 500;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: var(--text);
+}
+
+.directory-meta {
+ color: var(--text-secondary);
+ font-size: 12px;
+ margin-top: 2px;
+}
+
+.directory-status {
+ font-size: 16px;
+ width: 26px;
+ text-align: center;
+ flex-shrink: 0;
+}
+
+.directory-status.ok { color: var(--success); }
+.directory-status.warn { color: var(--warn); }
+.directory-status.err { color: var(--danger); }
+.directory-status.pending { color: var(--text-secondary); }
+
+.directory-actions {
+ display: flex;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.icon-btn {
+ width: 28px;
+ height: 28px;
+ border: 1px solid var(--border);
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--text);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
+}
+
+.icon-btn:hover {
+ background: rgba(255, 255, 255, 0.12);
+}
+
+.icon-btn.danger {
+ color: var(--danger);
+}
+
+.icon-btn.danger:hover {
+ background: rgba(255, 95, 86, 0.18);
+ border-color: rgba(255, 95, 86, 0.4);
+}
+
+.empty-list {
+ padding: 24px;
+ text-align: center;
+ color: var(--text-secondary);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px dashed var(--border);
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+/* ============================================================
+ * 按钮
+ * ============================================================ */
+.primary-btn {
+ padding: 8px 18px;
+ border: none;
+ background: var(--primary);
+ color: #fff;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 500;
+ transition: background 0.2s ease;
+}
+
+.primary-btn:hover {
+ background: var(--primary-hover);
+}
+
+.primary-btn:disabled {
+ background: rgba(255, 255, 255, 0.1);
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+.secondary-btn {
+ padding: 8px 18px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text);
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background 0.2s ease;
+}
+
+.secondary-btn:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+/* ============================================================
+ * 滚动条
+ * ============================================================ */
+.modal-body::-webkit-scrollbar {
+ width: 8px;
+}
+
+.modal-body::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.modal-body::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+}
+
+.modal-body::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+/* ============================================================
+ * 全屏:舞台自身进入全屏时让里面的内容继续 100% 占据
+ * ============================================================ */
+.stage:fullscreen,
+.stage:-webkit-full-screen {
+ width: 100vw;
+ height: 100vh;
+}
+
+@supports (height: 100dvh) {
+ .stage { height: 100dvh; }
+}
+
+/* ============================================================
+ * 较小屏幕适配
+ * ============================================================ */
+@media (max-width: 600px) {
+ .hint-title { font-size: 28px; }
+ .hint-desc { font-size: 14px; }
+ .controls { bottom: 16px; right: 16px; gap: 6px; }
+ .control-btn { width: 40px; height: 40px; font-size: 16px; }
+ .modal-content { width: calc(100vw - 16px); }
+ .modal-header,
+ .modal-body,
+ .modal-footer { padding-left: 16px; padding-right: 16px; }
+}
diff --git a/HTML/image-slideshow-web/main.js b/HTML/image-slideshow-web/main.js
new file mode 100644
index 0000000..9db008f
--- /dev/null
+++ b/HTML/image-slideshow-web/main.js
@@ -0,0 +1,768 @@
+/**
+ * 图片幻灯片 - 主逻辑
+ *
+ * 关键设计:
+ * - 两张 .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();
+})();