refactor(hls): [PKG-HLS-03] separate menus and SDK lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 00:18:19 +08:00
1 parent 33f6d19b77
commit ba93b98be5
24 files changed
+5849 -382

No files matched your search

@@ -0,0 +1,81 @@
# HLS control maintenance
The public entry remains `src/index.js`: `artplayerPluginHlsControl(option = {})` returns a
synchronous ArtPlayer plugin with `{ name: 'artplayerPluginHlsControl', update }`. `update()`
returns undefined. The consumer creates, attaches and destroys `art.hls`; this plugin does not
import Hls.js or own its media engine. Public declarations are still in `types/` pending the
separate TypeScript migration (PKG-HLS-04).
| Module | Responsibility |
| --- | --- |
| `src/index.js` | Core ready/restart/destroy hooks, current engine identity, refresh generation, selection event barrier |
| `src/mapping.js` | Quality/audio labels, duplicate groups, selected item and Auto policy; no ArtPlayer/DOM dependencies |
| `src/menu.js` | Existing controls/setting update/remove/check APIs, current callback ownership and stable menu reuse |
| `src/sdk-events.js` | Optional SDK event capabilities, six subscriptions, setup rollback and listener release |
The entry depends on these three modules; they do not import the entry or each other.
SVG icons remain local build inputs. There is no cross-instance cache, timer, worker or fetch.
## Refresh and selection
Registration does not require an Hls instance yet. The first ready/restart/manual update checks
`art.hls.media === art.template.$video` and binds SDK events. A plain compatible host without
`constructor.Events`, `on` and `off` retains manual/core-event updates. An engine replacement
releases only the plugin's own subscriptions. Core destroy detaches hooks and makes retained
updates/selectors inert; core remains responsible for DOM removal or preservation.
Supported SDK hooks are MANIFEST_PARSED, LEVELS_UPDATED, LEVEL_SWITCHED, AUDIO_TRACKS_UPDATED,
AUDIO_TRACK_SWITCHED and DESTROYING, using the SDK's actual exported event names. DESTROYING
retires that engine and removes owned menus; a subsequent callback cannot revive it.
If subscription setup throws, installed listeners are removed and a later update can retry.
Explicit update rebuilds selectors using current options. SDK events reuse current selectors
when engine, display surfaces, title, labels and values are unchanged, updating selection through
the normal core check APIs. An unavailable selection clears obsolete defaults/labels. Empty lists
or disabled display surfaces remove their respective entries, and later updates can restore them.
Selection writes the SDK property, then notice, then controls.check and setting.check, returning
the selected HTML. SDK events emitted synchronously during the write are deferred until this
operation finishes. A formatter or core callback that replaces the engine, destroys the player or
starts a newer refresh cannot commit the older refresh. Formatting/SDK write failures remain
synchronous; selection does not introduce a Promise. The SDK's own event exception handling still
applies to automatic event-driven formatting.
## Compatibility and intentional corrections
- Keep names `hls-quality`/`hls-audio`, right-side controls, existing SVGs, settings width 200,
title/tooltip defaults, factory/global name and root/legacy distribution paths.
- Pass original SDK objects to getName: selected-label calls have one argument; list-label calls
also receive the index. Do not bind a new `this` or claim the index is always present.
- Auto uses `autoLevelEnabled === true`; hosts without this capability fall back to currentLevel.
Quality's Auto value remains -1. Audio does not acquire a new synthetic Auto option.
- Duplicate labels still collapse into one group. If a later duplicate is selected, its value
represents that group so the real selection is not lost. Quality sorts by represented index;
audio retains group order. Undefined labels remain distinct.
- Old SDK callbacks after replacement/destroy, lingering empty menus and an Auto label derived
from the currently decoded automatic level were defects, not guarantees to preserve.
The plugin owns its two reserved menu names. Applications should use distinct names for unrelated
entries. Hls instances and third-party SDK listeners remain owned by their callers. No minimum
core/SDK version has been newly imposed; the tested historical core is 5.4.0 and SDK is 1.5.17.
## Validation and next changes
Use the repository's pinned Node and Yarn:
```sh
node --test test/hls-control.test.js
yarn test:browser hls-control.spec.js
yarn build artplayer-plugin-hls-control
yarn ci:check
```
ARTPLAYER_TEST_HLS accepts platform-delimited paths to built main/legacy/ESM files for additional
Node contracts. ARTPLAYER_HLS_ARTIFACT selects a built browser main/legacy file; invalid paths fail,
and test attachments record its bytes. These checks do not replace isolated tarball installation.
Use the local demo at `http://localhost:8082` with example `hls.control` during example work.
Windows Playwright WebKit lacks MSE; explicit capability/error cleanup runs there, while playback
cases are marked skipped. Safari/native HLS, SDK workers, grouped track changes, SDK version range,
updated example and isolated package verification remain PKG-HLS-05/06 work. See
[refactor validation](../../refactor/hls-validation.md). Keep these gaps visible in release reviews.
@@ -1,5 +1,7 @@
# artplayer-plugin-hls-control
Implementation boundaries and regression commands are documented in [ARCHITECTURE.md](ARCHITECTURE.md).
Hls control plugin for ArtPlayer
## Demo
@@ -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.artplayerPluginHlsControl=e(),define(function(){return t.artplayerPluginHlsControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginHlsControl=e()}(this,function(){"use strict";function t(t,e){const l=new Map;return t.filter(t=>{const o=t[e];return void 0===o||!l.has(o)&&l.set(o,1)})}return function(e={}){return l=>{const{$video:o}=l.template,{errorHandle:n}=l.constructor.utils;function i(){n(l.hls?.media===o,'Cannot find instance of HLS from "art.hls"'),function(o){if(!o.levels.length)return;const n=e.quality||{},i=n.auto||"Auto",s=n.title||"Quality",a=n.getName||(t=>t.name||`${t.height}P`),c=o.levels[o.currentLevel],r=c?a(c):i,u=t(o.levels.map((t,e)=>({html:a(t,e),value:e,default:o.currentLevel===e})),"html").sort((t,e)=>e.value-t.value);u.push({html:i,value:-1,default:-1===o.currentLevel});const h=t=>(o.currentLevel=t.value,l.notice.show=`${s}: ${t.html}`,n.control&&l.controls.check(t),n.setting&&l.setting.check(t),t.html);n.control&&l.controls.update({name:"hls-quality",position:"right",html:r,style:{padding:"0 10px"},selector:u,onSelect:h}),n.setting&&l.setting.update({name:"hls-quality",tooltip:r,html:s,icon:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>',width:200,selector:u,onSelect:h})}(l.hls),function(o){if(!o.audioTracks.length)return;const n=e.audio||{},i=n.auto||"Auto",s=n.title||"Audio",a=n.getName||(t=>t.name||t.lang||t.language),c=o.audioTracks[o.audioTrack],r=c?a(c):i,u=t(o.audioTracks.map((t,e)=>({html:a(t,e),value:t.id,default:o.audioTrack===t.id})),"html"),h=t=>(o.audioTrack=t.value,l.notice.show=`${s}: ${t.html}`,n.control&&l.controls.check(t),n.setting&&l.setting.check(t),t.html);n.control&&l.controls.update({name:"hls-audio",position:"right",html:r,style:{padding:"0 10px"},selector:u,onSelect:h}),n.setting&&l.setting.update({name:"hls-audio",tooltip:r,html:s,icon:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>',width:200,selector:u,onSelect:h})}(l.hls)}return l.on("ready",i),l.on("restart",i),{name:"artplayerPluginHlsControl",update:i}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginHlsControl=e(),define(function(){return t.artplayerPluginHlsControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginHlsControl=e()}(this,function(){"use strict";function t(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const o=e.get(t.html);return o?(t.default&&(o.default=!0,o.value=t.value),!1):(e.set(t.html,t),!0)})}function e(t,e,o,n,l,i){let r;const c={control:!1,setting:!1};function s(o){c[o]&&(c[o]=!1,t["control"===o?"controls":"setting"].remove(e))}function u(){r=void 0,s("control"),s("setting")}function a(e,o){for(const t of e.model.selector)t.default=t===o;e.config.control&&t.controls.check(o),e===r&&l(e.hls)&&e.config.setting&&t.setting.check(o)}return{update:function(f,d,h,m){if(!h)return void u();const g=r,v=h.selector.findIndex(t=>t.default);if(!m&&g?.hls===f&&g.model.title===h.title&&(-1!==v||!g.model.selector.some(t=>t.default)&&g.model.html===h.html)&&c.control===Boolean(d.control)&&c.setting===Boolean(d.setting)&&g.model.selector.length===h.selector.length&&g.model.selector.every((t,e)=>t.html===h.selector[e].html&&t.value===h.selector[e].value)){g.config=d;const t=g.model.selector[v];return void(!t||t.default&&g.model.html===h.html||(g.model.html=h.html,a(g,t)))}const p={hls:f,config:d,model:h};r=p;const y=()=>r===p&&l(f),T=e=>i(()=>y()?(f[o]=e.value,y()?(h.html=e.html,t.notice.show=`${h.title}: ${e.html}`,y()&&a(p,e),e.html):e.html):e.html);d.control?(c.control=!0,t.controls.update({name:e,position:"right",html:h.html,style:{padding:"0 10px"},selector:h.selector,onSelect:T})):s("control"),y()&&(d.setting?(c.setting=!0,t.setting.update({name:e,tooltip:h.html,html:h.title,icon:n,width:200,selector:h.selector,onSelect:T})):s("setting"))},clear:u,invalidate:function(){r=void 0}}}return function(o={}){return n=>{const{$video:l}=n.template,{errorHandle:i}=n.constructor.utils;let r,c,s=!1,u=0,a=()=>{},f=0;const d=new WeakSet,h=t=>!s&&!d.has(t)&&n.hls===t&&t.media===l,m=e(n,"hls-quality","currentLevel",'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>',h,v),g=e(n,"hls-audio","audioTrack",'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>',h,v);function v(t){f++;let e=!1;try{const o=t();return e=!0,o}finally{if(f--,!f){const t=c;c=void 0,e&&t&&p(t,!1)}}}function p(e,n){if(!h(e))return;const l=++u,i=o.quality||{},r=function(e,o){if(!e.levels.length)return null;const n=o.auto||"Auto",l=o.getName||(t=>t.name||`${t.height}P`),i=function(t){return!0===t.autoLevelEnabled?-1:t.currentLevel}(e),r=e.levels[i],c=r?l(r):n,s=t(e.levels.map((t,e)=>({html:l(t,e),value:e,default:i===e}))).sort((t,e)=>e.value-t.value);return s.push({html:n,value:-1,default:-1===i}),{html:c,title:o.title||"Quality",selector:s}}(e,i);if(!h(e)||l!==u)return;if(m.update(e,i,r,n),!h(e)||l!==u)return;const c=o.audio||{},s=function(e,o){if(!e.audioTracks.length)return null;const n=o.auto||"Auto",l=o.getName||(t=>t.name||t.lang||t.language),i=e.audioTracks[e.audioTrack],r=i?l(i):n,c=t(e.audioTracks.map((t,o)=>({html:l(t,o),value:t.id,default:e.audioTrack===t.id})));return{html:r,title:o.title||"Audio",selector:c}}(e,c);h(e)&&l===u&&g.update(e,c,s,n)}function y(){if(s)return;const t=n.hls;if(i(t?.media===l,'Cannot find instance of HLS from "art.hls"'),!d.has(t)){if(r!==t){a(),r=t;try{a=function(t,e,o){const n=t.constructor?.Events;if(!n||"function"!==typeof t.on||"function"!==typeof t.off)return()=>{};const l=[];let i=!0;function r(){if(i){i=!1;for(const[e,o]of l)t.off(e,o);l.length=0}}try{for(const r of["MANIFEST_PARSED","LEVELS_UPDATED","LEVEL_SWITCHED","AUDIO_TRACKS_UPDATED","AUDIO_TRACK_SWITCHED","DESTROYING"]){const c=n[r];if("string"!==typeof c||l.some(([t])=>t===c))continue;const s=()=>{i&&("DESTROYING"===r?o():e())};l.push([c,s]),t.on(c,s)}}catch(c){throw r(),c}return r}(t,()=>{f?c=t:p(t,!1)},()=>{d.add(t),a(),r===t&&(r=void 0,s||(m.clear(),g.clear()))})}catch(e){throw r=void 0,e}}p(t,!0)}}return n.on("ready",y),n.on("restart",y),n.on("destroy",function t(){s||(s=!0,u++,c=void 0,m.invalidate(),g.invalidate(),a(),n.off("ready",Line truncated
@@ -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.artplayerPluginHlsControl=e(),define(function(){return t.artplayerPluginHlsControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginHlsControl=e()}(this,function(){"use strict";function t(t,e){const l=new Map;return t.filter(t=>{const o=t[e];return void 0===o||!l.has(o)&&l.set(o,1)})}return function(e={}){return l=>{const{$video:o}=l.template,{errorHandle:n}=l.constructor.utils;function i(){var i;n((null==(i=l.hls)?void 0:i.media)===o,'Cannot find instance of HLS from "art.hls"'),function(o){if(!o.levels.length)return;const n=e.quality||{},i=n.auto||"Auto",a=n.title||"Quality",s=n.getName||(t=>t.name||`${t.height}P`),c=o.levels[o.currentLevel],r=c?s(c):i,u=t(o.levels.map((t,e)=>({html:s(t,e),value:e,default:o.currentLevel===e})),"html").sort((t,e)=>e.value-t.value);u.push({html:i,value:-1,default:-1===o.currentLevel});const h=t=>(o.currentLevel=t.value,l.notice.show=`${a}: ${t.html}`,n.control&&l.controls.check(t),n.setting&&l.setting.check(t),t.html);n.control&&l.controls.update({name:"hls-quality",position:"right",html:r,style:{padding:"0 10px"},selector:u,onSelect:h}),n.setting&&l.setting.update({name:"hls-quality",tooltip:r,html:a,icon:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>',width:200,selector:u,onSelect:h})}(l.hls),function(o){if(!o.audioTracks.length)return;const n=e.audio||{},i=n.auto||"Auto",a=n.title||"Audio",s=n.getName||(t=>t.name||t.lang||t.language),c=o.audioTracks[o.audioTrack],r=c?s(c):i,u=t(o.audioTracks.map((t,e)=>({html:s(t,e),value:t.id,default:o.audioTrack===t.id})),"html"),h=t=>(o.audioTrack=t.value,l.notice.show=`${a}: ${t.html}`,n.control&&l.controls.check(t),n.setting&&l.setting.check(t),t.html);n.control&&l.controls.update({name:"hls-audio",position:"right",html:r,style:{padding:"0 10px"},selector:u,onSelect:h}),n.setting&&l.setting.update({name:"hls-audio",tooltip:r,html:a,icon:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>',width:200,selector:u,onSelect:h})}(l.hls)}return l.on("ready",i),l.on("restart",i),{name:"artplayerPluginHlsControl",update:i}}}});
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginHlsControl=e(),define(function(){return t.artplayerPluginHlsControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginHlsControl=e()}(this,function(){"use strict";function t(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const o=e.get(t.html);return o?(t.default&&(o.default=!0,o.value=t.value),!1):(e.set(t.html,t),!0)})}function e(t,e,o,l,n,i){let r;const c={control:!1,setting:!1};function s(o){c[o]&&(c[o]=!1,t["control"===o?"controls":"setting"].remove(e))}function u(){r=void 0,s("control"),s("setting")}function a(e,o){for(const t of e.model.selector)t.default=t===o;e.config.control&&t.controls.check(o),e===r&&n(e.hls)&&e.config.setting&&t.setting.check(o)}return{update:function(f,d,h,m){if(!h)return void u();const g=r,v=h.selector.findIndex(t=>t.default);if(!m&&(null==g?void 0:g.hls)===f&&g.model.title===h.title&&(-1!==v||!g.model.selector.some(t=>t.default)&&g.model.html===h.html)&&c.control===Boolean(d.control)&&c.setting===Boolean(d.setting)&&g.model.selector.length===h.selector.length&&g.model.selector.every((t,e)=>t.html===h.selector[e].html&&t.value===h.selector[e].value)){g.config=d;const t=g.model.selector[v];return void(!t||t.default&&g.model.html===h.html||(g.model.html=h.html,a(g,t)))}const p={hls:f,config:d,model:h};r=p;const y=()=>r===p&&n(f),T=e=>i(()=>y()?(f[o]=e.value,y()?(h.html=e.html,t.notice.show=`${h.title}: ${e.html}`,y()&&a(p,e),e.html):e.html):e.html);d.control?(c.control=!0,t.controls.update({name:e,position:"right",html:h.html,style:{padding:"0 10px"},selector:h.selector,onSelect:T})):s("control"),y()&&(d.setting?(c.setting=!0,t.setting.update({name:e,tooltip:h.html,html:h.title,icon:l,width:200,selector:h.selector,onSelect:T})):s("setting"))},clear:u,invalidate:function(){r=void 0}}}return function(o={}){return l=>{const{$video:n}=l.template,{errorHandle:i}=l.constructor.utils;let r,c,s=!1,u=0,a=()=>{},f=0;const d=new WeakSet,h=t=>!s&&!d.has(t)&&l.hls===t&&t.media===n,m=e(l,"hls-quality","currentLevel",'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>',h,v),g=e(l,"hls-audio","audioTrack",'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>',h,v);function v(t){f++;let e=!1;try{const o=t();return e=!0,o}finally{if(f--,!f){const t=c;c=void 0,e&&t&&p(t,!1)}}}function p(e,l){if(!h(e))return;const n=++u,i=o.quality||{},r=function(e,o){if(!e.levels.length)return null;const l=o.auto||"Auto",n=o.getName||(t=>t.name||`${t.height}P`),i=function(t){return!0===t.autoLevelEnabled?-1:t.currentLevel}(e),r=e.levels[i],c=r?n(r):l,s=t(e.levels.map((t,e)=>({html:n(t,e),value:e,default:i===e}))).sort((t,e)=>e.value-t.value);return s.push({html:l,value:-1,default:-1===i}),{html:c,title:o.title||"Quality",selector:s}}(e,i);if(!h(e)||n!==u)return;if(m.update(e,i,r,l),!h(e)||n!==u)return;const c=o.audio||{},s=function(e,o){if(!e.audioTracks.length)return null;const l=o.auto||"Auto",n=o.getName||(t=>t.name||t.lang||t.language),i=e.audioTracks[e.audioTrack],r=i?n(i):l,c=t(e.audioTracks.map((t,o)=>({html:n(t,o),value:t.id,default:e.audioTrack===t.id})));return{html:r,title:o.title||"Audio",selector:c}}(e,c);h(e)&&n===u&&g.update(e,c,s,l)}function y(){if(s)return;const t=l.hls;if(i((null==t?void 0:t.media)===n,'Cannot find instance of HLS from "art.hls"'),!d.has(t)){if(r!==t){a(),r=t;try{a=function(t,e,o){var l;const n=null==(l=t.constructor)?void 0:l.Events;if(!n||"function"!==typeof t.on||"function"!==typeof t.off)return()=>{};const i=[];let r=!0;function c(){if(r){r=!1;for(const[e,o]of i)t.off(e,o);i.length=0}}try{for(const l of["MANIFEST_PARSED","LEVELS_UPDATED","LEVEL_SWITCHED","AUDIO_TRACKS_UPDATED","AUDIO_TRACK_SWITCHED","DESTROYING"]){const c=n[l];if("string"!==typeof c||i.some(([t])=>t===c))continue;const s=()=>{r&&("DESTROYING"===l?o():e())};i.push([c,s]),t.on(c,s)}}catch(s){throw c(),s}return c}(t,()=>{f?c=t:p(t,!1)},()=>{d.add(t),a(),r===t&&(r=void 0,s||(m.clear(),g.clear()))})}catch(e){throw r=void 0,e}}p(t,!0)}}return l.on("ready",y),l.on("restart",y),l.on("destroy",function t(){s||(s=!0,u++,cLine truncated
@@ -5,137 +5,261 @@
* Released under the MIT License.
*/
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
function uniqueLabels(items) {
const seen = /* @__PURE__ */ new Map();
return array.filter((item) => {
const key = item[property];
if (key === void 0) {
return items.filter((item) => {
if (item.html === void 0)
return true;
const first = seen.get(item.html);
if (first) {
if (item.default) {
first.default = true;
first.value = item.value;
}
return false;
}
return !seen.has(key) && seen.set(key, 1);
seen.set(item.html, item);
return true;
});
}
function qualitySelection(hls) {
return hls.autoLevelEnabled === true ? -1 : hls.currentLevel;
}
function qualityModel(hls, config) {
if (!hls.levels.length)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((level2) => level2.name || `${level2.height}P`);
const selected = qualitySelection(hls);
const level = hls.levels[selected];
const html = level ? getName(level) : auto;
const selector = uniqueLabels(hls.levels.map((item, index) => ({ html: getName(item, index), value: index, default: selected === index }))).sort((a, b) => b.value - a.value);
selector.push({ html: auto, value: -1, default: selected === -1 });
return { html, title: config.title || "Quality", selector };
}
function audioModel(hls, config) {
if (!hls.audioTracks.length)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((track2) => track2.name || track2.lang || track2.language);
const track = hls.audioTracks[hls.audioTrack];
const html = track ? getName(track) : auto;
const selector = uniqueLabels(hls.audioTracks.map((item, index) => ({ html: getName(item, index), value: item.id, default: hls.audioTrack === item.id })));
return { html, title: config.title || "Audio", selector };
}
function createMenu(art, name, field, icon, active, select) {
let current;
const owned = { control: false, setting: false };
function remove(surface) {
if (!owned[surface])
return;
owned[surface] = false;
art[surface === "control" ? "controls" : "setting"].remove(name);
}
function clear() {
current = void 0;
remove("control");
remove("setting");
}
function check(state, item) {
for (const entry of state.model.selector)
entry.default = entry === item;
if (state.config.control)
art.controls.check(item);
if (state !== current || !active(state.hls))
return;
if (state.config.setting)
art.setting.check(item);
}
function update(hls, config, model, force) {
if (!model) {
clear();
return;
}
const previous = current;
const selectedIndex = model.selector.findIndex((item) => item.default);
const reusable = !force && previous?.hls === hls && previous.model.title === model.title && (selectedIndex !== -1 || !previous.model.selector.some((item) => item.default) && previous.model.html === model.html) && owned.control === Boolean(config.control) && owned.setting === Boolean(config.setting) && previous.model.selector.length === model.selector.length && previous.model.selector.every((item, index) => item.html === model.selector[index].html && item.value === model.selector[index].value);
if (reusable) {
previous.config = config;
const target = previous.model.selector[selectedIndex];
if (target && (!target.default || previous.model.html !== model.html)) {
previous.model.html = model.html;
check(previous, target);
}
return;
}
const state = { hls, config, model };
current = state;
const valid = () => current === state && active(hls);
const onSelect = (item) => select(() => {
if (!valid())
return item.html;
hls[field] = item.value;
if (!valid())
return item.html;
model.html = item.html;
art.notice.show = `${model.title}: ${item.html}`;
if (valid())
check(state, item);
return item.html;
});
if (config.control) {
owned.control = true;
art.controls.update({ name, position: "right", html: model.html, style: { padding: "0 10px" }, selector: model.selector, onSelect });
} else {
remove("control");
}
if (!valid())
return;
if (config.setting) {
owned.setting = true;
art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect });
} else {
remove("setting");
}
}
function invalidate() {
current = void 0;
}
return { update, clear, invalidate };
}
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function subscribeHls(hls, refresh, destroyed) {
const events = hls.constructor?.Events;
if (!events || typeof hls.on !== "function" || typeof hls.off !== "function")
return () => {
};
const subscriptions = [];
let active = true;
function release() {
if (!active)
return;
active = false;
for (const [event, callback] of subscriptions)
hls.off(event, callback);
subscriptions.length = 0;
}
try {
for (const key of ["MANIFEST_PARSED", "LEVELS_UPDATED", "LEVEL_SWITCHED", "AUDIO_TRACKS_UPDATED", "AUDIO_TRACK_SWITCHED", "DESTROYING"]) {
const event = events[key];
if (typeof event !== "string" || subscriptions.some(([name]) => name === event))
continue;
const callback = () => {
if (!active)
return;
if (key === "DESTROYING")
destroyed();
else
refresh();
};
subscriptions.push([event, callback]);
hls.on(event, callback);
}
} catch (error) {
release();
throw error;
}
return release;
}
function artplayerPluginHlsControl(option = {}) {
return (art) => {
const { $video } = art.template;
const { errorHandle } = art.constructor.utils;
function updateQuality(hls) {
if (!hls.levels.length)
return;
const config = option.quality || {};
const auto = config.auto || "Auto";
const title = config.title || "Quality";
const getName = config.getName || ((level) => level.name || `${level.height}P`);
const defaultLevel = hls.levels[hls.currentLevel];
const defaultHtml = defaultLevel ? getName(defaultLevel) : auto;
const selector = uniqBy(
hls.levels.map((item, index) => {
return {
html: getName(item, index),
value: index,
default: hls.currentLevel === index
};
}),
"html"
).sort((a, b) => b.value - a.value);
selector.push({
html: auto,
value: -1,
default: hls.currentLevel === -1
});
const onSelect = (item) => {
hls.currentLevel = item.value;
art.notice.show = `${title}: ${item.html}`;
if (config.control)
art.controls.check(item);
if (config.setting)
art.setting.check(item);
return item.html;
};
if (config.control) {
art.controls.update({
name: "hls-quality",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "hls-quality",
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect
});
let closed = false;
let revision = 0;
let engine;
let unsubscribe = () => {
};
let selecting = 0;
let pending;
const retired = /* @__PURE__ */ new WeakSet();
const active = (hls) => !closed && !retired.has(hls) && art.hls === hls && hls.media === $video;
const quality = createMenu(art, "hls-quality", "currentLevel", $quality, active, select);
const audio = createMenu(art, "hls-audio", "audioTrack", $audio, active, select);
function select(callback) {
selecting++;
let succeeded = false;
try {
const result = callback();
succeeded = true;
return result;
} finally {
selecting--;
if (!selecting) {
const hls = pending;
pending = void 0;
if (succeeded && hls)
refresh(hls, false);
}
}
}
function updateAudio(hls) {
if (!hls.audioTracks.length)
function refresh(hls, force) {
if (!active(hls))
return;
const config = option.audio || {};
const auto = config.auto || "Auto";
const title = config.title || "Audio";
const getName = config.getName || ((track) => track.name || track.lang || track.language);
const defaultTrack = hls.audioTracks[hls.audioTrack];
const defaultHtml = defaultTrack ? getName(defaultTrack) : auto;
const selector = uniqBy(
hls.audioTracks.map((item, index) => {
return {
html: getName(item, index),
value: item.id,
default: hls.audioTrack === item.id
};
}),
"html"
);
const onSelect = (item) => {
hls.audioTrack = item.value;
art.notice.show = `${title}: ${item.html}`;
if (config.control)
art.controls.check(item);
if (config.setting)
art.setting.check(item);
return item.html;
};
if (config.control) {
art.controls.update({
name: "hls-audio",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "hls-audio",
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect
});
}
const version = ++revision;
const config = option.quality || {};
const model = qualityModel(hls, config);
if (!active(hls) || version !== revision)
return;
quality.update(hls, config, model, force);
if (!active(hls) || version !== revision)
return;
const audioConfig = option.audio || {};
const audioView = audioModel(hls, audioConfig);
if (active(hls) && version === revision)
audio.update(hls, audioConfig, audioView, force);
}
function update() {
errorHandle(art.hls?.media === $video, 'Cannot find instance of HLS from "art.hls"');
updateQuality(art.hls);
updateAudio(art.hls);
if (closed)
return;
const hls = art.hls;
errorHandle(hls?.media === $video, 'Cannot find instance of HLS from "art.hls"');
if (retired.has(hls))
return;
if (engine !== hls) {
unsubscribe();
engine = hls;
try {
unsubscribe = subscribeHls(hls, () => {
if (selecting)
pending = hls;
else
refresh(hls, false);
}, () => {
retired.add(hls);
unsubscribe();
if (engine === hls) {
engine = void 0;
if (!closed) {
quality.clear();
audio.clear();
}
}
});
} catch (error) {
engine = void 0;
throw error;
}
}
refresh(hls, true);
}
function destroy() {
if (closed)
return;
closed = true;
revision++;
pending = void 0;
quality.invalidate();
audio.invalidate();
unsubscribe();
art.off("ready", update);
art.off("restart", update);
art.off("destroy", destroy);
}
art.on("ready", update);
art.on("restart", update);
return {
name: "artplayerPluginHlsControl",
update
};
art.on("destroy", destroy);
return { name: "artplayerPluginHlsControl", update };
};
}
export {
@@ -1,152 +1,113 @@
import $audio from './audio.svg?raw'
import { audioModel, qualityModel } from './mapping'
import { createMenu } from './menu'
import $quality from './quality.svg?raw'
function uniqBy(array, property) {
const seen = new Map()
return array.filter((item) => {
const key = item[property]
if (key === undefined) {
return true
}
return !seen.has(key) && seen.set(key, 1)
})
}
import { subscribeHls } from './sdk-events'
export default function artplayerPluginHlsControl(option = {}) {
return (art) => {
const { $video } = art.template
const { errorHandle } = art.constructor.utils
let closed = false
let revision = 0
let engine
let unsubscribe = () => {}
let selecting = 0
let pending
const retired = new WeakSet()
const active = hls => !closed && !retired.has(hls) && art.hls === hls && hls.media === $video
const quality = createMenu(art, 'hls-quality', 'currentLevel', $quality, active, select)
const audio = createMenu(art, 'hls-audio', 'audioTrack', $audio, active, select)
function updateQuality(hls) {
if (!hls.levels.length)
return
const config = option.quality || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => level.name || `${level.height}P`)
const defaultLevel = hls.levels[hls.currentLevel]
const defaultHtml = defaultLevel ? getName(defaultLevel) : auto
const selector = uniqBy(
hls.levels.map((item, index) => {
return {
html: getName(item, index),
value: index,
default: hls.currentLevel === index,
}
}),
'html',
).sort((a, b) => b.value - a.value)
selector.push({
html: auto,
value: -1,
default: hls.currentLevel === -1,
})
const onSelect = (item) => {
hls.currentLevel = item.value
art.notice.show = `${title}: ${item.html}`
if (config.control)
art.controls.check(item)
if (config.setting)
art.setting.check(item)
return item.html
function select(callback) {
selecting++
let succeeded = false
try {
const result = callback()
succeeded = true
return result
}
if (config.control) {
art.controls.update({
name: 'hls-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'hls-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
finally {
selecting--
if (!selecting) {
const hls = pending
pending = undefined
if (succeeded && hls)
refresh(hls, false)
}
}
}
function updateAudio(hls) {
if (!hls.audioTracks.length)
function refresh(hls, force) {
if (!active(hls))
return
const config = option.audio || {}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.name || track.lang || track.language)
const defaultTrack = hls.audioTracks[hls.audioTrack]
const defaultHtml = defaultTrack ? getName(defaultTrack) : auto
const selector = uniqBy(
hls.audioTracks.map((item, index) => {
return {
html: getName(item, index),
value: item.id,
default: hls.audioTrack === item.id,
}
}),
'html',
)
const onSelect = (item) => {
hls.audioTrack = item.value
art.notice.show = `${title}: ${item.html}`
if (config.control)
art.controls.check(item)
if (config.setting)
art.setting.check(item)
return item.html
}
if (config.control) {
art.controls.update({
name: 'hls-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'hls-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
const version = ++revision
const config = option.quality || {}
const model = qualityModel(hls, config)
if (!active(hls) || version !== revision)
return
quality.update(hls, config, model, force)
if (!active(hls) || version !== revision)
return
const audioConfig = option.audio || {}
const audioView = audioModel(hls, audioConfig)
if (active(hls) && version === revision)
audio.update(hls, audioConfig, audioView, force)
}
function update() {
errorHandle(art.hls?.media === $video, 'Cannot find instance of HLS from "art.hls"')
updateQuality(art.hls)
updateAudio(art.hls)
if (closed)
return
const hls = art.hls
errorHandle(hls?.media === $video, 'Cannot find instance of HLS from "art.hls"')
if (retired.has(hls))
return
if (engine !== hls) {
unsubscribe()
engine = hls
try {
unsubscribe = subscribeHls(hls, () => {
if (selecting)
pending = hls
else
refresh(hls, false)
}, () => {
retired.add(hls)
unsubscribe()
if (engine === hls) {
engine = undefined
if (!closed) {
quality.clear()
audio.clear()
}
}
})
}
catch (error) {
engine = undefined
throw error
}
}
refresh(hls, true)
}
function destroy() {
if (closed)
return
closed = true
revision++
pending = undefined
quality.invalidate()
audio.invalidate()
unsubscribe()
art.off('ready', update)
art.off('restart', update)
art.off('destroy', destroy)
}
art.on('ready', update)
art.on('restart', update)
return {
name: 'artplayerPluginHlsControl',
update,
}
art.on('destroy', destroy)
return { name: 'artplayerPluginHlsControl', update }
}
}
@@ -0,0 +1,46 @@
function uniqueLabels(items) {
const seen = new Map()
return items.filter((item) => {
if (item.html === undefined)
return true
const first = seen.get(item.html)
if (first) {
if (item.default) {
first.default = true
first.value = item.value
}
return false
}
seen.set(item.html, item)
return true
})
}
export function qualitySelection(hls) {
return hls.autoLevelEnabled === true ? -1 : hls.currentLevel
}
export function qualityModel(hls, config) {
if (!hls.levels.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => level.name || `${level.height}P`)
const selected = qualitySelection(hls)
const level = hls.levels[selected]
const html = level ? getName(level) : auto
const selector = uniqueLabels(hls.levels.map((item, index) => ({ html: getName(item, index), value: index, default: selected === index })))
.sort((a, b) => b.value - a.value)
selector.push({ html: auto, value: -1, default: selected === -1 })
return { html, title: config.title || 'Quality', selector }
}
export function audioModel(hls, config) {
if (!hls.audioTracks.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => track.name || track.lang || track.language)
const track = hls.audioTracks[hls.audioTrack]
const html = track ? getName(track) : auto
const selector = uniqueLabels(hls.audioTracks.map((item, index) => ({ html: getName(item, index), value: item.id, default: hls.audioTrack === item.id })))
return { html, title: config.title || 'Audio', selector }
}
@@ -0,0 +1,87 @@
export function createMenu(art, name, field, icon, active, select) {
let current
const owned = { control: false, setting: false }
function remove(surface) {
if (!owned[surface])
return
owned[surface] = false
art[surface === 'control' ? 'controls' : 'setting'].remove(name)
}
function clear() {
current = undefined
remove('control')
remove('setting')
}
function check(state, item) {
for (const entry of state.model.selector)
entry.default = entry === item
if (state.config.control)
art.controls.check(item)
if (state !== current || !active(state.hls))
return
if (state.config.setting)
art.setting.check(item)
}
function update(hls, config, model, force) {
if (!model) {
clear()
return
}
const previous = current
const selectedIndex = model.selector.findIndex(item => item.default)
const reusable = !force && previous?.hls === hls && previous.model.title === model.title
&& (selectedIndex !== -1 || (!previous.model.selector.some(item => item.default) && previous.model.html === model.html))
&& owned.control === Boolean(config.control) && owned.setting === Boolean(config.setting)
&& previous.model.selector.length === model.selector.length
&& previous.model.selector.every((item, index) => item.html === model.selector[index].html && item.value === model.selector[index].value)
if (reusable) {
previous.config = config
const target = previous.model.selector[selectedIndex]
if (target && (!target.default || previous.model.html !== model.html)) {
previous.model.html = model.html
check(previous, target)
}
return
}
const state = { hls, config, model }
current = state
const valid = () => current === state && active(hls)
const onSelect = item => select(() => {
if (!valid())
return item.html
hls[field] = item.value
if (!valid())
return item.html
model.html = item.html
art.notice.show = `${model.title}: ${item.html}`
if (valid())
check(state, item)
return item.html
})
if (config.control) {
owned.control = true
art.controls.update({ name, position: 'right', html: model.html, style: { padding: '0 10px' }, selector: model.selector, onSelect })
}
else {
remove('control')
}
if (!valid())
return
if (config.setting) {
owned.setting = true
art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect })
}
else {
remove('setting')
}
}
function invalidate() {
current = undefined
}
return { update, clear, invalidate }
}
@@ -0,0 +1,37 @@
export function subscribeHls(hls, refresh, destroyed) {
const events = hls.constructor?.Events
if (!events || typeof hls.on !== 'function' || typeof hls.off !== 'function')
return () => {}
const subscriptions = []
let active = true
function release() {
if (!active)
return
active = false
for (const [event, callback] of subscriptions)
hls.off(event, callback)
subscriptions.length = 0
}
try {
for (const key of ['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING']) {
const event = events[key]
if (typeof event !== 'string' || subscriptions.some(([name]) => name === event))
continue
const callback = () => {
if (!active)
return
if (key === 'DESTROYING')
destroyed()
else
refresh()
}
subscriptions.push([event, callback])
hls.on(event, callback)
}
}
catch (error) {
release()
throw error
}
return release
}