/* ============================================================
* lib/player/deck.js · 混沌AI院演示播放器核心
*
* 职责:收集 .screen → 每屏状态机 idle→playing→done;
* 进入新屏自动播放入场动画/视频(首屏不黑屏);↓ 先演完当前动画再切屏(门控);
* 动画运行中 ↓ 快进到结尾;上下键/翻页器全兼容;进度条;两级导航(读 deck-config)。
* 鼠标支持:滚轮上/下 = 上/下一屏;侧键 前进=下章、后退=上章(防抖,兼容翻页笔)。
*
* 页面接入:
*
*
*
* 普通 script,非 module,file:// 可用。禁止页面自写 keydown/scroll。
* ============================================================ */
(function () {
var screens = [];
var current = 0;
var total = 0;
var cfg = null;
var inited = false;
/* ---------- 总控容器模式(shell)状态 ---------- */
var shellMode = !!(window.parent && window.parent !== window); // 页面被 iframe 装载
var shellPlan = null; // 编排配置(deck-plan)
var shellCur = 0; // 当前页序号
var shellFrames = []; // iframe 元素(不销毁,翻回保留状态)
var shellShown = []; // 是否已展示过(首次展示重演入场,无白屏)
var shellLast = []; // 各页最近上报的进度 {index,total,state}
var pendingGo = null; // 容器注册前的翻章请求缓冲(防止注册竞态丢失)
var shellFrom = 0; // 容器模式:切片起始屏(0-based,总控 deck-range 下发)
var shellTo = -1; // 容器模式:切片结束屏(-1 = 整节)
/* ---------- embed(JS 注入)模式状态 ---------- */
var embedMode = false; // 总控用 JS 注入单页 HTML(非 iframe)
var embedPages = []; // 注入后的屏信息 {page, screens, tlName}
var embedReady = false;
/* ---------- 工具 ---------- */
function readConfig() {
var el = document.querySelector('script.deck-config');
if (!el) return null;
try { return JSON.parse(el.textContent); } catch (e) { return null; }
}
/* 与总控容器(deck-shell)通信:postMessage 跨源/跨 file:// 均可用 */
function postShell(msg) {
try { window.parent.postMessage(msg, '*'); } catch (e) {}
}
function fmt(n, dec, sep) {
var s = n.toFixed(dec == null ? 0 : dec);
if (sep) s = s.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
return s;
}
function makeCtx(s) {
var el = s.el;
return {
el: el,
q: function (sel) { return el.querySelector(sel); },
qAll: function (sel) { return el.querySelectorAll(sel); },
// 数字滚动,返回 gsap tween,可用 tl.add(ctx.count(...), pos)
count: function (sel, to, opts) {
opts = opts || {};
var node = el.querySelector(sel);
if (!node || !window.gsap) return null;
var obj = { val: 0 };
var target = (opts.to != null) ? opts.to : to;
var tw = window.gsap.to(obj, {
val: target,
duration: opts.duration || 1.2,
ease: opts.ease || 'power1.out',
onUpdate: function () { node.textContent = fmt(obj.val, opts.dec || 0, opts.sep); }
});
return tw;
}
};
}
/* ---------- 状态 ---------- */
function collect() {
var els = Array.prototype.slice.call(document.querySelectorAll('.screen'));
screens = els.map(function (el) {
var tlKey = el.getAttribute('data-tl');
return {
el: el,
tlKey: tlKey,
hasTL: !!(tlKey && window.SCREEN_TIMELINES && typeof window.SCREEN_TIMELINES[tlKey] === 'function'),
video: el.querySelector('video[data-gate]'),
auto: el.hasAttribute('data-gate-autoplay'),
state: 'idle',
tl: null
};
});
total = screens.length;
}
/* ---------- 展示 ---------- */
function showScreen(i) {
screens.forEach(function (s, j) {
s.el.style.visibility = (j === i) ? 'visible' : 'hidden';
// 根因修复:`.screen[data-tl].in > .s-wrap { visibility: visible }`(base.css)是 .s-wrap 的
// 直接声明,会覆盖从屏级继承的 hidden。动画播完 .in 类残留 → 旧屏屏级 hidden 但 .s-wrap
// 仍 visible,内容叠在新屏上(旧屏盖新屏的根因)。此处对 .s-wrap 直接强制隐藏/恢复。
var wrap = s.el.querySelector('.s-wrap');
if (wrap) wrap.style.visibility = (j === i) ? '' : 'hidden';
});
current = i;
updateProgress();
highlightTab();
}
// 屏激活:进入新一页/新屏直接走入场动画加载(不黑屏)。
// 有时间线 → 自动播放;autoplay 视频 → 自动播;data-gate 视频 → 保持 idle 等 ↓;
// 无动画 → 直接视为完成。↓ 在动画运行中可快进(见 next)。
function activate(s) {
if (s.state !== 'idle') return;
if (s.hasTL) { playTimeline(s); return; }
if (s.video && s.auto) { playVideo(s); return; }
if (s.video) return; // data-gate 视频:保持 idle,等 ↓ 播放
s.state = 'done'; updateProgress();
}
/* ---------- 动画 / 视频门控 ---------- */
function playTimeline(s) {
if (!s.hasTL) return false;
s.el.classList.add('in');
var fn = window.SCREEN_TIMELINES[s.tlKey];
var ctx = makeCtx(s);
var tl = null;
try { tl = fn(ctx); } catch (e) { tl = null; }
if (!tl || !window.gsap) { s.state = 'done'; updateProgress(); return false; }
s.tl = tl;
s.state = 'playing';
tl.eventCallback('onComplete', function () {
// 有时间线 + data-gate-autoplay:时间线播完自动接播视频(再按 ↓ 才切屏)
if (s.video && s.auto) { playVideo(s); return; }
s.state = 'done'; updateProgress();
});
tl.play();
updateProgress();
return true;
}
function playVideo(s) {
var v = s.video;
if (!v) return;
var ended = function () { s.state = 'done'; updateProgress(); };
v.removeEventListener('ended', ended);
v.addEventListener('ended', ended);
var p = v.play();
if (p && p.catch) p.catch(function () { s.state = 'done'; updateProgress(); });
s.state = 'playing';
updateProgress();
}
function resetScreen(s) {
if (s.tl) {
// 先播到结尾让 from-tween 渲染到最终态(opacity 1),再 kill:
// 直接 kill 一个播放中的 timeline 会残留内联 opacity:0(immediateRender),
// 且后续新建的 timeline 无法推进 → 重演 / jump / prev 时整屏空白(CBEC p0/p1 实测)。
try { s.tl.progress(1); } catch (e) {}
try { s.tl.kill(); } catch (e) {}
s.tl = null;
}
s.el.classList.remove('in');
s.state = 'idle';
if (s.video) { s.video.pause(); s.video.currentTime = 0; }
}
/* ---------- 导航动作 ---------- */
function next() {
if (current >= total) return;
var s = screens[current];
if (s.state === 'done') { advance(); return; }
if (s.state === 'playing') {
// 入场动画运行中按 ↓:快进到结尾并切下一屏(新屏入场自动播放);视频保持本屏待重播
if (s.video && !s.video.paused) { s.video.currentTime = s.video.duration; return; }
if (s.tl) s.tl.progress(1);
advance();
return;
}
// idle(正常已被 activate 自动播放;防御 data-gate 视频屏)
if (s.hasTL && playTimeline(s)) return;
if (s.video) { playVideo(s); return; }
s.state = 'done';
advance();
}
function advance() {
// 容器切片:当前屏已是切片结束屏 → 直接翻章(交给总控切下一节),不进入切片外屏
if (shellTo >= 0 && current >= shellTo) { nextChapter(); return; }
if (current < total - 1) {
showScreen(current + 1);
activate(screens[current]); // showScreen 已把 current 指向新屏 → 自动播放入场
} else {
nextChapter();
}
}
function prev() {
// 容器切片:当前屏已是切片起始屏 → 直接翻上一章
if (shellFrom > 0 && current <= shellFrom) { prevChapter(); return; }
if (current > 0) {
resetScreen(screens[current]);
current--;
resetScreen(screens[current]);
showScreen(current);
activate(screens[current]); // 上一屏重演入场(可重演)
} else {
prevChapter();
}
}
function jump(i) {
// 容器切片:跳转范围钳制在 [from, to]
if (shellTo >= 0) i = Math.max(shellFrom, Math.min(i, shellTo));
if (i < 0 || i >= total) return;
resetScreen(screens[current]);
current = i;
resetScreen(screens[current]);
showScreen(current);
activate(screens[current]);
}
/* ---------- 章节导航 ---------- */
function goFile(file, dir) {
if (!file) return;
if (shellMode) {
// 容器模式:翻章请求交给总控(总控按 plan 顺序编排),不在 iframe 内跳文件
pendingGo = { type: 'deck-go', file: file, dir: dir };
postShell(pendingGo);
return;
}
var cur = location.pathname.split('/').pop();
if (file === cur) { dir > 0 ? jump(0) : jump(total - 1); return; }
location.href = file;
}
function nextChapter() {
if (embedMode && embedReady) { embedJumpSection(1); return; }
goFile(cfg ? cfg.next : null, 1);
}
function prevChapter() {
if (embedMode && embedReady) { embedJumpSection(-1); return; }
goFile(cfg ? cfg.prev : null, -1);
}
/* embed(JS注入)模式:节级跳转统一走这里(←→/侧键/触摸横滑/末屏翻章)。
* 节 = plan.pages 一项;embed 下所有屏 flat 注入,按节累计屏数定位「该节第一屏」。
* 不用 next()/prev()(那在 embed 下是切屏,不是切节)。 */
function embedSectionIndex() {
var acc = 0;
for (var q = 0; q < shellPlan.pages.length; q++) {
var cnt = (embedPages[q] && embedPages[q].count) || 0;
if (current < acc + cnt) return q;
acc += cnt;
}
return shellPlan.pages.length - 1;
}
function embedJumpSection(dir) {
var sec = embedSectionIndex() + dir;
if (sec < 0 || sec >= shellPlan.pages.length) return; // 边界节不翻,与 iframe showPage 一致
var target = 0;
for (var q = 0; q < sec; q++) target += (embedPages[q] && embedPages[q].count) || 0;
jump(target);
}
/* ---------- 界面 ---------- */
function ensureThemeBtn() {
if (document.querySelector('.theme-toggle')) return;
var topbar = document.querySelector('.deck-topbar');
if (!topbar) return;
var btn = null;
// 优先 theme.js 的图标按钮(太阳/月亮),缺失时退化为文字兜底
if (window.HDTheme && window.HDTheme.createToggleBtn) {
btn = window.HDTheme.createToggleBtn();
} else {
btn = document.createElement('button');
btn.className = 'theme-toggle';
btn.type = 'button';
btn.textContent = '主题';
btn.addEventListener('click', function () { if (window.HDTheme) window.HDTheme.toggle(); });
}
var left = topbar.querySelector('.deck-left');
if (!left) { left = document.createElement('div'); left.className = 'deck-left'; topbar.insertBefore(left, topbar.firstChild); }
left.appendChild(btn);
}
function highlightTab() {
if (!cfg) return;
var tabs = document.querySelectorAll('.deck-tab');
tabs.forEach(function (b) {
b.classList.toggle('cur', b.getAttribute('data-ch') === cfg.page.id);
});
scrollActiveTabIntoView();
}
/* 顶栏章节/页面 tab 自动横向滚动:把高亮当前 tab 滚动到居中(不触边)。
* 溢出检测用 scrollWidth(横向不溢出时忽略,含窄屏折叠面板纵向列)。 */
function scrollActiveTabIntoView() {
var nav = document.querySelector('.deck-tabs');
var cur = document.querySelector('.deck-tab.cur');
if (!nav || !cur) return;
if (nav.scrollWidth <= nav.clientWidth) return; // 无需滚动
var navRect = nav.getBoundingClientRect();
var curRect = cur.getBoundingClientRect();
var target = nav.scrollLeft + (curRect.left + curRect.width / 2) - (navRect.left + navRect.width / 2);
var max = nav.scrollWidth - nav.clientWidth;
target = Math.max(0, Math.min(target, max)); // 不触边:钳制在可滚范围
if (Math.abs(nav.scrollLeft - target) > 1) {
nav.scrollTo({ left: target, behavior: 'smooth' });
}
}
function buildChrome() {
if (shellMode) return; // 容器模式:顶栏/进度条由总控渲染,页面只保留 .screen 内容
var topbar = document.querySelector('.deck-topbar');
if (!topbar) { topbar = document.createElement('div'); topbar.className = 'deck-topbar'; document.body.appendChild(topbar); }
if (cfg && cfg.chapters && !topbar.querySelector('.deck-tabs')) {
var nav = document.createElement('nav');
nav.className = 'deck-tabs';
cfg.chapters.forEach(function (ch) {
var b = document.createElement('button');
b.className = 'deck-tab' + (ch.id === cfg.page.id ? ' cur' : '');
b.type = 'button';
b.setAttribute('data-ch', ch.id);
b.textContent = ch.title;
b.addEventListener('click', function () { goFile(ch.file, 1); });
nav.appendChild(b);
});
topbar.appendChild(nav);
}
ensureThemeBtn();
var right = topbar.querySelector('.deck-right');
if (!right) { right = document.createElement('div'); right.className = 'deck-right'; topbar.appendChild(right); }
// 右上角品牌 logo(theme.js 注入,随主题切换暗/浅图)
if (window.HDTheme && window.HDTheme.ensureLogo) window.HDTheme.ensureLogo(right);
var bottom = document.querySelector('.deck-bottom');
if (!bottom) {
bottom = document.createElement('div');
bottom.className = 'deck-bottom';
document.body.appendChild(bottom);
var prog = document.createElement('div');
prog.className = 'deck-progress';
var bar = document.createElement('div');
bar.className = 'bar';
prog.appendChild(bar);
bottom.appendChild(prog);
var pos = document.createElement('span');
pos.className = 'deck-pos';
bottom.appendChild(pos);
if (cfg && cfg.page) {
var pn = document.createElement('span');
pn.className = 'deck-pagename';
pn.textContent = cfg.page.title;
bottom.appendChild(pn);
}
}
}
function updateProgress() {
var pct = total ? (current / total) * 100 : 0;
var bar = document.querySelector('.deck-progress .bar');
if (bar) bar.style.width = pct + '%';
var pos = document.querySelector('.deck-pos');
if (pos) pos.textContent = (current + 1) + ' / ' + total;
// 容器模式:把本页进度上报总控(总控据此算全局进度条)
if (shellMode) {
var s = screens[current] || null;
postShell({ type: 'deck-progress', index: current, total: total, state: s ? s.state : 'done' });
}
// embed 模式:屏进度变化同步总控 chrome
if (embedMode && embedReady) renderShellChrome();
}
/* ---------- 输入 ---------- */
function bindKeys() {
document.addEventListener('keydown', function (e) {
var t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
var k = e.key;
if (k === 'ArrowDown' || k === 's' || k === 'S' || k === 'PageDown' || k === ' ') {
e.preventDefault(); next();
} else if (k === 'ArrowUp' || k === 'w' || k === 'W' || k === 'PageUp') {
e.preventDefault(); prev();
} else if (k === 'ArrowRight' || k === 'd' || k === 'D') {
e.preventDefault(); nextChapter();
} else if (k === 'ArrowLeft' || k === 'a' || k === 'A') {
e.preventDefault(); prevChapter();
} else if (k === 'Home') {
e.preventDefault(); jump(0);
} else if (k === 'End') {
e.preventDefault(); jump(total - 1);
}
});
}
function bindClick() {
document.addEventListener('click', function (e) {
var t = e.target;
if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
if (e.clientX > window.innerWidth * 0.78 && e.clientX < window.innerWidth) next();
});
}
function bindMouse() {
// 滚轮:累计方向,超过阈值触发一次,冷却期防连击(轨迹板/鼠标均稳定)
var wheelAcc = 0, lastDir = 0, wheelLock = 0;
var STEP = 60, COOLDOWN = 550;
document.addEventListener('wheel', function (e) {
var t = e.target;
// 顶栏章节/页面 tab 上滚轮 → 横向滚动 tabs(顶栏 hover 本就不翻屏;横向不溢出时忽略)
if (t && t.closest && t.closest('.deck-tabs')) {
var nav = t.closest('.deck-tabs');
if (nav.scrollWidth > nav.clientWidth) {
e.preventDefault();
var step = e.deltaMode === 0 ? (e.deltaY || e.deltaX) : e.deltaY * 40;
nav.scrollLeft += step;
}
return;
}
if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
var now = Date.now();
if (now - wheelLock < COOLDOWN) return;
if (e.deltaY === 0) return;
var dir = e.deltaY > 0 ? 1 : -1; // 下滚 = 下一屏,上滚 = 上一屏
wheelAcc = (lastDir === dir) ? wheelAcc + e.deltaY : e.deltaY;
lastDir = dir;
if (Math.abs(wheelAcc) < STEP) return; // 未到阈值先攒着
wheelAcc = 0; wheelLock = now;
e.preventDefault();
if (dir > 0) next(); else prev();
}, { passive: false });
// 鼠标侧键:button 3(前进) → 下章,button 4(后退) → 上章(auxclick/mouseup 双绑兼容,防抖)
var lastSide = 0;
function side(e) {
if (e.button !== 3 && e.button !== 4) return;
var t = e.target;
if (t && t.closest && (t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom'))) return;
var now = Date.now();
if (now - lastSide < 450) return;
lastSide = now;
e.preventDefault();
if (e.button === 3) nextChapter(); else prevChapter();
}
document.addEventListener('auxclick', side);
document.addEventListener('mouseup', side);
}
function bindTouch() {
// 触摸手势:上滑/下滑 = 下/上一屏(触屏习惯:上滑进下一页),左滑/右滑 = 下/上一章(与方向键一致)。
// 用 Touch Events 而非 Pointer Events:touchmove + passive:false 才能真正 preventDefault
// 阻止浏览器原生滚动/缩放;pointermove 的 preventDefault 对滚动无效(需 touch-action CSS,
// 而全局 touch-action:none 会破坏 chat-window/group-chat 内部滚动)。
if (!('ontouchstart' in window)) return; // 无触摸能力(桌面)直接跳过,不打扰鼠标
var startX = 0, startY = 0, axis = 0; // axis: 0=未锁定 1=水平 2=垂直
var tracking = false;
var touchLock = 0;
var THRESHOLD = 50, COOLDOWN = 550; // 阈值 50px、冷却 550ms(与滚轮一致)
function isIgnored(t) { // 与 wheel/click 相同的忽略集,避免误触
return !!(t && t.closest &&
(t.closest('a') || t.closest('button') || t.closest('.deck-topbar') || t.closest('.deck-bottom')));
}
document.addEventListener('touchstart', function (e) {
if (isIgnored(e.target)) return; // 交互元素不接管
if (e.touches.length > 1) return; // 多点(捏合缩放)不接管
tracking = true;
axis = 0;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
});
document.addEventListener('touchmove', function (e) {
if (!tracking) return;
if (e.touches.length > 1) { tracking = false; return; } // 双指出现 = 放弃手势
var t = e.touches[0];
var dx = t.clientX - startX;
var dy = t.clientY - startY;
if (axis === 0) {
// 方向锁定:位移超 8px 判定轴向(水平/垂直二选一),防斜滑误触发
if (Math.abs(dx) < 8 && Math.abs(dy) < 8) return;
axis = Math.abs(dx) > Math.abs(dy) ? 1 : 2;
}
e.preventDefault(); // 轴向锁定后才阻断原生滚动/缩放(passive:false)
}, { passive: false });
function finish(e) {
if (!tracking) return;
tracking = false;
if (axis === 0) return; // 未到锁定阈值 = tap,放行 click
var now = Date.now();
if (now - touchLock < COOLDOWN) return; // 防连击
var t = e.changedTouches[0];
var dx = t.clientX - startX;
var dy = t.clientY - startY;
if (axis === 1) { // 水平:左滑=下章,右滑=上章
if (Math.abs(dx) < THRESHOLD) return;
touchLock = now;
if (dx < 0) nextChapter(); else prevChapter();
} else { // 垂直:上滑=下一屏,下滑=上一屏
if (Math.abs(dy) < THRESHOLD) return;
touchLock = now;
if (dy < 0) next(); else prev();
}
}
document.addEventListener('touchend', finish);
document.addEventListener('touchcancel', function () { tracking = false; });
}
/* ---------- 入口 ---------- */
function init(opts) {
if (inited) return;
inited = true;
opts = opts || {};
window.addEventListener('message', onPageMessage);
if (opts.mode === 'shell') { initShell(opts); return; }
cfg = readConfig();
collect();
if (total === 0) return;
screens.forEach(function (s) {
s.el.style.position = 'absolute';
s.el.style.inset = '0';
});
buildChrome();
bindKeys();
bindClick();
bindMouse();
bindTouch();
showScreen(0);
activate(screens[0]); // 首屏自动播放入场,不黑屏
}
// 安全网:若页面未显式 init,DOM 就绪后自动启动
function auto() {
if (inited) return;
if (document.querySelectorAll('.screen').length) init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', auto);
} else {
setTimeout(auto, 0);
}
/* ============================================================
* 总控容器模式(shell):总控页用 iframe 加载多页 + 预加载无缝衔接 + 导航可编排。
*
* 用法(总控页,如 course/deck-shell.html):
*
*
*
*
* - plan.pages[].file 相对总控页;iframe 不销毁,翻回保留状态;预加载下一页(隐藏)→ 翻页无白屏。
* - 页内 deck 保持单页模式不变(可独立打开);容器内通过 postMessage 上报进度、转发翻章请求。
* - file:// 双击读内嵌 JSON(零 fetch);http 下可加 ?plan=plans/xxx.json 切换编排(fetch 失败回退内嵌)。
* ============================================================ */
function urlParam(name) {
try {
var m = location.search.match(new RegExp('[?&]' + name + '=([^&]*)'));
return m ? decodeURIComponent(m[1]) : null;
} catch (e) { return null; }
}
function readPlan(opts) {
if (opts.plan && typeof opts.plan === 'object' && opts.plan.pages) return opts.plan;
var url = opts.planUrl || urlParam('plan');
if (url && location.protocol !== 'file:') {
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, false); // 同步,保持零构建的确定性(仅 http 生效)
xhr.send(null);
if (xhr.status >= 200 && xhr.status < 300) {
var p = JSON.parse(xhr.responseText);
if (p && p.pages) return p;
}
} catch (e) {}
}
var el = opts.planEl ? document.querySelector(opts.planEl) : document.querySelector('script.deck-plan');
if (el) {
try {
var p2 = JSON.parse(el.textContent);
if (p2 && p2.pages) return p2;
} catch (e) {}
}
return null;
}
function initShell(opts) {
var plan = readPlan(opts);
if (!plan) {
var err = document.createElement('div');
err.style.cssText = 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;'
+ 'font:16px var(--font-mono);color:var(--text-3);background:var(--bg-0);text-align:center;padding:40px;';
err.textContent = '缺少编排配置:请在总控页内嵌