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

@@ -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'