From 7de04573b6ef746e6606200ed9d8463451bdbc31 Mon Sep 17 00:00:00 2001 From: Harvey Zhao Date: Sat, 12 Sep 2026 04:59:19 +0800 Subject: [PATCH] refactor(dash): [PKG-DASH-03] adapt SDK generations and own menu lifecycles --- .../compiled/artplayer-plugin-dash-control.js | 2 +- .../artplayer-plugin-dash-control.legacy.js | 2 +- .../artplayer-plugin-dash-control.mjs | 387 +++++--- package.json | 4 +- .../ARCHITECTURE.md | 80 ++ .../artplayer-plugin-dash-control/README.md | 9 +- .../dist/artplayer-plugin-dash-control.js | 2 +- .../artplayer-plugin-dash-control.legacy.js | 2 +- .../dist/artplayer-plugin-dash-control.mjs | 387 +++++--- .../src/index.js | 248 ++--- .../src/mapping.js | 79 ++ .../artplayer-plugin-dash-control/src/menu.js | 72 ++ .../artplayer-plugin-dash-control/src/sdk.js | 27 + refactor/baselines/dash-runtime.json | 854 ++++++++++++++++++ .../changes/2026-09-12-PKG-DASH-03-runtime.md | 36 + refactor/dash-validation.md | 34 +- refactor/plan.md | 5 +- refactor/progress.md | 15 + refactor/risk-table.md | 2 +- refactor/risks.json | 22 +- refactor/tasks.json | 8 +- test/browser/dash-control.spec.js | 99 +- test/dash-lifecycle.test.js | 259 ++++++ test/helpers/dash-control.js | 5 +- 24 files changed, 2107 insertions(+), 533 deletions(-) create mode 100644 packages/artplayer-plugin-dash-control/ARCHITECTURE.md create mode 100644 packages/artplayer-plugin-dash-control/src/mapping.js create mode 100644 packages/artplayer-plugin-dash-control/src/menu.js create mode 100644 packages/artplayer-plugin-dash-control/src/sdk.js create mode 100644 refactor/baselines/dash-runtime.json create mode 100644 refactor/changes/2026-09-12-PKG-DASH-03-runtime.md create mode 100644 test/dash-lifecycle.test.js diff --git a/docs/compiled/artplayer-plugin-dash-control.js b/docs/compiled/artplayer-plugin-dash-control.js index 97ce60f03..2c9950667 100644 --- a/docs/compiled/artplayer-plugin-dash-control.js +++ b/docs/compiled/artplayer-plugin-dash-control.js @@ -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.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t,e){const o=new Map;return t.filter(t=>{const n=t[e];return void 0===n||!o.has(n)&&o.set(n,1)})}return function(e={}){return o=>{const{$video:n}=o.template,{errorHandle:i}=o.constructor.utils;function a(){i(o.dash.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),function(n){const i=n.getRepresentationsByType("video");if(!i||!i.length)return;const a=e.quality||{},l=a.auto||"Auto",s=a.title||"Quality",r=a.getName||(t=>`${t.height}p`),c=n.getCurrentRepresentationForType("video"),u=c&&c.id,d=n.getSettings().streaming.abr.autoSwitchBitrate.video,h=!d&&c?r(c):l,g=t(i.map((t,e)=>({html:r(t),value:e,id:t.id,default:!d&&!!u&&t.id===u})),"html").sort((t,e)=>e.value-t.value);g.push({html:l,value:"auto",default:d});const p=t=>("auto"===t.value?n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}}):(n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),n.setRepresentationForTypeById("video",t.id)),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-quality",position:"right",html:h,style:{padding:"0 10px"},selector:g,onSelect:p}),a.setting&&o.setting.update({name:"dash-quality",tooltip:h,html:s,icon:'',width:200,selector:g,onSelect:p})}(o.dash),function(n){const i=n.getTracksFor("audio");if(!i||!i.length)return;const a=e.audio||{},l=a.auto||"Auto",s=a.title||"Audio",r=a.getName||(t=>t.lang||t.id),c=n.getCurrentTrackFor("audio")||i[0],u=c?r(c):l,d=t(i.map(t=>({html:r(t),value:t,default:c===t})),"html"),h=t=>(n.setCurrentTrack(t.value),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-audio",position:"right",html:u,style:{padding:"0 10px"},selector:d,onSelect:h}),a.setting&&o.setting.update({name:"dash-audio",tooltip:u,html:s,icon:'',width:200,selector:d,onSelect:h})}(o.dash)}return o.on("ready",a),o.on("restart",a),{name:"artplayerPluginDashControl",update:a}}}}); +!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const n=e.get(t.html);return n?(t.default&&Object.assign(n,t),!1):(e.set(t.html,t),!0)})}function e(t,e,n){let o;const r=new Map;function l(n,o=r.get(n)){if(!o||r.get(n)!==o)return;r.delete(n);const l=t[n];"controls"===n&&l.cache?.get&&l.cache.get(e)?.option?.onSelect!==o||"setting"===n&&l.find&&l.find(e)?.onSelect!==o||l.remove(e)}function i(){let t;o=void 0;for(const[n,o]of[...r])try{l(n,o)}catch(e){t||(t=e)}if(t)throw t}return{update:function(c,s,u){if(!s)return void i();const a={};o=a;const f=()=>a===o&&u(),d=e=>f()?(s.select(e,f),f()?(t.notice.show=`${s.title}: ${e.html}`,f()&&c.control&&t.controls.check(e),f()&&c.setting&&t.setting.check(e),e.html):e.html):e.html;c.control?(r.set("controls",d),t.controls.update({name:e,position:"right",html:s.html,style:{padding:"0 10px"},selector:s.selector,onSelect:d})):l("controls"),f()&&(c.setting?(r.set("setting",d),t.setting.update({name:e,tooltip:s.html,html:s.title,icon:n,width:200,selector:s.selector,onSelect:d})):l("setting"))},clear:i}}return function(n={}){return o=>{const{$video:r}=o.template,{errorHandle:l}=o.constructor.utils;let i=!1,c=0;const s=e(o,"dash-quality",''),u=e(o,"dash-audio",''),a=[];function f(t=()=>!0){let e;for(const o of[s.clear,u.clear]){if(!t())break;try{o()}catch(n){e||(e=n)}}if(e)throw e}function d(){if(i||o.isDestroy)return;const e=++c,a=o.dash,d=()=>!i&&!o.isDestroy&&e===c&&o.dash===a,h=()=>d()&&a.getVideoElement()===r&&d();try{if(l(a.getVideoElement()===r,'Cannot find instance of DASH from "art.dash"'),!d())return;const e=n.quality||{},o=function(e,n,o){const r=function(t){const e="function"===typeof t.getRepresentationsByType;return{levels:()=>e?t.getRepresentationsByType("video"):t.getBitrateInfoListFor("video"),current(n){if(e)return t.getCurrentRepresentationForType("video");const o=t.getQualityFor("video");return n.find(t=>t.qualityIndex===o)},item:(t,n,o,r)=>e?{value:n,id:t.id,default:!r&&null!=o&&t.id===o.id}:{value:t.qualityIndex,default:!r&&null!=o&&t.qualityIndex===o.qualityIndex},select(n,o){const r="auto"===n.value;t.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:r}}}}),!r&&o()&&(e?t.setRepresentationForTypeById("video",n.id):t.setQualityFor("video",n.value))}}}(e),l=r.levels();if(!o()||!l?.length)return null;const i=n.auto||"Auto",c=n.getName||(t=>`${t.height}p`),s=r.current(l);if(!o())return null;const u=e.getSettings().streaming.abr.autoSwitchBitrate.video;if(!o())return null;const a=!u&&s?c(s):i;if(!o())return null;const f=[];for(const[t,h]of l.entries()){const e=c(h);if(!o())return null;f.push({html:e,...r.item(h,t,s,u)})}const d=t(f).sort((t,e)=>e.value-t.value);return d.push({html:i,value:"auto",default:u}),{html:a,title:n.title||"Quality",selector:d,select:r.select}}(a,e,h);if(!h())return;if(s.update(e,o,h),!h())return;const i=n.audio||{},c=function(e,n,o){const r=e.getTracksFor("audio");if(!o()||!r?.length)return null;const l=n.auto||"Auto",i=n.getName||(t=>t.lang||t.id),c=e.getCurrentTrackFor("audio")||r[0];if(!o())return null;const s=c?i(c):l;if(!o())return null;const u=function(t,e){if(t.includes(e))return e;if(null==e.id&&null==e.index)return;const n=t.filter(t=>["id","index","lang"].every(n=>null==e[n]||t[n]===e[n]));return 1===n.length?n[0]:void 0}(r,c),a=[];for(const t of r){const e=i(t);if(!o())return null;a.push({html:e,value:t,default:t===u})}const f=t(a);return{html:s,title:n.title||"Audio",selector:f,select:t=>e.setCurrentTrack(t.value)}}(a,i,h);h()&&u.update(i,c,h)}catch(g){if(d()){const t=++c;try{f(()=>c===t)}catch(p){console.warn("ArtPlayer DASH cleanup failed:",p)}}throw g}}function h(){if(i)return;let t;i=!0,c++;const e=[f,...a.splice(0).map(([t,e])=>()=>o.off(t,e))];for(const o of e)try{o()}catch(n){t||(t=n)}if(t)throw t}try{for(const t of[["ready",d],["restart",d],["destroy",h]]){if(i)break;a.push(t),o.on(...t)}}catch(g){try{h()}catch(p){console.warn("ArtPlayer DASH subscription cleanup failed:",p)}throw g}return{name:"artplayerPluginDashControl",update:d}}}}); diff --git a/docs/compiled/artplayer-plugin-dash-control.legacy.js b/docs/compiled/artplayer-plugin-dash-control.legacy.js index 97ce60f03..a270906b6 100644 --- a/docs/compiled/artplayer-plugin-dash-control.legacy.js +++ b/docs/compiled/artplayer-plugin-dash-control.legacy.js @@ -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.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t,e){const o=new Map;return t.filter(t=>{const n=t[e];return void 0===n||!o.has(n)&&o.set(n,1)})}return function(e={}){return o=>{const{$video:n}=o.template,{errorHandle:i}=o.constructor.utils;function a(){i(o.dash.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),function(n){const i=n.getRepresentationsByType("video");if(!i||!i.length)return;const a=e.quality||{},l=a.auto||"Auto",s=a.title||"Quality",r=a.getName||(t=>`${t.height}p`),c=n.getCurrentRepresentationForType("video"),u=c&&c.id,d=n.getSettings().streaming.abr.autoSwitchBitrate.video,h=!d&&c?r(c):l,g=t(i.map((t,e)=>({html:r(t),value:e,id:t.id,default:!d&&!!u&&t.id===u})),"html").sort((t,e)=>e.value-t.value);g.push({html:l,value:"auto",default:d});const p=t=>("auto"===t.value?n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}}):(n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),n.setRepresentationForTypeById("video",t.id)),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-quality",position:"right",html:h,style:{padding:"0 10px"},selector:g,onSelect:p}),a.setting&&o.setting.update({name:"dash-quality",tooltip:h,html:s,icon:'',width:200,selector:g,onSelect:p})}(o.dash),function(n){const i=n.getTracksFor("audio");if(!i||!i.length)return;const a=e.audio||{},l=a.auto||"Auto",s=a.title||"Audio",r=a.getName||(t=>t.lang||t.id),c=n.getCurrentTrackFor("audio")||i[0],u=c?r(c):l,d=t(i.map(t=>({html:r(t),value:t,default:c===t})),"html"),h=t=>(n.setCurrentTrack(t.value),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-audio",position:"right",html:u,style:{padding:"0 10px"},selector:d,onSelect:h}),a.setting&&o.setting.update({name:"dash-audio",tooltip:u,html:s,icon:'',width:200,selector:d,onSelect:h})}(o.dash)}return o.on("ready",a),o.on("restart",a),{name:"artplayerPluginDashControl",update:a}}}}); +!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(e,n,o)=>n in e?t(e,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[n]=o,l=(t,l)=>{for(var i in l||(l={}))n.call(l,i)&&r(t,i,l[i]);if(e)for(var i of e(l))o.call(l,i)&&r(t,i,l[i]);return t};function i(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const n=e.get(t.html);return n?(t.default&&Object.assign(n,t),!1):(e.set(t.html,t),!0)})}function c(t,e,n){let o;const r=new Map;function l(n,o=r.get(n)){var l,i,c,u;if(!o||r.get(n)!==o)return;r.delete(n);const s=t[n];"controls"===n&&(null==(l=s.cache)?void 0:l.get)&&(null==(c=null==(i=s.cache.get(e))?void 0:i.option)?void 0:c.onSelect)!==o||"setting"===n&&s.find&&(null==(u=s.find(e))?void 0:u.onSelect)!==o||s.remove(e)}function i(){let t;o=void 0;for(const[n,o]of[...r])try{l(n,o)}catch(e){t||(t=e)}if(t)throw t}return{update:function(c,u,s){if(!u)return void i();const a={};o=a;const f=()=>a===o&&s(),d=e=>f()?(u.select(e,f),f()?(t.notice.show=`${u.title}: ${e.html}`,f()&&c.control&&t.controls.check(e),f()&&c.setting&&t.setting.check(e),e.html):e.html):e.html;c.control?(r.set("controls",d),t.controls.update({name:e,position:"right",html:u.html,style:{padding:"0 10px"},selector:u.selector,onSelect:d})):l("controls"),f()&&(c.setting?(r.set("setting",d),t.setting.update({name:e,tooltip:u.html,html:u.title,icon:n,width:200,selector:u.selector,onSelect:d})):l("setting"))},clear:i}}return function(t={}){return e=>{const{$video:n}=e.template,{errorHandle:o}=e.constructor.utils;let r=!1,u=0;const s=c(e,"dash-quality",''),a=c(e,"dash-audio",''),f=[];function d(t=()=>!0){let e;for(const o of[s.clear,a.clear]){if(!t())break;try{o()}catch(n){e||(e=n)}}if(e)throw e}function h(){if(r||e.isDestroy)return;const c=++u,f=e.dash,h=()=>!r&&!e.isDestroy&&c===u&&e.dash===f,p=()=>h()&&f.getVideoElement()===n&&h();try{if(o(f.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),!h())return;const e=t.quality||{},r=function(t,e,n){const o=function(t){const e="function"===typeof t.getRepresentationsByType;return{levels:()=>e?t.getRepresentationsByType("video"):t.getBitrateInfoListFor("video"),current(n){if(e)return t.getCurrentRepresentationForType("video");const o=t.getQualityFor("video");return n.find(t=>t.qualityIndex===o)},item:(t,n,o,r)=>e?{value:n,id:t.id,default:!r&&null!=o&&t.id===o.id}:{value:t.qualityIndex,default:!r&&null!=o&&t.qualityIndex===o.qualityIndex},select(n,o){const r="auto"===n.value;t.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:r}}}}),!r&&o()&&(e?t.setRepresentationForTypeById("video",n.id):t.setQualityFor("video",n.value))}}}(t),r=o.levels();if(!n()||!(null==r?void 0:r.length))return null;const c=e.auto||"Auto",u=e.getName||(t=>`${t.height}p`),s=o.current(r);if(!n())return null;const a=t.getSettings().streaming.abr.autoSwitchBitrate.video;if(!n())return null;const f=!a&&s?u(s):c;if(!n())return null;const d=[];for(const[i,p]of r.entries()){const t=u(p);if(!n())return null;d.push(l({html:t},o.item(p,i,s,a)))}const h=i(d).sort((t,e)=>e.value-t.value);return h.push({html:c,value:"auto",default:a}),{html:f,title:e.title||"Quality",selector:h,select:o.select}}(f,e,p);if(!p())return;if(s.update(e,r,p),!p())return;const c=t.audio||{},u=function(t,e,n){const o=t.getTracksFor("audio");if(!n()||!(null==o?void 0:o.length))return null;const r=e.auto||"Auto",l=e.getName||(t=>t.lang||t.id),c=t.getCurrentTrackFor("audio")||o[0];if(!n())return null;const u=c?l(c):r;if(!n())return null;const s=function(t,e){if(t.includes(e))return e;if(null==e.id&&null==e.index)return;const n=t.filter(t=>["id","index","lang"].every(n=>null==e[n]||t[n]===e[n]));return 1===n.length?n[0]:void 0}(o,c),a=[];for(const i of o){const t=l(i);if(!n())return null;a.push({html:t,value:i,default:i===s})}const f=i(a);return{html:u,title:e.title||"Audio",selector:f,select:e=>t.setCurrentTrack(e.value)}}(f,c,p);p()&&a.update(c,u,p)}catch(g){if(h()){const t=++u;try{d(()=>u===t)}catch(v){console.warn("ArtPlayer DASH cleanup failed:",v)}}throw g}}function p(){if(r)return;let t;r=!0,u++;const n=[d,...f.splice(0).map(([t,n])=>()=>e.off(t,n))];for(const e of n)try{e()}catch(o){t||(t=o)}if(t)throw t}try{for(const t of[["ready",h],["restart",h],["destroy",p]]){if(r)break;f.push(t),e.on(...t)}}catch(g){try{p()}catch(v){console.warn("ArtPlayer DASH subscription cleanup failed:",v)}throw g}return{name:"artplayerPluginDashControl",update:h}}}}); diff --git a/docs/compiled/artplayer-plugin-dash-control.mjs b/docs/compiled/artplayer-plugin-dash-control.mjs index 4ce15acf9..a0c85dbe8 100644 --- a/docs/compiled/artplayer-plugin-dash-control.mjs +++ b/docs/compiled/artplayer-plugin-dash-control.mjs @@ -5,163 +5,260 @@ * Released under the MIT License. */ const $audio = ''; -const $quality = ''; -function uniqBy(array, property) { - const seen = /* @__PURE__ */ new Map(); - return array.filter((item) => { - const key = item[property]; - if (key === void 0) { - return true; +function qualityAdapter(dash) { + const modern = typeof dash.getRepresentationsByType === "function"; + return { + levels: () => modern ? dash.getRepresentationsByType("video") : dash.getBitrateInfoListFor("video"), + current(levels) { + if (modern) + return dash.getCurrentRepresentationForType("video"); + const quality = dash.getQualityFor("video"); + return levels.find((level) => level.qualityIndex === quality); + }, + item(level, index, selected, automatic) { + return modern ? { value: index, id: level.id, default: !automatic && selected != null && level.id === selected.id } : { value: level.qualityIndex, default: !automatic && selected != null && level.qualityIndex === selected.qualityIndex }; + }, + select(item, valid) { + const automatic = item.value === "auto"; + dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: automatic } } } }); + if (!automatic && valid()) { + if (modern) + dash.setRepresentationForTypeById("video", item.id); + else + dash.setQualityFor("video", item.value); + } } - return !seen.has(key) && seen.set(key, 1); + }; +} +function uniqueLabels(items) { + const seen = /* @__PURE__ */ new Map(); + return items.filter((item) => { + if (item.html === void 0) + return true; + const first = seen.get(item.html); + if (first) { + if (item.default) + Object.assign(first, item); + return false; + } + seen.set(item.html, item); + return true; }); } +function qualityModel(dash, config, active) { + const adapter = qualityAdapter(dash); + const levels = adapter.levels(); + if (!active() || !levels?.length) + return null; + const auto = config.auto || "Auto"; + const getName = config.getName || ((level) => `${level.height}p`); + const selected = adapter.current(levels); + if (!active()) + return null; + const automatic = dash.getSettings().streaming.abr.autoSwitchBitrate.video; + if (!active()) + return null; + const html = !automatic && selected ? getName(selected) : auto; + if (!active()) + return null; + const items = []; + for (const [index, level] of levels.entries()) { + const label = getName(level); + if (!active()) + return null; + items.push({ html: label, ...adapter.item(level, index, selected, automatic) }); + } + const selector = uniqueLabels(items).sort((left, right) => right.value - left.value); + selector.push({ html: auto, value: "auto", default: automatic }); + return { html, title: config.title || "Quality", selector, select: adapter.select }; +} +function selectedTrack(tracks, current) { + if (tracks.includes(current)) + return current; + if (current.id == null && current.index == null) + return void 0; + const matching = tracks.filter((track) => ["id", "index", "lang"].every((key) => current[key] == null || track[key] === current[key])); + return matching.length === 1 ? matching[0] : void 0; +} +function audioModel(dash, config, active) { + const tracks = dash.getTracksFor("audio"); + if (!active() || !tracks?.length) + return null; + const auto = config.auto || "Auto"; + const getName = config.getName || ((track) => track.lang || track.id); + const current = dash.getCurrentTrackFor("audio") || tracks[0]; + if (!active()) + return null; + const html = current ? getName(current) : auto; + if (!active()) + return null; + const selected = selectedTrack(tracks, current); + const items = []; + for (const track of tracks) { + const label = getName(track); + if (!active()) + return null; + items.push({ html: label, value: track, default: track === selected }); + } + const selector = uniqueLabels(items); + return { html, title: config.title || "Audio", selector, select: (item) => dash.setCurrentTrack(item.value) }; +} +function createMenu(art, name, icon) { + let current; + const owned = /* @__PURE__ */ new Map(); + function remove(surface, callback = owned.get(surface)) { + if (!callback || owned.get(surface) !== callback) + return; + owned.delete(surface); + const registry = art[surface]; + if (surface === "controls" && registry.cache?.get && registry.cache.get(name)?.option?.onSelect !== callback) + return; + if (surface === "setting" && registry.find && registry.find(name)?.onSelect !== callback) + return; + registry.remove(name); + } + function clear() { + current = void 0; + let failure; + for (const [surface, callback] of [...owned]) { + try { + remove(surface, callback); + } catch (error) { + failure || (failure = error); + } + } + if (failure) + throw failure; + } + function update(config, model, active) { + if (!model) { + clear(); + return; + } + const state = {}; + current = state; + const valid = () => state === current && active(); + const onSelect = (item) => { + if (!valid()) + return item.html; + model.select(item, valid); + if (!valid()) + return item.html; + art.notice.show = `${model.title}: ${item.html}`; + if (valid() && config.control) + art.controls.check(item); + if (valid() && config.setting) + art.setting.check(item); + return item.html; + }; + if (config.control) { + owned.set("controls", onSelect); + art.controls.update({ name, position: "right", html: model.html, style: { padding: "0 10px" }, selector: model.selector, onSelect }); + } else { + remove("controls"); + } + if (!valid()) + return; + if (config.setting) { + owned.set("setting", onSelect); + art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect }); + } else { + remove("setting"); + } + } + return { update, clear }; +} +const $quality = ''; function artplayerPluginDashControl(option = {}) { return (art) => { const { $video } = art.template; const { errorHandle } = art.constructor.utils; - function updateQuality(dash) { - const qualities = dash.getRepresentationsByType("video"); - if (!qualities || !qualities.length) - return; - const config = option.quality || {}; - const auto = config.auto || "Auto"; - const title = config.title || "Quality"; - const getName = config.getName || ((level) => `${level.height}p`); - const currentRepresentation = dash.getCurrentRepresentationForType("video"); - const currentId = currentRepresentation && currentRepresentation.id; - const currentAuto = dash.getSettings().streaming.abr.autoSwitchBitrate.video; - const defaultHtml = !currentAuto && currentRepresentation ? getName(currentRepresentation) : auto; - const selector = uniqBy( - qualities.map((item, index) => { - return { - html: getName(item), - value: index, - id: item.id, - default: !currentAuto && !!currentId && item.id === currentId - }; - }), - "html" - ).sort((a, b) => b.value - a.value); - selector.push({ - html: auto, - value: "auto", - default: currentAuto - }); - const onSelect = (item) => { - if (item.value === "auto") { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: true - } - } - } - }); - } else { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: false - } - } - } - }); - dash.setRepresentationForTypeById("video", item.id); + let closed = false; + let revision = 0; + const quality = createMenu(art, "dash-quality", $quality); + const audio = createMenu(art, "dash-audio", $audio); + const subscriptions = []; + function clear(current = () => true) { + let failure; + for (const cleanup of [quality.clear, audio.clear]) { + if (!current()) + break; + try { + cleanup(); + } catch (error) { + failure || (failure = error); } - 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: "dash-quality", - position: "right", - html: defaultHtml, - style: { padding: "0 10px" }, - selector, - onSelect - }); - } - if (config.setting) { - art.setting.update({ - name: "dash-quality", - tooltip: defaultHtml, - html: title, - icon: $quality, - width: 200, - selector, - onSelect - }); - } - } - function updateAudio(dash) { - const audioTracks = dash.getTracksFor("audio"); - if (!audioTracks || !audioTracks.length) - return; - const config = option.audio || {}; - const auto = config.auto || "Auto"; - const title = config.title || "Audio"; - const getName = config.getName || ((track) => track.lang || track.id); - const currentTrack = dash.getCurrentTrackFor("audio") || audioTracks[0]; - const defaultHtml = currentTrack ? getName(currentTrack) : auto; - const selector = uniqBy( - audioTracks.map((item) => { - return { - html: getName(item), - value: item, - default: currentTrack === item - }; - }), - "html" - ); - const onSelect = (item) => { - dash.setCurrentTrack(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: "dash-audio", - position: "right", - html: defaultHtml, - style: { padding: "0 10px" }, - selector, - onSelect - }); - } - if (config.setting) { - art.setting.update({ - name: "dash-audio", - tooltip: defaultHtml, - html: title, - icon: $audio, - width: 200, - selector, - onSelect - }); } + if (failure) + throw failure; } function update() { - errorHandle(art.dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"'); - updateQuality(art.dash); - updateAudio(art.dash); + if (closed || art.isDestroy) + return; + const version = ++revision; + const dash = art.dash; + const current = () => !closed && !art.isDestroy && version === revision && art.dash === dash; + const valid = () => current() && dash.getVideoElement() === $video && current(); + try { + errorHandle(dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"'); + if (!current()) + return; + const qualityConfig = option.quality || {}; + const qualities = qualityModel(dash, qualityConfig, valid); + if (!valid()) + return; + quality.update(qualityConfig, qualities, valid); + if (!valid()) + return; + const audioConfig = option.audio || {}; + const tracks = audioModel(dash, audioConfig, valid); + if (valid()) + audio.update(audioConfig, tracks, valid); + } catch (error) { + if (current()) { + const cleanupVersion = ++revision; + try { + clear(() => revision === cleanupVersion); + } catch (cleanupError) { + console.warn("ArtPlayer DASH cleanup failed:", cleanupError); + } + } + throw error; + } } - art.on("ready", update); - art.on("restart", update); - return { - name: "artplayerPluginDashControl", - update - }; + function destroy() { + if (closed) + return; + closed = true; + revision++; + let failure; + const actions = [clear, ...subscriptions.splice(0).map(([name, callback]) => () => art.off(name, callback))]; + for (const action of actions) { + try { + action(); + } catch (error) { + failure || (failure = error); + } + } + if (failure) + throw failure; + } + try { + for (const entry of [["ready", update], ["restart", update], ["destroy", destroy]]) { + if (closed) + break; + subscriptions.push(entry); + art.on(...entry); + } + } catch (error) { + try { + destroy(); + } catch (cleanupError) { + console.warn("ArtPlayer DASH subscription cleanup failed:", cleanupError); + } + throw error; + } + return { name: "artplayerPluginDashControl", update }; }; } export { diff --git a/package.json b/package.json index c893adf41..e32558ece 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:playback": "node --test test/playback.test.js", "trans:docs": "node ./scripts/trans-docs.js", "create:plugin": "node ./scripts/plugin/create.js", - "test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js", + "test:dash-control": "node --test test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js", "dev": "npx cross-env NODE_ENV=development node ./scripts/dev.js", "build": "npx cross-env NODE_ENV=production node ./scripts/build.js", "lint": "eslint \"packages/*/{src,public,types,package.json}\" \"scripts/*.{js,mjs}\" \"test/*\" \"docs/assets/ts/*\" \"types/*.d.ts\" \"playwright*.config.js\" --no-fix", @@ -50,7 +50,7 @@ "ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports", "test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js", "typecheck": "node scripts/typecheck.mjs", - "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", + "test:unit": "node --test test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js", "test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs", "test": "yarn test:node && yarn test:baseline", "test:browser": "playwright test", diff --git a/packages/artplayer-plugin-dash-control/ARCHITECTURE.md b/packages/artplayer-plugin-dash-control/ARCHITECTURE.md new file mode 100644 index 000000000..7e622ea0e --- /dev/null +++ b/packages/artplayer-plugin-dash-control/ARCHITECTURE.md @@ -0,0 +1,80 @@ +# DASH Control implementation + +The factory remains `artplayerPluginDashControl(option = {})(art)`. Its result has +`name` and synchronous `update(): void`. Installation does not require `art.dash` +yet; `ready`, `restart`, or explicit `update()` reads and validates that instance. +The caller creates, attaches, replaces, and destroys the SDK. This plugin never +destroys it, changes its source, or adds a runtime dependency on dash.js. + +## Module map + +| File | Responsibility | +| --- | --- | +| `src/index.js` | Deferred installation, media identity, update revisions, event subscription and cleanup | +| `src/sdk.js` | 4.x qualityIndex vs 5.x representation ID access and manual/Auto selection | +| `src/mapping.js` | Names, current item matching, duplicate labels and selector ordering | +| `src/menu.js` | Existing control/setting registries, menu ownership and guarded selection callbacks | +| `src/audio.svg`, `src/quality.svg` | Existing setting icons | +| `types/artplayer-plugin-dash-control.d.ts` | Existing public declarations; precise TS migration follows in PKG-DASH-04 | + +Dependencies flow from the entry into mapping/menu, and from mapping into the SDK +adapter. Mapping has no DOM dependency. Menu receives a model and validity callback; +it does not choose SDK versions. This is the structural step, still in JavaScript. + +## Selection and lifetime + +An update validates the captured media element and starts a new revision. A callback +from an older update, replaced SDK, or destroyed player returns its previous label +without mutating the SDK or UI. This includes references retained by consumers. +Every caller formatter and SDK operation is followed by a validity check before +subsequent effects. A nested update wins over its older caller. + +Manual quality first disables video ABR, then uses the stored SDK selection key. +Auto only enables video ABR. Audio passes the original track object to the SDK. +Successful selection retains the existing notice, controls.check, setting.check +order and synchronous label return. SDK/getName exceptions remain synchronous with +their original identity. Other ABR settings are not overwritten. + +Duplicate labels still occupy one row. When a duplicate is current, that row keeps +the selected item's key/object so its highlight and subsequent click agree. Numeric +zero is a valid representation ID; a representation named `auto` is still manual +because the synthetic Auto row is distinguished by its value. Audio prefers object +identity, then a unique match on the current track's available id/index/lang fields. +Ambiguous matches remain unselected instead of selecting an arbitrary track. + +Only menus actually installed by this instance are removed on empty topology, +disabled surfaces, failed rendering, or destruction. Where registry lookup exists, +the saved callback identity protects an entry replaced by the caller. Cleanup takes +a snapshot so a synchronous restart cannot cause old cleanup to remove new menus. +Cleanup attempts all owned surfaces/subscriptions even if one removal fails. +Installation failure rolls back subscriptions, including one installed by a throwing +host. The primary error is preserved; secondary cleanup errors are reported. + +Keep `dash-quality`/`dash-audio`, selector shape, right control placement/padding, +setting width/icons, defaults and unbound getName calls compatible. Do not use a +filtered representation array index or absoluteIndex as the SDK 5 selection key. + +## Validation and next work + +Run at the workspace root with pinned Node and Yarn: + +```sh +yarn test:dash-control +yarn build artplayer-plugin-dash-control +yarn test:browser test/browser/dash-control.spec.js --trace on +yarn ci:check +``` + +`ARTPLAYER_TEST_DASH` adds main/legacy/module files to both SDK generations of Node +contracts and lifecycle tests; use the operating system's path delimiter. +`ARTPLAYER_DASH_ARTIFACT` selects the browser plugin artifact. Archive browser report +and result/trace directory before running another suite because the output is shared. + +Tests use frozen npm 1.1.0, a frozen pre-refactor 5.x implementation, and candidates. +Browser tests exercise real old/new core DOM, menu clicks, native MP4 and cleanup, +with controlled SDK methods. They do not load dash.js or test MPD/ABR decoding. +PKG-DASH-05 must verify pinned dash.js 4.5.2 and 5.2.1 with real adaptive media and +review SDK-driven refresh/source events and the existing demo lifecycle. Physical +devices and installed package acceptance remain separate release gates. +See [refactor validation](../../refactor/dash-validation.md) for baseline failures +and [task plan](../../refactor/plan.md) for remaining type and release work. diff --git a/packages/artplayer-plugin-dash-control/README.md b/packages/artplayer-plugin-dash-control/README.md index a7b9176bd..48f8175cb 100644 --- a/packages/artplayer-plugin-dash-control/README.md +++ b/packages/artplayer-plugin-dash-control/README.md @@ -2,7 +2,9 @@ Dash control plugin for ArtPlayer -Requires dash.js 5.x. dash.js 4.x is not supported. +Accepts a caller-owned dash.js instance at `art.dash`. Quality selection adapts to the +dash.js 4.x quality methods or 5.x representation methods. Version 5 selection uses +representation IDs so bitrate filtering does not change the selected quality. ## Demo @@ -11,3 +13,8 @@ Requires dash.js 5.x. dash.js 4.x is not supported. ## License MIT © Harvey Zhao + +## Maintenance + +See [ARCHITECTURE.md](ARCHITECTURE.md) for module ownership, compatibility rules, +test commands and the remaining real SDK playback validation scope. diff --git a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js index 97ce60f03..2c9950667 100644 --- a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js +++ b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js @@ -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.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t,e){const o=new Map;return t.filter(t=>{const n=t[e];return void 0===n||!o.has(n)&&o.set(n,1)})}return function(e={}){return o=>{const{$video:n}=o.template,{errorHandle:i}=o.constructor.utils;function a(){i(o.dash.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),function(n){const i=n.getRepresentationsByType("video");if(!i||!i.length)return;const a=e.quality||{},l=a.auto||"Auto",s=a.title||"Quality",r=a.getName||(t=>`${t.height}p`),c=n.getCurrentRepresentationForType("video"),u=c&&c.id,d=n.getSettings().streaming.abr.autoSwitchBitrate.video,h=!d&&c?r(c):l,g=t(i.map((t,e)=>({html:r(t),value:e,id:t.id,default:!d&&!!u&&t.id===u})),"html").sort((t,e)=>e.value-t.value);g.push({html:l,value:"auto",default:d});const p=t=>("auto"===t.value?n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}}):(n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),n.setRepresentationForTypeById("video",t.id)),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-quality",position:"right",html:h,style:{padding:"0 10px"},selector:g,onSelect:p}),a.setting&&o.setting.update({name:"dash-quality",tooltip:h,html:s,icon:'',width:200,selector:g,onSelect:p})}(o.dash),function(n){const i=n.getTracksFor("audio");if(!i||!i.length)return;const a=e.audio||{},l=a.auto||"Auto",s=a.title||"Audio",r=a.getName||(t=>t.lang||t.id),c=n.getCurrentTrackFor("audio")||i[0],u=c?r(c):l,d=t(i.map(t=>({html:r(t),value:t,default:c===t})),"html"),h=t=>(n.setCurrentTrack(t.value),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-audio",position:"right",html:u,style:{padding:"0 10px"},selector:d,onSelect:h}),a.setting&&o.setting.update({name:"dash-audio",tooltip:u,html:s,icon:'',width:200,selector:d,onSelect:h})}(o.dash)}return o.on("ready",a),o.on("restart",a),{name:"artplayerPluginDashControl",update:a}}}}); +!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const n=e.get(t.html);return n?(t.default&&Object.assign(n,t),!1):(e.set(t.html,t),!0)})}function e(t,e,n){let o;const r=new Map;function l(n,o=r.get(n)){if(!o||r.get(n)!==o)return;r.delete(n);const l=t[n];"controls"===n&&l.cache?.get&&l.cache.get(e)?.option?.onSelect!==o||"setting"===n&&l.find&&l.find(e)?.onSelect!==o||l.remove(e)}function i(){let t;o=void 0;for(const[n,o]of[...r])try{l(n,o)}catch(e){t||(t=e)}if(t)throw t}return{update:function(c,s,u){if(!s)return void i();const a={};o=a;const f=()=>a===o&&u(),d=e=>f()?(s.select(e,f),f()?(t.notice.show=`${s.title}: ${e.html}`,f()&&c.control&&t.controls.check(e),f()&&c.setting&&t.setting.check(e),e.html):e.html):e.html;c.control?(r.set("controls",d),t.controls.update({name:e,position:"right",html:s.html,style:{padding:"0 10px"},selector:s.selector,onSelect:d})):l("controls"),f()&&(c.setting?(r.set("setting",d),t.setting.update({name:e,tooltip:s.html,html:s.title,icon:n,width:200,selector:s.selector,onSelect:d})):l("setting"))},clear:i}}return function(n={}){return o=>{const{$video:r}=o.template,{errorHandle:l}=o.constructor.utils;let i=!1,c=0;const s=e(o,"dash-quality",''),u=e(o,"dash-audio",''),a=[];function f(t=()=>!0){let e;for(const o of[s.clear,u.clear]){if(!t())break;try{o()}catch(n){e||(e=n)}}if(e)throw e}function d(){if(i||o.isDestroy)return;const e=++c,a=o.dash,d=()=>!i&&!o.isDestroy&&e===c&&o.dash===a,h=()=>d()&&a.getVideoElement()===r&&d();try{if(l(a.getVideoElement()===r,'Cannot find instance of DASH from "art.dash"'),!d())return;const e=n.quality||{},o=function(e,n,o){const r=function(t){const e="function"===typeof t.getRepresentationsByType;return{levels:()=>e?t.getRepresentationsByType("video"):t.getBitrateInfoListFor("video"),current(n){if(e)return t.getCurrentRepresentationForType("video");const o=t.getQualityFor("video");return n.find(t=>t.qualityIndex===o)},item:(t,n,o,r)=>e?{value:n,id:t.id,default:!r&&null!=o&&t.id===o.id}:{value:t.qualityIndex,default:!r&&null!=o&&t.qualityIndex===o.qualityIndex},select(n,o){const r="auto"===n.value;t.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:r}}}}),!r&&o()&&(e?t.setRepresentationForTypeById("video",n.id):t.setQualityFor("video",n.value))}}}(e),l=r.levels();if(!o()||!l?.length)return null;const i=n.auto||"Auto",c=n.getName||(t=>`${t.height}p`),s=r.current(l);if(!o())return null;const u=e.getSettings().streaming.abr.autoSwitchBitrate.video;if(!o())return null;const a=!u&&s?c(s):i;if(!o())return null;const f=[];for(const[t,h]of l.entries()){const e=c(h);if(!o())return null;f.push({html:e,...r.item(h,t,s,u)})}const d=t(f).sort((t,e)=>e.value-t.value);return d.push({html:i,value:"auto",default:u}),{html:a,title:n.title||"Quality",selector:d,select:r.select}}(a,e,h);if(!h())return;if(s.update(e,o,h),!h())return;const i=n.audio||{},c=function(e,n,o){const r=e.getTracksFor("audio");if(!o()||!r?.length)return null;const l=n.auto||"Auto",i=n.getName||(t=>t.lang||t.id),c=e.getCurrentTrackFor("audio")||r[0];if(!o())return null;const s=c?i(c):l;if(!o())return null;const u=function(t,e){if(t.includes(e))return e;if(null==e.id&&null==e.index)return;const n=t.filter(t=>["id","index","lang"].every(n=>null==e[n]||t[n]===e[n]));return 1===n.length?n[0]:void 0}(r,c),a=[];for(const t of r){const e=i(t);if(!o())return null;a.push({html:e,value:t,default:t===u})}const f=t(a);return{html:s,title:n.title||"Audio",selector:f,select:t=>e.setCurrentTrack(t.value)}}(a,i,h);h()&&u.update(i,c,h)}catch(g){if(d()){const t=++c;try{f(()=>c===t)}catch(p){console.warn("ArtPlayer DASH cleanup failed:",p)}}throw g}}function h(){if(i)return;let t;i=!0,c++;const e=[f,...a.splice(0).map(([t,e])=>()=>o.off(t,e))];for(const o of e)try{o()}catch(n){t||(t=n)}if(t)throw t}try{for(const t of[["ready",d],["restart",d],["destroy",h]]){if(i)break;a.push(t),o.on(...t)}}catch(g){try{h()}catch(p){console.warn("ArtPlayer DASH subscription cleanup failed:",p)}throw g}return{name:"artplayerPluginDashControl",update:d}}}}); diff --git a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js index 97ce60f03..a270906b6 100644 --- a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js +++ b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js @@ -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.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";function t(t,e){const o=new Map;return t.filter(t=>{const n=t[e];return void 0===n||!o.has(n)&&o.set(n,1)})}return function(e={}){return o=>{const{$video:n}=o.template,{errorHandle:i}=o.constructor.utils;function a(){i(o.dash.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),function(n){const i=n.getRepresentationsByType("video");if(!i||!i.length)return;const a=e.quality||{},l=a.auto||"Auto",s=a.title||"Quality",r=a.getName||(t=>`${t.height}p`),c=n.getCurrentRepresentationForType("video"),u=c&&c.id,d=n.getSettings().streaming.abr.autoSwitchBitrate.video,h=!d&&c?r(c):l,g=t(i.map((t,e)=>({html:r(t),value:e,id:t.id,default:!d&&!!u&&t.id===u})),"html").sort((t,e)=>e.value-t.value);g.push({html:l,value:"auto",default:d});const p=t=>("auto"===t.value?n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}}):(n.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),n.setRepresentationForTypeById("video",t.id)),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-quality",position:"right",html:h,style:{padding:"0 10px"},selector:g,onSelect:p}),a.setting&&o.setting.update({name:"dash-quality",tooltip:h,html:s,icon:'',width:200,selector:g,onSelect:p})}(o.dash),function(n){const i=n.getTracksFor("audio");if(!i||!i.length)return;const a=e.audio||{},l=a.auto||"Auto",s=a.title||"Audio",r=a.getName||(t=>t.lang||t.id),c=n.getCurrentTrackFor("audio")||i[0],u=c?r(c):l,d=t(i.map(t=>({html:r(t),value:t,default:c===t})),"html"),h=t=>(n.setCurrentTrack(t.value),o.notice.show=`${s}: ${t.html}`,a.control&&o.controls.check(t),a.setting&&o.setting.check(t),t.html);a.control&&o.controls.update({name:"dash-audio",position:"right",html:u,style:{padding:"0 10px"},selector:d,onSelect:h}),a.setting&&o.setting.update({name:"dash-audio",tooltip:u,html:s,icon:'',width:200,selector:d,onSelect:h})}(o.dash)}return o.on("ready",a),o.on("restart",a),{name:"artplayerPluginDashControl",update:a}}}}); +!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginDashControl=e(),define(function(){return t.artplayerPluginDashControl})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginDashControl=e()}(this,function(){"use strict";var t=Object.defineProperty,e=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(e,n,o)=>n in e?t(e,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):e[n]=o,l=(t,l)=>{for(var i in l||(l={}))n.call(l,i)&&r(t,i,l[i]);if(e)for(var i of e(l))o.call(l,i)&&r(t,i,l[i]);return t};function i(t){const e=new Map;return t.filter(t=>{if(void 0===t.html)return!0;const n=e.get(t.html);return n?(t.default&&Object.assign(n,t),!1):(e.set(t.html,t),!0)})}function c(t,e,n){let o;const r=new Map;function l(n,o=r.get(n)){var l,i,c,u;if(!o||r.get(n)!==o)return;r.delete(n);const s=t[n];"controls"===n&&(null==(l=s.cache)?void 0:l.get)&&(null==(c=null==(i=s.cache.get(e))?void 0:i.option)?void 0:c.onSelect)!==o||"setting"===n&&s.find&&(null==(u=s.find(e))?void 0:u.onSelect)!==o||s.remove(e)}function i(){let t;o=void 0;for(const[n,o]of[...r])try{l(n,o)}catch(e){t||(t=e)}if(t)throw t}return{update:function(c,u,s){if(!u)return void i();const a={};o=a;const f=()=>a===o&&s(),d=e=>f()?(u.select(e,f),f()?(t.notice.show=`${u.title}: ${e.html}`,f()&&c.control&&t.controls.check(e),f()&&c.setting&&t.setting.check(e),e.html):e.html):e.html;c.control?(r.set("controls",d),t.controls.update({name:e,position:"right",html:u.html,style:{padding:"0 10px"},selector:u.selector,onSelect:d})):l("controls"),f()&&(c.setting?(r.set("setting",d),t.setting.update({name:e,tooltip:u.html,html:u.title,icon:n,width:200,selector:u.selector,onSelect:d})):l("setting"))},clear:i}}return function(t={}){return e=>{const{$video:n}=e.template,{errorHandle:o}=e.constructor.utils;let r=!1,u=0;const s=c(e,"dash-quality",''),a=c(e,"dash-audio",''),f=[];function d(t=()=>!0){let e;for(const o of[s.clear,a.clear]){if(!t())break;try{o()}catch(n){e||(e=n)}}if(e)throw e}function h(){if(r||e.isDestroy)return;const c=++u,f=e.dash,h=()=>!r&&!e.isDestroy&&c===u&&e.dash===f,p=()=>h()&&f.getVideoElement()===n&&h();try{if(o(f.getVideoElement()===n,'Cannot find instance of DASH from "art.dash"'),!h())return;const e=t.quality||{},r=function(t,e,n){const o=function(t){const e="function"===typeof t.getRepresentationsByType;return{levels:()=>e?t.getRepresentationsByType("video"):t.getBitrateInfoListFor("video"),current(n){if(e)return t.getCurrentRepresentationForType("video");const o=t.getQualityFor("video");return n.find(t=>t.qualityIndex===o)},item:(t,n,o,r)=>e?{value:n,id:t.id,default:!r&&null!=o&&t.id===o.id}:{value:t.qualityIndex,default:!r&&null!=o&&t.qualityIndex===o.qualityIndex},select(n,o){const r="auto"===n.value;t.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:r}}}}),!r&&o()&&(e?t.setRepresentationForTypeById("video",n.id):t.setQualityFor("video",n.value))}}}(t),r=o.levels();if(!n()||!(null==r?void 0:r.length))return null;const c=e.auto||"Auto",u=e.getName||(t=>`${t.height}p`),s=o.current(r);if(!n())return null;const a=t.getSettings().streaming.abr.autoSwitchBitrate.video;if(!n())return null;const f=!a&&s?u(s):c;if(!n())return null;const d=[];for(const[i,p]of r.entries()){const t=u(p);if(!n())return null;d.push(l({html:t},o.item(p,i,s,a)))}const h=i(d).sort((t,e)=>e.value-t.value);return h.push({html:c,value:"auto",default:a}),{html:f,title:e.title||"Quality",selector:h,select:o.select}}(f,e,p);if(!p())return;if(s.update(e,r,p),!p())return;const c=t.audio||{},u=function(t,e,n){const o=t.getTracksFor("audio");if(!n()||!(null==o?void 0:o.length))return null;const r=e.auto||"Auto",l=e.getName||(t=>t.lang||t.id),c=t.getCurrentTrackFor("audio")||o[0];if(!n())return null;const u=c?l(c):r;if(!n())return null;const s=function(t,e){if(t.includes(e))return e;if(null==e.id&&null==e.index)return;const n=t.filter(t=>["id","index","lang"].every(n=>null==e[n]||t[n]===e[n]));return 1===n.length?n[0]:void 0}(o,c),a=[];for(const i of o){const t=l(i);if(!n())return null;a.push({html:t,value:i,default:i===s})}const f=i(a);return{html:u,title:e.title||"Audio",selector:f,select:e=>t.setCurrentTrack(e.value)}}(f,c,p);p()&&a.update(c,u,p)}catch(g){if(h()){const t=++u;try{d(()=>u===t)}catch(v){console.warn("ArtPlayer DASH cleanup failed:",v)}}throw g}}function p(){if(r)return;let t;r=!0,u++;const n=[d,...f.splice(0).map(([t,n])=>()=>e.off(t,n))];for(const e of n)try{e()}catch(o){t||(t=o)}if(t)throw t}try{for(const t of[["ready",h],["restart",h],["destroy",p]]){if(r)break;f.push(t),e.on(...t)}}catch(g){try{p()}catch(v){console.warn("ArtPlayer DASH subscription cleanup failed:",v)}throw g}return{name:"artplayerPluginDashControl",update:h}}}}); diff --git a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.mjs b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.mjs index 4ce15acf9..a0c85dbe8 100644 --- a/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.mjs +++ b/packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.mjs @@ -5,163 +5,260 @@ * Released under the MIT License. */ const $audio = ''; -const $quality = ''; -function uniqBy(array, property) { - const seen = /* @__PURE__ */ new Map(); - return array.filter((item) => { - const key = item[property]; - if (key === void 0) { - return true; +function qualityAdapter(dash) { + const modern = typeof dash.getRepresentationsByType === "function"; + return { + levels: () => modern ? dash.getRepresentationsByType("video") : dash.getBitrateInfoListFor("video"), + current(levels) { + if (modern) + return dash.getCurrentRepresentationForType("video"); + const quality = dash.getQualityFor("video"); + return levels.find((level) => level.qualityIndex === quality); + }, + item(level, index, selected, automatic) { + return modern ? { value: index, id: level.id, default: !automatic && selected != null && level.id === selected.id } : { value: level.qualityIndex, default: !automatic && selected != null && level.qualityIndex === selected.qualityIndex }; + }, + select(item, valid) { + const automatic = item.value === "auto"; + dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: automatic } } } }); + if (!automatic && valid()) { + if (modern) + dash.setRepresentationForTypeById("video", item.id); + else + dash.setQualityFor("video", item.value); + } } - return !seen.has(key) && seen.set(key, 1); + }; +} +function uniqueLabels(items) { + const seen = /* @__PURE__ */ new Map(); + return items.filter((item) => { + if (item.html === void 0) + return true; + const first = seen.get(item.html); + if (first) { + if (item.default) + Object.assign(first, item); + return false; + } + seen.set(item.html, item); + return true; }); } +function qualityModel(dash, config, active) { + const adapter = qualityAdapter(dash); + const levels = adapter.levels(); + if (!active() || !levels?.length) + return null; + const auto = config.auto || "Auto"; + const getName = config.getName || ((level) => `${level.height}p`); + const selected = adapter.current(levels); + if (!active()) + return null; + const automatic = dash.getSettings().streaming.abr.autoSwitchBitrate.video; + if (!active()) + return null; + const html = !automatic && selected ? getName(selected) : auto; + if (!active()) + return null; + const items = []; + for (const [index, level] of levels.entries()) { + const label = getName(level); + if (!active()) + return null; + items.push({ html: label, ...adapter.item(level, index, selected, automatic) }); + } + const selector = uniqueLabels(items).sort((left, right) => right.value - left.value); + selector.push({ html: auto, value: "auto", default: automatic }); + return { html, title: config.title || "Quality", selector, select: adapter.select }; +} +function selectedTrack(tracks, current) { + if (tracks.includes(current)) + return current; + if (current.id == null && current.index == null) + return void 0; + const matching = tracks.filter((track) => ["id", "index", "lang"].every((key) => current[key] == null || track[key] === current[key])); + return matching.length === 1 ? matching[0] : void 0; +} +function audioModel(dash, config, active) { + const tracks = dash.getTracksFor("audio"); + if (!active() || !tracks?.length) + return null; + const auto = config.auto || "Auto"; + const getName = config.getName || ((track) => track.lang || track.id); + const current = dash.getCurrentTrackFor("audio") || tracks[0]; + if (!active()) + return null; + const html = current ? getName(current) : auto; + if (!active()) + return null; + const selected = selectedTrack(tracks, current); + const items = []; + for (const track of tracks) { + const label = getName(track); + if (!active()) + return null; + items.push({ html: label, value: track, default: track === selected }); + } + const selector = uniqueLabels(items); + return { html, title: config.title || "Audio", selector, select: (item) => dash.setCurrentTrack(item.value) }; +} +function createMenu(art, name, icon) { + let current; + const owned = /* @__PURE__ */ new Map(); + function remove(surface, callback = owned.get(surface)) { + if (!callback || owned.get(surface) !== callback) + return; + owned.delete(surface); + const registry = art[surface]; + if (surface === "controls" && registry.cache?.get && registry.cache.get(name)?.option?.onSelect !== callback) + return; + if (surface === "setting" && registry.find && registry.find(name)?.onSelect !== callback) + return; + registry.remove(name); + } + function clear() { + current = void 0; + let failure; + for (const [surface, callback] of [...owned]) { + try { + remove(surface, callback); + } catch (error) { + failure || (failure = error); + } + } + if (failure) + throw failure; + } + function update(config, model, active) { + if (!model) { + clear(); + return; + } + const state = {}; + current = state; + const valid = () => state === current && active(); + const onSelect = (item) => { + if (!valid()) + return item.html; + model.select(item, valid); + if (!valid()) + return item.html; + art.notice.show = `${model.title}: ${item.html}`; + if (valid() && config.control) + art.controls.check(item); + if (valid() && config.setting) + art.setting.check(item); + return item.html; + }; + if (config.control) { + owned.set("controls", onSelect); + art.controls.update({ name, position: "right", html: model.html, style: { padding: "0 10px" }, selector: model.selector, onSelect }); + } else { + remove("controls"); + } + if (!valid()) + return; + if (config.setting) { + owned.set("setting", onSelect); + art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect }); + } else { + remove("setting"); + } + } + return { update, clear }; +} +const $quality = ''; function artplayerPluginDashControl(option = {}) { return (art) => { const { $video } = art.template; const { errorHandle } = art.constructor.utils; - function updateQuality(dash) { - const qualities = dash.getRepresentationsByType("video"); - if (!qualities || !qualities.length) - return; - const config = option.quality || {}; - const auto = config.auto || "Auto"; - const title = config.title || "Quality"; - const getName = config.getName || ((level) => `${level.height}p`); - const currentRepresentation = dash.getCurrentRepresentationForType("video"); - const currentId = currentRepresentation && currentRepresentation.id; - const currentAuto = dash.getSettings().streaming.abr.autoSwitchBitrate.video; - const defaultHtml = !currentAuto && currentRepresentation ? getName(currentRepresentation) : auto; - const selector = uniqBy( - qualities.map((item, index) => { - return { - html: getName(item), - value: index, - id: item.id, - default: !currentAuto && !!currentId && item.id === currentId - }; - }), - "html" - ).sort((a, b) => b.value - a.value); - selector.push({ - html: auto, - value: "auto", - default: currentAuto - }); - const onSelect = (item) => { - if (item.value === "auto") { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: true - } - } - } - }); - } else { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: false - } - } - } - }); - dash.setRepresentationForTypeById("video", item.id); + let closed = false; + let revision = 0; + const quality = createMenu(art, "dash-quality", $quality); + const audio = createMenu(art, "dash-audio", $audio); + const subscriptions = []; + function clear(current = () => true) { + let failure; + for (const cleanup of [quality.clear, audio.clear]) { + if (!current()) + break; + try { + cleanup(); + } catch (error) { + failure || (failure = error); } - 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: "dash-quality", - position: "right", - html: defaultHtml, - style: { padding: "0 10px" }, - selector, - onSelect - }); - } - if (config.setting) { - art.setting.update({ - name: "dash-quality", - tooltip: defaultHtml, - html: title, - icon: $quality, - width: 200, - selector, - onSelect - }); - } - } - function updateAudio(dash) { - const audioTracks = dash.getTracksFor("audio"); - if (!audioTracks || !audioTracks.length) - return; - const config = option.audio || {}; - const auto = config.auto || "Auto"; - const title = config.title || "Audio"; - const getName = config.getName || ((track) => track.lang || track.id); - const currentTrack = dash.getCurrentTrackFor("audio") || audioTracks[0]; - const defaultHtml = currentTrack ? getName(currentTrack) : auto; - const selector = uniqBy( - audioTracks.map((item) => { - return { - html: getName(item), - value: item, - default: currentTrack === item - }; - }), - "html" - ); - const onSelect = (item) => { - dash.setCurrentTrack(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: "dash-audio", - position: "right", - html: defaultHtml, - style: { padding: "0 10px" }, - selector, - onSelect - }); - } - if (config.setting) { - art.setting.update({ - name: "dash-audio", - tooltip: defaultHtml, - html: title, - icon: $audio, - width: 200, - selector, - onSelect - }); } + if (failure) + throw failure; } function update() { - errorHandle(art.dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"'); - updateQuality(art.dash); - updateAudio(art.dash); + if (closed || art.isDestroy) + return; + const version = ++revision; + const dash = art.dash; + const current = () => !closed && !art.isDestroy && version === revision && art.dash === dash; + const valid = () => current() && dash.getVideoElement() === $video && current(); + try { + errorHandle(dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"'); + if (!current()) + return; + const qualityConfig = option.quality || {}; + const qualities = qualityModel(dash, qualityConfig, valid); + if (!valid()) + return; + quality.update(qualityConfig, qualities, valid); + if (!valid()) + return; + const audioConfig = option.audio || {}; + const tracks = audioModel(dash, audioConfig, valid); + if (valid()) + audio.update(audioConfig, tracks, valid); + } catch (error) { + if (current()) { + const cleanupVersion = ++revision; + try { + clear(() => revision === cleanupVersion); + } catch (cleanupError) { + console.warn("ArtPlayer DASH cleanup failed:", cleanupError); + } + } + throw error; + } } - art.on("ready", update); - art.on("restart", update); - return { - name: "artplayerPluginDashControl", - update - }; + function destroy() { + if (closed) + return; + closed = true; + revision++; + let failure; + const actions = [clear, ...subscriptions.splice(0).map(([name, callback]) => () => art.off(name, callback))]; + for (const action of actions) { + try { + action(); + } catch (error) { + failure || (failure = error); + } + } + if (failure) + throw failure; + } + try { + for (const entry of [["ready", update], ["restart", update], ["destroy", destroy]]) { + if (closed) + break; + subscriptions.push(entry); + art.on(...entry); + } + } catch (error) { + try { + destroy(); + } catch (cleanupError) { + console.warn("ArtPlayer DASH subscription cleanup failed:", cleanupError); + } + throw error; + } + return { name: "artplayerPluginDashControl", update }; }; } export { diff --git a/packages/artplayer-plugin-dash-control/src/index.js b/packages/artplayer-plugin-dash-control/src/index.js index 04de6a6d5..897fb28a0 100644 --- a/packages/artplayer-plugin-dash-control/src/index.js +++ b/packages/artplayer-plugin-dash-control/src/index.js @@ -1,179 +1,107 @@ 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) - }) -} - export default function artplayerPluginDashControl(option = {}) { return (art) => { const { $video } = art.template const { errorHandle } = art.constructor.utils + let closed = false + let revision = 0 + const quality = createMenu(art, 'dash-quality', $quality) + const audio = createMenu(art, 'dash-audio', $audio) + const subscriptions = [] - function updateQuality(dash) { - const qualities = dash.getRepresentationsByType('video') - if (!qualities || !qualities.length) - return - - const config = option.quality || {} - const auto = config.auto || 'Auto' - const title = config.title || 'Quality' - const getName = config.getName || (level => `${level.height}p`) - const currentRepresentation = dash.getCurrentRepresentationForType('video') - const currentId = currentRepresentation && currentRepresentation.id - const currentAuto = dash.getSettings().streaming.abr.autoSwitchBitrate.video - const defaultHtml = !currentAuto && currentRepresentation ? getName(currentRepresentation) : auto - - const selector = uniqBy( - qualities.map((item, index) => { - return { - html: getName(item), - value: index, - id: item.id, - default: !currentAuto && !!currentId && item.id === currentId, - } - }), - 'html', - ).sort((a, b) => b.value - a.value) - - selector.push({ - html: auto, - value: 'auto', - default: currentAuto, - }) - - const onSelect = (item) => { - if (item.value === 'auto') { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: true, - }, - }, - }, - }) + function clear(current = () => true) { + let failure + for (const cleanup of [quality.clear, audio.clear]) { + if (!current()) + break + try { + cleanup() } - else { - dash.updateSettings({ - streaming: { - abr: { - autoSwitchBitrate: { - video: false, - }, - }, - }, - }) - dash.setRepresentationForTypeById('video', item.id) + catch (error) { + failure ||= error } - 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: 'dash-quality', - position: 'right', - html: defaultHtml, - style: { padding: '0 10px' }, - selector, - onSelect, - }) - } - - if (config.setting) { - art.setting.update({ - name: 'dash-quality', - tooltip: defaultHtml, - html: title, - icon: $quality, - width: 200, - selector, - onSelect, - }) - } - } - - function updateAudio(dash) { - const audioTracks = dash.getTracksFor('audio') - if (!audioTracks || !audioTracks.length) - return - - const config = option.audio || {} - const auto = config.auto || 'Auto' - const title = config.title || 'Audio' - const getName = config.getName || (track => track.lang || track.id) - const currentTrack = dash.getCurrentTrackFor('audio') || audioTracks[0] - const defaultHtml = currentTrack ? getName(currentTrack) : auto - - const selector = uniqBy( - audioTracks.map((item) => { - return { - html: getName(item), - value: item, - default: currentTrack === item, - } - }), - 'html', - ) - - const onSelect = (item) => { - dash.setCurrentTrack(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: 'dash-audio', - position: 'right', - html: defaultHtml, - style: { padding: '0 10px' }, - selector, - onSelect, - }) - } - - if (config.setting) { - art.setting.update({ - name: 'dash-audio', - tooltip: defaultHtml, - html: title, - icon: $audio, - width: 200, - selector, - onSelect, - }) } + if (failure) + throw failure } function update() { - errorHandle(art.dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"') - updateQuality(art.dash) - updateAudio(art.dash) + if (closed || art.isDestroy) + return + const version = ++revision + const dash = art.dash + const current = () => !closed && !art.isDestroy && version === revision && art.dash === dash + const valid = () => current() && dash.getVideoElement() === $video && current() + try { + errorHandle(dash.getVideoElement() === $video, 'Cannot find instance of DASH from "art.dash"') + if (!current()) + return + const qualityConfig = option.quality || {} + const qualities = qualityModel(dash, qualityConfig, valid) + if (!valid()) + return + quality.update(qualityConfig, qualities, valid) + if (!valid()) + return + const audioConfig = option.audio || {} + const tracks = audioModel(dash, audioConfig, valid) + if (valid()) + audio.update(audioConfig, tracks, valid) + } + catch (error) { + if (current()) { + const cleanupVersion = ++revision + try { + clear(() => revision === cleanupVersion) + } + catch (cleanupError) { + console.warn('ArtPlayer DASH cleanup failed:', cleanupError) + } + } + throw error + } } - art.on('ready', update) - art.on('restart', update) - - return { - name: 'artplayerPluginDashControl', - update, + function destroy() { + if (closed) + return + closed = true + revision++ + let failure + const actions = [clear, ...subscriptions.splice(0).map(([name, callback]) => () => art.off(name, callback))] + for (const action of actions) { + try { + action() + } + catch (error) { + failure ||= error + } + } + if (failure) + throw failure } + + try { + for (const entry of [['ready', update], ['restart', update], ['destroy', destroy]]) { + if (closed) + break + subscriptions.push(entry) + art.on(...entry) + } + } + catch (error) { + try { + destroy() + } + catch (cleanupError) { + console.warn('ArtPlayer DASH subscription cleanup failed:', cleanupError) + } + throw error + } + return { name: 'artplayerPluginDashControl', update } } } diff --git a/packages/artplayer-plugin-dash-control/src/mapping.js b/packages/artplayer-plugin-dash-control/src/mapping.js new file mode 100644 index 000000000..88b4b71dd --- /dev/null +++ b/packages/artplayer-plugin-dash-control/src/mapping.js @@ -0,0 +1,79 @@ +import { qualityAdapter } from './sdk' + +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) + Object.assign(first, item) + return false + } + seen.set(item.html, item) + return true + }) +} + +export function qualityModel(dash, config, active) { + const adapter = qualityAdapter(dash) + const levels = adapter.levels() + if (!active() || !levels?.length) + return null + const auto = config.auto || 'Auto' + const getName = config.getName || (level => `${level.height}p`) + const selected = adapter.current(levels) + if (!active()) + return null + const automatic = dash.getSettings().streaming.abr.autoSwitchBitrate.video + if (!active()) + return null + const html = !automatic && selected ? getName(selected) : auto + if (!active()) + return null + const items = [] + for (const [index, level] of levels.entries()) { + const label = getName(level) + if (!active()) + return null + items.push({ html: label, ...adapter.item(level, index, selected, automatic) }) + } + const selector = uniqueLabels(items) + .sort((left, right) => right.value - left.value) + selector.push({ html: auto, value: 'auto', default: automatic }) + return { html, title: config.title || 'Quality', selector, select: adapter.select } +} + +function selectedTrack(tracks, current) { + if (tracks.includes(current)) + return current + if (current.id == null && current.index == null) + return undefined + const matching = tracks.filter(track => ['id', 'index', 'lang'].every(key => current[key] == null || track[key] === current[key])) + return matching.length === 1 ? matching[0] : undefined +} + +export function audioModel(dash, config, active) { + const tracks = dash.getTracksFor('audio') + if (!active() || !tracks?.length) + return null + const auto = config.auto || 'Auto' + const getName = config.getName || (track => track.lang || track.id) + const current = dash.getCurrentTrackFor('audio') || tracks[0] + if (!active()) + return null + const html = current ? getName(current) : auto + if (!active()) + return null + const selected = selectedTrack(tracks, current) + const items = [] + for (const track of tracks) { + const label = getName(track) + if (!active()) + return null + items.push({ html: label, value: track, default: track === selected }) + } + const selector = uniqueLabels(items) + return { html, title: config.title || 'Audio', selector, select: item => dash.setCurrentTrack(item.value) } +} diff --git a/packages/artplayer-plugin-dash-control/src/menu.js b/packages/artplayer-plugin-dash-control/src/menu.js new file mode 100644 index 000000000..447709a90 --- /dev/null +++ b/packages/artplayer-plugin-dash-control/src/menu.js @@ -0,0 +1,72 @@ +export function createMenu(art, name, icon) { + let current + const owned = new Map() + + function remove(surface, callback = owned.get(surface)) { + if (!callback || owned.get(surface) !== callback) + return + owned.delete(surface) + const registry = art[surface] + if (surface === 'controls' && registry.cache?.get && registry.cache.get(name)?.option?.onSelect !== callback) + return + if (surface === 'setting' && registry.find && registry.find(name)?.onSelect !== callback) + return + registry.remove(name) + } + + function clear() { + current = undefined + let failure + for (const [surface, callback] of [...owned]) { + try { + remove(surface, callback) + } + catch (error) { + failure ||= error + } + } + if (failure) + throw failure + } + + function update(config, model, active) { + if (!model) { + clear() + return + } + const state = {} + current = state + const valid = () => state === current && active() + const onSelect = (item) => { + if (!valid()) + return item.html + model.select(item, valid) + if (!valid()) + return item.html + art.notice.show = `${model.title}: ${item.html}` + if (valid() && config.control) + art.controls.check(item) + if (valid() && config.setting) + art.setting.check(item) + return item.html + } + if (config.control) { + owned.set('controls', onSelect) + art.controls.update({ name, position: 'right', html: model.html, style: { padding: '0 10px' }, selector: model.selector, onSelect }) + } + else { + remove('controls') + } + if (!valid()) + return + if (config.setting) { + owned.set('setting', onSelect) + art.setting.update({ name, tooltip: model.html, html: model.title, icon, width: 200, selector: model.selector, onSelect }) + } + else { + remove('setting') + } + } + + return { update, clear } +} diff --git a/packages/artplayer-plugin-dash-control/src/sdk.js b/packages/artplayer-plugin-dash-control/src/sdk.js new file mode 100644 index 000000000..49dfc0a6c --- /dev/null +++ b/packages/artplayer-plugin-dash-control/src/sdk.js @@ -0,0 +1,27 @@ +export function qualityAdapter(dash) { + const modern = typeof dash.getRepresentationsByType === 'function' + return { + levels: () => modern ? dash.getRepresentationsByType('video') : dash.getBitrateInfoListFor('video'), + current(levels) { + if (modern) + return dash.getCurrentRepresentationForType('video') + const quality = dash.getQualityFor('video') + return levels.find(level => level.qualityIndex === quality) + }, + item(level, index, selected, automatic) { + return modern + ? { value: index, id: level.id, default: !automatic && selected != null && level.id === selected.id } + : { value: level.qualityIndex, default: !automatic && selected != null && level.qualityIndex === selected.qualityIndex } + }, + select(item, valid) { + const automatic = item.value === 'auto' + dash.updateSettings({ streaming: { abr: { autoSwitchBitrate: { video: automatic } } } }) + if (!automatic && valid()) { + if (modern) + dash.setRepresentationForTypeById('video', item.id) + else + dash.setQualityFor('video', item.value) + } + }, + } +} diff --git a/refactor/baselines/dash-runtime.json b/refactor/baselines/dash-runtime.json new file mode 100644 index 000000000..0ea94c0e3 --- /dev/null +++ b/refactor/baselines/dash-runtime.json @@ -0,0 +1,854 @@ +{ + "schemaVersion": 1, + "task": "PKG-DASH-03", + "baseCommit": "762cc634", + "scope": "Runtime structure and compatible SDK call surfaces. Real old/new core DOM with formal plugin main/legacy and native MP4. SDK is controlled; actual dash.js, MPD/ABR, physical devices and installed tarball acceptance remain outstanding.", + "inputs": [ + { + "file": "packages/artplayer-plugin-dash-control/src/index.js", + "sha256LF": "3d82930fa8da9f729235ed34e5b9ae732bde6a986124b0ca1016eabefae4fc70" + }, + { + "file": "packages/artplayer-plugin-dash-control/src/sdk.js", + "sha256LF": "90f77e57fd9d8cae3c7876166b048285d18634a93eecfcf50a70f8d6c9da1050" + }, + { + "file": "packages/artplayer-plugin-dash-control/src/mapping.js", + "sha256LF": "03c1b99e0869263778f05896b05d2fe4004cf657fd5168cb303926a90de35b34" + }, + { + "file": "packages/artplayer-plugin-dash-control/src/menu.js", + "sha256LF": "e852cbdf07bd681594c95ee368834995d34c6d1314d06c1c6089a025b1d7b896" + }, + { + "file": "test/helpers/dash-control.js", + "sha256LF": "a32852c960a25c27ae18a6910f2b6d9e4f5786c1339db91f414b70a1fec041db" + }, + { + "file": "test/dash-contract.test.js", + "sha256LF": "4477b48fc53224890a7eb22ed5f1554b57fd6265c864fb4039393978e63ceb31" + }, + { + "file": "test/dash-lifecycle.test.js", + "sha256LF": "1374d51d7952c44a63580a6b127bc53f626ba161a03982414299d807cfc1525c" + }, + { + "file": "test/browser/dash-control.spec.js", + "sha256LF": "a770394af0f3291925734e116ab53f8c090349673aa14205bd9a1300c82ad154" + } + ], + "artifacts": [ + { + "file": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js", + "sha256": "c1c63bd0ec787a6f9eb445b53341dd5eaeaccccd1589455234be311fcf6ce5e6", + "bytes": 5469 + }, + { + "file": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js", + "sha256": "1b6fba04c90ba19197213cf1868599dab13d430e8923d3081e37c43dc4d5d00e", + "bytes": 5924 + }, + { + "file": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.mjs", + "sha256": "84b50383a8d6e75ad27dad6b550826b0d690aaaec9b2831a65111ec4f4c277b0", + "bytes": 9724 + } + ], + "reports": [ + { + "file": "refactor/.cache/dash03-main-first/report.json", + "sha256": "b17971ffd22f20acd68b64bb4ba7bff62b65c6fcc9ce10836b1f16f2d1bc9bd3", + "bytes": 15822733, + "stats": { + "startTime": "2026-09-11T20:51:00.164Z", + "duration": 64825.979, + "expected": 84, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "results": { + "directory": "refactor/.cache/dash03-main-first/results", + "files": 85, + "sha256": "a92c517dd7be719052b8766b3d19c00619469c0da792a9b2eead751048893594", + "algorithm": "SHA256 of JSON.stringify(sorted relative path and raw SHA256 pairs), UTF-8, slash paths" + }, + "pluginInput": { + "published": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-dash-control/1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-dash-control/-/artplayer-plugin-dash-control-1.1.0.tgz", + "integrity": "sha512-EssRkzYXWcln3a6nAzi4ESacBhzMih8s0bzdjXuvBBCh2UFmvK99ITD5E+0cuU8i5ZzIQGQSdce1/gANTrX0BQ==", + "sha256": "f4047a569d99b6a7de57b3719b7ad40109f2a1d360566a840f50ab32aacefcd9", + "archiveBytes": 3385, + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "98982f1123ff9a11f5d3d9a52da6e8fea9cd0989f7465605c27b0f9a46f2dbcd", + "package/dist/artplayer-plugin-dash-control.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.legacy.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.mjs": "e32bf08c33a481781883067dcc639f0495c9dcb8a88b91be13736370f4932fc4", + "package/package.json": "556e2adc479bab36c5973de617c0b57c5d86aa8425643a1952ab04738e9ce927", + "package/types/artplayer-plugin-dash-control.d.ts": "e15ef683e0c335d81531587828593325e037efe7ed29c022477cd14fe71aebba" + }, + "manifest": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "description": "Dash control plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.mjs", + "require": "./dist/artplayer-plugin-dash-control.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.legacy.js", + "require": "./dist/artplayer-plugin-dash-control.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-dash-control.js", + "module": "./dist/artplayer-plugin-dash-control.mjs", + "types": "./types/artplayer-plugin-dash-control.d.ts", + "legacy": "./dist/artplayer-plugin-dash-control.legacy.js", + "browserslist": "last 1 Chrome version" + } + }, + "sourceSHA256": "6c37d58c427aa688fe252127dbbc5bb131d0ef6e5141a3b22400f40e5fd177f9", + "candidate": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js", + "scope": "Real ArtPlayer DOM and native MP4 with controlled SDK methods. No dash.js instance, MPD, ABR or adaptive media validation." + }, + "engines": [ + { + "project": "chromium", + "version": "153.0.8010.12", + "platform": "win32" + }, + { + "project": "firefox", + "version": "155.0", + "platform": "win32" + }, + { + "project": "webkit", + "version": "26.6", + "platform": "win32" + } + ], + "manifest": { + "kind": "artplayer-browser-test", + "resources": { + "/candidate/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040 + }, + "/compiled/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/uncompiled/artplayer/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/candidate/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529 + }, + "/compiled/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/uncompiled/artplayer-plugin-chapter/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/published/artplayer.js": { + "source": { + "kind": "npm-release", + "version": "5.4.0", + "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==", + "member": "package/dist/artplayer.js" + }, + "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f", + "bytes": 134037 + }, + "/published/artplayer-plugin-chapter.js": { + "source": { + "kind": "npm-release", + "version": "1.1.0", + "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==", + "member": "package/dist/artplayer-plugin-chapter.js" + }, + "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0", + "bytes": 4973 + }, + "/test/pattern.mp4": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.mp4" + }, + "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233", + "bytes": 251538 + }, + "/test/pattern.webm": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.webm" + }, + "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f", + "bytes": 162073 + }, + "/test/audio-tone.m4a": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/audio-tone.m4a" + }, + "sha256": "83e6672b880b5d1c97093b1e26bdc40221c0b279615f40e99dbf9d2ade7b4d56", + "bytes": 132436 + }, + "/test/declaration-cues.vtt": { + "source": { + "kind": "test-subtitles", + "file": "test/browser/media/declaration-cues.vtt" + }, + "sha256": "5d149d4e641ce41cbb83376835c03daf1fb89f3b9f568fa59e064d51e2b57704", + "bytes": 54 + }, + "/test/thumbnail-grid.svg": { + "source": { + "kind": "test-thumbnail-grid", + "file": "test/browser/media/thumbnail-grid.svg" + }, + "sha256": "bb3f1ec9895d37def77ca4b192d3cd73577a2668558cc99de1588f511d65d3e6", + "bytes": 14969 + }, + "/test/legacy-safe-area.js": { + "source": { + "kind": "frozen-own-source", + "file": "test/helpers/legacy-safe-area.js", + "commit": "ccf77c4e" + }, + "sha256": "ce0368618ef6363b78271b8bcc7973abd0478e797cc464ddd912929e6dfa501a", + "bytes": 795 + }, + "/assets/sample/video.mp4": { + "source": { + "kind": "docs-media", + "file": "docs/assets/sample/video.mp4" + }, + "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980", + "bytes": 5818706 + }, + "/test/performance.js": { + "source": { + "kind": "adapted-baseline-fixture", + "file": "refactor/fixtures/performance.js" + }, + "sha256": "2318f39bdb5f3a3348d755cb4335907ae4adc944be3a435839c1bba201e81500", + "bytes": 8994 + } + } + } + }, + { + "file": "refactor/.cache/dash03-main-final/report.json", + "sha256": "c3175930e53b8d4771c5393de015520f0dbcfb56362cd8017c1263ac1eba8758", + "bytes": 15813200, + "stats": { + "startTime": "2026-09-11T20:53:02.032Z", + "duration": 64490.32, + "expected": 84, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "results": { + "directory": "refactor/.cache/dash03-main-final/results", + "files": 85, + "sha256": "d3efa538c4ba0e698aa95b40b112caabf8324b5f3beec05f6845e7f5675de857", + "algorithm": "SHA256 of JSON.stringify(sorted relative path and raw SHA256 pairs), UTF-8, slash paths" + }, + "pluginInput": { + "published": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-dash-control/1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-dash-control/-/artplayer-plugin-dash-control-1.1.0.tgz", + "integrity": "sha512-EssRkzYXWcln3a6nAzi4ESacBhzMih8s0bzdjXuvBBCh2UFmvK99ITD5E+0cuU8i5ZzIQGQSdce1/gANTrX0BQ==", + "sha256": "f4047a569d99b6a7de57b3719b7ad40109f2a1d360566a840f50ab32aacefcd9", + "archiveBytes": 3385, + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "98982f1123ff9a11f5d3d9a52da6e8fea9cd0989f7465605c27b0f9a46f2dbcd", + "package/dist/artplayer-plugin-dash-control.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.legacy.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.mjs": "e32bf08c33a481781883067dcc639f0495c9dcb8a88b91be13736370f4932fc4", + "package/package.json": "556e2adc479bab36c5973de617c0b57c5d86aa8425643a1952ab04738e9ce927", + "package/types/artplayer-plugin-dash-control.d.ts": "e15ef683e0c335d81531587828593325e037efe7ed29c022477cd14fe71aebba" + }, + "manifest": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "description": "Dash control plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.mjs", + "require": "./dist/artplayer-plugin-dash-control.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.legacy.js", + "require": "./dist/artplayer-plugin-dash-control.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-dash-control.js", + "module": "./dist/artplayer-plugin-dash-control.mjs", + "types": "./types/artplayer-plugin-dash-control.d.ts", + "legacy": "./dist/artplayer-plugin-dash-control.legacy.js", + "browserslist": "last 1 Chrome version" + } + }, + "sourceSHA256": "c1c63bd0ec787a6f9eb445b53341dd5eaeaccccd1589455234be311fcf6ce5e6", + "candidate": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.js", + "scope": "Real ArtPlayer DOM and native MP4 with controlled SDK methods. No dash.js instance, MPD, ABR or adaptive media validation." + }, + "engines": [ + { + "project": "chromium", + "version": "153.0.8010.12", + "platform": "win32" + }, + { + "project": "firefox", + "version": "155.0", + "platform": "win32" + }, + { + "project": "webkit", + "version": "26.6", + "platform": "win32" + } + ], + "manifest": { + "kind": "artplayer-browser-test", + "resources": { + "/candidate/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040 + }, + "/compiled/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/uncompiled/artplayer/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/candidate/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529 + }, + "/compiled/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/uncompiled/artplayer-plugin-chapter/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/published/artplayer.js": { + "source": { + "kind": "npm-release", + "version": "5.4.0", + "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==", + "member": "package/dist/artplayer.js" + }, + "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f", + "bytes": 134037 + }, + "/published/artplayer-plugin-chapter.js": { + "source": { + "kind": "npm-release", + "version": "1.1.0", + "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==", + "member": "package/dist/artplayer-plugin-chapter.js" + }, + "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0", + "bytes": 4973 + }, + "/test/pattern.mp4": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.mp4" + }, + "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233", + "bytes": 251538 + }, + "/test/pattern.webm": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.webm" + }, + "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f", + "bytes": 162073 + }, + "/test/audio-tone.m4a": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/audio-tone.m4a" + }, + "sha256": "83e6672b880b5d1c97093b1e26bdc40221c0b279615f40e99dbf9d2ade7b4d56", + "bytes": 132436 + }, + "/test/declaration-cues.vtt": { + "source": { + "kind": "test-subtitles", + "file": "test/browser/media/declaration-cues.vtt" + }, + "sha256": "5d149d4e641ce41cbb83376835c03daf1fb89f3b9f568fa59e064d51e2b57704", + "bytes": 54 + }, + "/test/thumbnail-grid.svg": { + "source": { + "kind": "test-thumbnail-grid", + "file": "test/browser/media/thumbnail-grid.svg" + }, + "sha256": "bb3f1ec9895d37def77ca4b192d3cd73577a2668558cc99de1588f511d65d3e6", + "bytes": 14969 + }, + "/test/legacy-safe-area.js": { + "source": { + "kind": "frozen-own-source", + "file": "test/helpers/legacy-safe-area.js", + "commit": "ccf77c4e" + }, + "sha256": "ce0368618ef6363b78271b8bcc7973abd0478e797cc464ddd912929e6dfa501a", + "bytes": 795 + }, + "/assets/sample/video.mp4": { + "source": { + "kind": "docs-media", + "file": "docs/assets/sample/video.mp4" + }, + "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980", + "bytes": 5818706 + }, + "/test/performance.js": { + "source": { + "kind": "adapted-baseline-fixture", + "file": "refactor/fixtures/performance.js" + }, + "sha256": "2318f39bdb5f3a3348d755cb4335907ae4adc944be3a435839c1bba201e81500", + "bytes": 8994 + } + } + } + }, + { + "file": "refactor/.cache/dash03-legacy-final/report.json", + "sha256": "5eaa45267a3f0e6a5c5922f974d46737a7d3c559c9a8e2dab84e8262269e6d83", + "bytes": 15807757, + "stats": { + "startTime": "2026-09-11T20:55:36.976Z", + "duration": 69224.891, + "expected": 84, + "skipped": 0, + "unexpected": 0, + "flaky": 0 + }, + "results": { + "directory": "refactor/.cache/dash03-legacy-final/results", + "files": 85, + "sha256": "a7016f915efb43c45b31f9d25b755fba95c649ec7008bb397130cc840e78b7fe", + "algorithm": "SHA256 of JSON.stringify(sorted relative path and raw SHA256 pairs), UTF-8, slash paths" + }, + "pluginInput": { + "published": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "metadataUrl": "https://registry.npmjs.org/artplayer-plugin-dash-control/1.1.0", + "tarball": "https://registry.npmjs.org/artplayer-plugin-dash-control/-/artplayer-plugin-dash-control-1.1.0.tgz", + "integrity": "sha512-EssRkzYXWcln3a6nAzi4ESacBhzMih8s0bzdjXuvBBCh2UFmvK99ITD5E+0cuU8i5ZzIQGQSdce1/gANTrX0BQ==", + "sha256": "f4047a569d99b6a7de57b3719b7ad40109f2a1d360566a840f50ab32aacefcd9", + "archiveBytes": 3385, + "registryGitHead": "daf133b22630b4a0eecfa3336bbddab0e9119d96", + "files": { + "package/README.md": "98982f1123ff9a11f5d3d9a52da6e8fea9cd0989f7465605c27b0f9a46f2dbcd", + "package/dist/artplayer-plugin-dash-control.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.legacy.js": "f0cbbcf8badeaca13c05ae178932818cb52fbce37ed504ac0e62160b9c7e1145", + "package/dist/artplayer-plugin-dash-control.mjs": "e32bf08c33a481781883067dcc639f0495c9dcb8a88b91be13736370f4932fc4", + "package/package.json": "556e2adc479bab36c5973de617c0b57c5d86aa8425643a1952ab04738e9ce927", + "package/types/artplayer-plugin-dash-control.d.ts": "e15ef683e0c335d81531587828593325e037efe7ed29c022477cd14fe71aebba" + }, + "manifest": { + "name": "artplayer-plugin-dash-control", + "version": "1.1.0", + "description": "Dash control plugin for ArtPlayer", + "author": "Harvey Zhao ", + "license": "MIT", + "homepage": "https://artplayer.org", + "repository": { + "type": "git", + "url": "git+https://github.com/zhw2590582/ArtPlayer.git" + }, + "bugs": { + "url": "https://github.com/zhw2590582/ArtPlayer/issues" + }, + "keywords": [ + "html5", + "video", + "player" + ], + "exports": { + ".": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.mjs", + "require": "./dist/artplayer-plugin-dash-control.js" + }, + "./legacy": { + "types": "./types/artplayer-plugin-dash-control.d.ts", + "import": "./dist/artplayer-plugin-dash-control.legacy.js", + "require": "./dist/artplayer-plugin-dash-control.legacy.js" + } + }, + "main": "./dist/artplayer-plugin-dash-control.js", + "module": "./dist/artplayer-plugin-dash-control.mjs", + "types": "./types/artplayer-plugin-dash-control.d.ts", + "legacy": "./dist/artplayer-plugin-dash-control.legacy.js", + "browserslist": "last 1 Chrome version" + } + }, + "sourceSHA256": "1b6fba04c90ba19197213cf1868599dab13d430e8923d3081e37c43dc4d5d00e", + "candidate": "packages/artplayer-plugin-dash-control/dist/artplayer-plugin-dash-control.legacy.js", + "scope": "Real ArtPlayer DOM and native MP4 with controlled SDK methods. No dash.js instance, MPD, ABR or adaptive media validation." + }, + "engines": [ + { + "project": "chromium", + "version": "153.0.8010.12", + "platform": "win32" + }, + { + "project": "firefox", + "version": "155.0", + "platform": "win32" + }, + { + "project": "webkit", + "version": "26.6", + "platform": "win32" + } + ], + "manifest": { + "kind": "artplayer-browser-test", + "resources": { + "/candidate/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040 + }, + "/compiled/artplayer.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/uncompiled/artplayer/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer\\src\\index.ts" + }, + "sha256": "df495dd386d32ea96be5006e1bbfda1ea91cfeb32aa21758197b46a155ebe0dc", + "bytes": 378040, + "aliasOf": "/candidate/artplayer.js" + }, + "/candidate/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529 + }, + "/compiled/artplayer-plugin-chapter.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/uncompiled/artplayer-plugin-chapter/index.js": { + "source": { + "kind": "workspace-build", + "entry": "packages\\artplayer-plugin-chapter\\src\\index.ts" + }, + "sha256": "1a8eb891d3690e2526e78a2e53183a25367b1d02a5fd349d821548edb731ee3a", + "bytes": 8529, + "aliasOf": "/candidate/artplayer-plugin-chapter.js" + }, + "/published/artplayer.js": { + "source": { + "kind": "npm-release", + "version": "5.4.0", + "integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==", + "member": "package/dist/artplayer.js" + }, + "sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f", + "bytes": 134037 + }, + "/published/artplayer-plugin-chapter.js": { + "source": { + "kind": "npm-release", + "version": "1.1.0", + "integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==", + "member": "package/dist/artplayer-plugin-chapter.js" + }, + "sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0", + "bytes": 4973 + }, + "/test/pattern.mp4": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.mp4" + }, + "sha256": "0ad1d7ad286aec6e0fa9e27fc1e487e7277df2e603e30040fb942365763d0233", + "bytes": 251538 + }, + "/test/pattern.webm": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/pattern.webm" + }, + "sha256": "9d3c2b3dec9581a599de2d6732992ef7846790e0d1368e6e131bd339b39e984f", + "bytes": 162073 + }, + "/test/audio-tone.m4a": { + "source": { + "kind": "generated-media", + "file": "test/browser/media/audio-tone.m4a" + }, + "sha256": "83e6672b880b5d1c97093b1e26bdc40221c0b279615f40e99dbf9d2ade7b4d56", + "bytes": 132436 + }, + "/test/declaration-cues.vtt": { + "source": { + "kind": "test-subtitles", + "file": "test/browser/media/declaration-cues.vtt" + }, + "sha256": "5d149d4e641ce41cbb83376835c03daf1fb89f3b9f568fa59e064d51e2b57704", + "bytes": 54 + }, + "/test/thumbnail-grid.svg": { + "source": { + "kind": "test-thumbnail-grid", + "file": "test/browser/media/thumbnail-grid.svg" + }, + "sha256": "bb3f1ec9895d37def77ca4b192d3cd73577a2668558cc99de1588f511d65d3e6", + "bytes": 14969 + }, + "/test/legacy-safe-area.js": { + "source": { + "kind": "frozen-own-source", + "file": "test/helpers/legacy-safe-area.js", + "commit": "ccf77c4e" + }, + "sha256": "ce0368618ef6363b78271b8bcc7973abd0478e797cc464ddd912929e6dfa501a", + "bytes": 795 + }, + "/assets/sample/video.mp4": { + "source": { + "kind": "docs-media", + "file": "docs/assets/sample/video.mp4" + }, + "sha256": "30a2de667ba267b6bc605edcee1c4bd728c048ed76f500ea0e73d1f1a6f8f980", + "bytes": 5818706 + }, + "/test/performance.js": { + "source": { + "kind": "adapted-baseline-fixture", + "file": "refactor/fixtures/performance.js" + }, + "sha256": "2318f39bdb5f3a3348d755cb4335907ae4adc944be3a435839c1bba201e81500", + "bytes": 8994 + } + } + } + } + ], + "logs": [ + { + "file": "refactor/.cache/dash03-before.log", + "sha256": "5a4e92a1bcf6a8117ba6745d1d54ade25d58643200ffc1d18d0f09e8e1ba8e28", + "bytes": 175508 + }, + { + "file": "refactor/.cache/dash03-reentry-before.log", + "sha256": "bc04fcd6b8518f74718bcc732a26f88fb919a79f63a1dc073fe724849ea0c99d", + "bytes": 4865 + }, + { + "file": "refactor/.cache/dash03-formatter-before.log", + "sha256": "0799ccc96096116e75e83db65289102bde825f57796bfb620579927d982084d0", + "bytes": 4840 + }, + { + "file": "refactor/.cache/dash03-node-final.log", + "sha256": "4455e88c907ea0f2f335b67c020bf819e47b124fc3e766c1bb220b6135934f6d", + "bytes": 11463 + }, + { + "file": "refactor/.cache/dash03-artifacts-realm-before.log", + "sha256": "0a09f96f3457128982aa93d3d5c9a93f84900ea1bf09add729c47d048714b7be", + "bytes": 36034 + }, + { + "file": "refactor/.cache/dash03-artifacts-final.log", + "sha256": "c68ec1c1ab54f7e389ce210bc95276ed6484f499e3d2d90cb023bf92a2dcb76b", + "bytes": 31662 + }, + { + "file": "refactor/.cache/dash03-build-final.log", + "sha256": "90a8251da20f5ebe7cf2759e87284ab0265bc9db4b7a12dc14304f14019883c1", + "bytes": 1472 + }, + { + "file": "refactor/.cache/dash03-main.log", + "sha256": "9c55a02c2bc12d2128779677d9a02a4be3e3c57a50a5ad46631bc703b9d58a99", + "bytes": 16163 + }, + { + "file": "refactor/.cache/dash03-main-final.log", + "sha256": "896e9acf28c7b66998dd25ad9c39304242180d8e273f27f16a52a8c3d02fe162", + "bytes": 16162 + }, + { + "file": "refactor/.cache/dash03-legacy-final.log", + "sha256": "837a8ad81b4306231d44f9ca38f328afef8a57c1f604196bc64319b772049167", + "bytes": 16160 + }, + { + "file": "refactor/.cache/dash03-ci.log", + "sha256": "4d88eba96b783e9e26ea2197c398160b2565e622a58ab5b80db44375bcd85722", + "bytes": 74484 + } + ], + "results": { + "initialRegression": { + "passed": 1, + "failed": 21 + }, + "reentrantCleanupBefore": { + "passed": 29, + "failed": 2 + }, + "formatterShutdownBefore": { + "passed": 29, + "failed": 2 + }, + "nodeFinal": 116, + "nodeWithThreeArtifacts": 266, + "mainBrowser": 84, + "legacyBrowser": 84, + "ci": { + "unit": 661, + "engineering": 14, + "baseline": 37 + } + }, + "localEvidence": "Raw logs and browser reports/traces remain local cache. Committed hashes locate evidence but are not uploaded traces; rerun on a fresh machine." +} diff --git a/refactor/changes/2026-09-12-PKG-DASH-03-runtime.md b/refactor/changes/2026-09-12-PKG-DASH-03-runtime.md new file mode 100644 index 000000000..7ecdc4b9f --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-DASH-03-runtime.md @@ -0,0 +1,36 @@ +# PKG-DASH-03:两代 SDK 适配、菜单与生命周期拆分 + +起点 `762cc634`。把单一入口拆为入口生命周期、SDK 方法适配、selector 模型、菜单所有权 +四个模块。保持 JavaScript 完成本步结构与行为修复,下一步 04 再迁移严格 TS 和公开声明。 +包内 [架构图](../../packages/artplayer-plugin-dash-control/ARCHITECTURE.md) 随实际实现维护。 + +## 行为修正 + +- 恢复发布版 dash.js 4.x 方法表,同时保留工作区 5.x 稳定 ID;不使用过滤后的下标选画质。 +- 空拓扑、禁用菜单、渲染失败清理自有 UI;换 SDK/重建/销毁后旧引用无副作用。 +- 销毁释放 ready/restart/destroy 订阅,安装异常回滚;不销毁调用方 SDK,不误删调用方替换的菜单。 +- 当前重复标签保留实际当前 key/track;零 ID、唯一克隆轨道高亮正确,歧义轨道不伪造选中。 +- SDK/getName 重入使旧 revision 失效;回调销毁后停止后续格式化、SDK 写入和提示。 + 清理中同步重建时只释放旧所有权,不删除重建的新菜单。 + +公开工厂、同步 update/选中返回、getName 身份/参数、通知顺序、DOM/CSS hooks 与包入口保持。 +错误继续同步传播原对象,次要清理异常不覆盖原错误。上述历史缺陷作为有意修正记录, +不把冻结旧实现的缺陷观察改成候选期望。README 更新 SDK 适配说明,没有改 demo 或增加依赖。 + +## 验证 + +最初 22 项新增回归先运行旧源码;后续重入和格式化关闭的两组红灯另保留。正式三格式加入 +生命周期回归时发现 4 项 Node VM 数组原型比较问题,实际内容相同;用本地 Array.from +比较原有精确内容,未放宽断言。所有日志和输入指纹见 [执行证据](../baselines/dash-runtime.json)。 + +最终源码 116 项、包含三格式及两代 SDK 的 Node 266 项通过;正式 main/legacy 浏览器各 +84 项通过。完整 CI 712 项(661 单元、14 工程、37 基线)通过;包三格式构建成功, +计划/风险/本地链接校验通过。没有重试或跳过。DASH-LIFE-01 关闭,SDK/实际轨道风险仍 open。 +当前 217 项:73 done、3 doing、141 todo。已完成结构步,不计为 TS 迁移或发布完成。 + +真实 DOM 浏览器测试扩充到两种候选 SDK、两代核心及空拓扑/销毁:每格式 84 项。 +仍是可控 SDK 方法与原生 MP4,不是实际 dash.js/MPD/ABR 验收。SDK/状态的真实媒体风险 +继续由 05 接续;04 精确类型、06 分发、真机和全局发布复盘没有被本步替代。 + +本步通过标准构建脚本生成 dist/docs compiled,未手改产物。独立本地提交;不推送、不发布。 +回退本提交同时撤回结构、行为修正、回归和产物,恢复重构前仅支持 5.x 的源码;该限制需知悉。 diff --git a/refactor/dash-validation.md b/refactor/dash-validation.md index 2cc60f621..0f34d4b34 100644 --- a/refactor/dash-validation.md +++ b/refactor/dash-validation.md @@ -1,30 +1,34 @@ # DASH Control 验证与维护入口 -PKG-DASH-02 保护真实 npm 1.1.0 的既有调用契约,以及重构前工作区的 SDK 5.x 稳定 ID -修正。版本来源见 [发布契约](baselines/dash-control-contract.md)。本步没有更改生产实现。 +测试保护真实 npm 1.1.0 的既有调用契约,以及重构前工作区的 SDK 5.x 稳定 ID 修正。 +版本来源见 [发布契约](baselines/dash-control-contract.md)。02 建立基线,03 已改造生产实现; +实际模块地图与生命周期规则见 [包内架构](../packages/artplayer-plugin-dash-control/ARCHITECTURE.md)。 ## 可重跑的测试 -- `yarn test:dash-control`:原有 5 项过滤/稳定 ID 回归和新增 69 项行为测试,共 74 项。 +- `yarn test:dash-control`:原有 5 项过滤/稳定 ID 回归、80 项行为/历史观察、31 项候选生命周期,共 116 项。 该脚本和 `test:unit` 都保留旧测试,完整 CI 自动执行。 -- `yarn test:browser test/browser/dash-control.spec.js --trace on`:48 项,两个核心、两个插件、 - 两类菜单、两种选择、Chromium/Firefox/WebKit。浏览器套件串行运行,避免共享端口和报告覆盖。 +- `yarn test:browser test/browser/dash-control.spec.js --trace on`:84 项,两个核心、发布插件 SDK 4/ + 候选插件 SDK 4/5、两类菜单、两种选择及候选清理、Chromium/Firefox/WebKit。 + 浏览器套件串行运行,避免共享端口和报告覆盖。 - `yarn ci:check`:工具链、计划、lint、声明漂移、严格类型和 Node/基线测试。 Node helper 分别加载当前源码、固定 Git 提交的旧工作区 UMD、实际 npm main/legacy/module。 Git 内容以 LF 指纹核对,发布归档与成员以原始字节指纹核对。`ARTPLAYER_TEST_DASH` 可指定 -待测产物,以系统路径分隔符连接;未设置时不会把源码测试冒充三种候选产物验证。 +待测产物,以系统路径分隔符连接;同时指定 main/legacy/module 时共 266 项,包含两代 SDK 的 +行为与生命周期。未设置时不会把源码测试冒充三种候选产物验证。 `test/helpers/dash-control.js` 只提供可控 SDK 方法和组件注册表,检查 this、类型参数、 原始轨道身份、调用顺序、错误身份、ABR 配置保留、换 SDK、多实例和关闭后的引用。 -真实旧实现的缺陷观察保持冻结;03 修复时为候选实现新增正确行为断言,不把历史缺陷当作兼容要求。 +真实旧实现的缺陷观察保持冻结;候选生命周期新增正确行为断言,不把历史缺陷当作兼容要求。 浏览器使用真实发布/候选 ArtPlayer DOM、真实发布/候选插件和本地原生 MP4。SDK 方法可控, 断言真实点击触发的参数及控制栏/设置同步,再通过真实播放按钮验证视频时钟推进、无媒体错误。 **没有加载 dash.js,没有验证 MPD、ABR 自动切换或自适应流解码。** 这些仍是 05 必须完成的 -固定 dash.js 4.5.2 / 5.2.1 集成验证。`ARTPLAYER_DASH_ARTIFACT` 可替换插件脚本;本次没有使用。 +固定 dash.js 4.5.2 / 5.2.1 集成验证。`ARTPLAYER_DASH_ARTIFACT` 可替换插件脚本;02 未设置, +03 分别使用正式 main 与 legacy 文件。核心仍按服务端 manifest 加载发布/候选源码构建。 -## 首次失败与证据 +## 02 首次失败与证据 首次浏览器运行 24 通过、24 失败;全部失败位于设置选项文字定位。trace 中选项确实已渲染, 整行文本包含图标 SVG 的换行,`^fr$`/`^720p$` 不能匹配。改为当前面板内专用 @@ -35,11 +39,11 @@ Git 内容以 LF 指纹核对,发布归档与成员以原始字节指纹核对 [本步执行记录](baselines/dash-validation.json)。缓存不入 Git,永久保存的是测试、来源和结果摘要; 换机器应按上述命令重新取得执行证据,不能声称本地 trace 已上传。 -## 下一步缺陷与边界 +## 03 修正与后续边界 -- DASH-SDK-01:两代旧实现分别在另一代 SDK 方法表上失败。03 通过能力适配恢复旧用法。 -- DASH-LIFE-01:空列表/禁用后 UI 残留,旧回调仍写旧 SDK,销毁后 update 和订阅仍有效。 -- DASH-STATE-01:克隆当前轨道、重复标签、数字零 representation ID 会丢失高亮。 +- DASH-SDK-01:通过能力适配恢复两代方法表,实际 SDK/媒体仍由 05 验证。 +- DASH-LIFE-01:空列表/禁用后 UI、旧回调、销毁后 update/订阅已修正,补充重入和错误回归。 +- DASH-STATE-01:唯一克隆当前轨道、重复标签、数字零 ID 已修正;实际 SDK 轨道身份仍由 05 核验。 -这些受控复现仍是 open,不能以本步绿色测试关闭。03 处理结构、清理与正确当前项,04 完成公开 -类型兼容,05 完成真实 SDK/媒体/组合验证,06 完成包分发验收。物理设备和 npm 发布准入仍待完成。 +状态以 risks.json 为准;受控修复不替代真实 SDK 验收。04 完成严格 TS/公开类型兼容,05 完成 +真实 SDK/媒体/组合验证,06 完成包分发验收。物理设备和 npm 发布准入仍待完成。 diff --git a/refactor/plan.md b/refactor/plan.md index ba8b69941..871963c9d 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -4,7 +4,7 @@ 基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 217 项,范围 22 个包及工作区/示例。 -状态:todo 142 / doing 3 / blocked 0 / done 72 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 +状态:todo 141 / doing 3 / blocked 0 / done 73 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。 前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。 @@ -203,7 +203,7 @@ | --- | --- | --- | --- | --- | --- | --- | | PKG-DASH-01 | artplayer-plugin-dash-control
核对包契约与历史用法 | BASE-05 | quality/audio、representation ID/Auto、getName 和 update | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-DASH-02 | artplayer-plugin-dash-control
建立特有行为与错误测试 | PKG-DASH-01, ENG-03, ENG-05 | 保留已有 5 项稳定 ID 回归,补发布版 SDK 4.x/当前 5.x、音轨/空列表/过滤/换源与关闭引用 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | -| PKG-DASH-03 | artplayer-plugin-dash-control
整理内部职责与资源 | PKG-DASH-02, CORE-11, CORE-14 | 能力适配保留 SDK 4.x 与 5.x,稳定 ID 映射、ABR 状态、UI 清理和生命周期职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | +| PKG-DASH-03 | artplayer-plugin-dash-control
整理内部职责与资源 | PKG-DASH-02, CORE-11, CORE-14 | 能力适配保留 SDK 4.x 与 5.x,稳定 ID 映射、ABR 状态、UI 清理和生命周期职责分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done | | PKG-DASH-04 | artplayer-plugin-dash-control
迁移自有源码和公开类型 | PKG-DASH-03, ENG-04, ENG-06, CORE-07 | dash.js adapter、selector 和回调类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-DASH-05 | artplayer-plugin-dash-control
验证新旧核心和组合 | PKG-DASH-04, CORE-22 | 固定 dash.js 4.5.2/5.2.1 与本地 DASH 实际清晰度/音轨、高亮、Auto 和换源组合 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | | PKG-DASH-06 | artplayer-plugin-dash-control
验证分发并同步文档 | PKG-DASH-05, ENG-07 | dash.control.js、支持的 dash.js 版本与产物 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo | @@ -481,3 +481,4 @@ - PKG-HLS-SDK-01: [记录](changes/2026-09-12-PKG-HLS-SDK-01-integration.md) [记录](baselines/hls-sdk-validation.json) [记录](baselines/hls-sdk-diagnostics.json) - PKG-DASH-01: [记录](changes/2026-09-12-PKG-DASH-01-contract.md) [记录](baselines/dash-control-contract.md) [记录](baselines/dash-control-release.json) - PKG-DASH-02: [记录](changes/2026-09-12-PKG-DASH-02-tests.md) [记录](dash-validation.md) [记录](baselines/dash-validation.json) +- PKG-DASH-03: [记录](changes/2026-09-12-PKG-DASH-03-runtime.md) [记录](baselines/dash-runtime.json) [记录](dash-validation.md) diff --git a/refactor/progress.md b/refactor/progress.md index ebcfa9d4b..1a83c02f1 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -1,5 +1,20 @@ # 进度与证据 +## 最新完成:DASH-03 SDK 适配与生命周期源码改造 + +DASH 源码已拆为入口生命周期、SDK 适配、selector 模型、菜单所有权四个 JS 模块。 +恢复发布版 4.x 方法表,保留 5.x 稳定 ID;修复空菜单残留、过期引用、销毁/安装异常清理、 +重复标签/零 ID/唯一克隆轨道高亮,以及清理重入误删新 UI 和销毁后继续格式化的问题。 + +源码 116 项、三格式两代 SDK Node 266 项通过;正式插件 main/legacy 三引擎、新旧核心 +DOM/原生 MP4 各 84 项通过。完整 CI 712 项通过,实际 SDK/MPD/ABR 与设备验证仍未完成。 +初始 21 项失败和两组重入/格式化失败、VM 数组比较修正证据均保留,未以重试跳过制造通过。 +DASH-LIFE-01 关闭;DASH-SDK-01/DASH-STATE-01 仍需 05 实际 SDK 验证。 + +当前 217 项:73 done、3 doing、141 todo。下一步 DASH-04 迁移这四个模块和公开类型到严格 TS。 +详见 [源码变更](changes/2026-09-12-PKG-DASH-03-runtime.md)、[执行证据](baselines/dash-runtime.json) +及 [包内架构](../packages/artplayer-plugin-dash-control/ARCHITECTURE.md)。独立本地提交,不推送/发布。 + ## 最新完成:DASH-02 行为测试与真实 DOM 交互 保留原有 5 项稳定 ID 回归,新增发布三格式/冻结工作区/候选源码契约测试,定向 74 项通过。 diff --git a/refactor/risk-table.md b/refactor/risk-table.md index af2d04f3d..0df46db58 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -136,7 +136,7 @@ | CHAPTER-LAYOUT-01 | resolved / 已复现 | 长章节标题超出窄播放器进度条宽度 | PKG-CHAPTER-05 | | CHAPTER-TIMING-01 | open / 已复现 | Windows WebKit 清晰度切换曾在等待窗口内未见 restart,稍后状态恢复 | PKG-CHAPTER-05 | | DASH-SDK-01 | open / 已复现 | npm 发布版 4.x 与工作区仅 5.x 的 SDK 接口不兼容 | PKG-DASH-02, PKG-DASH-03, PKG-DASH-05 | -| DASH-LIFE-01 | open / 已复现 | DASH 空拓扑残留 UI,监听和旧选择回调缺乏关闭守卫 | PKG-DASH-02, PKG-DASH-03 | +| DASH-LIFE-01 | resolved / 已复现 | DASH 空拓扑残留 UI,监听和旧选择回调缺乏关闭守卫 | PKG-DASH-02, PKG-DASH-03 | | DASH-STATE-01 | open / 已复现 | DASH 当前项高亮使用真值 ID、音轨身份和标签去重,边界待核实 | PKG-DASH-02, PKG-DASH-03, PKG-DASH-05 | | DASH-TYPE-01 | open / 源码/产物事实 | DASH 类型未表达默认工厂参数、SDK 回调对象和现代模块入口 | PKG-DASH-04 | | DASH-DEMO-01 | open / 源码/产物事实 | DASH 示例仅新 SDK 且重复安装会累积销毁回调 | PKG-DASH-06 | diff --git a/refactor/risks.json b/refactor/risks.json index 861bf99a1..5cd84de15 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -2848,7 +2848,9 @@ "packages/artplayer-plugin-dash-control/types/artplayer-plugin-dash-control.d.ts", "docs/assets/example/dash.control.js", "test/dash-contract.test.js", - "refactor/dash-validation.md" + "refactor/dash-validation.md", + "test/dash-lifecycle.test.js", + "refactor/baselines/dash-runtime.json" ], "compatibleResolution": "按方法能力适配两代已存在用法,保留 v5 稳定 ID,不能要求旧消费者改用新 SDK。", "closureCriteria": "归档旧用法、v4/v5 受控及实际媒体矩阵通过,清晰度键不混用。" @@ -2857,7 +2859,7 @@ "id": "DASH-LIFE-01", "title": "DASH 空拓扑残留 UI,监听和旧选择回调缺乏关闭守卫", "confirmation": "reproduced", - "status": "open", + "status": "resolved", "owners": [ "PKG-DASH-02", "PKG-DASH-03" @@ -2869,10 +2871,18 @@ "packages/artplayer-plugin-dash-control/types/artplayer-plugin-dash-control.d.ts", "docs/assets/example/dash.control.js", "test/dash-contract.test.js", - "refactor/dash-validation.md" + "refactor/dash-validation.md", + "test/dash-lifecycle.test.js", + "refactor/baselines/dash-runtime.json" ], "compatibleResolution": "仅释放插件自有 ready/restart/destroy 订阅和 UI,SDK 仍由调用方拥有;替换后旧回调不写旧实例。", - "closureCriteria": "空列表、换源/SDK、销毁后引用、安装异常有回归,其他实例与 SDK 不被误释放。" + "closureCriteria": "空列表、换源/SDK、销毁后引用、安装异常有回归,其他实例与 SDK 不被误释放。", + "resolutionEvidence": [ + "refactor/changes/2026-09-12-PKG-DASH-03-runtime.md", + "refactor/baselines/dash-runtime.json", + "packages/artplayer-plugin-dash-control/ARCHITECTURE.md" + ], + "resolutionRationale": "空拓扑/禁用/失败/销毁清理自有 UI 与订阅,旧 revision/SDK 引用无副作用;先红后绿、两代方法表及三格式 Node 生命周期、新旧核心 main/legacy 各 84 项真实 DOM 通过。只关闭插件所有权和引用缺陷,不替代实际 SDK/MPD、demo 或设备验证。" }, { "id": "DASH-STATE-01", @@ -2891,7 +2901,9 @@ "packages/artplayer-plugin-dash-control/types/artplayer-plugin-dash-control.d.ts", "docs/assets/example/dash.control.js", "test/dash-contract.test.js", - "refactor/dash-validation.md" + "refactor/dash-validation.md", + "test/dash-lifecycle.test.js", + "refactor/baselines/dash-runtime.json" ], "compatibleResolution": "验证零 ID、重复标签和克隆音轨当前项,在保持原展示语义时按实际可选项定位高亮。", "closureCriteria": "有受控和真实 SDK 证据说明选中的 representation/track 与 UI 一致。" diff --git a/refactor/tasks.json b/refactor/tasks.json index 777e2919f..b896ab4e8 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -2014,11 +2014,15 @@ "CORE-11", "CORE-14" ], - "status": "todo", + "status": "done", "risk": "H", "deliverable": "能力适配保留 SDK 4.x 与 5.x,稳定 ID 映射、ABR 状态、UI 清理和生命周期职责分离", "acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过", - "evidence": [] + "evidence": [ + "changes/2026-09-12-PKG-DASH-03-runtime.md", + "baselines/dash-runtime.json", + "dash-validation.md" + ] }, { "id": "PKG-DASH-04", diff --git a/test/browser/dash-control.spec.js b/test/browser/dash-control.spec.js index e986a0669..5ecda9c0f 100644 --- a/test/browser/dash-control.spec.js +++ b/test/browser/dash-control.spec.js @@ -10,22 +10,14 @@ let sourceCode let evidence test.beforeAll(async () => { const { release } = JSON.parse(fs.readFileSync(new URL('../../refactor/baselines/dash-control-release.json', import.meta.url))) - const member = 'package/dist/artplayer-plugin-dash-control.js' - const bytes = readMember(await ensureArchive(release), member) - assert.equal(hash(bytes), release.files[member]) - publishedCode = bytes.toString() - assert(!process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Controlled DASH source tests do not represent installed SDK/package acceptance') - sourceCode = process.env.ARTPLAYER_DASH_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_DASH_ARTIFACT, 'utf8') : await compilePackage(release.name, 'umd') - evidence = { published: release, sourceSHA256: hash(sourceCode), candidate: process.env.ARTPLAYER_DASH_ARTIFACT || 'workspace source build', scope: 'Real ArtPlayer DOM and native MP4 with controlled SDK methods. No dash.js instance, MPD, ABR or adaptive media validation.' } }) - test.afterEach(async ({ page }, testInfo) => { const state = await page.evaluate(() => ({ calls: window.dashCalls, @@ -36,23 +28,20 @@ test.afterEach(async ({ page }, testInfo) => { audio: window.art?.controls['dash-audio']?.textContent, settingRows: [...document.querySelectorAll('.art-setting-panel.art-current .art-setting-item')].map(row => ({ text: row.textContent, label: row.querySelector('.art-setting-item-left-text')?.textContent })), })).catch(error => ({ error: error.message })) - await testInfo.attach('controlled-dash-state', { contentType: 'application/json', body: JSON.stringify(state) }) - - if (!page.isClosed()) - await page.evaluate(() => window.art?.destroy()) + if (!page.isClosed()) { + await page.evaluate(() => { + if (window.art && !window.art.isDestroy) + window.art.destroy() + }) + } }) - -async function openDash(page, core, plugin, testInfo) { +async function openDash(page, core, plugin, sdk, testInfo) { await testInfo.attach('controlled-dash-inputs', { contentType: 'application/json', body: JSON.stringify(evidence) }) - await page.goto(`/test/player.html?core=${core}`) - await page.addScriptTag({ content: plugin === 'published' ? publishedCode : sourceCode }) - await page.evaluate((sdk) => { window.dashCalls = [] - window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', @@ -62,13 +51,9 @@ async function openDash(page, core, plugin, testInfo) { const levels = sdk === 4 ? [{ qualityIndex: 0, height: 360 }, { qualityIndex: 1, height: 720 }] : [{ id: 'low', height: 360 }, { id: 'high', height: 720 }] - const tracks = [{ id: 'en', lang: 'en' }, { id: 'fr', lang: 'fr' }] - window.dashState = { levels, tracks, track: tracks[0], current: levels[0], quality: 0, settings: { streaming: { abr: { autoSwitchBitrate: { video: true } } } } } - const state = window.dashState - art.dash = { getVideoElement: () => art.template.$video, getSettings: () => state.settings, @@ -83,7 +68,6 @@ async function openDash(page, core, plugin, testInfo) { window.dashCalls.push(['track', track.id, tracks.includes(track)]) }, } - if (sdk === 4) { Object.assign(art.dash, { getBitrateInfoListFor: () => levels, @@ -104,68 +88,83 @@ async function openDash(page, core, plugin, testInfo) { }, }) } - return window.artplayerPluginDashControl({ quality: { control: true, setting: true }, audio: { control: true, setting: true } })(art) }], }) - document.querySelector('#play').onclick = () => window.art.play() - document.querySelector('#pause').onclick = () => window.art.pause() - }, plugin === 'published' ? 4 : 5) - + }, sdk) await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true) - await expect(page.locator('.art-control-dash-quality .art-selector-value')).toHaveText('Auto') - await expect(page.locator('.art-control-dash-audio .art-selector-value')).toHaveText('en') } - for (const core of ['published', 'candidate']) { - for (const plugin of ['published', 'candidate']) { + for (const [plugin, sdk] of [['published', 4], ['candidate', 4], ['candidate', 5]]) { for (const surface of ['control', 'setting']) { for (const type of ['quality', 'audio']) { - test(`${core} core + ${plugin} DASH: controlled SDK ${type} selection through real ${surface} DOM`, async ({ page }, testInfo) => { - await openDash(page, core, plugin, testInfo) - + test(`${core} core + ${plugin} DASH SDK ${sdk}: controlled SDK ${type} selection through real ${surface} DOM`, async ({ page }, testInfo) => { + await openDash(page, core, plugin, sdk, testInfo) const text = type === 'quality' ? '720p' : 'fr' - if (surface === 'control') { const control = page.locator(`.art-control-dash-${type}`) - await control.hover() - await expect(control.locator('.art-selector-list')).toHaveCSS('opacity', '1') - await control.locator('.art-selector-item').filter({ hasText: new RegExp(`^${text}$`) }).click() } else { await page.locator('.art-control-setting').click() - await page.locator(`[data-name="dash-${type}"]`).click() - await page.locator('.art-setting-panel.art-current .art-setting-item-left-text').filter({ hasText: new RegExp(`^${text}$`) }).click() } - const expected = type === 'quality' - ? [['auto', false], plugin === 'published' ? ['qualityIndex', 'video', 1] : ['representationId', 'video', 'high']] + ? [['auto', false], sdk === 4 ? ['qualityIndex', 'video', 1] : ['representationId', 'video', 'high']] : [['track', 'fr', true]] - await expect.poll(() => page.evaluate(() => window.dashCalls)).toEqual(expected) - await expect(page.locator(`.art-control-dash-${type} .art-selector-value`)).toHaveText(text) - expect(await page.evaluate(type => window.art.setting.find(`dash-${type}`).tooltip, type)).toBe(text) - await testInfo.attach('controlled-dash-selection', { contentType: 'image/png', body: await page.screenshot() }) - await page.locator('#play').click() - await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.2) - expect(await page.evaluate(() => window.art.video.error)).toBeNull() }) } } } } +for (const core of ['published', 'candidate']) { + for (const sdk of [4, 5]) { + test(`${core} core + candidate DASH SDK ${sdk}: empty topology and destroy clear real menus and retained callbacks`, async ({ page }, testInfo) => { + await openDash(page, core, 'candidate', sdk, testInfo) + const result = await page.evaluate((sdk) => { + const art = window.art + const plugin = art.plugins.artplayerPluginDashControl + const quality = art.controls.cache.get('dash-quality').option + const audio = art.setting.find('dash-audio') + const state = window.dashState + const levels = state.levels + const tracks = state.tracks + state.levels.splice(0) + state.tracks = [] + plugin.update() + const cleared = !document.querySelector('.art-control-dash-quality, .art-control-dash-audio') && !art.setting.find('dash-quality') && !art.setting.find('dash-audio') + window.dashCalls.length = 0 + quality.onSelect(quality.selector[0]) + audio.onSelect(audio.selector[1]) + const staleWrites = window.dashCalls.length + // Replace the lists after the empty-topology observation. + state.levels.push(...(sdk === 4 ? [{ qualityIndex: 0, height: 360 }, { qualityIndex: 1, height: 720 }] : [{ id: 'low', height: 360 }, { id: 'high', height: 720 }])) + state.tracks = tracks + plugin.update() + const restored = Boolean(art.controls['dash-quality'] && art.controls['dash-audio']) + const retained = art.controls.cache.get('dash-quality').option + art.destroy(false) + window.dashCalls.length = 0 + retained.onSelect(retained.selector[0]) + plugin.update() + return { cleared, staleWrites, restored, afterDestroyWrites: window.dashCalls.length, sameList: state.levels === levels } + }, sdk) + expect(result).toEqual({ cleared: true, staleWrites: 0, restored: true, afterDestroyWrites: 0, sameList: true }) + await testInfo.attach('controlled-dash-cleanup', { contentType: 'application/json', body: JSON.stringify(result) }) + }) + } +} diff --git a/test/dash-lifecycle.test.js b/test/dash-lifecycle.test.js new file mode 100644 index 000000000..a25669340 --- /dev/null +++ b/test/dash-lifecycle.test.js @@ -0,0 +1,259 @@ +import assert from 'node:assert/strict' +// eslint-disable-next-line test/no-import-node-test -- Runtime lifecycle contracts use Node's built-in runner. +import test from 'node:test' +import { bothMenus, dashHost, dashImplementations } from './helpers/dash-control.js' + +const candidates = (await dashImplementations()).filter(item => item.name.startsWith('source') || item.name.startsWith('artifact')) +const { factory } = candidates[0] +for (const { sdk, name, factory } of candidates) { + test(`DASH ${sdk} (${name}): supports the original SDK method generation and stable selection keys`, () => { + const host = dashHost(sdk) + factory(bothMenus())(host.art).update() + const menu = host.controls.get('dash-quality') + const selected = menu.selector.find(item => item.html === '720p') + host.calls.length = 0 + assert.equal(menu.onSelect(selected), '720p') + assert.deepEqual(host.calls[1], sdk === 4 ? ['setQualityFor', 'video', 1] : ['setRepresentationForTypeById', 'video', 'high']) + }) + + test(`DASH ${sdk} (${name}): empty lists clear owned UI and invalidate retained selectors`, () => { + const host = dashHost(sdk) + const plugin = factory(bothMenus())(host.art) + plugin.update() + const menu = host.controls.get('dash-quality') + const { levels, tracks } = host.state + host.state.levels = [] + host.state.tracks = [] + plugin.update() + assert.equal(host.controls.size + host.settings.size, 0) + host.calls.length = 0 + assert.equal(menu.onSelect(menu.selector[0]), '1080p') + assert.equal(host.calls.length, 0) + host.state.levels = levels + host.state.tracks = tracks + host.art.emit('restart') + assert.equal(host.controls.size + host.settings.size, 4) + }) + + test(`DASH ${sdk} (${name}): disabling one surface removes only that surface`, () => { + const host = dashHost(sdk) + const options = bothMenus() + const plugin = factory(options)(host.art) + plugin.update() + options.quality.control = false + options.audio.setting = false + plugin.update() + assert.deepEqual([...host.controls.keys()], ['dash-audio']) + assert.deepEqual([...host.settings.keys()], ['dash-quality']) + }) + + test(`DASH ${sdk} (${name}): destroy unsubscribes and makes update/retained callbacks inert without owning SDK`, () => { + const host = dashHost(sdk) + const other = dashHost(sdk) + factory(bothMenus())(other.art).update() + const plugin = factory(bothMenus())(host.art) + plugin.update() + const menu = host.controls.get('dash-audio') + host.art.destroy() + assert.equal(host.controls.size + host.settings.size, 0) + assert.equal([...host.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0) + host.calls.length = 0 + plugin.update() + host.art.emit('ready') + host.art.emit('restart') + host.art.destroy() + assert.equal(menu.onSelect(menu.selector[1]), 'fr') + assert.equal(host.calls.length, 0) + assert.equal(other.controls.size + other.settings.size, 4) + }) + + test(`DASH ${sdk} (${name}): replacing SDK immediately silences old callbacks before and after update`, () => { + const host = dashHost(sdk) + const next = dashHost(sdk) + const plugin = factory(bothMenus())(host.art) + plugin.update() + const menu = host.controls.get('dash-quality') + next.state.video = host.video + host.art.dash = next.dash + host.calls.length = 0 + menu.onSelect(menu.selector[0]) + assert.equal(host.calls.length, 0) + plugin.update() + host.calls.length = 0 + menu.onSelect(menu.selector[0]) + assert.equal(host.calls.length, 0) + assert.equal(next.calls.length, 0) + }) + + test(`DASH ${sdk} (${name}): duplicate labels preserve the current selectable object`, () => { + const host = dashHost(sdk) + host.state.settings.streaming.abr.autoSwitchBitrate.video = false + host.state.levels[1].height = 360 + host.state.tracks[1].lang = 'en' + host.state.currentTrack = host.state.tracks[1] + factory(bothMenus())(host.art).update() + const quality = host.controls.get('dash-quality') + const selected = quality.selector.filter(item => item.default) + assert.equal(selected.length, 1) + host.calls.length = 0 + quality.onSelect(selected[0]) + assert.deepEqual(host.calls[1], sdk === 4 ? ['setQualityFor', 'video', 1] : ['setRepresentationForTypeById', 'video', 'high']) + const audio = host.controls.get('dash-audio').selector.filter(item => item.default) + assert.equal(audio.length, 1) + assert.equal(audio[0].value, host.state.tracks[1]) + }) + + test(`DASH ${sdk} (${name}): cloned current track matches a unique track but never fabricates ambiguous selection`, () => { + const host = dashHost(sdk) + const plugin = factory(bothMenus())(host.art) + host.state.currentTrack = { ...host.state.tracks[1] } + plugin.update() + assert.equal(host.controls.get('dash-audio').selector.find(item => item.default).value, host.state.tracks[1]) + host.state.tracks.push({ ...host.state.tracks[1], lang: 'fr-duplicate' }) + host.state.currentTrack = { id: 'fr', index: 1 } + plugin.update() + assert.equal(host.controls.get('dash-audio').selector.some(item => item.default), false) + }) + + test(`DASH ${sdk} (${name}): reentrant getName destruction cannot repopulate menus`, () => { + const host = dashHost(sdk) + const options = bothMenus() + let calls = 0 + options.quality.getName = (level) => { + calls++ + host.art.destroy() + return `${level.height}p` + } + factory(options)(host.art).update() + assert.equal(host.controls.size + host.settings.size, 0) + assert.equal(host.calls.length, 0) + assert.equal(calls, 1) + }) + + test(`DASH ${sdk} (${name}): synchronous SDK destruction stops later selection writes and notifications`, () => { + const host = dashHost(sdk) + factory(bothMenus())(host.art).update() + const menu = host.controls.get('dash-quality') + const original = host.dash.updateSettings + host.dash.updateSettings = (value) => { + original.call(host.dash, value) + host.art.destroy() + } + host.calls.length = 0 + assert.equal(menu.onSelect(menu.selector[0]), '1080p') + assert.equal(host.calls.some(call => /^set(?:Quality|Representation)/.test(call[0])), false) + assert.equal(host.calls.some(call => call[0] === 'notice' || call[0].endsWith('.check')), false) + }) + + test(`DASH ${sdk} (${name}): rendering errors clear partial own menus and allow an explicit retry`, () => { + const host = dashHost(sdk) + const failure = new Error('setting rendering failed') + const original = host.art.setting.update + host.art.setting.update = (option) => { + original(option) + throw failure + } + const plugin = factory(bothMenus())(host.art) + assert.throws(plugin.update, error => error === failure) + assert.equal(host.controls.size + host.settings.size, 0) + host.art.setting.update = original + plugin.update() + assert.equal(host.controls.size + host.settings.size, 4) + }) + + test(`DASH ${sdk} (${name}): nested update from a formatter keeps the latest render`, () => { + const host = dashHost(sdk) + const options = bothMenus() + const plugin = factory(options)(host.art) + let nested = false + options.quality.getName = (level) => { + if (!nested) { + nested = true + host.state.levels = [host.state.levels[2]] + plugin.update() + } + return `${level.height}p` + } + plugin.update() + assert.deepEqual(Array.from(host.controls.get('dash-quality').selector, item => item.html), ['1080p', 'Auto']) + assert.equal(host.controls.size + host.settings.size, 4) + }) + + test(`DASH ${sdk} (${name}): a cleanup-triggered restart does not remove newly installed menus`, () => { + const host = dashHost(sdk) + const plugin = factory(bothMenus())(host.art) + plugin.update() + const { levels } = host.state + host.state.levels = [] + const remove = host.art.controls.remove + host.art.controls.remove = (name) => { + remove(name) + host.state.levels = levels + plugin.update() + } + plugin.update() + assert.equal(host.controls.size + host.settings.size, 4) + }) + + test(`DASH ${sdk} (${name}): clearing a caller-replaced registry entry preserves the caller entry`, () => { + const host = dashHost(sdk) + host.art.controls.cache = { get: name => ({ option: host.controls.get(name) }) } + host.art.setting.find = name => host.settings.get(name) + factory(bothMenus())(host.art).update() + const replacement = { onSelect() {} } + host.controls.set('dash-quality', replacement) + host.settings.set('dash-audio', replacement) + host.art.destroy() + assert.equal(host.controls.get('dash-quality'), replacement) + assert.equal(host.settings.get('dash-audio'), replacement) + assert.equal(host.controls.size + host.settings.size, 2) + }) + + test(`DASH ${sdk} (${name}): cleanup failure does not retain other surfaces or listeners`, () => { + const host = dashHost(sdk) + factory(bothMenus())(host.art).update() + const failure = new Error('control cleanup failed') + const remove = host.art.controls.remove + host.art.controls.remove = (name) => { + remove(name) + throw failure + } + assert.throws(() => host.art.destroy(), error => error === failure) + assert.equal(host.controls.size + host.settings.size, 0) + assert.equal([...host.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0) + host.art.destroy() + }) +} + +test('DASH v5: a numeric zero ID is a valid manually selected representation', () => { + const host = dashHost(5) + host.state.levels[1].id = 0 + host.state.settings.streaming.abr.autoSwitchBitrate.video = false + factory(bothMenus())(host.art).update() + assert.deepEqual(host.controls.get('dash-quality').selector.filter(item => item.default).map(item => item.id), [0]) +}) + +test('DASH installation failure removes even the listener registered by a throwing host', () => { + const host = dashHost() + const failure = new Error('subscription failed') + const on = host.art.on + host.art.on = (name, callback) => { + on(name, callback) + if (name === 'destroy') + throw failure + } + assert.throws(() => factory(bothMenus())(host.art), error => error === failure) + assert.equal([...host.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0) +}) + +test('DASH reentrant destruction during subscription cannot install later listeners', () => { + const host = dashHost() + const on = host.art.on + host.art.on = (name, callback) => { + on(name, callback) + if (name === 'destroy') + callback() + } + factory()(host.art) + assert.equal([...host.listeners.values()].reduce((sum, set) => sum + set.size, 0), 0) +}) diff --git a/test/helpers/dash-control.js b/test/helpers/dash-control.js index 6dffe4307..44c67f7fa 100644 --- a/test/helpers/dash-control.js +++ b/test/helpers/dash-control.js @@ -22,6 +22,7 @@ export async function dashImplementations() { const { release } = baseline const archive = await ensureArchive(release) const implementations = [{ name: 'source', sdk: 5, factory: (await loadPackage(release.name)).default }] + implementations.push({ name: 'source-v4', sdk: 4, factory: implementations[0].factory }) const historicalFile = `packages/${release.name}/dist/artplayer-plugin-dash-control.js` const historical = execFileSync('git', ['show', `${baseline.sourceCommit}:${historicalFile}`]) assert.equal(hash(historical.toString().replace(/\r\n/g, '\n')), baseline.source[historicalFile]) @@ -34,7 +35,9 @@ export async function dashImplementations() { } for (const filename of (process.env.ARTPLAYER_TEST_DASH || '').split(path.delimiter).filter(Boolean)) { const bytes = fs.readFileSync(filename) - implementations.push({ name: `artifact-${path.basename(filename)}`, sdk: 5, factory: await factoryFromBytes(bytes, filename.endsWith('.mjs')) }) + const factory = await factoryFromBytes(bytes, filename.endsWith('.mjs')) + for (const sdk of [4, 5]) + implementations.push({ name: `artifact-${path.basename(filename)}-v${sdk}`, sdk, factory }) } return implementations }