/* ============================================================ * 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 = '缺少编排配置:请在总控页内嵌