chore(HTML): 添加 image-slideshow-web

This commit is contained in:
2026-10-08 23:15:46 +08:00
parent 0738fd7487
commit 4fbd5bc5a1
4 changed files with 1670 additions and 0 deletions
+33
View File
@@ -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']);
+129
View File
@@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>图片幻灯片</title>
<link rel="stylesheet" href="main.css" />
</head>
<body>
<!-- 幻灯片舞台:同时进入全屏的元素 -->
<main class="stage" id="stage">
<!-- 两张互相叠加的 slide,用于交叉过渡 -->
<div class="slideshow" id="slideshow">
<div class="slide" id="slideA"></div>
<div class="slide" id="slideB"></div>
</div>
<!-- 空状态提示 -->
<div class="hint" id="emptyHint">
<h1 class="hint-title">图片幻灯片</h1>
<p class="hint-desc">
点击右下角的 <span class="kbd">⚙</span> 按钮,在设置中添加至少一个图片目录,即可开始播放。
</p>
<p class="hint-meta">
⚠️ 请使用基于 Chromium 内核的浏览器(Chrome / Edge / Opera),并在 HTTPS 或 localhost 下访问本页面。
</p>
</div>
<!-- 加载状态 -->
<div class="loading" id="loadingHint" hidden>
<div class="spinner"></div>
<p>正在读取图片…</p>
</div>
<!-- 控制按钮条 -->
<div class="controls" id="controls">
<button class="control-btn" id="btnPrev" title="上一张 (←)" aria-label="上一张">⏮</button>
<button class="control-btn" id="btnPlayPause" title="播放/暂停 (空格)" aria-label="播放/暂停">
<span id="iconPlayPause">⏸</span>
</button>
<button class="control-btn" id="btnNext" title="下一张 (→)" aria-label="下一张">⏭</button>
<button class="control-btn" id="btnFullscreen" title="切换全屏 (F)" aria-label="切换全屏">⛶</button>
<button class="control-btn" id="btnSettings" title="设置" aria-label="设置">⚙</button>
</div>
</main>
<!-- 设置面板(模态框) -->
<div class="modal" id="settingsModal" hidden role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<div class="modal-backdrop" id="modalBackdrop"></div>
<div class="modal-content">
<header class="modal-header">
<h2 id="settingsTitle">设置</h2>
<button class="close-btn" id="btnCloseSettings" aria-label="关闭">×</button>
</header>
<div class="modal-body">
<!-- 目录列表 -->
<section class="setting-section">
<div class="section-header">
<h3>图片目录</h3>
<button class="primary-btn" id="btnAddDirectory" type="button">+ 添加目录</button>
</div>
<p class="setting-tip">
💡 每次重新加载页面后,浏览器需要您重新授权访问已添加的目录。
</p>
<ul class="directory-list" id="directoryList"></ul>
</section>
<!-- 切换间隔 -->
<section class="setting-section">
<h3>切换间隔</h3>
<div class="form-row">
<input type="range" id="inputInterval" min="500" max="30000" step="500" />
<input type="number" id="inputIntervalNum" min="500" max="30000" step="500" />
<span class="unit">毫秒</span>
</div>
</section>
<!-- 切换动画 -->
<section class="setting-section">
<h3>切换动画</h3>
<div class="form-row">
<select id="selectAnimation">
<option value="none">无(直接切换)</option>
<option value="fade">淡入淡出</option>
<option value="slide">滑动</option>
<option value="zoom">缩放</option>
<option value="flip">翻转</option>
</select>
</div>
</section>
<!-- 动画时长 -->
<section class="setting-section">
<h3>动画时长</h3>
<div class="form-row">
<input type="range" id="inputDuration" min="0" max="3000" step="50" />
<input type="number" id="inputDurationNum" min="0" max="3000" step="50" />
<span class="unit">毫秒</span>
</div>
</section>
<!-- 随机显示 -->
<section class="setting-section">
<h3>随机显示</h3>
<label class="toggle">
<input type="checkbox" id="inputRandom" />
<span class="toggle-slider"></span>
<span class="toggle-text">启用后将以随机顺序展示图片</span>
</label>
</section>
</div>
<footer class="modal-footer">
<button class="secondary-btn" id="btnReloadImages" type="button" title="重新读取所有目录中的图片">
重新加载图片
</button>
<button class="primary-btn" id="btnSaveSettings" type="button">
保存并关闭
</button>
</footer>
</div>
</div>
<script src="config.js"></script>
<script src="main.js"></script>
</body>
</html>
+740
View File
@@ -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; }
}
+768
View File
@@ -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<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) =>
(
{
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}
)[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();
})();