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

@@ -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
+238 -114
View File
@@ -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 {
@@ -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
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
# PKG-HLS-03:HLS 映射、菜单与订阅分层
## 结构调整
入口只协调核心生命周期、实例绑定和刷新代次;mapping.js 负责标签/选择模型,menu.js 负责
既有 controls/setting API 与回调归属,sdk-events.js 负责 SDK 能力探测、订阅与解绑。
没有引入 Hls 运行时依赖或新公开参数/方法。自有源码仍是 JS,严格 TS 和公开声明迁移继续
由 PKG-HLS-04 完成,不能把本步的源码拆分计入 TS 文件数。
## 缺陷修正与兼容处理
- HLS-UI-01:空 levels/audioTracks 或关闭显示开关时删除相应旧菜单,再恢复时可以重建。
- HLS-LIFE-01:旧菜单回调检查当前实例/菜单归属;核心销毁解绑自身 core/SDK 监听器,保留
update 调用不再触碰 SDK/UI。SDK DESTROYING 清理自身菜单、永久拒绝该实例重新绑定。
- HLS-AUTO-01:自动策略与实际播放档位分开;真实 Auto 播放的标签保持 Auto,手动选择与
外部音轨切换通过 SDK 事件同步。无 autoLevelEnabled 能力的旧适配对象保留 currentLevel fallback。
- 重复标签合并时,如果实际选中项位于后面的重复项,用该项的 value 代表同名组,避免丢失
default;这一内部代表项修正单独测试,不将它称为完全无行为变化的文件移动。
- SDK 事件优先复用未变化的菜单,不在 ABR 事件中反复销毁控件;当前音轨未知时清除旧高亮。
同步 SDK 事件延后至本次选择操作完成,保留 write→notice→checks→同步返回顺序。
- formatter 销毁/替换实例或嵌套 update 时,旧刷新不能覆盖较新的 UI;SDK 注册异常撤回已经
安装的监听器并允许重试。没有延后 SDK 写入或将 update 改成 Promise。
公开工厂、name/update、配置、原回调对象与可选索引、控件名、SVG、布局和分发路径保持。
getName 的旧声明缺陷仍属 04;自动模式不再为错误的当前档位标签调用一次 selected getName,
列表命名仍按原对象与索引执行。参见 [包内实际架构](../../packages/artplayer-plugin-hls-control/ARCHITECTURE.md)。
## 验证与范围
证据见 [冻结记录](../baselines/hls-modules-validation.json)。Node 专项含共享发布契约和候选修复;
额外读取本次 build 生成的 main/legacy/ESM 运行同一组正常契约。浏览器使用明确的构建文件,
覆盖新旧核心/插件组合及真实 SDK 外部事件的选中态、菜单节点身份、清理等行为。
Hls 1.5.17、Chromium/Firefox、Windows WebKit 的不支持能力边界与旧版测试分开保留。
本步构建产物及 docs/compiled 副本由正式 build 生成;未手改分发文件。真实 Safari/native、
SDK worker/版本/分组轨道、官方示例和隔离 tarball 消费仍为 05/06,SDK-01/HLS-ENV-01 不关闭。
完整 ci:check 检查类型、lint、工程与旧基线;没有把源码浏览器或工作区 dist 当作 npm 安装通过。
本任务没有改变核心源码,不重复全量核心浏览器矩阵。
最终 Node 专项 56 项通过;加入三个构建格式后 83 项通过。modern/legacy 浏览器各 35 passed、
16 skipped、0 failed/retry;跳过仅 Windows WebKit 的无 MSE 播放场景,能力/失败清理仍执行。
完整 CI 533 项通过(496 单元、11 工程、26 基线),249 个既有生产 TS 文件严格检查通过。
modern 为 5257 raw / 2536 gzip9 字节,发布基线为 3156 / 1439;增加订阅与生命周期成本,
不称为体积优化。package/dist 与 docs/compiled 三格式逐字一致,源/产物/报告指纹已核对。
既有生成 editor 声明的 unused eslint-disable warning 沿用,不手改生成文件。
回退本任务的独立 commit 可恢复单文件实现及其生成产物,保留 01/02 冻结来源和缺陷观察。
下一步 PKG-HLS-04,迁移已拆分模块与兼容公开类型,同样独立提交。
+9
View File
@@ -1,5 +1,14 @@
# HLS control 测试与环境边界
PKG-HLS-03 已在入口、映射、菜单和 SDK 订阅四个模块实施修复,实际地图见
[包内架构](../packages/artplayer-plugin-hls-control/ARCHITECTURE.md),本轮证据见
[模块验证](baselines/hls-modules-validation.json)。下方 02 的数量与缺陷观察属于冻结的迁移前基线。
新版用例增加事件同步、菜单节点复用和候选生命周期断言,公开 TS 迁移仍是 04。
新增构建文件入口:ARTPLAYER_TEST_HLS 以平台路径分隔符接受 main/legacy/ESM,用于同一 Node
契约;ARTPLAYER_HLS_ARTIFACT 选择实际 main/legacy 浏览器文件。测试记录实际 hash,错误路径
明确失败。这是工作区构建验证;隔离安装以及整套 ARTPLAYER_BROWSER_ARTIFACTS 映射仍由 06 完成。
PKG-HLS-02 建立迁移前基线。源码目前仍为 src/index.js,不能把测试任务当作 TS 迁移已完成。
来源见 [发布契约](baselines/hls-control-contract.md)、[SDK 固定记录](baselines/hls-sdk.json)、
[本轮证据](baselines/hls-validation.json)。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 215 项,范围 22 个包及工作区/示例。
状态:todo 152 / doing 0 / blocked 0 / done 63 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 151 / doing 0 / blocked 0 / done 64 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -190,7 +190,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-HLS-01 | artplayer-plugin-hls-control<br>核对包契约与历史用法 | BASE-05 | quality/audio 配置、getName、update、控件名及 Hls 实例绑定 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-HLS-02 | artplayer-plugin-hls-control<br>建立特有行为与错误测试 | PKG-HLS-01, ENG-03, ENG-05 | Auto/手动实际 level、音轨、去重/过滤和拓扑变化 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-HLS-03 | artplayer-plugin-hls-control<br>整理内部职责与资源 | PKG-HLS-02, CORE-11, CORE-14 | 映射计算与 selector 更新/删除、Hls 事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-HLS-03 | artplayer-plugin-hls-control<br>整理内部职责与资源 | PKG-HLS-02, CORE-11, CORE-14 | 映射计算与 selector 更新/删除、Hls 事件订阅分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-HLS-04 | artplayer-plugin-hls-control<br>迁移自有源码和公开类型 | PKG-HLS-03, ENG-04, ENG-06, CORE-07 | HLS 能力适配类型、回调上下文和旧 getName 参数 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-HLS-05 | artplayer-plugin-hls-control<br>验证新旧核心和组合 | PKG-HLS-04, CORE-22 | 本地多码率 HLS、换成无轨道来源、旧核心和最终核心 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-HLS-06 | artplayer-plugin-hls-control<br>验证分发并同步文档 | PKG-HLS-05, ENG-07 | hls.control.js、依赖范围与回退记录 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -467,3 +467,4 @@
- PKG-CHAPTER-04: [记录](changes/2026-09-10-PKG-CHAPTER-04-public-types.md) [记录](baselines/chapter-types-validation.json)
- PKG-HLS-01: [记录](baselines/hls-control-contract.md) [记录](baselines/hls-control-release.json) [记录](changes/2026-09-11-PKG-HLS-01-contract.md)
- PKG-HLS-02: [记录](hls-validation.md) [记录](baselines/hls-sdk.json) [记录](baselines/hls-validation.json) [记录](changes/2026-09-11-PKG-HLS-02-tests.md)
- PKG-HLS-03: [记录](changes/2026-09-12-PKG-HLS-03-modules.md) [记录](baselines/hls-modules-validation.json) [记录](hls-validation.md)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 最新完成:PKG-HLS-03 源码分层与生命周期修复
HLS control 已拆为入口、选择映射、菜单管理、SDK 订阅四个 JS 模块,保持公开入口和同步调用;修复空轨道旧菜单、过期回调、Auto 标签、重复组丢失选中态,并补事件同步、销毁/订阅失败恢复及重入保护。公开类型和严格 TS 迁移属于下一步 04。
56 项 Node 专项通过,加入三种构建格式后 83 项通过;完整 CI 533 项、249 个既有生产 TS 文件通过。modern/legacy 浏览器各 35 passed、16 skipped,无失败/重试;Windows WebKit 的 MSE 缺口未关闭。三份 package/dist 与 docs/compiled 一致,modern gzip 为 2536 字节(发布基线 1439),不声称体积优化。
见 [交付记录](changes/2026-09-12-PKG-HLS-03-modules.md)、[冻结证据](baselines/hls-modules-validation.json) 与包内 ARCHITECTURE.md。HLS-UI-01/LIFE-01/AUTO-01 已按范围关闭,SDK-01/ENV-01 与其余发布门槛仍 open。64 done、151 todo;本任务独立本地 commit,核实后进入 PKG-HLS-04。没有 push/tag/publish/merge。
## 最新完成:PKG-HLS-02 行为与 SDK 基线
45 项 HLS Node 契约通过,覆盖源码及发布 main/legacy/ESM;完整 CI 522 项、249 个生产 TS 文件通过。真实 Hls.js 1.5.17 与本地双档位/双音轨流验证四组合的解码、实际选择、pause/seek、切源、失败恢复和销毁。
+4 -4
View File
@@ -33,7 +33,7 @@
| VENDOR-06 | open / 待取证 | monaco-static-assets 来源、版本与许可闭环 | SITE-01, SITE-05 |
| VENDOR-07 | open / 待取证 | vconsole 来源、版本与许可闭环 | SITE-01 |
| VENDOR-08 | open / 待取证 | console-bundle 来源、版本与许可闭环 | SITE-01 |
| SDK-01 | open / 源码/产物事实 | hls.js 实际集成验证范围 | PKG-HLS-03, PKG-HLS-05, EX-03 |
| SDK-01 | open / 源码/产物事实 | hls.js 实际集成验证范围 | PKG-HLS-05, EX-03 |
| SDK-02 | open / 待取证 | dash.js 实际集成验证范围 | PKG-DASH-01, EX-03 |
| SDK-03 | open / 待取证 | flv.js 实际集成验证范围 | EX-03 |
| SDK-04 | open / 待取证 | mpegts.js 实际集成验证范围 | EX-03 |
@@ -120,7 +120,7 @@
| BASE-LIFE-47 | resolved / 已复现 | AirPlay methods can call a picker or publish events and notices after destruction | CORE-20 |
| BASE-LIFE-48 | resolved / 已复现 | Proxy-time destruction cannot clean a template before its public assignment | CORE-20 |
| BASE-LIFE-49 | resolved / 已复现 | Constructor and Player initialization continue after a callback destroys the instance | CORE-20 |
| HLS-UI-01 | open / 已复现 | 空轨道和关闭配置仍残留 HLS 菜单 | PKG-HLS-03 |
| HLS-LIFE-01 | open / 已复现 | 过期 HLS 选择回调与销毁后 update 仍工作 | PKG-HLS-03 |
| HLS-AUTO-01 | open / 已复现 | 真实 ABR 播放的 Auto 标签在 update 后丢失 | PKG-HLS-03, PKG-HLS-05 |
| HLS-UI-01 | resolved / 已复现 | 空轨道和关闭配置仍残留 HLS 菜单 | PKG-HLS-03 |
| HLS-LIFE-01 | resolved / 已复现 | 过期 HLS 选择回调与销毁后 update 仍工作 | PKG-HLS-03 |
| HLS-AUTO-01 | resolved / 已复现 | 真实 ABR 播放的 Auto 标签在 update 后丢失 | PKG-HLS-03, PKG-HLS-05 |
| HLS-ENV-01 | open / 已复现 | Windows WebKit 缺失 MSE,HLS 播放矩阵尚缺 Safari 证据 | PKG-HLS-05, REL-09, REVIEW-02 |
+24 -7
View File
@@ -606,7 +606,6 @@
"confirmation": "source-observed",
"status": "open",
"owners": [
"PKG-HLS-03",
"PKG-HLS-05",
"EX-03"
],
@@ -2527,7 +2526,7 @@
"id": "HLS-UI-01",
"title": "空轨道和关闭配置仍残留 HLS 菜单",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-HLS-03"
],
@@ -2537,13 +2536,19 @@
"test/browser/hls-control.spec.js"
],
"compatibleResolution": "使用既有 remove API 清理不再可用的 UI,保留菜单名称及选择语义。",
"closureCriteria": "候选空轨道、关闭开关及恢复测试通过。"
"closureCriteria": "候选空轨道、关闭开关及恢复测试通过。",
"resolutionEvidence": [
"refactor/baselines/hls-modules-validation.json",
"refactor/changes/2026-09-12-PKG-HLS-03-modules.md",
"packages/artplayer-plugin-hls-control/ARCHITECTURE.md"
],
"resolutionRationale": "PKG-HLS-03 候选回归与新旧核心/实际 Hls.js 1.5.17 验证:空菜单、过期回调/订阅、Auto 及音轨同步。环境/SDK 扩展范围仍由 SDK-01 与 HLS-ENV-01 保持 open,不据此放行发布。"
},
{
"id": "HLS-LIFE-01",
"title": "过期 HLS 选择回调与销毁后 update 仍工作",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-HLS-03"
],
@@ -2553,13 +2558,19 @@
"test/browser/hls-control.spec.js"
],
"compatibleResolution": "限制过期实例回调,整理监听器归属,Hls 仍由调用方销毁。",
"closureCriteria": "替换、销毁、重入及 SDK 订阅解绑的候选回归通过。"
"closureCriteria": "替换、销毁、重入及 SDK 订阅解绑的候选回归通过。",
"resolutionEvidence": [
"refactor/baselines/hls-modules-validation.json",
"refactor/changes/2026-09-12-PKG-HLS-03-modules.md",
"packages/artplayer-plugin-hls-control/ARCHITECTURE.md"
],
"resolutionRationale": "PKG-HLS-03 候选回归与新旧核心/实际 Hls.js 1.5.17 验证:空菜单、过期回调/订阅、Auto 及音轨同步。环境/SDK 扩展范围仍由 SDK-01 与 HLS-ENV-01 保持 open,不据此放行发布。"
},
{
"id": "HLS-AUTO-01",
"title": "真实 ABR 播放的 Auto 标签在 update 后丢失",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-HLS-03",
"PKG-HLS-05"
@@ -2570,7 +2581,13 @@
"test/browser/hls-control.spec.js"
],
"compatibleResolution": "区分自动策略与实际播放档位,保留手动及 Auto 选择值。",
"closureCriteria": "实际 SDK 自动/手动切换和事件同步通过。"
"closureCriteria": "实际 SDK 自动/手动切换和事件同步通过。",
"resolutionEvidence": [
"refactor/baselines/hls-modules-validation.json",
"refactor/changes/2026-09-12-PKG-HLS-03-modules.md",
"packages/artplayer-plugin-hls-control/ARCHITECTURE.md"
],
"resolutionRationale": "PKG-HLS-03 候选回归与新旧核心/实际 Hls.js 1.5.17 验证:空菜单、过期回调/订阅、Auto 及音轨同步。环境/SDK 扩展范围仍由 SDK-01 与 HLS-ENV-01 保持 open,不据此放行发布。"
},
{
"id": "HLS-ENV-01",
+6 -2
View File
@@ -1827,11 +1827,15 @@
"CORE-11",
"CORE-14"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "映射计算与 selector 更新/删除、Hls 事件订阅分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-12-PKG-HLS-03-modules.md",
"baselines/hls-modules-validation.json",
"hls-validation.md"
]
},
{
"id": "PKG-HLS-04",
+4 -2
View File
@@ -1023,16 +1023,18 @@
"id": "hls.js",
"riskId": "SDK-01",
"owners": [
"PKG-HLS-03",
"PKG-HLS-05",
"EX-03"
],
"sourceFiles": [
"packages/artplayer-plugin-hls-control/src/index.js",
"packages/artplayer-plugin-hls-control/src/mapping.js",
"packages/artplayer-plugin-hls-control/src/menu.js",
"packages/artplayer-plugin-hls-control/src/sdk-events.js",
"docs/assets/example/hls.control.js"
],
"contract": "Caller supplies art.hls; homepage demo loads hls.js 1.5.17. Preserve quality/audio selectors and old core integration.",
"validationStatus": "Hls.js 1.5.17 frozen in refactor/baselines/hls-sdk.json; Chromium/Firefox local playback baseline verified in PKG-HLS-02. Windows WebKit has no MSE; Safari/native/worker/version and installed-package matrices remain open. See refactor/hls-validation.md.",
"validationStatus": "PKG-HLS-03 verifies actual Hls.js 1.5.17 on Chromium/Firefox with source/published core and modern/legacy candidate builds. Auto/topology/lifecycle defects fixed; Windows WebKit has no MSE. Safari/native, detach/reattach, worker, grouped tracks, versions and isolated installation remain HLS-05/06 and EX-03.",
"demoPathsSource": "refactor/baselines/demo-inventory.json"
},
{
+36 -2
View File
@@ -40,7 +40,9 @@ test.beforeAll(async () => {
publishedCode = await code(plugin.release, 'package/dist/artplayer-plugin-hls-control.js')
// This task exercises source builds. Installed artifacts are a separate HLS-06 gate.
assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'HLS source fixture cannot masquerade as an installed plugin')
sourceCode = await compilePackage('artplayer-plugin-hls-control', 'umd')
sourceCode = process.env.ARTPLAYER_HLS_ARTIFACT
? fs.readFileSync(process.env.ARTPLAYER_HLS_ARTIFACT, 'utf8')
: await compilePackage('artplayer-plugin-hls-control', 'umd')
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
media = new Map()
for (const [name, expected] of Object.entries(manifest.files)) {
@@ -50,7 +52,7 @@ test.beforeAll(async () => {
assert.equal(bytes.length, expected.bytes)
media.set(name, bytes)
}
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'source candidate, not installed package', 'no physical device certification'] }
evidence = { sdk: sdk.release, plugin: plugin.release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_HLS_ARTIFACT || 'workspace source build', media: manifest, limitations: ['SDK worker disabled', 'local deterministic media', 'not an isolated installed package', 'no physical device certification'] }
})
async function openHls(page, core, plugin, testInfo, manifest = 'master.m3u8') {
@@ -211,6 +213,9 @@ test.describe('MSE playback matrix', () => {
// Real SDK corroboration of the frozen historical empty-topology defect.
await expect(page.locator('.art-control-hls-audio')).toHaveCount(1)
}
else {
await expect(page.locator('.art-control-hls-audio')).toHaveCount(0)
}
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
await page.evaluate(() => window.art.destroy())
expect(await page.evaluate(() => window.destroyedEngines)).toBe(2)
@@ -232,5 +237,34 @@ test.describe('MSE playback matrix', () => {
expect(await page.evaluate(() => window.art.hls.autoLevelEnabled)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test(`${core} core / candidate HLS: SDK events synchronize Auto and audio without replacing menus`, async ({ page }, testInfo) => {
await openHls(page, core, 'candidate', testInfo)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.art.video.currentTime)).toBeGreaterThan(0.3)
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
await page.evaluate(() => {
window.qualityElement = document.querySelector('.art-control-hls-quality')
window.audioElement = document.querySelector('.art-control-hls-audio')
window.art.hls.currentLevel = 0
})
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBe(0)
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('90P')
await page.evaluate(() => {
window.art.hls.currentLevel = -1
})
await expect.poll(() => page.evaluate(() => window.art.hls.currentLevel)).toBeGreaterThanOrEqual(0)
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
await page.evaluate(() => {
window.art.hls.audioTrack = 1
})
await expect(page.locator('.art-control-hls-audio .art-selector-value')).toHaveText('French')
expect(await page.evaluate(() => ({ quality: window.qualityElement === document.querySelector('.art-control-hls-quality'), audio: window.audioElement === document.querySelector('.art-control-hls-audio') }))).toEqual({ quality: true, audio: true })
await page.evaluate(() => window.art.plugins.artplayerPluginHlsControl.update())
await expect(page.locator('.art-control-hls-quality .art-selector-value')).toHaveText('Auto')
expect(await page.evaluate(() => window.sdkErrors.filter(error => error.fatal))).toEqual([])
await page.evaluate(() => window.art.destroy())
})
}
})
+37
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import vm from 'node:vm'
import { ensureArchive, hash, readMember } from '../../refactor/scripts/releases.mjs'
import { loadPackage } from './load.js'
@@ -26,6 +28,21 @@ export async function hlsImplementations() {
assert.equal(typeof factory, 'function')
implementations.push({ name: `published-${format}`, factory })
}
for (const filename of (process.env.ARTPLAYER_TEST_HLS || '').split(path.delimiter).filter(Boolean)) {
const bytes = fs.readFileSync(filename)
let factory
if (filename.endsWith('.mjs')) {
factory = (await import(`data:text/javascript;base64,${bytes.toString('base64')}`)).default
}
else {
const context = { module: { exports: {} } }
context.exports = context.module.exports
vm.runInNewContext(bytes.toString(), context, { timeout: 5000 })
factory = context.module.exports
}
assert.equal(typeof factory, 'function')
implementations.push({ name: `artifact-${path.basename(filename)}`, factory })
}
return implementations
}
@@ -102,3 +119,23 @@ export function hlsHost() {
export const bothMenus = () => ({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })
export const selections = menu => Array.from(menu.selector, item => [item.html, item.value, item.default])
export function attachHlsEvents(hls) {
const listeners = new Map()
const Events = Object.fromEntries(['MANIFEST_PARSED', 'LEVELS_UPDATED', 'LEVEL_SWITCHED', 'AUDIO_TRACKS_UPDATED', 'AUDIO_TRACK_SWITCHED', 'DESTROYING'].map(key => [key, key]))
hls.constructor = { Events }
hls.on = (name, callback) => {
if (!listeners.has(name))
listeners.set(name, new Set())
listeners.get(name).add(callback)
}
hls.off = (name, callback) => listeners.get(name)?.delete(callback)
return {
listeners,
emit(name) {
for (const callback of [...(listeners.get(name) || [])])
callback(name, {})
},
count: () => [...listeners.values()].reduce((total, entries) => total + entries.size, 0),
}
}
+209 -1
View File
@@ -1,9 +1,10 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- The repo uses Node's runner for controlled contracts.
import { test } from 'node:test'
import { bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
import { attachHlsEvents, bothMenus, hlsHost, hlsImplementations, selections } from './helpers/hls-control.js'
const implementations = await hlsImplementations()
const candidate = implementations.find(item => item.name === 'source').factory
for (const { name, factory } of implementations) {
test(`${name}: optional factory, synchronous result and deferred core events`, () => {
const { art, controls, calls, listeners } = hlsHost()
@@ -219,3 +220,210 @@ for (const { name, factory } of implementations.filter(item => item.name.startsW
assert.equal(controls.get('hls-quality').selector.some(item => item.default), false)
})
}
test('candidate removes empty and disabled menus, then restores them', () => {
const { art, hls, controls, settings } = hlsHost()
const option = bothMenus()
const plugin = candidate(option)(art)
plugin.update()
hls.levels = []
hls.audioTracks = []
plugin.update()
assert.equal(controls.size, 0)
assert.equal(settings.size, 0)
hls.levels = [{ height: 240 }]
hls.audioTracks = [{ id: 0, name: 'New' }]
plugin.update()
assert.equal(controls.size, 2)
option.quality.control = false
option.audio.setting = false
plugin.update()
assert.deepEqual([...controls.keys()], ['hls-audio'])
assert.deepEqual([...settings.keys()], ['hls-quality'])
})
test('candidate ignores superseded callbacks and retained update after destroy', () => {
const { art, hls, calls, controls, listeners } = hlsHost()
const events = attachHlsEvents(hls)
const plugin = candidate(bothMenus())(art)
plugin.update()
const old = controls.get('hls-quality')
const next = hlsHost().hls
next.media = art.template.$video
art.hls = next
calls.length = 0
old.onSelect(old.selector[0])
assert.equal(hls.currentLevel, -1)
assert.equal(calls.length, 0)
plugin.update()
assert.equal(events.count(), 0)
const current = controls.get('hls-quality')
art.emit('destroy')
calls.length = 0
assert.equal(plugin.update(), undefined)
current.onSelect(current.selector[0])
assert.equal(calls.length, 0)
assert.equal([...listeners.values()].every(set => !set.size), true)
})
test('candidate subscriptions update selection without replacing unchanged menus', () => {
const { art, hls, calls, controls } = hlsHost()
const events = attachHlsEvents(hls)
hls.autoLevelEnabled = true
hls.currentLevel = 1
const plugin = candidate(bothMenus())(art)
plugin.update()
assert.equal(events.count(), 6)
const menu = controls.get('hls-quality')
assert.equal(menu.html, 'Auto')
assert.equal(menu.selector.at(-1).default, true)
hls.currentLevel = 2
calls.length = 0
events.emit('LEVEL_SWITCHED')
assert.equal(controls.get('hls-quality'), menu)
assert.equal(calls.length, 0)
hls.autoLevelEnabled = false
events.emit('LEVEL_SWITCHED')
assert.equal(controls.get('hls-quality'), menu)
assert.equal(menu.selector[0].default, true)
assert.deepEqual(calls.map(call => call[0]), ['controls.check', 'setting.check'])
calls.length = 0
events.emit('LEVEL_SWITCHED')
assert.equal(calls.length, 0)
hls.audioTracks = []
events.emit('AUDIO_TRACKS_UPDATED')
assert.equal(controls.has('hls-audio'), false)
events.emit('DESTROYING')
assert.equal(events.count(), 0)
assert.equal(controls.size, 0)
})
test('candidate duplicate labels retain the actual selected representation', () => {
const { art, hls, controls } = hlsHost()
hls.levels = [{ height: 720 }, { height: 720 }]
hls.currentLevel = 1
candidate(bothMenus())(art).update()
assert.deepEqual(selections(controls.get('hls-quality')), [['720P', 1, true], ['Auto', -1, false]])
})
test('candidate rolls back failed subscription setup and retries on update', () => {
const { art, hls } = hlsHost()
const events = attachHlsEvents(hls)
const on = hls.on
hls.on = (name, callback) => {
on(name, callback)
if (name === 'LEVEL_SWITCHED')
throw new Error('subscription rejected')
}
const plugin = candidate(bothMenus())(art)
assert.throws(() => plugin.update(), /subscription rejected/)
assert.equal(events.count(), 0)
hls.on = on
plugin.update()
assert.equal(events.count(), 6)
plugin.update()
assert.equal(events.count(), 6)
art.emit('destroy')
assert.equal(events.count(), 0)
})
test('candidate synchronously emitted SDK events preserve selection order', () => {
const { art, hls, controls, calls } = hlsHost()
const events = attachHlsEvents(hls)
let selected = -1
Object.defineProperty(hls, 'currentLevel', {
get: () => selected,
set(value) {
calls.push(['currentLevel', value])
selected = value
events.emit('LEVEL_SWITCHED')
},
})
candidate(bothMenus())(art).update()
const menu = controls.get('hls-quality')
const item = menu.selector[0]
calls.length = 0
assert.equal(menu.onSelect(item), item.html)
assert.deepEqual(calls, [['currentLevel', 2], ['notice', 'Quality: 1080P'], ['controls.check', item], ['setting.check', item]])
})
test('candidate cancels UI publication when getName destroys or replaces the player engine', () => {
for (const action of ['destroy', 'replace']) {
const { art, calls } = hlsHost()
const option = bothMenus()
option.quality.getName = () => {
if (action === 'destroy')
art.emit('destroy')
else
art.hls = hlsHost().hls
return 'Label'
}
const plugin = candidate(option)(art)
plugin.update()
assert.equal(calls.length, 0)
}
})
test('candidate removes only its own SDK listeners and never revives a destroying engine', () => {
const { art, hls, calls } = hlsHost()
const events = attachHlsEvents(hls)
const foreign = () => {}
hls.on('LEVEL_SWITCHED', foreign)
const plugin = candidate(bothMenus())(art)
plugin.update()
assert.equal(events.count(), 7)
events.emit('DESTROYING')
assert.equal(events.count(), 1)
assert.equal(events.listeners.get('LEVEL_SWITCHED').has(foreign), true)
calls.length = 0
plugin.update()
assert.equal(events.count(), 1)
assert.equal(calls.length, 0)
})
test('candidate nested update wins over the outer formatter operation', () => {
const { art, controls } = hlsHost()
const option = bothMenus()
let nested = false
let plugin
option.quality.getName = () => {
if (!nested) {
nested = true
option.quality.getName = () => 'Latest'
plugin.update()
}
return 'Stale'
}
plugin = candidate(option)(art)
plugin.update()
assert.equal(controls.get('hls-quality').selector[0].html, 'Latest')
})
test('candidate clears selection and fallback label while an audio track is temporarily unknown', () => {
const { art, hls, controls } = hlsHost()
const events = attachHlsEvents(hls)
candidate(bothMenus())(art).update()
hls.audioTrack = -1
events.emit('AUDIO_TRACKS_UPDATED')
const menu = controls.get('hls-audio')
assert.equal(menu.html, 'Auto')
assert.equal(menu.selector.some(item => item.default), false)
events.emit('AUDIO_TRACKS_UPDATED')
assert.equal(controls.get('hls-audio'), menu)
hls.audioTrack = 1
events.emit('AUDIO_TRACK_SWITCHED')
assert.equal(menu.selector[1].default, true)
})
test('candidate does not repeat UI writes when getName has different current and list labels', () => {
const { art, hls, calls } = hlsHost()
const events = attachHlsEvents(hls)
const options = bothMenus()
options.quality.getName = (level, index) => index === undefined ? `Current ${level.height}` : `Choice ${index}`
candidate(options)(art).update()
hls.currentLevel = 1
events.emit('LEVEL_SWITCHED')
calls.length = 0
events.emit('LEVEL_SWITCHED')
assert.equal(calls.length, 0)
})