chore(HTML): 添加 image-slideshow-web
This commit is contained in:
@@ -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']);
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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) =>
|
||||||
|
(
|
||||||
|
{
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": ''',
|
||||||
|
}
|
||||||
|
)[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();
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user