refactor(chapter): PKG-CHAPTER-03 split runtime into typed lifecycle and view modules

This commit is contained in:
Harvey Zhao committed 2026-09-10 23:12:56 +08:00
1 parent a0286c4e95
commit 48660c4e12
23 files changed
+1207 -497

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";if("undefined"!==typeof document){const t="artplayer-plugin-chapter";let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(e)}):(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"}return function(t={}){return e=>{const{$player:r}=e.template,{setStyle:a,append:n,clamp:o,query:i,addClass:p,removeClass:s}=e.constructor.utils;let d=[];const l=e.query(".art-control-progress-inner"),c=n(l,'<div class="art-chapters"></div>'),h=n(l,'<div class="art-chapter-title"></div>');function u({$chapter:t,width:e}){const r=t.dataset.title.trim();if(r){h.textContent=r;const t=h.clientWidth;e<=t/2?a(h,"left",0):e>l.clientWidth-t/2?a(h,"left",l.clientWidth-t+"px"):a(h,"left",e-t/2+"px")}}function g(t=[]){if(d=[],c.textContent="",s(r,"artplayer-plugin-chapter"),Array.isArray(t)&&t.length&&e.duration){t=t.sort((t,e)=>t.start-e.start);for(let r=0;r<t.length;r++){const a=t[r],n=t[r+1];if(a.end===1/0&&(a.end=e.duration),"number"!==typeof a.start||"number"!==typeof a.end||"string"!==typeof a.title)throw new TypeError("Illegal chapter data type");if(a.start<0||a.end>e.duration||a.start>=a.end)throw new Error("Illegal chapter time point");if(n&&a.end>n.start)throw new Error("Illegal chapter time point")}t[0].start>0&&t.unshift({start:0,end:t[0].start,title:""}),t[t.length-1].end<e.duration&&t.push({start:t[t.length-1].end,end:e.duration,title:""});for(let e=0;e<t.length-1;e++)t[e].end!==t[e+1].start&&t.splice(e+1,0,{start:t[e].end,end:t[e+1].start,title:""});d=t.map(t=>{const r=n(c,'\n <div class="art-chapter">\n <div class="art-chapter-inner">\n <div class="art-progress-hover"></div>\n <div class="art-progress-loaded"></div>\n <div class="art-progress-played"></div>\n </div>\n </div>\n '),a=o(t.start,0,e.duration),p=o(t.end,0,e.duration),s=p-a,d=s/e.duration;return r.dataset.start=a,r.dataset.end=p,r.dataset.duration=s,r.dataset.title=t.title.trim(),r.style.width=100*d+"%",{$chapter:r,$hover:i(".art-progress-hover",r),$loaded:i(".art-progress-loaded",r),$played:i(".art-progress-played",r)}}),p(r,"artplayer-plugin-chapter"),e.emit("setBar","loaded",e.loaded||0)}}return e.on("setBar",(t,r)=>{if(d.length)for(let n=0;n<d.length;n++){const{$chapter:o,$loaded:i,$played:p,$hover:s}=d[n],l={hover:s,loaded:i,played:p}[t];if(!l)return;const h=c.clientWidth*r,g=e.duration*r,y=Number.parseFloat(o.dataset.duration),f=Number.parseFloat(o.dataset.start),m=Number.parseFloat(o.dataset.end);if(g<f&&a(l,"width",0),g>m&&a(l,"width","100%"),g>=f&&g<=m){a(l,"width",100*((g-f)/y)+"%"),"hover"===t&&h>0&&u({$chapter:o,width:h})}}}),e.once("video:loadedmetadata",()=>g(t.chapters)),{name:"artplayerPluginChapter",update:({chapters:t})=>g(t)}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";return function(){if("undefined"===typeof document)return;const t="artplayer-plugin-chapter",e=()=>{let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"};document.getElementById(t)||"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}(),function(t={}){return e=>{const r=e.template.$player,a=e.query(".art-control-progress-inner");if(!a)throw new Error("Missing ArtPlayer progress container");const n="artplayer-plugin-chapter";let o=function(t){const e=(e,r)=>{const a=t.ownerDocument.createElement("div");return a.className=r,e.appendChild(a),a},r=e(t,"art-chapters"),a=e(t,"art-chapter-title");let n=[];function o(){a.textContent="",a.style.left="0px",a.style.visibility="hidden"}function i(){n=[],r.textContent="",o()}return o(),{clear:i,render:function(t,a){n=t.map(t=>{const n=e(r,"art-chapter"),o=e(n,"art-chapter-inner"),i=t.end-t.start;return n.dataset.start=String(t.start),n.dataset.end=String(t.end),n.dataset.duration=String(i),n.dataset.title=t.title.trim(),n.style.width=i/a*100+"%",{start:t.start,end:t.end,title:t.title.trim(),bars:{hover:e(o,"art-progress-hover"),loaded:e(o,"art-progress-loaded"),played:e(o,"art-progress-played")}}})},setBar:function(e,i,s){if("hover"!==e&&"loaded"!==e&&"played"!==e)return;if(!Number.isFinite(i))return;const l=s*i;let p;for(const t of n)t.bars[e].style.width=l<t.start?"0px":l>t.end?"100%":(l-t.start)/(t.end-t.start)*100+"%",l>=t.start&&l<=t.end&&(p=t);if("hover"!==e)return;o();const d=r.clientWidth*i;p?.title&&d>0&&(a.textContent=p.title,a.style.visibility="visible",a.style.left=`${Math.max(0,Math.min(d-a.clientWidth/2,t.clientWidth-a.clientWidth))}px`)},destroy:function(){i(),r.remove(),a.remove()}}}(a);function i(t){if(!o||e.isDestroy)return;o.clear(),r.classList.remove(n);const a=function(t=[],e){if(!Array.isArray(t)||!t.length||!Number.isFinite(e)||e<=0)return[];t.sort((t,e)=>t.start-e.start);for(const[n,o]of t.entries()){if(!o||"number"!==typeof o.start||"number"!==typeof o.end||"string"!==typeof o.title)throw new TypeError("Illegal chapter data type");o.end===1/0&&(o.end=e);const r=t[n+1];if(!Number.isFinite(o.start)||!Number.isFinite(o.end)||o.start<0||o.end>e||o.start>=o.end||r&&o.end>r.start)throw new Error("Illegal chapter time point")}const r=t[0],a=t[t.length-1];r&&r.start>0&&t.unshift({start:0,end:r.start,title:""}),a&&a.end<e&&t.push({start:a.end,end:e,title:""});for(let n=0;n<t.length-1;n++){const e=t[n],r=t[n+1];e&&r&&e.end!==r.start&&t.splice(n+1,0,{start:e.end,end:r.start,title:""})}return t}(t,e.duration);a.length&&(o.render(a,e.duration),r.classList.add(n),e.emit("setBar","loaded",e.loaded||0))}function s(t,r){e.isDestroy||o?.setBar(t,r,e.duration)}function l(){i(t.chapters)}return e.on("setBar",s),e.once("video:loadedmetadata",l),e.on("destroy",function t(){e.off("setBar",s),e.off("video:loadedmetadata",l),e.off("destroy",t),o?.destroy(),o=void 0,r.classList.remove(n)}),{name:"artplayerPluginChapter",update:({chapters:t})=>i(t)}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";if("undefined"!==typeof document){const t="artplayer-plugin-chapter";let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(e)}):(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"}return function(t={}){return e=>{const{$player:r}=e.template,{setStyle:a,append:n,clamp:o,query:i,addClass:p,removeClass:s}=e.constructor.utils;let d=[];const l=e.query(".art-control-progress-inner"),c=n(l,'<div class="art-chapters"></div>'),h=n(l,'<div class="art-chapter-title"></div>');function u({$chapter:t,width:e}){const r=t.dataset.title.trim();if(r){h.textContent=r;const t=h.clientWidth;e<=t/2?a(h,"left",0):e>l.clientWidth-t/2?a(h,"left",l.clientWidth-t+"px"):a(h,"left",e-t/2+"px")}}function g(t=[]){if(d=[],c.textContent="",s(r,"artplayer-plugin-chapter"),Array.isArray(t)&&t.length&&e.duration){t=t.sort((t,e)=>t.start-e.start);for(let r=0;r<t.length;r++){const a=t[r],n=t[r+1];if(a.end===1/0&&(a.end=e.duration),"number"!==typeof a.start||"number"!==typeof a.end||"string"!==typeof a.title)throw new TypeError("Illegal chapter data type");if(a.start<0||a.end>e.duration||a.start>=a.end)throw new Error("Illegal chapter time point");if(n&&a.end>n.start)throw new Error("Illegal chapter time point")}t[0].start>0&&t.unshift({start:0,end:t[0].start,title:""}),t[t.length-1].end<e.duration&&t.push({start:t[t.length-1].end,end:e.duration,title:""});for(let e=0;e<t.length-1;e++)t[e].end!==t[e+1].start&&t.splice(e+1,0,{start:t[e].end,end:t[e+1].start,title:""});d=t.map(t=>{const r=n(c,'\n <div class="art-chapter">\n <div class="art-chapter-inner">\n <div class="art-progress-hover"></div>\n <div class="art-progress-loaded"></div>\n <div class="art-progress-played"></div>\n </div>\n </div>\n '),a=o(t.start,0,e.duration),p=o(t.end,0,e.duration),s=p-a,d=s/e.duration;return r.dataset.start=a,r.dataset.end=p,r.dataset.duration=s,r.dataset.title=t.title.trim(),r.style.width=100*d+"%",{$chapter:r,$hover:i(".art-progress-hover",r),$loaded:i(".art-progress-loaded",r),$played:i(".art-progress-played",r)}}),p(r,"artplayer-plugin-chapter"),e.emit("setBar","loaded",e.loaded||0)}}return e.on("setBar",(t,r)=>{if(d.length)for(let n=0;n<d.length;n++){const{$chapter:o,$loaded:i,$played:p,$hover:s}=d[n],l={hover:s,loaded:i,played:p}[t];if(!l)return;const h=c.clientWidth*r,g=e.duration*r,y=Number.parseFloat(o.dataset.duration),f=Number.parseFloat(o.dataset.start),m=Number.parseFloat(o.dataset.end);if(g<f&&a(l,"width",0),g>m&&a(l,"width","100%"),g>=f&&g<=m){a(l,"width",100*((g-f)/y)+"%"),"hover"===t&&h>0&&u({$chapter:o,width:h})}}}),e.once("video:loadedmetadata",()=>g(t.chapters)),{name:"artplayerPluginChapter",update:({chapters:t})=>g(t)}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";return function(){if("undefined"===typeof document)return;const t="artplayer-plugin-chapter",e=()=>{let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"};document.getElementById(t)||"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}(),function(t={}){return e=>{const r=e.template.$player,a=e.query(".art-control-progress-inner");if(!a)throw new Error("Missing ArtPlayer progress container");const n="artplayer-plugin-chapter";let o=function(t){const e=(e,r)=>{const a=t.ownerDocument.createElement("div");return a.className=r,e.appendChild(a),a},r=e(t,"art-chapters"),a=e(t,"art-chapter-title");let n=[];function o(){a.textContent="",a.style.left="0px",a.style.visibility="hidden"}function i(){n=[],r.textContent="",o()}return o(),{clear:i,render:function(t,a){n=t.map(t=>{const n=e(r,"art-chapter"),o=e(n,"art-chapter-inner"),i=t.end-t.start;return n.dataset.start=String(t.start),n.dataset.end=String(t.end),n.dataset.duration=String(i),n.dataset.title=t.title.trim(),n.style.width=i/a*100+"%",{start:t.start,end:t.end,title:t.title.trim(),bars:{hover:e(o,"art-progress-hover"),loaded:e(o,"art-progress-loaded"),played:e(o,"art-progress-played")}}})},setBar:function(e,i,s){if("hover"!==e&&"loaded"!==e&&"played"!==e)return;if(!Number.isFinite(i))return;const l=s*i;let p;for(const t of n)t.bars[e].style.width=l<t.start?"0px":l>t.end?"100%":(l-t.start)/(t.end-t.start)*100+"%",l>=t.start&&l<=t.end&&(p=t);if("hover"!==e)return;o();const d=r.clientWidth*i;(null==p?void 0:p.title)&&d>0&&(a.textContent=p.title,a.style.visibility="visible",a.style.left=`${Math.max(0,Math.min(d-a.clientWidth/2,t.clientWidth-a.clientWidth))}px`)},destroy:function(){i(),r.remove(),a.remove()}}}(a);function i(t){if(!o||e.isDestroy)return;o.clear(),r.classList.remove(n);const a=function(t=[],e){if(!Array.isArray(t)||!t.length||!Number.isFinite(e)||e<=0)return[];t.sort((t,e)=>t.start-e.start);for(const[n,o]of t.entries()){if(!o||"number"!==typeof o.start||"number"!==typeof o.end||"string"!==typeof o.title)throw new TypeError("Illegal chapter data type");o.end===1/0&&(o.end=e);const r=t[n+1];if(!Number.isFinite(o.start)||!Number.isFinite(o.end)||o.start<0||o.end>e||o.start>=o.end||r&&o.end>r.start)throw new Error("Illegal chapter time point")}const r=t[0],a=t[t.length-1];r&&r.start>0&&t.unshift({start:0,end:r.start,title:""}),a&&a.end<e&&t.push({start:a.end,end:e,title:""});for(let n=0;n<t.length-1;n++){const e=t[n],r=t[n+1];e&&r&&e.end!==r.start&&t.splice(n+1,0,{start:e.end,end:r.start,title:""})}return t}(t,e.duration);a.length&&(o.render(a,e.duration),r.classList.add(n),e.emit("setBar","loaded",e.loaded||0))}function s(t,r){e.isDestroy||null==o||o.setBar(t,r,e.duration)}function l(){i(t.chapters)}return e.on("setBar",s),e.once("video:loadedmetadata",l),e.on("destroy",function t(){e.off("setBar",s),e.off("video:loadedmetadata",l),e.off("destroy",t),null==o||o.destroy(),o=void 0,r.classList.remove(n)}),{name:"artplayerPluginChapter",update:({chapters:t})=>i(t)}}}});
+159 -147
View File
@@ -4,156 +4,168 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const style = ".artplayer-plugin-chapter .art-control-progress-inner {\n height: 100% !important;\n background-color: transparent !important;\n}\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played {\n display: none !important;\n}\n.artplayer-plugin-chapter .art-control-thumbnails {\n bottom: calc(var(--art-bottom-gap) + 64px) !important;\n}\n.artplayer-plugin-chapter .art-chapters {\n position: absolute;\n z-index: 0;\n inset: 0;\n display: flex;\n align-items: center;\n gap: 4px;\n height: 100%;\n transform: scaleY(1.25);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter {\n display: flex;\n align-items: center;\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner {\n position: relative;\n cursor: pointer;\n width: 100%;\n height: 50%;\n border-radius: 10px;\n overflow: hidden;\n transition: height var(--art-transition-duration) ease;\n background-color: var(--art-progress-color);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner {\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapter-title {\n transform-origin: bottom center;\n transform: scale(0.5);\n opacity: 0;\n position: absolute;\n z-index: 70;\n top: -50px;\n left: 0;\n padding: 3px 5px;\n line-height: 1;\n font-size: 14px;\n border-radius: var(--art-border-radius);\n white-space: nowrap;\n background-color: var(--art-tip-background);\n transition: transform var(--art-transition-duration) ease, opacity var(--art-transition-duration) ease;\n}\n.artplayer-plugin-chapter.art-progress-hover .art-chapter-title {\n transform: scale(1);\n opacity: 1;\n}\n";
function artplayerPluginChapter(option = {}) {
return (art) => {
const { $player } = art.template;
const { setStyle, append, clamp, query, addClass, removeClass } = art.constructor.utils;
const html = `
<div class="art-chapter">
<div class="art-chapter-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
</div>
</div>
`;
let $chapters = [];
const $inner = art.query(".art-control-progress-inner");
const $control = append($inner, '<div class="art-chapters"></div>');
const $title = append($inner, '<div class="art-chapter-title"></div>');
function showTitle({ $chapter, width }) {
const title = $chapter.dataset.title.trim();
if (title) {
$title.textContent = title;
const titleWidth = $title.clientWidth;
if (width <= titleWidth / 2) {
setStyle($title, "left", 0);
} else if (width > $inner.clientWidth - titleWidth / 2) {
setStyle($title, "left", `${$inner.clientWidth - titleWidth}px`);
} else {
setStyle($title, "left", `${width - titleWidth / 2}px`);
}
}
}
function update(chapters = []) {
$chapters = [];
$control.textContent = "";
removeClass($player, "artplayer-plugin-chapter");
if (!Array.isArray(chapters))
return;
if (!chapters.length)
return;
if (!art.duration)
return;
chapters = chapters.sort((a, b) => a.start - b.start);
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i];
const nextChapter = chapters[i + 1];
if (chapter.end === Infinity) {
chapter.end = art.duration;
}
if (typeof chapter.start !== "number" || typeof chapter.end !== "number" || typeof chapter.title !== "string") {
throw new TypeError("Illegal chapter data type");
}
if (chapter.start < 0 || chapter.end > art.duration || chapter.start >= chapter.end) {
throw new Error("Illegal chapter time point");
}
if (nextChapter && chapter.end > nextChapter.start) {
throw new Error("Illegal chapter time point");
}
}
if (chapters[0].start > 0) {
chapters.unshift({ start: 0, end: chapters[0].start, title: "" });
}
if (chapters[chapters.length - 1].end < art.duration) {
chapters.push({ start: chapters[chapters.length - 1].end, end: art.duration, title: "" });
}
for (let i = 0; i < chapters.length - 1; i++) {
if (chapters[i].end !== chapters[i + 1].start) {
chapters.splice(i + 1, 0, {
start: chapters[i].end,
end: chapters[i + 1].start,
title: ""
});
}
}
$chapters = chapters.map((chapter) => {
const $chapter = append($control, html);
const start = clamp(chapter.start, 0, art.duration);
const end = clamp(chapter.end, 0, art.duration);
const duration = end - start;
const percentage = duration / art.duration;
$chapter.dataset.start = start;
$chapter.dataset.end = end;
$chapter.dataset.duration = duration;
$chapter.dataset.title = chapter.title.trim();
$chapter.style.width = `${percentage * 100}%`;
return {
$chapter,
$hover: query(".art-progress-hover", $chapter),
$loaded: query(".art-progress-loaded", $chapter),
$played: query(".art-progress-played", $chapter)
};
});
addClass($player, "artplayer-plugin-chapter");
art.emit("setBar", "loaded", art.loaded || 0);
}
art.on("setBar", (type, percentage) => {
if (!$chapters.length)
return;
for (let i = 0; i < $chapters.length; i++) {
const { $chapter, $loaded, $played, $hover } = $chapters[i];
const $target = {
hover: $hover,
loaded: $loaded,
played: $played
}[type];
if (!$target)
return;
const width = $control.clientWidth * percentage;
const currentTime = art.duration * percentage;
const duration = Number.parseFloat($chapter.dataset.duration);
const start = Number.parseFloat($chapter.dataset.start);
const end = Number.parseFloat($chapter.dataset.end);
if (currentTime < start) {
setStyle($target, "width", 0);
}
if (currentTime > end) {
setStyle($target, "width", "100%");
}
if (currentTime >= start && currentTime <= end) {
const percentage2 = (currentTime - start) / duration;
setStyle($target, "width", `${percentage2 * 100}%`);
if (type === "hover" && width > 0) {
showTitle({ $chapter, width });
}
}
}
});
art.once("video:loadedmetadata", () => update(option.chapters));
return {
name: "artplayerPluginChapter",
update: ({ chapters }) => update(chapters)
};
};
}
if (typeof document !== "undefined") {
const id = "artplayer-plugin-chapter";
let $style = document.getElementById(id);
if (!$style) {
$style = document.createElement("style");
$style.id = id;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
document.head.appendChild($style);
});
} else {
(document.head || document.documentElement).appendChild($style);
function normalizeChapters(chapters = [], duration) {
if (!Array.isArray(chapters) || !chapters.length || !Number.isFinite(duration) || duration <= 0)
return [];
chapters.sort((a, b) => a.start - b.start);
for (const [index, chapter] of chapters.entries()) {
if (!chapter || typeof chapter.start !== "number" || typeof chapter.end !== "number" || typeof chapter.title !== "string")
throw new TypeError("Illegal chapter data type");
if (chapter.end === Infinity)
chapter.end = duration;
const next = chapters[index + 1];
if (!Number.isFinite(chapter.start) || !Number.isFinite(chapter.end) || chapter.start < 0 || chapter.end > duration || chapter.start >= chapter.end || next && chapter.end > next.start) {
throw new Error("Illegal chapter time point");
}
}
$style.textContent = style;
const first = chapters[0];
const last = chapters[chapters.length - 1];
if (first && first.start > 0)
chapters.unshift({ start: 0, end: first.start, title: "" });
if (last && last.end < duration)
chapters.push({ start: last.end, end: duration, title: "" });
for (let index = 0; index < chapters.length - 1; index++) {
const current = chapters[index];
const next = chapters[index + 1];
if (current && next && current.end !== next.start)
chapters.splice(index + 1, 0, { start: current.end, end: next.start, title: "" });
}
return chapters;
}
function createProgress(inner) {
const create = (parent, className) => {
const element = inner.ownerDocument.createElement("div");
element.className = className;
parent.appendChild(element);
return element;
};
const control = create(inner, "art-chapters");
const title = create(inner, "art-chapter-title");
let segments = [];
function clearTitle() {
title.textContent = "";
title.style.left = "0px";
title.style.visibility = "hidden";
}
function clear() {
segments = [];
control.textContent = "";
clearTitle();
}
function render(chapters, duration) {
segments = chapters.map((chapter) => {
const element = create(control, "art-chapter");
const body = create(element, "art-chapter-inner");
const length = chapter.end - chapter.start;
element.dataset.start = String(chapter.start);
element.dataset.end = String(chapter.end);
element.dataset.duration = String(length);
element.dataset.title = chapter.title.trim();
element.style.width = `${length / duration * 100}%`;
return {
start: chapter.start,
end: chapter.end,
title: chapter.title.trim(),
bars: {
hover: create(body, "art-progress-hover"),
loaded: create(body, "art-progress-loaded"),
played: create(body, "art-progress-played")
}
};
});
}
function setBar(type, percentage, duration) {
if (type !== "hover" && type !== "loaded" && type !== "played")
return;
if (!Number.isFinite(percentage))
return;
const currentTime = duration * percentage;
let hovered;
for (const segment of segments) {
const target = segment.bars[type];
target.style.width = currentTime < segment.start ? "0px" : currentTime > segment.end ? "100%" : `${(currentTime - segment.start) / (segment.end - segment.start) * 100}%`;
if (currentTime >= segment.start && currentTime <= segment.end)
hovered = segment;
}
if (type !== "hover")
return;
clearTitle();
const width = control.clientWidth * percentage;
if (hovered?.title && width > 0) {
title.textContent = hovered.title;
title.style.visibility = "visible";
title.style.left = `${Math.max(0, Math.min(width - title.clientWidth / 2, inner.clientWidth - title.clientWidth))}px`;
}
}
function destroy() {
clear();
control.remove();
title.remove();
}
clearTitle();
return { clear, render, setBar, destroy };
}
const style = ".artplayer-plugin-chapter .art-control-progress-inner {\n height: 100% !important;\n background-color: transparent !important;\n}\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played {\n display: none !important;\n}\n.artplayer-plugin-chapter .art-control-thumbnails {\n bottom: calc(var(--art-bottom-gap) + 64px) !important;\n}\n.artplayer-plugin-chapter .art-chapters {\n position: absolute;\n z-index: 0;\n inset: 0;\n display: flex;\n align-items: center;\n gap: 4px;\n height: 100%;\n transform: scaleY(1.25);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter {\n display: flex;\n align-items: center;\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner {\n position: relative;\n cursor: pointer;\n width: 100%;\n height: 50%;\n border-radius: 10px;\n overflow: hidden;\n transition: height var(--art-transition-duration) ease;\n background-color: var(--art-progress-color);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner {\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapter-title {\n transform-origin: bottom center;\n transform: scale(0.5);\n opacity: 0;\n position: absolute;\n z-index: 70;\n top: -50px;\n left: 0;\n padding: 3px 5px;\n line-height: 1;\n font-size: 14px;\n border-radius: var(--art-border-radius);\n white-space: nowrap;\n background-color: var(--art-tip-background);\n transition: transform var(--art-transition-duration) ease, opacity var(--art-transition-duration) ease;\n}\n.artplayer-plugin-chapter.art-progress-hover .art-chapter-title {\n transform: scale(1);\n opacity: 1;\n}\n";
function installStyle() {
if (typeof document === "undefined")
return;
const id = "artplayer-plugin-chapter";
const install = () => {
let element = document.getElementById(id);
if (!element) {
element = document.createElement("style");
element.id = id;
(document.head || document.documentElement).appendChild(element);
}
element.textContent = style;
};
if (document.getElementById(id) || document.readyState !== "loading")
install();
else
document.addEventListener("DOMContentLoaded", install, { once: true });
}
function artplayerPluginChapter(option = {}) {
return (art) => {
const player = art.template.$player;
const inner = art.query(".art-control-progress-inner");
if (!inner)
throw new Error("Missing ArtPlayer progress container");
const className = "artplayer-plugin-chapter";
let progress = createProgress(inner);
function update(chapters) {
if (!progress || art.isDestroy)
return;
progress.clear();
player.classList.remove(className);
const normalized = normalizeChapters(chapters, art.duration);
if (!normalized.length)
return;
progress.render(normalized, art.duration);
player.classList.add(className);
art.emit("setBar", "loaded", art.loaded || 0);
}
function setBar(type, percentage) {
if (!art.isDestroy)
progress?.setBar(type, percentage, art.duration);
}
function initialize() {
update(option.chapters);
}
function destroy() {
art.off("setBar", setBar);
art.off("video:loadedmetadata", initialize);
art.off("destroy", destroy);
progress?.destroy();
progress = void 0;
player.classList.remove(className);
}
art.on("setBar", setBar);
art.once("video:loadedmetadata", initialize);
art.on("destroy", destroy);
return { name: "artplayerPluginChapter", update: ({ chapters }) => update(chapters) };
};
}
installStyle();
export {
artplayerPluginChapter as default
};
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
@@ -0,0 +1,88 @@
# Chapter implementation and maintenance
The default export remains the synchronous `artplayerPluginChapter(option?)`
factory. It registers `artplayerPluginChapter` with a synchronous `update(option)`
method; the update object is required. All implementation modules are TypeScript.
## Module map
| File | Responsibility |
| --- | --- |
| `src/index.ts` | Public factory, ArtPlayer integration, metadata initialization, update transaction and listener ownership |
| `src/chapters.ts` | DOM-free validation, sorting, Infinity replacement and gap insertion |
| `src/progress.ts` | Owned chapter/title elements, progress widths and hover title positioning |
| `src/stylesheet.ts` | Import-time global stylesheet installation, including deferred document readiness |
| `src/types.ts` | Internal aliases derived from the existing public declaration; no runtime imports |
| `src/style.less` | Existing CSS classes and theme variables |
| `types/artplayer-plugin-chapter.d.ts` | Consumer declaration and compatibility boundary |
Dependencies flow from the entry to normalization and rendering. Neither of those
modules receives an ArtPlayer instance. Rendering accepts a progress container and
normalized ranges; only the entry reads player duration or emits/subscribes to
player events. No new runtime dependency or new core API is required.
## Updates and compatibility
The first `video:loadedmetadata` initializes chapters from the factory option.
Each update clears the previous view/class/title before validating. A valid finite
timeline sorts the caller array in place, replaces `Infinity` ends, validates ranges,
then inserts empty-title gaps in that same array. Existing entry references are
preserved. Do not replace this with immutable normalization without assessing old
callers. Titles remain unmodified in caller objects and are trimmed for display.
The renderer creates the historical classes and data-start/end/duration/title
attributes. A successful update adds the historical player class, then emits
`setBar('loaded', art.loaded || 0)` synchronously. Progress events update the three
bars; a shared boundary belongs to the later chapter for hover title selection,
matching the old traversal order. Titles use textContent, never caller HTML.
`update({})` and non-array chapter input clear the view. Invalid types throw
TypeError and invalid time ranges throw Error with the existing messages. NaN and
non-finite starts/ends are now rejected; an Infinity end is still normalized first.
Without a positive finite duration the view stays empty and caller data is untouched.
Initialization remains once-only: after changing sources, callers explicitly update
with chapters for the new duration. No implicit source ownership or new events were added.
## Resource ownership
Each instance owns its progress container children and named setBar, metadata and
destroy callbacks. On destroy it removes only those callbacks (including a pending
once callback), removes its two owned DOM roots and player class, and releases the
render state. This cleanup also applies to `destroy(false)` while leaving the core's
retained player tree and other plugins alone. Retained plugin results become no-ops
after destruction; a late metadata callback cannot recreate UI or mutate input.
The stylesheet is document-owned, shared across instances and retained after destroy.
Deferred installation rechecks the ID when DOMContentLoaded fires, so two script
loads during document parsing cannot create duplicate style elements. Its readiness
listener is once-only. SSR import never reads document when it is unavailable.
## Tests and changes
From the repository root:
```sh
yarn test:unit
yarn typecheck
yarn test:browser
yarn test:package
yarn build artplayer-plugin-chapter
```
`test/chapter.test.js` covers normalization and invalid timelines. Browser chapter
tests run the historical and candidate plugin on published core 5.4.0, and cover
real hover/seek, source changes, empty titles, invalid updates, multiple instances,
early destruction and duplicate script loads. Browser playback tests also exercise
the current core. For installed artifacts, use the mapping described in
`test/package/README.md` at repository root. The runnable chapter demo uses
`yarn dev artplayer-plugin-chapter` and the normal localhost:8082 example URL.
For time semantics, start at chapters.ts and run normalization plus browser tests.
For UI, start at progress.ts/style.less and verify real mouse interaction. For event
or cleanup changes, start at index.ts and rerun early destroy/multiple-instance tests.
Any public declaration change also needs new/old compiler and tarball consumers.
The public declaration is intentionally retained during the structural migration;
PKG-CHAPTER-04 owns NodeNext ESM and legacy subpath declaration fixes. The frozen
published baseline is under refactor/baselines; never overwrite it with candidate
behavior. Physical device, full editor and final release checks remain separate tasks.
+8 -1
View File
@@ -28,9 +28,16 @@ call update with chapters appropriate to the new duration; initialization listen
## Maintenance
The implementation is currently in `src/index.js`, styles in `src/style.less`, and public declarations
The implementation starts in `src/index.ts`, styles in `src/style.less`, and public declarations
in `types/artplayer-plugin-chapter.d.ts`. Its migration contracts and known defects are recorded in
[the chapter contract](../../refactor/baselines/chapter-contract.md).
See [ARCHITECTURE.md](./ARCHITECTURE.md) for module boundaries, event ordering, cleanup ownership,
compatibility decisions and the tests to run when changing each responsibility.
Blank chapters and replacement updates clear the hover title. Non-finite time points are rejected
(an Infinity end is still supported); chapters wait for a positive finite media duration.
Destroy releases this plugin's listeners and DOM, including when the player keeps its HTML.
A retained plugin result ignores updates after destruction.
Run `yarn test:browser chapter.spec.js` for published/current plugin behavior on the published core.
Run `yarn typecheck` and `yarn test:baseline` for strict consumer checks, including optional factory
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";if("undefined"!==typeof document){const t="artplayer-plugin-chapter";let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(e)}):(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"}return function(t={}){return e=>{const{$player:r}=e.template,{setStyle:a,append:n,clamp:o,query:i,addClass:p,removeClass:s}=e.constructor.utils;let d=[];const l=e.query(".art-control-progress-inner"),c=n(l,'<div class="art-chapters"></div>'),h=n(l,'<div class="art-chapter-title"></div>');function u({$chapter:t,width:e}){const r=t.dataset.title.trim();if(r){h.textContent=r;const t=h.clientWidth;e<=t/2?a(h,"left",0):e>l.clientWidth-t/2?a(h,"left",l.clientWidth-t+"px"):a(h,"left",e-t/2+"px")}}function g(t=[]){if(d=[],c.textContent="",s(r,"artplayer-plugin-chapter"),Array.isArray(t)&&t.length&&e.duration){t=t.sort((t,e)=>t.start-e.start);for(let r=0;r<t.length;r++){const a=t[r],n=t[r+1];if(a.end===1/0&&(a.end=e.duration),"number"!==typeof a.start||"number"!==typeof a.end||"string"!==typeof a.title)throw new TypeError("Illegal chapter data type");if(a.start<0||a.end>e.duration||a.start>=a.end)throw new Error("Illegal chapter time point");if(n&&a.end>n.start)throw new Error("Illegal chapter time point")}t[0].start>0&&t.unshift({start:0,end:t[0].start,title:""}),t[t.length-1].end<e.duration&&t.push({start:t[t.length-1].end,end:e.duration,title:""});for(let e=0;e<t.length-1;e++)t[e].end!==t[e+1].start&&t.splice(e+1,0,{start:t[e].end,end:t[e+1].start,title:""});d=t.map(t=>{const r=n(c,'\n <div class="art-chapter">\n <div class="art-chapter-inner">\n <div class="art-progress-hover"></div>\n <div class="art-progress-loaded"></div>\n <div class="art-progress-played"></div>\n </div>\n </div>\n '),a=o(t.start,0,e.duration),p=o(t.end,0,e.duration),s=p-a,d=s/e.duration;return r.dataset.start=a,r.dataset.end=p,r.dataset.duration=s,r.dataset.title=t.title.trim(),r.style.width=100*d+"%",{$chapter:r,$hover:i(".art-progress-hover",r),$loaded:i(".art-progress-loaded",r),$played:i(".art-progress-played",r)}}),p(r,"artplayer-plugin-chapter"),e.emit("setBar","loaded",e.loaded||0)}}return e.on("setBar",(t,r)=>{if(d.length)for(let n=0;n<d.length;n++){const{$chapter:o,$loaded:i,$played:p,$hover:s}=d[n],l={hover:s,loaded:i,played:p}[t];if(!l)return;const h=c.clientWidth*r,g=e.duration*r,y=Number.parseFloat(o.dataset.duration),f=Number.parseFloat(o.dataset.start),m=Number.parseFloat(o.dataset.end);if(g<f&&a(l,"width",0),g>m&&a(l,"width","100%"),g>=f&&g<=m){a(l,"width",100*((g-f)/y)+"%"),"hover"===t&&h>0&&u({$chapter:o,width:h})}}}),e.once("video:loadedmetadata",()=>g(t.chapters)),{name:"artplayerPluginChapter",update:({chapters:t})=>g(t)}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";return function(){if("undefined"===typeof document)return;const t="artplayer-plugin-chapter",e=()=>{let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"};document.getElementById(t)||"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}(),function(t={}){return e=>{const r=e.template.$player,a=e.query(".art-control-progress-inner");if(!a)throw new Error("Missing ArtPlayer progress container");const n="artplayer-plugin-chapter";let o=function(t){const e=(e,r)=>{const a=t.ownerDocument.createElement("div");return a.className=r,e.appendChild(a),a},r=e(t,"art-chapters"),a=e(t,"art-chapter-title");let n=[];function o(){a.textContent="",a.style.left="0px",a.style.visibility="hidden"}function i(){n=[],r.textContent="",o()}return o(),{clear:i,render:function(t,a){n=t.map(t=>{const n=e(r,"art-chapter"),o=e(n,"art-chapter-inner"),i=t.end-t.start;return n.dataset.start=String(t.start),n.dataset.end=String(t.end),n.dataset.duration=String(i),n.dataset.title=t.title.trim(),n.style.width=i/a*100+"%",{start:t.start,end:t.end,title:t.title.trim(),bars:{hover:e(o,"art-progress-hover"),loaded:e(o,"art-progress-loaded"),played:e(o,"art-progress-played")}}})},setBar:function(e,i,s){if("hover"!==e&&"loaded"!==e&&"played"!==e)return;if(!Number.isFinite(i))return;const l=s*i;let p;for(const t of n)t.bars[e].style.width=l<t.start?"0px":l>t.end?"100%":(l-t.start)/(t.end-t.start)*100+"%",l>=t.start&&l<=t.end&&(p=t);if("hover"!==e)return;o();const d=r.clientWidth*i;p?.title&&d>0&&(a.textContent=p.title,a.style.visibility="visible",a.style.left=`${Math.max(0,Math.min(d-a.clientWidth/2,t.clientWidth-a.clientWidth))}px`)},destroy:function(){i(),r.remove(),a.remove()}}}(a);function i(t){if(!o||e.isDestroy)return;o.clear(),r.classList.remove(n);const a=function(t=[],e){if(!Array.isArray(t)||!t.length||!Number.isFinite(e)||e<=0)return[];t.sort((t,e)=>t.start-e.start);for(const[n,o]of t.entries()){if(!o||"number"!==typeof o.start||"number"!==typeof o.end||"string"!==typeof o.title)throw new TypeError("Illegal chapter data type");o.end===1/0&&(o.end=e);const r=t[n+1];if(!Number.isFinite(o.start)||!Number.isFinite(o.end)||o.start<0||o.end>e||o.start>=o.end||r&&o.end>r.start)throw new Error("Illegal chapter time point")}const r=t[0],a=t[t.length-1];r&&r.start>0&&t.unshift({start:0,end:r.start,title:""}),a&&a.end<e&&t.push({start:a.end,end:e,title:""});for(let n=0;n<t.length-1;n++){const e=t[n],r=t[n+1];e&&r&&e.end!==r.start&&t.splice(n+1,0,{start:e.end,end:r.start,title:""})}return t}(t,e.duration);a.length&&(o.render(a,e.duration),r.classList.add(n),e.emit("setBar","loaded",e.loaded||0))}function s(t,r){e.isDestroy||o?.setBar(t,r,e.duration)}function l(){i(t.chapters)}return e.on("setBar",s),e.once("video:loadedmetadata",l),e.on("destroy",function t(){e.off("setBar",s),e.off("video:loadedmetadata",l),e.off("destroy",t),o?.destroy(),o=void 0,r.classList.remove(n)}),{name:"artplayerPluginChapter",update:({chapters:t})=>i(t)}}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";if("undefined"!==typeof document){const t="artplayer-plugin-chapter";let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(e)}):(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"}return function(t={}){return e=>{const{$player:r}=e.template,{setStyle:a,append:n,clamp:o,query:i,addClass:p,removeClass:s}=e.constructor.utils;let d=[];const l=e.query(".art-control-progress-inner"),c=n(l,'<div class="art-chapters"></div>'),h=n(l,'<div class="art-chapter-title"></div>');function u({$chapter:t,width:e}){const r=t.dataset.title.trim();if(r){h.textContent=r;const t=h.clientWidth;e<=t/2?a(h,"left",0):e>l.clientWidth-t/2?a(h,"left",l.clientWidth-t+"px"):a(h,"left",e-t/2+"px")}}function g(t=[]){if(d=[],c.textContent="",s(r,"artplayer-plugin-chapter"),Array.isArray(t)&&t.length&&e.duration){t=t.sort((t,e)=>t.start-e.start);for(let r=0;r<t.length;r++){const a=t[r],n=t[r+1];if(a.end===1/0&&(a.end=e.duration),"number"!==typeof a.start||"number"!==typeof a.end||"string"!==typeof a.title)throw new TypeError("Illegal chapter data type");if(a.start<0||a.end>e.duration||a.start>=a.end)throw new Error("Illegal chapter time point");if(n&&a.end>n.start)throw new Error("Illegal chapter time point")}t[0].start>0&&t.unshift({start:0,end:t[0].start,title:""}),t[t.length-1].end<e.duration&&t.push({start:t[t.length-1].end,end:e.duration,title:""});for(let e=0;e<t.length-1;e++)t[e].end!==t[e+1].start&&t.splice(e+1,0,{start:t[e].end,end:t[e+1].start,title:""});d=t.map(t=>{const r=n(c,'\n <div class="art-chapter">\n <div class="art-chapter-inner">\n <div class="art-progress-hover"></div>\n <div class="art-progress-loaded"></div>\n <div class="art-progress-played"></div>\n </div>\n </div>\n '),a=o(t.start,0,e.duration),p=o(t.end,0,e.duration),s=p-a,d=s/e.duration;return r.dataset.start=a,r.dataset.end=p,r.dataset.duration=s,r.dataset.title=t.title.trim(),r.style.width=100*d+"%",{$chapter:r,$hover:i(".art-progress-hover",r),$loaded:i(".art-progress-loaded",r),$played:i(".art-progress-played",r)}}),p(r,"artplayer-plugin-chapter"),e.emit("setBar","loaded",e.loaded||0)}}return e.on("setBar",(t,r)=>{if(d.length)for(let n=0;n<d.length;n++){const{$chapter:o,$loaded:i,$played:p,$hover:s}=d[n],l={hover:s,loaded:i,played:p}[t];if(!l)return;const h=c.clientWidth*r,g=e.duration*r,y=Number.parseFloat(o.dataset.duration),f=Number.parseFloat(o.dataset.start),m=Number.parseFloat(o.dataset.end);if(g<f&&a(l,"width",0),g>m&&a(l,"width","100%"),g>=f&&g<=m){a(l,"width",100*((g-f)/y)+"%"),"hover"===t&&h>0&&u({$chapter:o,width:h})}}}),e.once("video:loadedmetadata",()=>g(t.chapters)),{name:"artplayerPluginChapter",update:({chapters:t})=>g(t)}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginChapter=e(),define(function(){return t.artplayerPluginChapter})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginChapter=e()}(this,function(){"use strict";return function(){if("undefined"===typeof document)return;const t="artplayer-plugin-chapter",e=()=>{let e=document.getElementById(t);e||(e=document.createElement("style"),e.id=t,(document.head||document.documentElement).appendChild(e)),e.textContent=".artplayer-plugin-chapter .art-control-progress-inner{height:100%!important;background-color:transparent!important}.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-hover,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-loaded,.artplayer-plugin-chapter .art-control-progress-inner>.art-progress-played{display:none!important}.artplayer-plugin-chapter .art-control-thumbnails{bottom:calc(var(--art-bottom-gap) + 64px)!important}.artplayer-plugin-chapter .art-chapters{position:absolute;z-index:0;inset:0;display:flex;align-items:center;gap:4px;height:100%;transform:scaleY(1.25)}.artplayer-plugin-chapter .art-chapters .art-chapter{display:flex;align-items:center;height:100%}.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner{position:relative;cursor:pointer;width:100%;height:50%;border-radius:10px;overflow:hidden;transition:height var(--art-transition-duration) ease;background-color:var(--art-progress-color)}.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner{height:100%}.artplayer-plugin-chapter .art-chapter-title{transform-origin:bottom center;transform:scale(.5);opacity:0;position:absolute;z-index:70;top:-50px;left:0;padding:3px 5px;line-height:1;font-size:14px;border-radius:var(--art-border-radius);white-space:nowrap;background-color:var(--art-tip-background);transition:transform var(--art-transition-duration) ease,opacity var(--art-transition-duration) ease}.artplayer-plugin-chapter.art-progress-hover .art-chapter-title{transform:scale(1);opacity:1}"};document.getElementById(t)||"loading"!==document.readyState?e():document.addEventListener("DOMContentLoaded",e,{once:!0})}(),function(t={}){return e=>{const r=e.template.$player,a=e.query(".art-control-progress-inner");if(!a)throw new Error("Missing ArtPlayer progress container");const n="artplayer-plugin-chapter";let o=function(t){const e=(e,r)=>{const a=t.ownerDocument.createElement("div");return a.className=r,e.appendChild(a),a},r=e(t,"art-chapters"),a=e(t,"art-chapter-title");let n=[];function o(){a.textContent="",a.style.left="0px",a.style.visibility="hidden"}function i(){n=[],r.textContent="",o()}return o(),{clear:i,render:function(t,a){n=t.map(t=>{const n=e(r,"art-chapter"),o=e(n,"art-chapter-inner"),i=t.end-t.start;return n.dataset.start=String(t.start),n.dataset.end=String(t.end),n.dataset.duration=String(i),n.dataset.title=t.title.trim(),n.style.width=i/a*100+"%",{start:t.start,end:t.end,title:t.title.trim(),bars:{hover:e(o,"art-progress-hover"),loaded:e(o,"art-progress-loaded"),played:e(o,"art-progress-played")}}})},setBar:function(e,i,s){if("hover"!==e&&"loaded"!==e&&"played"!==e)return;if(!Number.isFinite(i))return;const l=s*i;let p;for(const t of n)t.bars[e].style.width=l<t.start?"0px":l>t.end?"100%":(l-t.start)/(t.end-t.start)*100+"%",l>=t.start&&l<=t.end&&(p=t);if("hover"!==e)return;o();const d=r.clientWidth*i;(null==p?void 0:p.title)&&d>0&&(a.textContent=p.title,a.style.visibility="visible",a.style.left=`${Math.max(0,Math.min(d-a.clientWidth/2,t.clientWidth-a.clientWidth))}px`)},destroy:function(){i(),r.remove(),a.remove()}}}(a);function i(t){if(!o||e.isDestroy)return;o.clear(),r.classList.remove(n);const a=function(t=[],e){if(!Array.isArray(t)||!t.length||!Number.isFinite(e)||e<=0)return[];t.sort((t,e)=>t.start-e.start);for(const[n,o]of t.entries()){if(!o||"number"!==typeof o.start||"number"!==typeof o.end||"string"!==typeof o.title)throw new TypeError("Illegal chapter data type");o.end===1/0&&(o.end=e);const r=t[n+1];if(!Number.isFinite(o.start)||!Number.isFinite(o.end)||o.start<0||o.end>e||o.start>=o.end||r&&o.end>r.start)throw new Error("Illegal chapter time point")}const r=t[0],a=t[t.length-1];r&&r.start>0&&t.unshift({start:0,end:r.start,title:""}),a&&a.end<e&&t.push({start:a.end,end:e,title:""});for(let n=0;n<t.length-1;n++){const e=t[n],r=t[n+1];e&&r&&e.end!==r.start&&t.splice(n+1,0,{start:e.end,end:r.start,title:""})}return t}(t,e.duration);a.length&&(o.render(a,e.duration),r.classList.add(n),e.emit("setBar","loaded",e.loaded||0))}function s(t,r){e.isDestroy||null==o||o.setBar(t,r,e.duration)}function l(){i(t.chapters)}return e.on("setBar",s),e.once("video:loadedmetadata",l),e.on("destroy",function t(){e.off("setBar",s),e.off("video:loadedmetadata",l),e.off("destroy",t),null==o||o.destroy(),o=void 0,r.classList.remove(n)}),{name:"artplayerPluginChapter",update:({chapters:t})=>i(t)}}}});
@@ -4,156 +4,168 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const style = ".artplayer-plugin-chapter .art-control-progress-inner {\n height: 100% !important;\n background-color: transparent !important;\n}\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played {\n display: none !important;\n}\n.artplayer-plugin-chapter .art-control-thumbnails {\n bottom: calc(var(--art-bottom-gap) + 64px) !important;\n}\n.artplayer-plugin-chapter .art-chapters {\n position: absolute;\n z-index: 0;\n inset: 0;\n display: flex;\n align-items: center;\n gap: 4px;\n height: 100%;\n transform: scaleY(1.25);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter {\n display: flex;\n align-items: center;\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner {\n position: relative;\n cursor: pointer;\n width: 100%;\n height: 50%;\n border-radius: 10px;\n overflow: hidden;\n transition: height var(--art-transition-duration) ease;\n background-color: var(--art-progress-color);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner {\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapter-title {\n transform-origin: bottom center;\n transform: scale(0.5);\n opacity: 0;\n position: absolute;\n z-index: 70;\n top: -50px;\n left: 0;\n padding: 3px 5px;\n line-height: 1;\n font-size: 14px;\n border-radius: var(--art-border-radius);\n white-space: nowrap;\n background-color: var(--art-tip-background);\n transition: transform var(--art-transition-duration) ease, opacity var(--art-transition-duration) ease;\n}\n.artplayer-plugin-chapter.art-progress-hover .art-chapter-title {\n transform: scale(1);\n opacity: 1;\n}\n";
function artplayerPluginChapter(option = {}) {
return (art) => {
const { $player } = art.template;
const { setStyle, append, clamp, query, addClass, removeClass } = art.constructor.utils;
const html = `
<div class="art-chapter">
<div class="art-chapter-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
</div>
</div>
`;
let $chapters = [];
const $inner = art.query(".art-control-progress-inner");
const $control = append($inner, '<div class="art-chapters"></div>');
const $title = append($inner, '<div class="art-chapter-title"></div>');
function showTitle({ $chapter, width }) {
const title = $chapter.dataset.title.trim();
if (title) {
$title.textContent = title;
const titleWidth = $title.clientWidth;
if (width <= titleWidth / 2) {
setStyle($title, "left", 0);
} else if (width > $inner.clientWidth - titleWidth / 2) {
setStyle($title, "left", `${$inner.clientWidth - titleWidth}px`);
} else {
setStyle($title, "left", `${width - titleWidth / 2}px`);
}
}
}
function update(chapters = []) {
$chapters = [];
$control.textContent = "";
removeClass($player, "artplayer-plugin-chapter");
if (!Array.isArray(chapters))
return;
if (!chapters.length)
return;
if (!art.duration)
return;
chapters = chapters.sort((a, b) => a.start - b.start);
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i];
const nextChapter = chapters[i + 1];
if (chapter.end === Infinity) {
chapter.end = art.duration;
}
if (typeof chapter.start !== "number" || typeof chapter.end !== "number" || typeof chapter.title !== "string") {
throw new TypeError("Illegal chapter data type");
}
if (chapter.start < 0 || chapter.end > art.duration || chapter.start >= chapter.end) {
throw new Error("Illegal chapter time point");
}
if (nextChapter && chapter.end > nextChapter.start) {
throw new Error("Illegal chapter time point");
}
}
if (chapters[0].start > 0) {
chapters.unshift({ start: 0, end: chapters[0].start, title: "" });
}
if (chapters[chapters.length - 1].end < art.duration) {
chapters.push({ start: chapters[chapters.length - 1].end, end: art.duration, title: "" });
}
for (let i = 0; i < chapters.length - 1; i++) {
if (chapters[i].end !== chapters[i + 1].start) {
chapters.splice(i + 1, 0, {
start: chapters[i].end,
end: chapters[i + 1].start,
title: ""
});
}
}
$chapters = chapters.map((chapter) => {
const $chapter = append($control, html);
const start = clamp(chapter.start, 0, art.duration);
const end = clamp(chapter.end, 0, art.duration);
const duration = end - start;
const percentage = duration / art.duration;
$chapter.dataset.start = start;
$chapter.dataset.end = end;
$chapter.dataset.duration = duration;
$chapter.dataset.title = chapter.title.trim();
$chapter.style.width = `${percentage * 100}%`;
return {
$chapter,
$hover: query(".art-progress-hover", $chapter),
$loaded: query(".art-progress-loaded", $chapter),
$played: query(".art-progress-played", $chapter)
};
});
addClass($player, "artplayer-plugin-chapter");
art.emit("setBar", "loaded", art.loaded || 0);
}
art.on("setBar", (type, percentage) => {
if (!$chapters.length)
return;
for (let i = 0; i < $chapters.length; i++) {
const { $chapter, $loaded, $played, $hover } = $chapters[i];
const $target = {
hover: $hover,
loaded: $loaded,
played: $played
}[type];
if (!$target)
return;
const width = $control.clientWidth * percentage;
const currentTime = art.duration * percentage;
const duration = Number.parseFloat($chapter.dataset.duration);
const start = Number.parseFloat($chapter.dataset.start);
const end = Number.parseFloat($chapter.dataset.end);
if (currentTime < start) {
setStyle($target, "width", 0);
}
if (currentTime > end) {
setStyle($target, "width", "100%");
}
if (currentTime >= start && currentTime <= end) {
const percentage2 = (currentTime - start) / duration;
setStyle($target, "width", `${percentage2 * 100}%`);
if (type === "hover" && width > 0) {
showTitle({ $chapter, width });
}
}
}
});
art.once("video:loadedmetadata", () => update(option.chapters));
return {
name: "artplayerPluginChapter",
update: ({ chapters }) => update(chapters)
};
};
}
if (typeof document !== "undefined") {
const id = "artplayer-plugin-chapter";
let $style = document.getElementById(id);
if (!$style) {
$style = document.createElement("style");
$style.id = id;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
document.head.appendChild($style);
});
} else {
(document.head || document.documentElement).appendChild($style);
function normalizeChapters(chapters = [], duration) {
if (!Array.isArray(chapters) || !chapters.length || !Number.isFinite(duration) || duration <= 0)
return [];
chapters.sort((a, b) => a.start - b.start);
for (const [index, chapter] of chapters.entries()) {
if (!chapter || typeof chapter.start !== "number" || typeof chapter.end !== "number" || typeof chapter.title !== "string")
throw new TypeError("Illegal chapter data type");
if (chapter.end === Infinity)
chapter.end = duration;
const next = chapters[index + 1];
if (!Number.isFinite(chapter.start) || !Number.isFinite(chapter.end) || chapter.start < 0 || chapter.end > duration || chapter.start >= chapter.end || next && chapter.end > next.start) {
throw new Error("Illegal chapter time point");
}
}
$style.textContent = style;
const first = chapters[0];
const last = chapters[chapters.length - 1];
if (first && first.start > 0)
chapters.unshift({ start: 0, end: first.start, title: "" });
if (last && last.end < duration)
chapters.push({ start: last.end, end: duration, title: "" });
for (let index = 0; index < chapters.length - 1; index++) {
const current = chapters[index];
const next = chapters[index + 1];
if (current && next && current.end !== next.start)
chapters.splice(index + 1, 0, { start: current.end, end: next.start, title: "" });
}
return chapters;
}
function createProgress(inner) {
const create = (parent, className) => {
const element = inner.ownerDocument.createElement("div");
element.className = className;
parent.appendChild(element);
return element;
};
const control = create(inner, "art-chapters");
const title = create(inner, "art-chapter-title");
let segments = [];
function clearTitle() {
title.textContent = "";
title.style.left = "0px";
title.style.visibility = "hidden";
}
function clear() {
segments = [];
control.textContent = "";
clearTitle();
}
function render(chapters, duration) {
segments = chapters.map((chapter) => {
const element = create(control, "art-chapter");
const body = create(element, "art-chapter-inner");
const length = chapter.end - chapter.start;
element.dataset.start = String(chapter.start);
element.dataset.end = String(chapter.end);
element.dataset.duration = String(length);
element.dataset.title = chapter.title.trim();
element.style.width = `${length / duration * 100}%`;
return {
start: chapter.start,
end: chapter.end,
title: chapter.title.trim(),
bars: {
hover: create(body, "art-progress-hover"),
loaded: create(body, "art-progress-loaded"),
played: create(body, "art-progress-played")
}
};
});
}
function setBar(type, percentage, duration) {
if (type !== "hover" && type !== "loaded" && type !== "played")
return;
if (!Number.isFinite(percentage))
return;
const currentTime = duration * percentage;
let hovered;
for (const segment of segments) {
const target = segment.bars[type];
target.style.width = currentTime < segment.start ? "0px" : currentTime > segment.end ? "100%" : `${(currentTime - segment.start) / (segment.end - segment.start) * 100}%`;
if (currentTime >= segment.start && currentTime <= segment.end)
hovered = segment;
}
if (type !== "hover")
return;
clearTitle();
const width = control.clientWidth * percentage;
if (hovered?.title && width > 0) {
title.textContent = hovered.title;
title.style.visibility = "visible";
title.style.left = `${Math.max(0, Math.min(width - title.clientWidth / 2, inner.clientWidth - title.clientWidth))}px`;
}
}
function destroy() {
clear();
control.remove();
title.remove();
}
clearTitle();
return { clear, render, setBar, destroy };
}
const style = ".artplayer-plugin-chapter .art-control-progress-inner {\n height: 100% !important;\n background-color: transparent !important;\n}\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-hover,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-loaded,\n.artplayer-plugin-chapter .art-control-progress-inner > .art-progress-played {\n display: none !important;\n}\n.artplayer-plugin-chapter .art-control-thumbnails {\n bottom: calc(var(--art-bottom-gap) + 64px) !important;\n}\n.artplayer-plugin-chapter .art-chapters {\n position: absolute;\n z-index: 0;\n inset: 0;\n display: flex;\n align-items: center;\n gap: 4px;\n height: 100%;\n transform: scaleY(1.25);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter {\n display: flex;\n align-items: center;\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter .art-chapter-inner {\n position: relative;\n cursor: pointer;\n width: 100%;\n height: 50%;\n border-radius: 10px;\n overflow: hidden;\n transition: height var(--art-transition-duration) ease;\n background-color: var(--art-progress-color);\n}\n.artplayer-plugin-chapter .art-chapters .art-chapter:hover .art-chapter-inner {\n height: 100%;\n}\n.artplayer-plugin-chapter .art-chapter-title {\n transform-origin: bottom center;\n transform: scale(0.5);\n opacity: 0;\n position: absolute;\n z-index: 70;\n top: -50px;\n left: 0;\n padding: 3px 5px;\n line-height: 1;\n font-size: 14px;\n border-radius: var(--art-border-radius);\n white-space: nowrap;\n background-color: var(--art-tip-background);\n transition: transform var(--art-transition-duration) ease, opacity var(--art-transition-duration) ease;\n}\n.artplayer-plugin-chapter.art-progress-hover .art-chapter-title {\n transform: scale(1);\n opacity: 1;\n}\n";
function installStyle() {
if (typeof document === "undefined")
return;
const id = "artplayer-plugin-chapter";
const install = () => {
let element = document.getElementById(id);
if (!element) {
element = document.createElement("style");
element.id = id;
(document.head || document.documentElement).appendChild(element);
}
element.textContent = style;
};
if (document.getElementById(id) || document.readyState !== "loading")
install();
else
document.addEventListener("DOMContentLoaded", install, { once: true });
}
function artplayerPluginChapter(option = {}) {
return (art) => {
const player = art.template.$player;
const inner = art.query(".art-control-progress-inner");
if (!inner)
throw new Error("Missing ArtPlayer progress container");
const className = "artplayer-plugin-chapter";
let progress = createProgress(inner);
function update(chapters) {
if (!progress || art.isDestroy)
return;
progress.clear();
player.classList.remove(className);
const normalized = normalizeChapters(chapters, art.duration);
if (!normalized.length)
return;
progress.render(normalized, art.duration);
player.classList.add(className);
art.emit("setBar", "loaded", art.loaded || 0);
}
function setBar(type, percentage) {
if (!art.isDestroy)
progress?.setBar(type, percentage, art.duration);
}
function initialize() {
update(option.chapters);
}
function destroy() {
art.off("setBar", setBar);
art.off("video:loadedmetadata", initialize);
art.off("destroy", destroy);
progress?.destroy();
progress = void 0;
player.classList.remove(className);
}
art.on("setBar", setBar);
art.once("video:loadedmetadata", initialize);
art.on("destroy", destroy);
return { name: "artplayerPluginChapter", update: ({ chapters }) => update(chapters) };
};
}
installStyle();
export {
artplayerPluginChapter as default
};
@@ -0,0 +1,36 @@
import type { Chapter } from './types'
export function normalizeChapters(chapters: Chapter[] = [], duration: number): Chapter[] {
if (!Array.isArray(chapters) || !chapters.length || !Number.isFinite(duration) || duration <= 0)
return []
// Sorting, Infinity replacement and gap insertion intentionally mutate caller data.
chapters.sort((a, b) => a.start - b.start)
for (const [index, chapter] of chapters.entries()) {
if (!chapter || typeof chapter.start !== 'number' || typeof chapter.end !== 'number' || typeof chapter.title !== 'string')
throw new TypeError('Illegal chapter data type')
if (chapter.end === Infinity)
chapter.end = duration
const next = chapters[index + 1]
if (!Number.isFinite(chapter.start) || !Number.isFinite(chapter.end)
|| chapter.start < 0 || chapter.end > duration || chapter.start >= chapter.end
|| (next && chapter.end > next.start)) { throw new Error('Illegal chapter time point') }
}
const first = chapters[0]
const last = chapters[chapters.length - 1]
if (first && first.start > 0)
chapters.unshift({ start: 0, end: first.start, title: '' })
if (last && last.end < duration)
chapters.push({ start: last.end, end: duration, title: '' })
for (let index = 0; index < chapters.length - 1; index++) {
const current = chapters[index]
const next = chapters[index + 1]
if (current && next && current.end !== next.start)
chapters.splice(index + 1, 0, { start: current.end, end: next.start, title: '' })
}
return chapters
}
@@ -1,188 +0,0 @@
import style from './style.less?inline'
export default function artplayerPluginChapter(option = {}) {
return (art) => {
const { $player } = art.template
const { setStyle, append, clamp, query, addClass, removeClass } = art.constructor.utils
const html = `
<div class="art-chapter">
<div class="art-chapter-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
</div>
</div>
`
let $chapters = []
const $inner = art.query('.art-control-progress-inner')
const $control = append($inner, '<div class="art-chapters"></div>')
const $title = append($inner, '<div class="art-chapter-title"></div>')
function showTitle({ $chapter, width }) {
const title = $chapter.dataset.title.trim()
if (title) {
$title.textContent = title
const titleWidth = $title.clientWidth
if (width <= titleWidth / 2) {
setStyle($title, 'left', 0)
}
else if (width > $inner.clientWidth - titleWidth / 2) {
setStyle($title, 'left', `${$inner.clientWidth - titleWidth}px`)
}
else {
setStyle($title, 'left', `${width - titleWidth / 2}px`)
}
}
}
function update(chapters = []) {
$chapters = []
$control.textContent = ''
removeClass($player, 'artplayer-plugin-chapter')
if (!Array.isArray(chapters))
return
if (!chapters.length)
return
if (!art.duration)
return
chapters = chapters.sort((a, b) => a.start - b.start)
for (let i = 0; i < chapters.length; i++) {
const chapter = chapters[i]
const nextChapter = chapters[i + 1]
if (chapter.end === Infinity) {
chapter.end = art.duration
}
if (
typeof chapter.start !== 'number'
|| typeof chapter.end !== 'number'
|| typeof chapter.title !== 'string'
) {
throw new TypeError('Illegal chapter data type')
}
if (chapter.start < 0 || chapter.end > art.duration || chapter.start >= chapter.end) {
throw new Error('Illegal chapter time point')
}
if (nextChapter && chapter.end > nextChapter.start) {
throw new Error('Illegal chapter time point')
}
}
if (chapters[0].start > 0) {
chapters.unshift({ start: 0, end: chapters[0].start, title: '' })
}
if (chapters[chapters.length - 1].end < art.duration) {
chapters.push({ start: chapters[chapters.length - 1].end, end: art.duration, title: '' })
}
for (let i = 0; i < chapters.length - 1; i++) {
if (chapters[i].end !== chapters[i + 1].start) {
chapters.splice(i + 1, 0, {
start: chapters[i].end,
end: chapters[i + 1].start,
title: '',
})
}
}
$chapters = chapters.map((chapter) => {
const $chapter = append($control, html)
const start = clamp(chapter.start, 0, art.duration)
const end = clamp(chapter.end, 0, art.duration)
const duration = end - start
const percentage = duration / art.duration
$chapter.dataset.start = start
$chapter.dataset.end = end
$chapter.dataset.duration = duration
$chapter.dataset.title = chapter.title.trim()
$chapter.style.width = `${percentage * 100}%`
return {
$chapter,
$hover: query('.art-progress-hover', $chapter),
$loaded: query('.art-progress-loaded', $chapter),
$played: query('.art-progress-played', $chapter),
}
})
addClass($player, 'artplayer-plugin-chapter')
art.emit('setBar', 'loaded', art.loaded || 0)
}
art.on('setBar', (type, percentage) => {
if (!$chapters.length)
return
for (let i = 0; i < $chapters.length; i++) {
const { $chapter, $loaded, $played, $hover } = $chapters[i]
const $target = {
hover: $hover,
loaded: $loaded,
played: $played,
}[type]
if (!$target)
return
const width = $control.clientWidth * percentage
const currentTime = art.duration * percentage
const duration = Number.parseFloat($chapter.dataset.duration)
const start = Number.parseFloat($chapter.dataset.start)
const end = Number.parseFloat($chapter.dataset.end)
if (currentTime < start) {
setStyle($target, 'width', 0)
}
if (currentTime > end) {
setStyle($target, 'width', '100%')
}
if (currentTime >= start && currentTime <= end) {
const percentage = (currentTime - start) / duration
setStyle($target, 'width', `${percentage * 100}%`)
if (type === 'hover' && width > 0) {
showTitle({ $chapter, width })
}
}
}
})
art.once('video:loadedmetadata', () => update(option.chapters))
return {
name: 'artplayerPluginChapter',
update: ({ chapters }) => update(chapters),
}
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-chapter'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,54 @@
import type Artplayer from 'artplayer'
import type { Bar, Option, Result } from './types'
import { normalizeChapters } from './chapters'
import { createProgress } from './progress'
import { installStyle } from './stylesheet'
export default function artplayerPluginChapter(option: Option = {}) {
return (art: Artplayer): Result => {
const player = art.template.$player
const inner = art.query<HTMLElement>('.art-control-progress-inner')
if (!inner)
throw new Error('Missing ArtPlayer progress container')
const className = 'artplayer-plugin-chapter'
let progress: ReturnType<typeof createProgress> | undefined = createProgress(inner)
function update(chapters?: Option['chapters']) {
if (!progress || art.isDestroy)
return
progress.clear()
player.classList.remove(className)
const normalized = normalizeChapters(chapters, art.duration)
if (!normalized.length)
return
progress.render(normalized, art.duration)
player.classList.add(className)
art.emit('setBar', 'loaded', art.loaded || 0)
}
function setBar(type: Bar, percentage: number) {
if (!art.isDestroy)
progress?.setBar(type, percentage, art.duration)
}
function initialize() {
update(option.chapters)
}
function destroy() {
art.off('setBar', setBar)
art.off('video:loadedmetadata', initialize)
art.off('destroy', destroy)
progress?.destroy()
progress = undefined
player.classList.remove(className)
}
art.on('setBar', setBar)
art.once('video:loadedmetadata', initialize)
art.on('destroy', destroy)
return { name: 'artplayerPluginChapter', update: ({ chapters }) => update(chapters) }
}
}
installStyle()
@@ -0,0 +1,93 @@
import type { Bar, Chapter } from './types'
interface Segment {
start: number
end: number
title: string
bars: Record<Bar, HTMLDivElement>
}
export function createProgress(inner: HTMLElement) {
const create = (parent: HTMLElement, className: string) => {
const element = inner.ownerDocument.createElement('div')
element.className = className
parent.appendChild(element)
return element
}
const control = create(inner, 'art-chapters')
const title = create(inner, 'art-chapter-title')
let segments: Segment[] = []
function clearTitle() {
title.textContent = ''
title.style.left = '0px'
title.style.visibility = 'hidden'
}
function clear() {
segments = []
control.textContent = ''
clearTitle()
}
function render(chapters: Chapter[], duration: number) {
segments = chapters.map((chapter) => {
const element = create(control, 'art-chapter')
const body = create(element, 'art-chapter-inner')
const length = chapter.end - chapter.start
element.dataset.start = String(chapter.start)
element.dataset.end = String(chapter.end)
element.dataset.duration = String(length)
element.dataset.title = chapter.title.trim()
element.style.width = `${length / duration * 100}%`
return {
start: chapter.start,
end: chapter.end,
title: chapter.title.trim(),
bars: {
hover: create(body, 'art-progress-hover'),
loaded: create(body, 'art-progress-loaded'),
played: create(body, 'art-progress-played'),
},
}
})
}
function setBar(type: Bar, percentage: number, duration: number) {
if (type !== 'hover' && type !== 'loaded' && type !== 'played')
return
if (!Number.isFinite(percentage))
return
const currentTime = duration * percentage
let hovered: Segment | undefined
for (const segment of segments) {
const target = segment.bars[type]
target.style.width = currentTime < segment.start
? '0px'
: currentTime > segment.end
? '100%'
: `${(currentTime - segment.start) / (segment.end - segment.start) * 100}%`
if (currentTime >= segment.start && currentTime <= segment.end)
hovered = segment
}
if (type !== 'hover')
return
clearTitle()
const width = control.clientWidth * percentage
if (hovered?.title && width > 0) {
title.textContent = hovered.title
title.style.visibility = 'visible'
title.style.left = `${Math.max(0, Math.min(width - title.clientWidth / 2, inner.clientWidth - title.clientWidth))}px`
}
}
function destroy() {
clear()
control.remove()
title.remove()
}
clearTitle()
return { clear, render, setBar, destroy }
}
@@ -0,0 +1,21 @@
import style from './style.less?inline'
export function installStyle() {
if (typeof document === 'undefined')
return
const id = 'artplayer-plugin-chapter'
const install = () => {
let element = document.getElementById(id)
if (!element) {
element = document.createElement('style')
element.id = id
;(document.head || document.documentElement).appendChild(element)
}
element.textContent = style
}
if (document.getElementById(id) || document.readyState !== 'loading')
install()
else
document.addEventListener('DOMContentLoaded', install, { once: true })
}
@@ -0,0 +1,6 @@
import type artplayerPluginChapter from '../types/artplayer-plugin-chapter'
export type Option = NonNullable<Parameters<typeof artplayerPluginChapter>[0]>
export type Chapter = NonNullable<Option['chapters']>[number]
export type Result = ReturnType<ReturnType<typeof artplayerPluginChapter>>
export type Bar = 'hover' | 'loaded' | 'played'
@@ -0,0 +1,364 @@
{
"task": "PKG-CHAPTER-03",
"capturedAt": "2026-09-10T15:10:43.475Z",
"source": {
"chapters.ts": "92206f524624ac3a7304677b6a69fda88e62caeca52e1e530d746e51579d3a4d",
"index.ts": "83dc022253c0539587590f7a551e090684d1944dcb2010a76e8498a683e7ef36",
"progress.ts": "4a18d19ed703772d73622ea3fa9d433dbc2c6092ba244c9bb1ec727368996951",
"style.less": "3b5aebcad4c433f09261facab26a881617238ec37e9c0be7a368f3f8e980d66b",
"stylesheet.ts": "6ccd88b6d17127acf6f6c1f02a7fbb3a55d59f427d2d92c007bf658122abd51c",
"types.ts": "07c528f9753cc54c12211276f28ff01a5f6e8f1ef1451b2b534f2d6cfaa17ee9"
},
"package": {
"name": "artplayer-plugin-chapter",
"version": "1.1.0",
"archive": "artplayer-plugin-chapter.tgz",
"sha256": "2d4fd1331d1bb2eebe8456afce84bcbb9060bf727b38f3bf47a25e4eb4cb623f",
"files": {
"package/ARCHITECTURE.md": "ed62ae2dbcdc31a2bde2b471738139390efe73edc1c71854f4f65ca1f8120ac5",
"package/README.md": "9fa74ae131401f43276baff287916bce7f2374ebf2b696324436e2f20bc0ee6a",
"package/dist/artplayer-plugin-chapter.js": "6ac3dfe28f46084ac7caedd571b1a97dcee3cf9d708112f1b8bbd6566e1dc5c5",
"package/dist/artplayer-plugin-chapter.legacy.js": "6d9bff885412169e701670961aa00c2e46c4b42c9b9647ff4e6d198777d2a359",
"package/dist/artplayer-plugin-chapter.mjs": "26415cecab2c0198c566cef0232e20123413782d5bad7534a32939b3298617af",
"package/package.json": "0fd20d82a9ddec642da8b26467b45c6c3e443f188ed75e7de3004e48efd4f766",
"package/types/artplayer-plugin-chapter.d.ts": "80338946aea05020abd285daca133fddfc81bd8b800345b2c76548fb93a555a9"
}
},
"runtimeChecks": 23,
"knownTypeBlockers": 11,
"ciChecks": 61,
"browser": {
"startTime": "2026-09-10T15:10:44.220Z",
"duration": 24021.322,
"expected": 54,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"tests": [
{
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate clears titles on blank chapters, zero hover and replacement",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate rejects NaN and can recover after an invalid update",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate ignores pending metadata initialization after immediate destroy",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate deduplicates stylesheet injection while the document is loading",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate clears titles on blank chapters, zero hover and replacement",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate rejects NaN and can recover after an invalid update",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate ignores pending metadata initialization after immediate destroy",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate deduplicates stylesheet injection while the document is loading",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "published-only observations: blank title, NaN and once-only metadata are historical defects",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate clears titles on blank chapters, zero hover and replacement",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate rejects NaN and can recover after an invalid update",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate releases only its listeners and DOM across multiple instances and early destruction",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate ignores pending metadata initialization after immediate destroy",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate deduplicates stylesheet injection while the document is loading",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "factory, registration, input normalization and safe title text",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "progress bars, real hover, title edges and boundary seek",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "sync update, Infinity, object identity, empty lists and invalid data",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "explicit source update and core-owned destruction",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "published: real playback, pause, seek, switch and destroy",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "candidate: real playback, pause, seek, switch and destroy",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "controlled HTTP media failure emits a real video error",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "Range, media identity and docs candidate routes",
"project": "chromium",
"browser": "153.0.8010.12",
"status": "passed"
},
{
"title": "published: real playback, pause, seek, switch and destroy",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "candidate: real playback, pause, seek, switch and destroy",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "controlled HTTP media failure emits a real video error",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "Range, media identity and docs candidate routes",
"project": "firefox",
"browser": "155.0",
"status": "passed"
},
{
"title": "published: real playback, pause, seek, switch and destroy",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "candidate: real playback, pause, seek, switch and destroy",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "controlled HTTP media failure emits a real video error",
"project": "webkit",
"browser": "26.6",
"status": "passed"
},
{
"title": "Range, media identity and docs candidate routes",
"project": "webkit",
"browser": "26.6",
"status": "passed"
}
]
}
@@ -0,0 +1,47 @@
# PKG-CHAPTER-03:以 TypeScript 拆分章节实现
## 实现
删除生产 JS 入口,直接迁移为严格 TS,避免先拆 JS 再重复迁移。实际维护地图见
[包内架构](../../packages/artplayer-plugin-chapter/ARCHITECTURE.md):入口拥有 player 集成和
事件生命周期,chapters 处理纯区间运算,progress 拥有 DOM/标题/进度,stylesheet 拥有全局样式。
内部类型从现有公开声明推导,无 any、全局忽略或运行时核心依赖;CSS 保持原样。
保留默认导出/工厂/注册 name、同步 update、参数默认值、调用方数组原地排序/补齐和对象引用、
Infinity end 替换、metadata 一次初始化和成功更新的 loaded setBar 事件。DOM/CSS 类名、
data 属性、标题纯文本和进度边界保持正常旧调用兼容,不引入新核心方法。
## 明确修复与兼容差异
| 原问题 | 当前行为及覆盖 |
| --- | --- |
| hover 进入空白 gap 或更新后显示上一章节标题 | 空白/零位置/替换/清空重置并隐藏标题;真实鼠标验证 |
| NaN 时间被渲染进 data/CSS | 非有限非法时间点沿用时间错误信息拒绝;Infinity end 仍合法 |
| 非有限媒体 duration 生成无效区间 | 等待正的有限 duration,输入保持不变;有限时间轴恢复后可显式更新 |
| destroy 后保留插件监听器和可更新的 DOM | 移除本插件三个回调及两个 DOM 根,保留其他订阅和实例;旧 result.update 成为无操作 |
| destroy(false) 保留插件活动 UI | 只删除本插件 UI/类名,保留核心播放器树,避免恢复过期章节 |
| 页面解析期间两次载入插件可重复注入 style | DOMContentLoaded 时重新核对 ID,once 监听;样式仍为全局并在销毁后保留 |
非法输入的部分变更时序不作为合法调用契约;已有合法数组变更经过新旧浏览器对照。
切源后的章节所有权保持显式 update,没有顺带新增自动重算策略。
## 验证
- 严格分包 TypeScript 检查通过;公开声明没有在本任务改形状,旧编译器消费继续通过原测试。
- 新增四项区间单元测试;完整 ci:check 共 61 项 Node/基线测试通过。
- 三浏览器源码 54 项通过;新增五个场景在 Chromium/Firefox/WebKit 各执行一次。
- `yarn build artplayer-plugin-chapter` 重建三格式 dist 与 docs/compiled;全部由脚本生成。
- `yarn test:package` 的真实 TS 构建、tarball 安装和 23 项运行时/公开属性形状对照通过。
精确历史类型诊断仍为 11 条,留给 PKG-CHAPTER-04/核心类型任务,不作为本任务已修复。
- 安装后候选文件再次运行三浏览器共 54 项通过,无重试/跳过;结果和指纹见
[执行记录](../baselines/chapter-migration-validation.json)。
试点发现 ENG-07 的构建快照缺根 tsconfig;本任务补复制根配置与资源声明,消费者隔离规则不变。
不添加新依赖,test:unit 新增章节用例入口。源码入口、类型及生成格式由已有仓库脚本管理。
## 接续与回退
本任务完成第一包自有运行时 TS 化和职责拆分,不代表 chapter 全部任务或核心已完成。
PKG-CHAPTER-04 继续公开类型/旧声明子路径/NodeNext 消费闭环,然后进入试点汇总。
任务 03/04 分工更新为实际执行顺序;全项目范围和兼容门槛保留。
未推送、未发布、未升级版本。回退用本任务的独立提交 revert,同时恢复源码和生成产物。
+4 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 182 / doing 0 / blocked 0 / done 32 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 181 / doing 0 / blocked 0 / done 33 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -134,8 +134,8 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-CHAPTER-01 | artplayer-plugin-chapter<br>核对包契约与历史用法 | BASE-05 | chapters 时间区间、update、name 和进度 DOM 清单 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | M | done |
| PKG-CHAPTER-02 | artplayer-plugin-chapter<br>建立特有行为与错误测试 | PKG-CHAPTER-01, ENG-03, ENG-05 | 区间重叠/空列表/边界 seek、hover 标题和 update 用例 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言;修正 BASE-TYPE-02 的可选参数声明并补正反例 | M | done |
| PKG-CHAPTER-03 | artplayer-plugin-chapter<br>整理内部职责与资源 | PKG-CHAPTER-02, ENG-07 | 在已有核心上整理时间区间计算、进度 DOM、事件/布局,合理简单代码可保持 | 不依赖新核心方法或大范围设置重构;旧 API/事件和资源通过,必要适配限包内 | M | todo |
| PKG-CHAPTER-04 | artplayer-plugin-chapter<br>迁移自有源码和公开类型 | PKG-CHAPTER-03, ENG-04, ENG-06 | 基于既有公开核心类型的 Chapters/Option/Result 与 update 推导;JS/TS 工具链试点 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;处理 BASE-TYPE-01/03,保持运行时和新旧 TS 模块解析消费 | M | todo |
| PKG-CHAPTER-03 | artplayer-plugin-chapter<br>以 TypeScript 拆分内部职责与资源 | PKG-CHAPTER-02, ENG-07 | 以严格 TS 拆分区间计算、进度 DOM、事件生命周期与样式注入;公开声明消费在 04 闭环 | 不依赖新核心方法或大范围设置重构;旧 API/事件和资源通过,必要适配限包内 | M | done |
| PKG-CHAPTER-04 | artplayer-plugin-chapter<br>迁移自有源码和公开类型 | PKG-CHAPTER-03, ENG-04, ENG-06 | 承接 03 的自有源码 TS 化,完善公开 Chapters/Option/Result 与 update 推导,关闭声明模块解析和旧消费差异 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;处理 BASE-TYPE-01/03,保持运行时和新旧 TS 模块解析消费 | M | todo |
| PKG-CHAPTER-05 | artplayer-plugin-chapter<br>验证新旧核心和组合 | PKG-CHAPTER-04, CORE-22 | chapter + quality/thumbnail、移动和全屏进度场景 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | M | todo |
| PKG-CHAPTER-06 | artplayer-plugin-chapter<br>验证分发并同步文档 | PKG-CHAPTER-05, ENG-07 | chapter.js 示例、产物和变更记录 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | M | todo |
@@ -435,3 +435,4 @@
- ENG-07: [记录](changes/2026-09-10-ENG-07-package-consumers.md) [记录](baselines/package-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
+4
View File
@@ -1,5 +1,9 @@
# 进度与证据
## 当前实施:PKG-CHAPTER-03 首包 TS 源码拆分完成
chapter 已从单体 JS 入口改为五个 TS 文件,区间、DOM、生命周期和样式职责清楚;修复标题、NaN、销毁和重复样式问题。61 项 Node/基线检查、源码及安装产物各 54 项三浏览器测试通过;三格式产物已生成。见 [交付](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md)。当前 214 项,33 完成、181 待办。生产迁移:chapter 自有运行时已 TS 化,核心尚未开始。ENG-07 提交 a0286c4e;下一项 PKG-CHAPTER-04 公开类型与消费者闭环。
## 当前实施:ENG-07 打包消费入口完成
实际重建、打包、仓库外安装及 23 项运行时检查通过;安装产物三浏览器 39 项通过,ci:check 57 项通过。精确的 11 条历史类型诊断仍阻止严格发布检查,未作为发布豁免。见 [交付](changes/2026-09-10-ENG-07-package-consumers.md)。当前 214 项,32 完成、182 待办;生产源码 TS 迁移仍为零。下一项直接实施 PKG-CHAPTER-03 的 TS 模块拆分,再以 04 完成公开类型和消费者闭环。
+8 -5
View File
@@ -1176,7 +1176,7 @@
{
"id": "PKG-CHAPTER-03",
"phase": "5 包迁移:artplayer-plugin-chapter",
"title": "整理内部职责与资源",
"title": "以 TypeScript 拆分内部职责与资源",
"scope": [
"artplayer-plugin-chapter"
],
@@ -1184,11 +1184,14 @@
"PKG-CHAPTER-02",
"ENG-07"
],
"status": "todo",
"status": "done",
"risk": "M",
"deliverable": "在已有核心上整理时间区间计算、进度 DOM、事件/布局,合理简单代码可保持",
"deliverable": "以严格 TS 拆分区间计算、进度 DOM、事件生命周期与样式注入;公开声明消费在 04 闭环",
"acceptance": "不依赖新核心方法或大范围设置重构;旧 API/事件和资源通过,必要适配限包内",
"evidence": []
"evidence": [
"changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md",
"baselines/chapter-migration-validation.json"
]
},
{
"id": "PKG-CHAPTER-04",
@@ -1204,7 +1207,7 @@
],
"status": "todo",
"risk": "M",
"deliverable": "基于既有公开核心类型的 Chapters/Option/Result 与 update 推导;JS/TS 工具链试点",
"deliverable": "承接 03 的自有源码 TS 化,完善公开 Chapters/Option/Result 与 update 推导,关闭声明模块解析和旧消费差异",
"acceptance": "严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容;处理 BASE-TYPE-01/03,保持运行时和新旧 TS 模块解析消费",
"evidence": []
},
+2 -1
View File
@@ -75,7 +75,8 @@ export async function checkPackages({ release = false } = {}) {
const output = fs.mkdtempSync(path.join(parent, 'run-'))
const snapshot = path.join(output, 'build')
fs.mkdirSync(snapshot)
for (const file of ['package.json', 'yarn.lock']) fs.copyFileSync(path.join(workspace, file), path.join(snapshot, file))
for (const file of ['package.json', 'yarn.lock', 'tsconfig.json', 'tsconfig.base.json']) fs.copyFileSync(path.join(workspace, file), path.join(snapshot, file))
fs.cpSync(path.join(workspace, 'types'), path.join(snapshot, 'types'), { recursive: true })
fs.cpSync(path.join(workspace, 'scripts'), path.join(snapshot, 'scripts'), { recursive: true })
fs.symlinkSync(path.join(workspace, 'node_modules'), path.join(snapshot, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
for (const name of names) {
+100
View File
@@ -153,3 +153,103 @@ test('published-only observations: blank title, NaN and once-only metadata are h
expect(await page.locator('.art-chapter').first().evaluate(node => node.dataset.start)).toBe('NaN')
await testInfo.attach('chapter-known-defects', { body: JSON.stringify({ staleTitle, endAfterSourceChange: 8, acceptsNaN: true }), contentType: 'application/json' })
})
test('candidate clears titles on blank chapters, zero hover and replacement', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 2, title: 'Previous' }, { start: 4, end: 8, title: 'Next' }])
const title = page.locator('.art-chapter-title')
await hover(page, 0.1)
await expect(title).toHaveText('Previous')
await hover(page, 0.375)
await expect(title).toHaveText('')
await expect(title).toHaveCSS('visibility', 'hidden')
await hover(page, 0.75)
await expect(title).toHaveText('Next')
await hover(page, 0)
await expect(title).toHaveText('')
await hover(page, 0.1)
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Replacement' }] }))
await expect(title).toHaveText('')
await hover(page, 0.5)
await expect(title).toHaveText('Replacement')
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({}))
await expect(title).toHaveText('')
})
test('candidate rejects NaN and can recover after an invalid update', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'Valid' }])
expect(await page.evaluate(() => {
try {
window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: Number.NaN, end: 3, title: 'Invalid' }] })
}
catch (error) { return error.message }
})).toBe('Illegal chapter time point')
await expect(page.locator('.art-chapter')).toHaveCount(0)
await expect(page.locator('.art-video-player')).not.toHaveClass(/artplayer-plugin-chapter/)
await page.evaluate(() => window.art.plugins.artplayerPluginChapter.update({ chapters: [{ start: 0, end: Infinity, title: 'Recovered' }] }))
await expect(page.locator('.art-chapter')).toHaveCount(1)
await hover(page, 0.5)
await expect(page.locator('.art-chapter-title')).toHaveText('Recovered')
})
test('candidate releases only its listeners and DOM across multiple instances and early destruction', async ({ page }) => {
await openChapter(page, 'candidate', [{ start: 0, end: 8, title: 'First' }])
expect(await page.evaluate(() => {
const art = window.art
const plugin = art.plugins.artplayerPluginChapter
let observerCalls = 0
const observer = () => observerCalls++
art.on('setBar', observer)
const listenersBefore = art.e.setBar.length
const container = document.createElement('div')
container.className = 'second-player'
document.body.appendChild(container)
const other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, plugins: [window.artplayerPluginChapter()] })
other.plugins.artplayerPluginChapter.update({ chapters: [] })
art.destroy(false)
const entry = { start: 0, end: Infinity, title: 'Ignored' }
plugin.update({ chapters: [entry] })
const listenersAfter = art.e.setBar.length
art.emit('setBar', 'loaded', 0.5)
const result = {
ownDom: art.template.$player.querySelectorAll('.art-chapters, .art-chapter-title').length,
classRemoved: !art.template.$player.classList.contains('artplayer-plugin-chapter'),
listenersRemoved: listenersBefore - listenersAfter,
observerCalls,
inputUntouched: entry.end === Infinity,
otherDom: container.querySelectorAll('.art-chapters, .art-chapter-title').length,
otherAlive: !other.isDestroy,
}
other.destroy()
return result
})).toEqual({ ownDom: 0, classRemoved: true, listenersRemoved: 1, observerCalls: 1, inputUntouched: true, otherDom: 2, otherAlive: true })
expect(await page.evaluate(() => window.Artplayer.instances.length)).toBe(0)
await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
})
test('candidate ignores pending metadata initialization after immediate destroy', async ({ page }) => {
await page.goto('/test/player.html?core=published&chapter=candidate')
expect(await page.evaluate(() => {
const original = window.artplayerPluginChapter
let initialize
let plugin
const chapters = [{ start: 0, end: Infinity, title: 'Pending' }]
const art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [(art) => {
const count = art.e['video:loadedmetadata']?.length || 0
plugin = original({ chapters })(art)
initialize = art.e['video:loadedmetadata'][count].fn._
return plugin
}] })
const before = art.e['video:loadedmetadata'].length
art.destroy()
const after = art.e['video:loadedmetadata']?.length || 0
initialize()
plugin.update({ chapters })
return { removed: before - after, untouched: chapters[0].end === Infinity, children: document.querySelector('.player').childElementCount }
})).toEqual({ removed: 1, untouched: true, children: 0 })
})
test('candidate deduplicates stylesheet injection while the document is loading', async ({ page, baseURL }) => {
await page.setContent(`<html><head><script src="${baseURL}/candidate/artplayer-plugin-chapter.js"></script><script src="${baseURL}/candidate/artplayer-plugin-chapter.js"></script></head><body></body></html>`)
await expect(page.locator('style#artplayer-plugin-chapter')).toHaveCount(1)
expect(await page.locator('style#artplayer-plugin-chapter').textContent()).toContain('.art-chapters')
})
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Pure chapter logic uses the repository Node runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { normalize } = await loadModules({ normalize: { file: 'packages/artplayer-plugin-chapter/src/chapters', name: 'normalizeChapters' } })
test('Chapter normalization preserves caller array and objects while filling every gap', () => {
const first = { start: 1, end: 2, title: ' First ' }
const last = { start: 4, end: 6, title: 'Last' }
const input = [last, first]
assert.equal(normalize(input, 8), input)
assert.equal(input[1], first)
assert.equal(input[3], last)
assert.deepEqual(input, [
{ start: 0, end: 1, title: '' },
first,
{ start: 2, end: 4, title: '' },
last,
{ start: 6, end: 8, title: '' },
])
assert.equal(normalize(input, 8), input)
assert.equal(input.length, 5, 'Repeated updates must not duplicate gaps')
})
test('Chapter endpoints, adjacency and Infinity normalization retain historical behavior', () => {
const input = [{ start: 0, end: 2, title: 'A' }, { start: 2, end: Infinity, title: 'B' }]
assert.equal(normalize(input, 8), input)
assert.equal(input[1].end, 8)
assert.equal(input.length, 2)
})
test('No usable timeline leaves input untouched and produces no chapters', () => {
for (const duration of [0, -1, Infinity, Number.NaN]) {
const input = [{ start: 3, end: Infinity, title: 'Pending' }]
assert.deepEqual(normalize(input, duration), [])
assert.equal(input[0].end, Infinity)
}
for (const input of [undefined, null, false, {}, []])
assert.deepEqual(normalize(input, 8), [])
})
test('Invalid chapter types and times are rejected including non-finite values', () => {
for (const input of [[null], [{ start: '0', end: 8, title: 'Bad' }], [{ start: 0, end: 8, title: 42 }]])
assert.throws(() => normalize(input, 8), TypeError)
for (const [start, end] of [[Number.NaN, 3], [0, Number.NaN], [-Infinity, 2], [Infinity, Infinity], [0, -Infinity], [-1, 2], [2, 2], [3, 2], [0, 9]])
assert.throws(() => normalize([{ start, end, title: 'Bad' }], 8), { name: 'Error', message: 'Illegal chapter time point' })
assert.throws(() => normalize([{ start: 0, end: 4, title: 'A' }, { start: 3, end: 6, title: 'B' }], 8), /Illegal chapter time point/)
})