refactor(core): CORE-14 split typed settings and scope tree lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 06:49:04 +08:00
1 parent 697817eceb
commit 64d1939daa
51 files changed
+28144 -1519

No files matched your search

+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+1221 -454
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/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:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+85
View File
@@ -4,6 +4,91 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Settings (CORE-14)
setting/index.ts remains the specialized manager: panel cache keys are option arrays,
add/update return the original item, remove returns undefined and missing find returns
null. A narrow constructor type view preserves the actual Component prototype chain
without imposing its incompatible string-keyed cache or registry return types.
It delegates tree work to setting/model.ts and measurement to setting/layout.ts.
The tree model preserves object/array identity and immutable hidden getter descriptors,
while rebinding a removed/moved node to its current parent. Automatic names skip explicit
names anywhere in the tree. Structural validation runs before installing bindings;
traverse retains preorder and callback-driven child insertion. Model ownership uses
an opaque owner state and root scope, with no direct player/manager reference. Closing
the owner clears its root and scope references so bindings do not retain the old owner.
It rejects an item that still belongs to another active tree before modifying either
tree. Removing the subtree or closing its owner permits original-object reuse, with
the same immutable getters and event array. Simultaneous sharing was already broken
in the published player (the second instance had no row); it now reports an explicit
ownership error instead of allowing the new player to steal the first player's row.
The layout module computes dimensions independently from DOM reads. Width is bounded
by player width and resolved bottom padding; height fits above actual control rows,
using the existing scrolling panel. Control coordinates account for CSS scaling.
The existing right-edge CSS fallback and mobile/rotation positioning branches remain.
render.ts now owns DOM/descriptor rendering, selection.ts owns callback generations,
and resources.ts owns item listeners, deferred mounts and recursive panel disposal.
The four builtin setting factories are TS and release their Emitter subscriptions.
events.ts owns root subscriptions and size/style observers. Control height changes
can animate the panel's bottom offset; transitionend recalculates its final height.
The MutationObserver fallback also consumes the existing resize event for container
changes. All these callbacks stop when the instance closes.
Failed additions unlink the attempted entry without releasing a duplicate's existing
listeners. Successfully formatted new entries also release partial render resources.
registration.ts prevents an obsolete failed add from removing a reentrant successful
add/update of the same object; removal and update supersede the pending registration.
Removal finishes row cleanup and root rendering even if event removal throws, then
reports collected errors. Nonextensible metadata and unwritable generated names are
checked before binding earlier entries. These checks do not roll back arbitrary
user getter/setter or Proxy side effects.
panels.ts gives each cached panel a root-owned scope. Rows are child scopes and the
back header is owned by its panel while retaining the parent's public event array.
Disposing the panel removes its cache entry, listeners, pending mounts and DOM.
New-panel rendering checkpoints item descriptors and the original position of raw
content nodes, without calling user getters. A failure restores those inputs, the
previous active panel and owned layout properties. Creation stops if a header hook
removes the parent or destroys the instance.
update.ts suspends the old subtree instead of disposing it before assignment. The
same public event array temporarily holds only replacement registrations; suspended
scopes retain their old registrations privately. activity.ts prevents suspended
callbacks and asynchronous writes. Success disposes the old resources; failure
resumes the original registrations and restores descriptors, real DOM nodes, range
array identity/live input values, switch state, tree bindings and owned layout.
The current input getter order is retained, but assignment stops after a reentrant
operation supersedes it. Pending operations are tracked across their descendants;
removing an ancestor cancels its child's update before releasing the subtree.
Renderers retain their captured operation/scope so a late getter failure cannot
dispose a newer row. Cleanup failures after a committed replacement are reported;
the successful new row is not rolled back to already-disposed resources. Arbitrary
user accessor/Proxy side effects remain outside the managed-state rollback boundary.
Cached-panel navigation now restores the previous panel/layout on failure. A newer
navigation, removal or destruction supersedes that restoration, even if the failing
outer call resumes afterward. It does not rebuild cached rows or their listeners.
Long labels and tooltips shrink with ellipsis while retaining their full DOM content.
Icons and native controls keep their width, including in a 320px player. Tests cover
trusted keyboard input/change ordering, pointer switch callbacks, touch selection,
and actual builtin autoOrientation transforms with a local video. Mobile UA/viewport
and touch emulation are not certification on physical mobile devices.
Public Setting return declaration discrepancies remain tracked by BASE-TYPE-07 for
the compatible public facade at CORE-21. The source manager has precise item/null/void
returns and does not change runtime behavior to match incorrect legacy declarations.
Tests: setting-model.test.js, setting-layout.test.js, setting-resources.test.js,
types/setting-model.ts, types/setting-manager.ts and
browser/setting.spec.js, browser/setting-update.spec.js and browser/setting-ownership.spec.js.
browser/setting-interaction.spec.js covers real input, long labels and mobile rotation.
Run the shared test scripts and the full installed UMD/legacy browser matrices after
changing these boundaries. The per-package and final release tasks own the complete
ecosystem/SDK matrix, remote CI and physical-device release review.
## Components and controls (CORE-13)
The public registries remain Component-based. utils/component.ts owns add/update/remove,
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var a=t=>{try{s(r.next(t))}catch(e){o(e)}},i=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+1221 -454
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
import type ResourceScope from '../lifecycle/scope'
const pauses = new WeakMap<ResourceScope, number>()
export function settingScopeActive(scope: ResourceScope): boolean {
return !scope.closed && !pauses.get(scope)
}
export function pauseSettingScope(scope: ResourceScope): () => void {
pauses.set(scope, (pauses.get(scope) || 0) + 1)
let paused = true
return () => {
if (!paused)
return
paused = false
const count = (pauses.get(scope) || 1) - 1
if (count)
pauses.set(scope, count)
else
pauses.delete(scope)
}
}
@@ -1,11 +1,14 @@
export default function aspectRatio(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function aspectRatio(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, ASPECT_RATIO },
} = art
function getI18n(value) {
function getI18n(value: string) {
return value === 'default' ? i18n.get('Default') : value
}
@@ -29,12 +32,12 @@ export default function aspectRatio(art) {
}
}),
onSelect(item) {
art.aspectRatio = item.value
art.aspectRatio = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('aspectRatio', () => update())
subscribeSetting(art, item, 'aspectRatio', () => update())
},
}
}
@@ -0,0 +1,40 @@
import type { SettingItem } from './types'
type CaptureState = () => () => void
const states = new WeakMap<SettingItem, CaptureState>()
export function rememberSettingState(item: SettingItem, capture?: CaptureState): void {
if (capture)
states.set(item, capture)
else
states.delete(item)
}
// Capture descriptors without invoking user getters while preparing a render.
export function captureSettingItem(item: SettingItem): () => void {
const descriptors = new Map(Reflect.ownKeys(item).map(key => [key, Object.getOwnPropertyDescriptor(item, key)!]))
const nodes: { node: Node, parent: Node | null, next: Node | null }[] = []
const state = states.get(item)
const restoreState = state?.()
for (const key of ['html', 'icon', 'tooltip']) {
const value: unknown = descriptors.get(key)?.value
if (value instanceof Node)
nodes.push({ node: value, parent: value.parentNode, next: value.nextSibling })
}
return () => {
for (const key of Reflect.ownKeys(item)) {
if (!descriptors.has(key) && Object.getOwnPropertyDescriptor(item, key)?.configurable)
Reflect.deleteProperty(item, key)
}
for (const [key, descriptor] of descriptors)
Object.defineProperty(item, key, descriptor)
restoreState?.()
rememberSettingState(item, state)
for (const { node, parent, next } of nodes) {
if (parent)
parent.insertBefore(node, next?.parentNode === parent ? next : null)
else
node.parentNode?.removeChild(node)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { SettingEvents, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function installSettingEvents(setting: SettingManager): void {
const { art } = setting
const scope = getScope(art).child()
const on = <Name extends keyof SettingEvents>(name: Name, callback: (...args: SettingEvents[Name]) => unknown) => {
if (scope.closed || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (!scope.closed && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
on('blur', () => {
if (setting.show) {
setting.show = false
setting.render()
}
})
on('focus', (event) => {
const isControl = includeFromEvent(event, art.controls.setting)
const isSetting = includeFromEvent(event, setting.$parent)
if (setting.show && !isControl && !isSetting) {
setting.show = false
setting.render()
}
})
on('resize', () => setting.resize())
const resize = () => {
if (!scope.closed && !isClosing(art))
setting.resize()
}
const transitioned = art.proxy(setting.$parent, 'transitionend', (event) => {
if (event.target === setting.$parent && (event as TransitionEvent).propertyName === 'bottom')
resize()
})
scope.add(() => {
art.events.remove(transitioned)
})
const { $player, $controls } = art.template
if (!scope.closed && typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(resize)
observer.observe($player)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
if (!scope.closed && typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(resize)
if (typeof ResizeObserver === 'undefined')
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
// Controls can publish their measured height after our ResizeObserver callback.
observer.observe($player, { attributes: true, attributeFilter: ['class', 'style'] })
scope.add(() => {
observer.disconnect()
})
}
}
@@ -1,13 +1,15 @@
import type { SettingHost, SettingItem } from './types'
import { capitalize } from '../utils'
import { subscribeSetting } from './resources'
export default function flip(art) {
export default function flip(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, FLIP },
} = art
function getI18n(value) {
function getI18n(value: string) {
return i18n.get(capitalize(value))
}
@@ -31,12 +33,12 @@ export default function flip(art) {
}
}),
onSelect(item) {
art.flip = item.value
art.flip = item.value as string
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('flip', () => update())
subscribeSetting(art, item, 'flip', () => update())
},
}
}
-558
View File
@@ -1,558 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getRect,
has,
includeFromEvent,
inverseClass,
isMobile,
remove,
replaceElement,
setStyle,
} from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import flip from './flip'
import playbackRate from './playbackRate'
import subtitleOffset from './subtitleOffset'
export default class Setting extends Component {
constructor(art) {
super(art)
const {
option,
controls,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
if (option.setting) {
this.format()
this.render()
art.on('blur', () => {
if (this.show) {
this.show = false
this.render()
}
})
art.on('focus', (event) => {
const isControl = includeFromEvent(event, controls.setting)
const isSetting = includeFromEvent(event, this.$parent)
if (this.show && !isControl && !isSetting) {
this.show = false
this.render()
}
})
art.on('resize', () => this.resize())
}
}
get builtin() {
const result = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback, option = this.option) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
callback(item)
if (item.selector?.length) {
this.traverse(callback, item.selector)
}
}
}
check(target) {
if (!target) {
return
}
target.$parent.tooltip = target.html
this.traverse((item) => {
item.default = item === target
if (item.default && item.$item) {
inverseClass(item.$item, 'art-current')
}
}, target.$option)
this.render(target.$parents)
}
format(option = this.option, parent, parents, names = []) {
for (let index = 0; index < option.length; index++) {
const item = option[index]
if (item?.name) {
errorHandle(!names.includes(item.name), `The [${item.name}] already exists in [setting]`)
names.push(item.name)
}
else {
item.name = `setting-${this.id++}`
}
if (!item.$formatted) {
def(item, '$parent', {
get: () => parent,
})
def(item, '$parents', {
get: () => parents,
})
def(item, '$option', {
get: () => option,
})
const $events = []
def(item, '$events', {
get: () => $events,
})
def(item, '$formatted', {
get: () => true,
})
}
this.format(item.selector || [], item, option, names)
}
this.option = option
}
find(name = '') {
let result = null
this.traverse((item) => {
if (item.name === name) {
result = item
}
})
return result
}
resize() {
const {
controls,
constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT },
template: { $player, $setting },
} = this.art
if (controls.setting && this.show) {
const settingWidth = this.active[0]?.$parent?.width || SETTING_WIDTH
const { left: controlLeft, width: controlWidth } = getRect(controls.setting)
const { left: playerLeft, width: playerWidth } = getRect($player)
const settingLeft = controlLeft - playerLeft + controlWidth / 2 - settingWidth / 2
const settingHeight
= this.active === this.option
? this.active.length * SETTING_ITEM_HEIGHT
: (this.active.length + 1) * SETTING_ITEM_HEIGHT
setStyle($setting, 'height', `${settingHeight}px`)
setStyle($setting, 'width', `${settingWidth}px`)
if (this.art.isRotate || isMobile)
return
if (settingLeft + settingWidth > playerWidth) {
setStyle($setting, 'left', null)
setStyle($setting, 'right', null)
}
else {
setStyle($setting, 'left', `${settingLeft}px`)
setStyle($setting, 'right', 'auto')
}
}
}
inactivate(item) {
for (let index = 0; index < item.$events.length; index++) {
this.art.events.remove(item.$events[index])
}
item.$events.length = 0
}
remove(name) {
const item = this.find(name)
errorHandle(item, `Can't find [${name}] in the [setting]`)
const index = item.$option.indexOf(item)
item.$option.splice(index, 1)
this.inactivate(item)
if (item.$item)
remove(item.$item)
this.render()
}
update(target) {
const item = this.find(target.name)
if (item) {
this.inactivate(item)
Object.assign(item, target)
this.format()
this.createItem(item, true)
this.render()
return item
}
else {
return this.add(target)
}
}
add(item, option = this.option) {
option.push(item)
this.format()
this.createItem(item)
this.render()
return item
}
createHeader(item) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const {
proxy,
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = this.art
const $item = createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent.html)
const event = proxy($item, 'click', () => this.render(item.$parents))
item.$parent.$events.push(event)
append($panel, $item)
}
createItem(item, isUpdate = false) {
if (!this.cache.has(item.$option))
return
const $panel = this.cache.get(item.$option)
const oldItem = item.$item
let type = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, proxy, constructor } = this.art
const $item = createElement('div')
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = item.value || ''
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range':
append($icon, item.icon || icons.config)
break
case 'selector':
if (item.selector?.length) {
append($icon, item.icon || icons.config)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
def(item, '$icon', {
configurable: true,
get: () => $icon,
})
def(item, 'icon', {
configurable: true,
get() {
return $icon.innerHTML
},
set(value) {
$icon.innerHTML = ''
append($icon, value)
},
})
const $html = createElement('div')
addClass($html, 'art-setting-item-left-text')
append($html, item.html || '')
append($left, $html)
def(item, '$html', {
configurable: true,
get: () => $html,
})
def(item, 'html', {
configurable: true,
get() {
return $html.innerHTML
},
set(value) {
$html.innerHTML = ''
append($html, value)
},
})
const $tooltip = createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
append($tooltip, item.tooltip || '')
append($right, $tooltip)
def(item, '$tooltip', {
configurable: true,
get: () => $tooltip,
})
def(item, 'tooltip', {
configurable: true,
get() {
return $tooltip.innerHTML
},
set(value) {
$tooltip.innerHTML = ''
append($tooltip, value)
},
})
switch (type) {
case 'switch': {
const $switch = createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
setStyle(item.switch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = append($state, '<input type="range">')
$range.value = item.range[0]
$range.min = item.range[1]
$range.max = item.range[2]
$range.step = item.range[3]
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range]
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value) {
$rangeValue = [...value]
$range.value = value[0]
$range.min = value[1]
$range.max = value[2]
$range.step = value[3]
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
switch (type) {
case 'switch': {
if (item.onSwitch) {
const event = proxy($item, 'click', async (event) => {
item.switch = await item.onSwitch.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
}
case 'range': {
if (item.$range) {
if (item.onRange) {
const event = proxy(item.$range, 'change', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onRange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
if (item.onChange) {
const event = proxy(item.$range, 'input', async (event) => {
item.range[0] = item.$range.valueAsNumber
item.tooltip = await item.onChange.call(this.art, item, $item, event)
})
item.$events.push(event)
}
}
break
}
case 'selector':
{
const event = proxy($item, 'click', async (event) => {
if (item.selector?.length) {
this.render(item.selector)
}
else {
this.check(item)
if (item.$parent.onSelect) {
item.$parent.tooltip = await item.$parent.onSelect.call(this.art, item, $item, event)
}
}
})
item.$events.push(event)
if (item.default) {
addClass($item, 'art-current')
}
}
break
case 'button':
if (item.onClick) {
const event = proxy($item, 'click', async (event) => {
item.tooltip = await item.onClick.call(this.art, item, $item, event)
})
item.$events.push(event)
}
break
default:
break
}
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
replaceElement($item, oldItem)
}
else {
append($panel, $item)
}
if (item.mounted) {
timeout(getScope(this.art), () => item.mounted.call(this.art, item.$item, item), 0)
}
}
render(option = this.option) {
this.active = option
if (this.cache.has(option)) {
const $panel = this.cache.get(option)
inverseClass($panel, 'art-current')
}
else {
const $panel = createElement('div')
this.cache.set(option, $panel)
addClass($panel, 'art-setting-panel')
append(this.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent) {
this.createHeader(option[0])
}
for (let index = 0; index < option.length; index++) {
this.createItem(option[index])
}
}
this.resize()
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { errorHandle } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
import { installSettingEvents } from './events'
import flip from './flip'
import { resizeSetting } from './layout'
import { findItem, formatTree, registerTreeOwner, releaseTreeOwner, traverseTree } from './model'
import playbackRate from './playbackRate'
import { beginSettingAdd, cancelSettingAdd } from './registration'
import { createSettingHeader, createSettingItem, renderSetting } from './render'
import { releaseSettingTree } from './resources'
import { checkSetting } from './selection'
import subtitleOffset from './subtitleOffset'
import { cancelSettingUpdate, updateSetting } from './update'
// Keep the exact runtime inheritance; Setting overrides the registry operations and cache.
const SettingBase: new (art: SettingHost) => Pick<Component<SettingHost>, 'id' | 'art' | 'show' | 'toggle'> = Component
export default class Setting extends SettingBase implements SettingManager {
declare name: string
declare $parent: HTMLElement
declare cache: Map<SettingItem[], HTMLDivElement>
declare active: SettingItem[] | null
declare option: SettingItem[]
constructor(art: SettingHost) {
super(art)
const {
option,
template: { $setting },
} = art
this.name = 'setting'
this.$parent = $setting
this.id = 0
this.active = null
this.cache = new Map()
this.option = [...this.builtin, ...option.settings]
const scope = getScope(art)
registerTreeOwner(this, scope)
scope.add(() => {
releaseTreeOwner(this)
})
if (option.setting) {
this.format()
this.render()
installSettingEvents(this)
}
}
get builtin(): SettingItem[] {
const result: SettingItem[] = []
const { option } = this.art
if (option.playbackRate) {
result.push(playbackRate(this.art))
}
if (option.aspectRatio) {
result.push(aspectRatio(this.art))
}
if (option.flip) {
result.push(flip(this.art))
}
if (option.subtitleOffset) {
result.push(subtitleOffset(this.art))
}
return result
}
traverse(callback: (item: SettingItem) => void, option = this.option): void {
traverseTree(option, callback)
}
check(target?: SettingItem | null): void {
checkSetting(this, target)
}
format(option = this.option, parent?: SettingItem, parents?: SettingItem[], names: string[] = []): void {
this.option = formatTree(this, option, parent, parents, names)
}
find(name = ''): SettingItem | null {
return findItem(this.option, name)
}
resize(): void {
resizeSetting(this)
}
inactivate(item: SettingItem): void {
releaseSettingTree(this, item)
}
remove(name: string): void {
const item = this.find(name)!
errorHandle(item, `Can't find [${name}] in the [setting]`)
this.traverse((item) => {
cancelSettingAdd(item)
cancelSettingUpdate(item)
}, [item])
const index = item.$option!.indexOf(item)
item.$option!.splice(index, 1)
const element = item.$item
const failures: unknown[] = []
for (const cleanup of [() => this.inactivate(item), () => element?.remove(), () => this.render()]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
update(target: SettingItem): SettingItem {
return updateSetting(this, target)
}
add<Item extends SettingItem>(item: Item, option: SettingItem[] = this.option): Item {
if (isClosing(this.art))
return item
let registered = false
this.traverse((existing) => {
registered ||= existing === item
})
const index = option.length
const registration = registered ? undefined : beginSettingAdd(item)
let formatted = false
try {
option.push(item)
this.format()
formatted = true
this.createItem(item)
this.render()
}
catch (error) {
if (isClosing(this.art) || (registration && !registration.current()))
throw error
if (option[index] === item)
option.splice(index, 1)
if (!registered && formatted) {
try {
this.inactivate(item)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
item.$item?.remove()
}
throw error
}
finally {
registration?.finish()
}
return item
}
createHeader(item: SettingItem): void {
createSettingHeader(this, item)
}
createItem(item: SettingItem, isUpdate = false): void {
createSettingItem(this, item, isUpdate)
}
render(option = this.option): void {
renderSetting(this, option)
}
}
+71
View File
@@ -0,0 +1,71 @@
import { isMobile } from '../utils/compatibility'
interface LayoutItem {
readonly $parent?: { width?: number }
}
export interface SettingLayout {
active: LayoutItem[] | null
option: LayoutItem[]
show: boolean
art: {
isRotate: boolean
controls: { setting?: HTMLElement }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number }
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement }
}
}
export interface LayoutInput {
containerWidth: number
containerHeight: number
requestedWidth: number
rows: number
rowHeight: number
controlCenter: number
bottom: number
padding: number
}
export function calculateSettingLayout(input: LayoutInput): { width: number, height: number, left: number } {
const { containerWidth, containerHeight, requestedWidth, rows, rowHeight, controlCenter, bottom, padding } = input
const inset = Math.min(Math.max(0, padding), Math.max(0, containerWidth / 2))
const width = Math.min(Math.max(0, requestedWidth), Math.max(0, containerWidth - 2 * inset))
const height = Math.min(Math.max(0, rows * rowHeight), Math.max(0, containerHeight - bottom - inset))
const left = Math.max(inset, Math.min(controlCenter - width / 2, containerWidth - inset - width))
return { width, height, left }
}
export function resizeSetting(setting: SettingLayout): void {
const { art, active } = setting
const { controls, constructor: { SETTING_WIDTH, SETTING_ITEM_HEIGHT }, template: { $player, $setting, $bottom } } = art
if (!controls.setting || !setting.show || !active)
return
const player = $player.getBoundingClientRect()
const control = controls.setting.getBoundingClientRect()
const scale = player.width / $player.offsetWidth || 1
const controlCenter = (control.left - player.left + control.width / 2) / scale - $player.clientLeft
const layout = calculateSettingLayout({
containerWidth: $player.clientWidth,
containerHeight: $player.clientHeight,
requestedWidth: active[0]?.$parent?.width || SETTING_WIDTH,
rows: active.length + (active === setting.option ? 0 : 1),
rowHeight: SETTING_ITEM_HEIGHT,
controlCenter,
bottom: Number.parseFloat(getComputedStyle($setting).bottom) || 0,
padding: Number.parseFloat(getComputedStyle($bottom).paddingLeft) || 0,
})
$setting.style.height = `${layout.height}px`
$setting.style.width = `${layout.width}px`
if (art.isRotate || isMobile)
return
if (controlCenter + layout.width / 2 > $player.clientWidth) {
// Preserve the existing CSS right-edge anchor when centering would overflow.
$setting.style.left = ''
$setting.style.right = ''
}
else {
$setting.style.left = `${layout.left}px`
$setting.style.right = 'auto'
}
}
+183
View File
@@ -0,0 +1,183 @@
import { errorHandle } from '../utils/error'
export interface TreeItem {
name?: string
selector?: TreeItem[]
readonly $parent?: TreeItem
readonly $parents?: TreeItem[]
readonly $option?: TreeItem[]
readonly $events?: (() => void)[]
readonly $formatted?: boolean
}
export interface TreeBinding {
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
events: (() => void)[]
owner: TreeOwnerState
}
interface TreeOwnerState {
active: boolean
root?: TreeItem[]
scope?: { readonly closed: boolean }
}
interface Placement {
item: TreeItem
parent?: TreeItem
parents?: TreeItem[]
option: TreeItem[]
name: string
generated: boolean
}
const bindings = new WeakMap<TreeItem, TreeBinding>()
const owners = new WeakMap<object, TreeOwnerState>()
const keys = ['$parent', '$parents', '$option', '$events', '$formatted'] as const
export function hasTreeBinding(item: TreeItem): boolean {
return bindings.has(item)
}
export function registerTreeOwner(owner: object, scope: { readonly closed: boolean }): void {
owners.set(owner, { active: true, scope })
}
export function releaseTreeOwner(owner: object): void {
const state = owners.get(owner)
if (state) {
state.active = false
state.root = undefined
state.scope = undefined
}
}
function activeBinding(item: TreeItem): boolean {
const visited = new Set<TreeItem>()
let current: TreeItem | undefined = item
while (current && !visited.has(current)) {
visited.add(current)
const binding: TreeBinding | undefined = bindings.get(current)
if (!binding || !binding.owner.active || binding.owner.scope?.closed || !binding.option.includes(current))
return false
if (!binding.parent)
return binding.owner.root === binding.option
if (bindings.get(binding.parent)?.owner !== binding.owner)
return false
current = binding.parent
}
return false
}
function canAssignName(item: TreeItem): boolean {
let target: object | null = item
while (target) {
const descriptor = Object.getOwnPropertyDescriptor(target, 'name')
if (descriptor) {
if ('set' in descriptor)
return typeof descriptor.set === 'function'
return Boolean(descriptor.writable && (target === item || Object.isExtensible(item)))
}
target = Object.getPrototypeOf(target)
}
return Object.isExtensible(item)
}
export function treeBinding(item: TreeItem): TreeBinding {
const binding = bindings.get(item)
if (!binding)
throw new Error('Setting item has not been formatted')
return binding
}
export function traverseTree(option: TreeItem[], callback: (item: TreeItem) => void): void {
for (let index = 0; index < option.length; index++) {
const item = option[index]!
callback(item)
if (item.selector?.length)
traverseTree(item.selector, callback)
}
}
export function findItem(option: TreeItem[], name = ''): TreeItem | null {
let result: TreeItem | null = null
traverseTree(option, (item) => {
if (item.name === name)
result = item
})
return result
}
// Validate the complete structure before installing any names or bindings.
export function formatTree<Item extends TreeItem>(owner: { id: number }, option: Item[], parent?: TreeItem, parents?: TreeItem[], names: string[] = []): Item[] {
const ownerState = owners.get(owner) || { active: true }
const placements: Placement[] = []
const seen = new Set<TreeItem>()
const used = new Set(names)
const collect = (list: TreeItem[], parent?: TreeItem, parents?: TreeItem[]) => {
for (let index = 0; index < list.length; index++) {
const item = list[index]!
if (!item || typeof item !== 'object')
throw new TypeError('Setting item must be an object')
if (seen.has(item))
throw new Error('Setting items must appear only once in a tree')
seen.add(item)
const previous = bindings.get(item)
if (previous && previous.owner !== ownerState && activeBinding(item))
errorHandle(false, `Setting item [${item.name || ''}] already belongs to another active player`)
if (item.name) {
errorHandle(!used.has(item.name), `The [${item.name}] already exists in [setting]`)
used.add(item.name)
}
if (!bindings.has(item)) {
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(item, key)
if (descriptor ? !descriptor.configurable : !Object.isExtensible(item))
throw new TypeError(`Cannot format setting item property [${key}]`)
}
}
if (!item.name && !canAssignName(item))
throw new TypeError('Cannot assign an automatic setting item name')
placements.push({ item, parent, parents, option: list, name: item.name || '', generated: !item.name })
if (item.selector?.length)
collect(item.selector, item, list)
}
}
collect(option, parent, parents)
let nextId = owner.id
owners.set(owner, ownerState)
ownerState.root = option
for (const placement of placements) {
if (!placement.name) {
do {
placement.name = `setting-${nextId++}`
} while (used.has(placement.name))
used.add(placement.name)
}
}
for (const placement of placements) {
const { item, name, parent, parents, option } = placement
const previous = bindings.get(item)
if (!previous) {
Object.defineProperties(item, {
$parent: { get: () => treeBinding(item).parent },
$parents: { get: () => treeBinding(item).parents },
$option: { get: () => treeBinding(item).option },
$events: { get: () => treeBinding(item).events },
$formatted: { get: () => true },
})
}
bindings.set(item, { parent, parents, option, events: previous?.events || [], owner: ownerState })
if (placement.generated)
item.name = name
}
// Preserve the caller-provided accumulator and monotonic autogenerated IDs.
for (const placement of placements) {
if (!placement.generated)
names.push(placement.name)
}
owner.id = nextId
return option
}
+31
View File
@@ -0,0 +1,31 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingItem, SettingManager } from './types'
import { getScope } from '../lifecycle/instance'
const scopes = new WeakMap<HTMLDivElement, ResourceScope>()
export function ownSettingPanel(setting: SettingManager, option: SettingItem[], panel: HTMLDivElement): ResourceScope {
const scope = getScope(setting.art).child()
scopes.set(panel, scope)
scope.add(() => {
if (setting.cache.get(option) === panel)
setting.cache.delete(option)
panel.remove()
})
return scope
}
export function settingPanelScope(panel: HTMLDivElement): ResourceScope {
const scope = scopes.get(panel)
if (!scope)
throw new Error('Setting panel has not been registered')
return scope
}
export function releaseSettingPanel(panel: HTMLDivElement): void {
const scope = scopes.get(panel)
if (scope)
scope.dispose()
else
panel.remove()
}
@@ -1,11 +1,14 @@
export default function playbackRate(art) {
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function playbackRate(art: SettingHost): SettingItem {
const {
i18n,
icons,
constructor: { SETTING_ITEM_WIDTH, PLAYBACK_RATE },
} = art
function getI18n(value) {
function getI18n(value: number) {
return value === 1.0 ? i18n.get('Normal') : value.toFixed(1)
}
@@ -29,12 +32,12 @@ export default function playbackRate(art) {
}
}),
onSelect(item) {
art.playbackRate = item.value
art.playbackRate = item.value as number
return item.html
},
mounted: () => {
mounted: (_element, item) => {
update()
art.on('video:ratechange', () => update())
subscribeSetting(art, item, 'video:ratechange', () => update())
},
}
}
@@ -0,0 +1,20 @@
import type { SettingItem } from './types'
const registrations = new WeakMap<SettingItem, object>()
export function cancelSettingAdd(item: SettingItem): void {
registrations.delete(item)
}
export function beginSettingAdd(item: SettingItem): { current: () => boolean, finish: () => void } {
const token = {}
registrations.set(item, token)
const current = () => registrations.get(item) === token
return {
current,
finish() {
if (current())
registrations.delete(item)
},
}
}
+378
View File
@@ -0,0 +1,378 @@
import type { SettingKind } from './selection'
import type { SettingItem, SettingManager, SettingRange } from './types'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { addClass, append, def, has, inverseClass, setStyle } from '../utils'
import { settingScopeActive } from './activity'
import { captureSettingItem, rememberSettingState } from './checkpoint'
import { ownSettingPanel, releaseSettingPanel, settingPanelScope } from './panels'
import { ownSettingItem, proxySetting, settingItemOwner } from './resources'
import { bindSettingActions } from './selection'
import { captureSettingUpdate } from './update'
const navigations = new WeakMap<SettingManager, number>()
function bindContent(item: SettingItem, key: 'icon' | 'html' | 'tooltip', element: HTMLDivElement): void {
def(item, `$${key}`, { configurable: true, get: () => element })
def(item, key, {
configurable: true,
get: () => element.innerHTML,
set(value: unknown) {
element.innerHTML = ''
append(element, value)
},
})
}
export function createSettingHeader(setting: SettingManager, item: SettingItem): void {
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const {
icons: { arrowLeft },
constructor: { SETTING_ITEM_HEIGHT },
} = setting.art
const $item = document.createElement('div')
setStyle($item, 'height', `${SETTING_ITEM_HEIGHT}px`)
addClass($item, 'art-setting-item')
addClass($item, 'art-setting-item-back')
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
append($icon, arrowLeft)
append($left, $icon)
append($left, item.$parent!.html)
proxySetting(setting.art, item.$parent!, $item, 'click', () => setting.render(item.$parents), settingPanelScope($panel))
append($panel, $item)
}
export function createSettingItem(setting: SettingManager, item: SettingItem, isUpdate = false): void {
const currentUpdate = captureSettingUpdate(item)
if (!setting.cache.has(item.$option!))
return
const $panel = setting.cache.get(item.$option!)!
const oldItem = item.$item
let type: SettingKind = 'selector'
if (has(item, 'switch')) {
type = 'switch'
}
if (has(item, 'range')) {
type = 'range'
}
if (has(item, 'onClick')) {
type = 'button'
}
const { icons, constructor } = setting.art
if (!currentUpdate())
return
const scope = ownSettingItem(setting.art, item, settingPanelScope($panel))
const current = () => settingScopeActive(scope) && !isClosing(setting.art) && currentUpdate()
if (!current())
return
const $item = document.createElement('div')
try {
rememberSettingState(item)
addClass($item, 'art-setting-item')
setStyle($item, 'height', `${constructor.SETTING_ITEM_HEIGHT}px`)
$item.dataset.name = item.name || ''
$item.dataset.value = String(item.value || '')
if (!current())
return
const $left = append($item, '<div class="art-setting-item-left"></div>')
const $right = append($item, '<div class="art-setting-item-right"></div>')
const $icon = document.createElement('div')
addClass($icon, 'art-setting-item-left-icon')
switch (type) {
case 'button':
case 'switch':
case 'range': {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
break
}
case 'selector':
if (item.selector?.length) {
const icon = item.icon || icons.config
if (!current())
return
append($icon, icon)
}
else {
append($icon, icons.check)
}
break
default:
break
}
append($left, $icon)
if (!current())
return
bindContent(item, 'icon', $icon)
const $html = document.createElement('div')
addClass($html, 'art-setting-item-left-text')
const html = item.html || ''
if (!current())
return
append($html, html)
append($left, $html)
bindContent(item, 'html', $html)
const $tooltip = document.createElement('div')
addClass($tooltip, 'art-setting-item-right-tooltip')
const tooltip = item.tooltip || ''
if (!current())
return
append($tooltip, tooltip)
append($right, $tooltip)
bindContent(item, 'tooltip', $tooltip)
switch (type) {
case 'switch': {
const $switch = document.createElement('div')
addClass($switch, 'art-setting-item-right-icon')
const $switchOn = append($switch, icons.switchOn)
const $switchOff = append($switch, icons.switchOff)
const initialSwitch = item.switch
if (!current())
return
setStyle(initialSwitch ? $switchOff : $switchOn, 'display', 'none')
append($right, $switch)
def(item, '$switch', {
configurable: true,
get: () => $switch,
})
let $switchValue = item.switch
if (!current())
return
rememberSettingState(item, () => {
const value = $switchValue
return () => {
$switchValue = value
}
})
def(item, 'switch', {
configurable: true,
get: () => $switchValue,
set(value) {
$switchValue = value
if (value) {
setStyle($switchOff, 'display', 'none')
setStyle($switchOn, 'display', null)
}
else {
setStyle($switchOff, 'display', null)
setStyle($switchOn, 'display', 'none')
}
},
})
break
}
case 'range':
{
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
const $range = document.createElement('input')
$range.type = 'range'
append($state, $range)
for (const [index, key] of ['value', 'min', 'max', 'step'].entries()) {
const value = item.range![index]
if (!current())
return
Reflect.set($range, key, String(value))
}
addClass($range, 'art-setting-range')
append($right, $state)
def(item, '$range', {
configurable: true,
get: () => $range,
})
let $rangeValue = [...item.range!]
if (!current())
return
rememberSettingState(item, () => {
const value = $rangeValue
const values = [...value]
const input = { min: $range.min, max: $range.max, step: $range.step, value: $range.value }
return () => {
value.splice(0, value.length, ...values)
$rangeValue = value
Object.assign($range, input)
}
})
def(item, 'range', {
configurable: true,
get: () => $rangeValue,
set(value: SettingRange) {
$rangeValue = [...value]
$range.value = String(value[0])
$range.min = String(value[1])
$range.max = String(value[2])
$range.step = String(value[3])
},
})
}
break
case 'selector':
if (item.selector?.length) {
const $state = document.createElement('div')
addClass($state, 'art-setting-item-right-icon')
append($state, icons.arrowRight)
append($right, $state)
}
break
default:
break
}
if (!current())
return
bindSettingActions(setting, item, $item, type)
if (!current())
return
def(item, '$item', {
configurable: true,
get: () => $item,
})
if (isUpdate) {
if (oldItem?.parentNode)
oldItem.replaceWith($item)
else
append($panel, $item)
}
else {
append($panel, $item)
}
if (item.mounted && current()) {
timeout(scope, () => {
if (isClosing(setting.art))
return
try {
const result = item.mounted?.call(setting.art, $item, item)
void Promise.resolve(result).catch(error => console.warn('ArtPlayer setting mounted failed:', error))
}
catch (error) {
console.warn('ArtPlayer setting mounted failed:', error)
}
}, 0)
}
}
catch (error) {
try {
scope.dispose()
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
$item.remove()
throw error
}
}
export function renderSetting(setting: SettingManager, option = setting.option): void {
if (isClosing(setting.art))
return
const cached = setting.cache.get(option)
if (cached && !settingScopeActive(settingPanelScope(cached)))
return
const navigation = (navigations.get(setting) || 0) + 1
navigations.set(setting, navigation)
const currentNavigation = () => navigations.get(setting) === navigation && !isClosing(setting.art)
const previous = setting.active
const previousLayout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const restoreNavigation = () => {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of previousLayout)
setting.$parent.style.setProperty(key, value, priority)
}
setting.active = option
if (cached) {
try {
inverseClass(cached, 'art-current')
setting.resize()
}
catch (error) {
if (currentNavigation() && setting.cache.get(option) === cached && !settingPanelScope(cached).closed)
restoreNavigation()
throw error
}
}
else {
const $panel = document.createElement('div')
setting.cache.set(option, $panel)
const scope = ownSettingPanel(setting, option, $panel)
const current = () => !scope.closed && !isClosing(setting.art) && setting.cache.get(option) === $panel
const checkpoints: { item: SettingItem, restore: () => void }[] = []
try {
if (!current())
return
addClass($panel, 'art-setting-panel')
append(setting.$parent, $panel)
inverseClass($panel, 'art-current')
if (option[0]?.$parent)
setting.createHeader(option[0])
for (let index = 0; index < option.length && current(); index++) {
const item = option[index]!
checkpoints.push({ item, restore: captureSettingItem(item) })
setting.createItem(item)
}
if (!current())
return
setting.resize()
}
catch (error) {
const restore = current()
const restoreActive = setting.active === option && currentNavigation()
try {
releaseSettingPanel($panel)
}
catch (cleanupError) {
console.warn('ArtPlayer setting cleanup failed:', cleanupError)
}
if (restore && !isClosing(setting.art)) {
for (const checkpoint of checkpoints.reverse()) {
if (settingItemOwner(checkpoint.item) === scope) {
try {
checkpoint.restore()
}
catch (restoreError) {
console.warn('ArtPlayer setting restore failed:', restoreError)
}
}
}
if (restoreActive)
restoreNavigation()
}
throw error
}
}
}
+202
View File
@@ -0,0 +1,202 @@
import type ResourceScope from '../lifecycle/scope'
import type { SettingEvents, SettingHost, SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import { pauseSettingScope, settingScopeActive } from './activity'
import { hasTreeBinding, treeBinding } from './model'
import { releaseSettingPanel } from './panels'
const scopes = new WeakMap<SettingItem, ResourceScope>()
const owners = new WeakMap<SettingItem, ResourceScope>()
const eventLists = new WeakMap<ResourceScope, (() => void)[]>()
function releaseEvents(art: SettingHost, events: (() => void)[]): void {
const pending = events.splice(0)
const failures: unknown[] = []
for (const cleanup of pending) {
try {
art.events.remove(cleanup)
}
catch (error) {
failures.push(error)
}
}
if (failures.length)
throw new ResourceCleanupError(failures)
}
export function ownSettingItem(art: SettingHost, item: SettingItem, owner = getScope(art)): ResourceScope {
const previous = scopes.get(item)
if (!previous || settingScopeActive(previous) || previous.closed)
releaseSettingItem(art, item)
const scope = owner.child()
scopes.set(item, scope)
owners.set(item, owner)
eventLists.set(scope, treeBinding(item).events)
scope.add(() => {
releaseEvents(art, eventLists.get(scope)!)
})
return scope
}
export function settingItemOwner(item: SettingItem): ResourceScope | undefined {
return owners.get(item)
}
export function settingScope(item: SettingItem): ResourceScope {
const scope = scopes.get(item)
if (!scope)
throw new Error('Setting item has not been rendered')
return scope
}
export function releaseSettingItem(art: SettingHost, item: SettingItem): void {
const scope = scopes.get(item)
if (scope && !scope.closed)
scope.dispose()
else if (hasTreeBinding(item))
releaseEvents(art, treeBinding(item).events)
}
export interface SettingSuspension {
resume: () => void
dispose: () => void
}
export function suspendSettingItem(art: SettingHost, item: SettingItem): SettingSuspension | undefined {
const previous = scopes.get(item)
if (!previous || previous.closed)
return
if (!settingScopeActive(previous))
throw new Error('Setting item update is already in progress')
const owner = owners.get(item)!
const events = treeBinding(item).events
const saved = events.splice(0)
eventLists.set(previous, saved)
const unpause = pauseSettingScope(previous)
let pending = true
return {
resume() {
if (!pending)
return
pending = false
const failures: unknown[] = []
const current = scopes.get(item)
for (const cleanup of [() => {
if (current && current !== previous)
current.dispose()
}, () => releaseEvents(art, events)]) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
if (!previous.closed && !isClosing(art)) {
events.push(...saved.splice(0))
eventLists.set(previous, events)
scopes.set(item, previous)
owners.set(item, owner)
}
unpause()
if (failures.length)
throw new ResourceCleanupError(failures)
},
dispose() {
if (!pending)
return
pending = false
try {
previous.dispose()
}
finally {
unpause()
}
},
}
}
export function proxySetting(art: SettingHost, item: SettingItem, target: EventTarget, name: string, callback: (event: Event) => unknown, owner?: ResourceScope): void {
const scope = owner || settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const event = art.proxy(target, name, (event) => {
if (settingScopeActive(scope) && !isClosing(art)) {
try {
return callback(event)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
})
if (!settingScopeActive(scope) || isClosing(art)) {
art.events.remove(event)
}
else {
treeBinding(item).events.push(event)
if (owner && owner !== scopes.get(item)) {
owner.add(() => {
const events = treeBinding(item).events
const index = events.indexOf(event)
if (index >= 0) {
events.splice(index, 1)
art.events.remove(event)
}
})
}
}
}
export function subscribeSetting<Name extends keyof SettingEvents>(art: SettingHost, item: SettingItem, name: Name, callback: (...args: SettingEvents[Name]) => unknown): void {
const scope = settingScope(item)
if (!settingScopeActive(scope) || isClosing(art))
return
const guarded = (...args: SettingEvents[Name]) => {
if (settingScopeActive(scope) && !isClosing(art))
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
export function releaseSettingTree(setting: SettingManager, item: SettingItem): void {
const failures: unknown[] = []
const visited = new Set<SettingItem>()
const visit = (item: SettingItem) => {
if (visited.has(item))
return
visited.add(item)
for (const child of item.selector || [])
visit(child)
if (item.selector) {
const panel = setting.cache.get(item.selector)
setting.cache.delete(item.selector)
try {
if (panel)
releaseSettingPanel(panel)
}
catch (error) {
failures.push(error)
}
}
try {
releaseSettingItem(setting.art, item)
}
catch (error) {
if (error instanceof ResourceCleanupError)
failures.push(...error.errors)
else
failures.push(error)
}
}
visit(item)
if (failures.length)
throw new ResourceCleanupError(failures)
}
@@ -0,0 +1,83 @@
import type { SettingCallback, SettingItem, SettingManager } from './types'
import { isClosing } from '../lifecycle/instance'
import { addClass, inverseClass } from '../utils/dom'
import { settingScopeActive } from './activity'
import { proxySetting, settingScope } from './resources'
export type SettingKind = 'button' | 'selector' | 'switch' | 'range'
const generations = new WeakMap<SettingItem, number>()
export function checkSetting(setting: SettingManager, target?: SettingItem | null): void {
if (!target?.$parent)
return
target.$parent.tooltip = target.html
setting.traverse((item) => {
item.default = item === target
if (item.default && item.$item)
inverseClass(item.$item, 'art-current')
}, target.$option)
setting.render(target.$parents)
}
async function act(setting: SettingManager, item: SettingItem, element: HTMLDivElement, event: Event, callback: SettingCallback, target: SettingItem, property: 'switch' | 'tooltip', before?: () => void): Promise<void> {
try {
const scope = settingScope(item)
const targetScope = settingScope(target)
const generation = (generations.get(target) || 0) + 1
generations.set(target, generation)
const active = () => settingScopeActive(scope) && settingScopeActive(targetScope) && !isClosing(setting.art) && generations.get(target) === generation
before?.()
if (!active())
return
const value = await callback.call(setting.art, item, element, event)
if (active())
Reflect.set(target, property, value)
}
catch (error) {
console.warn('ArtPlayer setting callback failed:', error)
}
}
export function bindSettingActions(setting: SettingManager, item: SettingItem, element: HTMLDivElement, kind: SettingKind): void {
const { art } = setting
const scope = settingScope(item)
const proxy = (target: EventTarget, name: string, callback: (event: Event) => unknown) => proxySetting(art, item, target, name, callback, scope)
switch (kind) {
case 'switch':
if (item.onSwitch)
proxy(element, 'click', event => act(setting, item, element, event, item.onSwitch!, item, 'switch'))
break
case 'range': {
const range = item.$range
if (range) {
for (const [name, callback] of [['change', 'onRange'], ['input', 'onChange']] as const) {
if (item[callback]) {
proxy(range, name, event => act(setting, item, element, event, item[callback]!, item, 'tooltip', () => {
item.range![0] = range.valueAsNumber
}))
}
}
}
break
}
case 'selector':
proxy(element, 'click', (event) => {
if (item.selector?.length) {
setting.render(item.selector)
}
else if (item.$parent?.onSelect) {
return act(setting, item, element, event, item.$parent.onSelect, item.$parent, 'tooltip', () => setting.check(item))
}
else {
setting.check(item)
}
})
if (item.default)
addClass(element, 'art-current')
break
case 'button':
if (item.onClick)
proxy(element, 'click', event => act(setting, item, element, event, item.onClick!, item, 'tooltip'))
break
}
}
@@ -1,22 +0,0 @@
export default function subtitleOffset(art) {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range[0]
return `${item.range[0]}s`
},
mounted: (_, item) => {
art.on('subtitleOffset', (value) => {
item.$range.value = value
item.tooltip = `${value}s`
})
},
}
}
@@ -0,0 +1,25 @@
import type { SettingHost, SettingItem } from './types'
import { subscribeSetting } from './resources'
export default function subtitleOffset(art: SettingHost): SettingItem {
const { i18n, icons, constructor } = art
return {
width: constructor.SETTING_ITEM_WIDTH,
name: 'subtitle-offset',
html: i18n.get('Subtitle Offset'),
icon: icons.subtitle,
tooltip: '0s',
range: [0, -10, 10, 0.1],
onChange(item) {
art.subtitleOffset = item.range![0]!
return `${item.range![0]}s`
},
mounted: (_, item) => {
subscribeSetting(art, item, 'subtitleOffset', (value) => {
item.$range!.value = String(value)
item.tooltip = `${value}s`
})
},
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { TreeItem } from './model'
export type SettingContent = string | HTMLElement | number
export type SettingRange = [value?: number, min?: number, max?: number, step?: number]
export type SettingCallback = (this: SettingHost, item: SettingItem, element: HTMLDivElement, event: Event) => unknown
export interface SettingItem extends TreeItem {
html?: SettingContent
icon?: SettingContent
tooltip?: SettingContent
width?: number
value?: string | number
default?: boolean
switch?: boolean
range?: SettingRange
selector?: SettingItem[]
mounted?: (this: SettingHost, element: HTMLDivElement, item: SettingItem) => unknown
onClick?: SettingCallback
onSwitch?: SettingCallback
onRange?: SettingCallback
onChange?: SettingCallback
onSelect?: SettingCallback
readonly $parent?: SettingItem
readonly $parents?: SettingItem[]
readonly $option?: SettingItem[]
readonly $item?: HTMLDivElement
readonly $icon?: HTMLDivElement
readonly $html?: HTMLDivElement
readonly $tooltip?: HTMLDivElement
readonly $switch?: HTMLDivElement
readonly $range?: HTMLInputElement
}
export interface SettingEvents {
'blur': []
'focus': [event: Event]
'resize': []
'video:ratechange': []
'aspectRatio': [value: string]
'flip': [value: string]
'subtitleOffset': [value: number]
}
export interface SettingHost extends ComponentHost, SubscriptionHost<SettingEvents> {
template: { $player: HTMLElement, $setting: HTMLElement, $bottom: HTMLElement, $controls: HTMLElement }
controls: { setting?: HTMLElement }
option: { setting: boolean, settings: SettingItem[], playbackRate: boolean, aspectRatio: boolean, flip: boolean, subtitleOffset: boolean }
constructor: { SETTING_WIDTH: number, SETTING_ITEM_HEIGHT: number, SETTING_ITEM_WIDTH: number, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
icons: Record<'arrowLeft' | 'arrowRight' | 'config' | 'check' | 'switchOn' | 'switchOff' | 'playbackRate' | 'aspectRatio' | 'flip' | 'subtitle', HTMLElement>
proxy: ComponentHost['events']['proxy']
i18n: { get: (key: string) => string }
setting: SettingManager
isRotate: boolean
playbackRate: number
aspectRatio: string
flip: string
subtitleOffset: number
}
export interface SettingManager {
id: number
art: SettingHost
name: string
$parent: HTMLElement
cache: Map<SettingItem[], HTMLDivElement>
active: SettingItem[] | null
option: SettingItem[]
show: boolean
traverse: (callback: (item: SettingItem) => void, option?: SettingItem[]) => void
check: (target?: SettingItem | null) => void
render: (option?: SettingItem[]) => void
resize: () => void
createItem: (item: SettingItem, isUpdate?: boolean) => void
createHeader: (item: SettingItem) => void
find: (name?: string) => SettingItem | null
format: () => void
add: <Item extends SettingItem>(item: Item, option?: SettingItem[]) => Item
}
+210
View File
@@ -0,0 +1,210 @@
import type { SettingItem, SettingManager } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
import { ResourceCleanupError } from '../lifecycle/scope'
import captureTemplate from '../lifecycle/template-rollback'
import { inverseClass } from '../utils/dom'
import { pauseSettingScope } from './activity'
import { captureSettingItem } from './checkpoint'
import { releaseSettingPanel, settingPanelScope } from './panels'
import { cancelSettingAdd } from './registration'
import { suspendSettingItem } from './resources'
const pending = new WeakMap<SettingItem, () => void>()
function finish(cleanups: (() => void)[]): void {
const errors: unknown[] = []
for (const cleanup of cleanups) {
try {
cleanup()
}
catch (error) {
if (error instanceof ResourceCleanupError)
errors.push(...error.errors)
else
errors.push(error)
}
}
if (errors.length)
throw new ResourceCleanupError(errors)
}
function suspendTree(setting: SettingManager, root: SettingItem): { items: Set<SettingItem>, resume: () => void, dispose: () => void } {
const items = new Set<SettingItem>()
const panels = new Set<HTMLDivElement>()
const collect = (item: SettingItem) => {
if (items.has(item))
return
items.add(item)
if (item.selector) {
const panel = setting.cache.get(item.selector)
if (panel)
panels.add(panel)
item.selector.forEach(collect)
}
}
collect(root)
const suspended: NonNullable<ReturnType<typeof suspendSettingItem>>[] = []
const unpause: (() => void)[] = []
try {
for (const item of items) {
const suspension = suspendSettingItem(setting.art, item)
if (suspension)
suspended.push(suspension)
}
for (const panel of panels)
unpause.push(pauseSettingScope(settingPanelScope(panel)))
}
catch (error) {
try {
finish([...suspended.reverse().map(item => item.resume), ...unpause])
}
catch (cleanupError) {
console.warn('ArtPlayer setting restore failed:', cleanupError)
}
throw error
}
let active = true
return {
items,
resume() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.resume), ...unpause])
},
dispose() {
if (!active)
return
active = false
finish([...suspended.reverse().map(item => item.dispose), ...Array.from(panels).reverse().map(panel => () => releaseSettingPanel(panel)), ...unpause])
},
}
}
// A newer operation restores the pending one before applying its own changes.
export function cancelSettingUpdate(item: SettingItem): void {
pending.get(item)?.()
}
export function captureSettingUpdate(item: SettingItem): () => boolean {
const operation = pending.get(item)
return () => !operation || pending.get(item) === operation
}
function assignSetting(item: SettingItem, target: SettingItem, current: () => boolean): void {
for (const key of Reflect.ownKeys(target)) {
if (!current())
return
const descriptor = Object.getOwnPropertyDescriptor(target, key)
if (!descriptor?.enumerable || !current())
continue
const value: unknown = Reflect.get(target, key)
if (!current())
return
if (!Reflect.set(item, key, value))
throw new TypeError(`Cannot assign setting item property [${String(key)}]`)
}
}
export function updateSetting(setting: SettingManager, target: SettingItem): SettingItem {
const item = setting.find(target.name)
if (isClosing(setting.art))
return item || target
if (!item)
return setting.add(target)
cancelSettingAdd(item)
cancelSettingUpdate(item)
const restoreItem = captureSettingItem(item)
const element = item.$item
const parent = element?.parentNode
const next = element?.nextSibling
const restoreTemplate = element ? captureTemplate(element) : undefined
const previous = setting.active
const layout = ['height', 'width', 'left', 'right'].map(key => [key, setting.$parent.style.getPropertyValue(key), setting.$parent.style.getPropertyPriority(key)] as const)
const tree = suspendTree(setting, item)
let active = true
let rendered = false
const cancel = () => {
if (!active)
return
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
if (isClosing(setting.art)) {
tree.dispose()
return
}
const replacement = item.$item
try {
finish([
tree.resume,
() => {
if (replacement && replacement !== element)
replacement.remove()
},
restoreItem,
() => restoreTemplate?.(),
() => {
if (parent && element)
parent.insertBefore(element, next?.parentNode === parent ? next : null)
},
() => setting.format(),
() => {
if (rendered) {
setting.active = previous
if (previous && setting.cache.has(previous))
inverseClass(setting.cache.get(previous), 'art-current')
for (const [key, value, priority] of layout)
setting.$parent.style.setProperty(key, value, priority)
}
},
])
}
catch (error) {
console.warn('ArtPlayer setting restore failed:', error)
}
}
const current = () => active && !isClosing(setting.art) && pending.get(item) === cancel
for (const affected of tree.items)
pending.set(affected, cancel)
const release = getScope(setting.art).add(() => {
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
})
try {
assignSetting(item, target, current)
if (!current())
return item
setting.format()
if (!current())
return item
setting.createItem(item, true)
if (!current())
return item
rendered = true
setting.render()
if (!current())
return item
active = false
for (const affected of tree.items) {
if (pending.get(affected) === cancel)
pending.delete(affected)
}
tree.dispose()
return item
}
catch (error) {
if (current())
cancel()
throw error
}
finally {
release()
}
}
+19 -1
View File
@@ -26,6 +26,7 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 5px;
padding: 0 5px;
cursor: pointer;
overflow: hidden;
@@ -50,28 +51,45 @@
.art-setting-item-left {
.art-center();
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
gap: 5px;
.art-setting-item-left-icon {
.art-center();
flex-shrink: 0;
height: var(--art-settings-icon-size);
width: var(--art-settings-icon-size);
}
.art-setting-item-left-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.art-setting-item-right {
.art-center();
flex-shrink: 0;
min-width: 0;
max-width: 55%;
gap: 5px;
font-size: 12px;
.art-setting-item-right-tooltip {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: rgba(255, 255, 255, 0.5);
}
.art-setting-item-right-icon {
.art-center();
flex-shrink: 0;
min-width: 32px;
height: 24px;
}
@@ -0,0 +1,543 @@
{
"task": "CORE-14",
"status": "partial",
"date": "2026-09-11",
"node": "v24.21.0",
"scope": "Typed specialized manager, root event ownership, animated layout recovery, add failure unlink and removal cleanup",
"files": {
"packages/artplayer/src/setting/aspectRatio.ts": "a16eb58fadf77f0a9009cf162d957ffd59d9b8fde83cc193d510fd642e3456cb",
"packages/artplayer/src/setting/events.ts": "f7a3347ca146232caf2a8c9a5a18731cf7bc91dca4cd36370e0265c6eb70df53",
"packages/artplayer/src/setting/flip.ts": "e72a4ae33300d9e48880897a4f85a04fa6d9a5fff7b06ecaf9081cfb1bb14404",
"packages/artplayer/src/setting/index.ts": "5041441ae24cb3610fc28dba563fb3904dd007710423175373b36f47eefb95b2",
"packages/artplayer/src/setting/layout.ts": "584604e8fffaeaf1d633d1ed45e64d94595c2a56976e3a3f7ede01839b83a37d",
"packages/artplayer/src/setting/model.ts": "944006a0191b675a8d76605a0544d81409e1e63224332f4b56de43a5f1a3a173",
"packages/artplayer/src/setting/playbackRate.ts": "2e70e0d2a33cb0ec9fcdd6d22424979fed7b2e01f87015d6be4e520d5cf4e88a",
"packages/artplayer/src/setting/render.ts": "256c2561d28bac2e0af8bb81b971f62c180063928ecdd70782b6e0d035488d8f",
"packages/artplayer/src/setting/resources.ts": "4b1a9507ecb13833fe80e6afc5d8ee315bdd4aab7b4fe761491472fad937d71d",
"packages/artplayer/src/setting/selection.ts": "7664f73e31837fd0b8d22f7eb401877f04ab7fe3a972e4cb958e0f817ed3a92c",
"packages/artplayer/src/setting/subtitleOffset.ts": "86513835d4e542ac39e9d551507473e3e14c71fd0d5ea725267008ecbff6ab83",
"packages/artplayer/src/setting/types.ts": "12930e46c43b66803521403759dbdef658860e13006f8ac6f917d2fb1be96b5c",
"test/setting-model.test.js": "931bee643bea3cbd93927f7c9739a1bf559da5aaf58783d19b6898a092ba56bc",
"test/setting-layout.test.js": "a7da46075bd478182559b9af4817b128eb9f9c219b6032d77fd437370f2a3563",
"test/setting-resources.test.js": "8fbc541cfede17cc63a7c54f630fcad2eba91ad780172c56f8fbe774d1586cc8",
"test/types/setting-model.ts": "ba024ffeae26428d06f07f98f62f211db94ef8b259251f9f424a4203f1187c7b",
"test/types/setting-manager.ts": "27e3687a89909ea6f97aa842347cb8fbcd6f8ce5cf779a24d5dabad3eff8dc5c",
"test/browser/setting.spec.js": "b36e91e4ba96138db482f894a57307cb56e0f58d624861ff21841784275b9526",
"package.json": "82459e37eb54391bb73ebd84b1b27a3c37750d7cc2cd24607fc8f983f7342960"
},
"ci": {
"passed": 194,
"unit": 165,
"engineering": 4,
"baseline": 25,
"strictSourceFiles": 122,
"logSha256": "fdd702b63a53c1d973a1eca7010bb19181e7182596a8b0f86a8e557398c9db14"
},
"browser": {
"stats": {
"startTime": "2026-09-10T21:45:56.846Z",
"duration": 20664.667999999998,
"expected": 72,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"reportSha256": "9b38aef6211411133a08c75d7ca782d146331546d66833731cc6541394f950d0",
"candidate": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "dec3a08b4c185499fdddfd0ba91c33b2bbfb4cbba62b64b63c6ce1d52ad30e34",
"bytes": 256143
},
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"cases": [
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=false",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=true",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: failed setting additions leave existing rows usable and allow retry",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting removal finishes DOM cleanup when the event-removal hook throws",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=false",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=true",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: failed setting additions leave existing rows usable and allow retry",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting removal finishes DOM cleanup when the event-removal hook throws",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=false",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: open settings follow container and control height changes with fallback=true",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: failed setting additions leave existing rows usable and allow retry",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting removal finishes DOM cleanup when the event-removal hook throws",
"engine": "webkit",
"status": "passed",
"retry": 0
}
]
},
"failures": [
{
"file": "refactor/.cache/core14-manager-browser.json",
"sha256": "9319f4023678b3cec40d5dcc617a86bd4ba1a5cb17e43fc485d576bf9ea645ed",
"stats": {
"startTime": "2026-09-10T21:34:14.601Z",
"duration": 30176.837,
"expected": 63,
"skipped": 0,
"unexpected": 3,
"flaky": 0
},
"reason": "ResizeObserver reads animated bottom before the final control-height offset"
},
{
"file": "refactor/.cache/core14-manager-layout-browser.json",
"sha256": "08dc1621f4e475f1f7b7e74e37d985330912bf8844316821d964b967deecc31f",
"stats": {
"startTime": "2026-09-10T21:40:47.436Z",
"duration": 29928.708,
"expected": 63,
"skipped": 0,
"unexpected": 3,
"flaky": 0
},
"reason": "Style observation alone still reads the transition starting value"
},
{
"file": "refactor/.cache/core14-manager-recovery-browser.json",
"sha256": "b519d98e1248894c606c6cf4faa54cfcda5f3da3a7d54874d9833527fb94fa29",
"stats": {
"startTime": "2026-09-10T21:44:13.564Z",
"duration": 20791.101000000002,
"expected": 69,
"skipped": 0,
"unexpected": 3,
"flaky": 0
},
"reason": "Fault injection used unregistered callbacks, which real Events.remove does not invoke"
}
],
"limits": [
"Source fixture validation only; final CORE-14 installed UMD and legacy validation remains outstanding",
"Transactional update and panel-render rollback, independent header cleanup and reentry remain unfinished",
"Cross-instance sharing/reuse, switch/range, long text and mobile/rotation still need coverage",
"No generic rollback of arbitrary getter/setter or Proxy side effects; failed new input may retain hidden metadata",
"Without ResizeObserver, external container resize requires the existing window/core resize signal",
"BASE-DOM-02/06/07 and BASE-LIFE-12/13/14 remain open"
]
}
@@ -0,0 +1,200 @@
{
"task": "CORE-14",
"status": "partial",
"scope": "Tree model and initial bounded layout only; manager/render/selection/resource migration is unfinished.",
"date": "2026-09-11",
"files": {
"packages/artplayer/src/setting/model.ts": "2486cf5b4dc68ca2bf23351eed2d0010506682324e4e1ac8f1c318ac2cc35163",
"packages/artplayer/src/setting/layout.ts": "584604e8fffaeaf1d633d1ed45e64d94595c2a56976e3a3f7ede01839b83a37d",
"packages/artplayer/src/setting/index.js": "6bf37e3c321200474d0b6c953ed923a52f399b32674930589bb38fd71f0c535c",
"test/setting-model.test.js": "bb1c4237646f128436450d5412a3202f6007831629f104c54dbcb025cd04d27a",
"test/setting-layout.test.js": "a7da46075bd478182559b9af4817b128eb9f9c219b6032d77fd437370f2a3563",
"test/types/setting-model.ts": "ba024ffeae26428d06f07f98f62f211db94ef8b259251f9f424a4203f1187c7b",
"test/browser/setting.spec.js": "498d4281b6373c456d64f4257b335cc7f72604577d5ead701a781ec2dad042b2",
"package.json": "900034e913876951a050e9d896d5b6b83871663d252a0fe602a5ed016fd8ebaa"
},
"ci": {
"passed": 187,
"unit": 158,
"engineering": 4,
"baseline": 25,
"strictSourceFiles": 112,
"logSha256": "6e3a59e7662709c0baad4f9474be3d3853df98f24b898a536945d24a8aa11775"
},
"browser": {
"stats": {
"startTime": "2026-09-10T21:08:42.006Z",
"duration": 13188.141,
"expected": 24,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"candidate": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "e2f0b26373cc8e9865eb326d9d8d4f62122307154c9dadbfdd78843c17bb1f2a",
"bytes": 248698
},
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"cases": [
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
}
]
},
"limits": [
"Source fixture validation only; no final CORE-14 installed-package validation",
"Setting manager and builtin rendering remain JavaScript",
"Descendant/panel cleanup, obsolete callbacks, mounted timers and strict host integration remain pending",
"BASE-DOM-02/06/07 remain open",
"Mobile/rotation, long text and dynamic container changes require further browser coverage"
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,426 @@
{
"task": "CORE-14",
"status": "partial",
"scope": "Tree/layout plus rendering, callback generations, item resources and builtin TS migration; manager integration and failure rollback remain unfinished.",
"date": "2026-09-11",
"node": "v24.21.0",
"files": {
"packages/artplayer/src/setting/aspectRatio.ts": "a16eb58fadf77f0a9009cf162d957ffd59d9b8fde83cc193d510fd642e3456cb",
"packages/artplayer/src/setting/flip.ts": "e72a4ae33300d9e48880897a4f85a04fa6d9a5fff7b06ecaf9081cfb1bb14404",
"packages/artplayer/src/setting/index.js": "50d1c640a18faa671368b94bd750a0c2b3cf57316c93439a4b2dbfe9c87e0426",
"packages/artplayer/src/setting/layout.ts": "584604e8fffaeaf1d633d1ed45e64d94595c2a56976e3a3f7ede01839b83a37d",
"packages/artplayer/src/setting/model.ts": "2486cf5b4dc68ca2bf23351eed2d0010506682324e4e1ac8f1c318ac2cc35163",
"packages/artplayer/src/setting/playbackRate.ts": "2e70e0d2a33cb0ec9fcdd6d22424979fed7b2e01f87015d6be4e520d5cf4e88a",
"packages/artplayer/src/setting/render.ts": "256c2561d28bac2e0af8bb81b971f62c180063928ecdd70782b6e0d035488d8f",
"packages/artplayer/src/setting/resources.ts": "8f0bbc4246ad5848fbd9bbd02c7eb9614ad64cd67615622995597a2eaae0f356",
"packages/artplayer/src/setting/selection.ts": "7664f73e31837fd0b8d22f7eb401877f04ab7fe3a972e4cb958e0f817ed3a92c",
"packages/artplayer/src/setting/subtitleOffset.ts": "86513835d4e542ac39e9d551507473e3e14c71fd0d5ea725267008ecbff6ab83",
"packages/artplayer/src/setting/types.ts": "81ad8ae3892510a2189bdd414b1743696bc713cccff8e4bc58eed24abcc99eac",
"test/setting-model.test.js": "bb1c4237646f128436450d5412a3202f6007831629f104c54dbcb025cd04d27a",
"test/setting-layout.test.js": "a7da46075bd478182559b9af4817b128eb9f9c219b6032d77fd437370f2a3563",
"test/setting-resources.test.js": "8fbc541cfede17cc63a7c54f630fcad2eba91ad780172c56f8fbe774d1586cc8",
"test/types/setting-model.ts": "ba024ffeae26428d06f07f98f62f211db94ef8b259251f9f424a4203f1187c7b",
"test/browser/setting.spec.js": "4c4c001b42fafeeeb1677b9d1b2d596af0591c6897a3882a93c1e84d1f56925e",
"package.json": "82459e37eb54391bb73ebd84b1b27a3c37750d7cc2cd24607fc8f983f7342960"
},
"ci": {
"passed": 193,
"unit": 164,
"engineering": 4,
"baseline": 25,
"strictSourceFiles": 120,
"logSha256": "8e49058f0d7c8f7565add94c0e94bc7c2e65242305854a0b932972ade7d26792"
},
"browser": {
"stats": {
"startTime": "2026-09-10T21:25:43.963Z",
"duration": 18728.229,
"expected": 60,
"skipped": 0,
"unexpected": 0,
"flaky": 0
},
"candidate": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "2904030e8d84eca8678ecc6cedadb01af0ebffccae66a58ff0248800e66524c2",
"bytes": 252544
},
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"cases": [
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "chromium",
"status": "passed",
"retry": 0
},
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "firefox",
"status": "passed",
"retry": 0
},
{
"title": "published: builtin setting subscriptions are replaced and removed with their row",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: deferred setting mounts belong to the rendered item generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: removed setting subtrees release child listeners and cached panels",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting button results cannot overwrite an updated or newer item",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: sibling setting selections share the parent result generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting callback rejection retains its original failure identity",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "published: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: builtin setting subscriptions are replaced and removed with their row",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: deferred setting mounts belong to the rendered item generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: removed setting subtrees release child listeners and cached panels",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting button results cannot overwrite an updated or newer item",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: sibling setting selections share the parent result generation",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting callback rejection retains its original failure identity",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: a wide nested setting panel fits the narrow player and remains selectable",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: setting registry retains item returns and hidden tree bindings",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: moved setting items bind to their current parent and panel",
"engine": "webkit",
"status": "passed",
"retry": 0
},
{
"title": "candidate: automatic setting names and explicit nested names have an explicit baseline",
"engine": "webkit",
"status": "passed",
"retry": 0
}
]
},
"limits": [
"Source fixture validation only; no final CORE-14 installed-package validation",
"Setting manager remains JavaScript; its root subscriptions have not yet migrated",
"Atomic update/render failure, independent header cleanup and cross-instance reuse still require work",
"BASE-DOM-02/06/07 and BASE-LIFE-12/13/14 remain open",
"Dynamic layout, switch/range, long text and mobile/rotation still need additional coverage"
]
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,105 @@
# CORE-14 设置树、渲染与布局
状态:完成。设置目录的 TS 拆分、生命周期、失败恢复、布局和交互已通过最终安装包验收。以下各批记录保留当时的进度与失败原因,当前结论以末尾最终交付证据为准。
## 已接入的源码
setting/model.ts 管理树遍历、查找、结构预检、自动名称及父级绑定。setting/index.ts 的 traverse/format/find 委托该模型;仍保留按选项数组缓存面板的专用管理器,不把它强行套进 Control 返回类型。add/update 返回原条目、remove 返回 undefined、find 缺失返回 null;当前公开 d.ts 保持不变,BASE-TYPE-07 的兼容视图出口仍需 CORE-21 完成。
父级、所在数组、父级数组和事件列表保留非枚举、不可配置 getter;WeakMap 允许同一条目从旧父级移除后重新绑定,getter 函数及事件数组身份保持。遍历保留前序和回调期间添加子节点的可见性。正常显式名称和自动计数规则保留;自动名称遇到整树显式名称时跳过冲突 ID。重复名称、循环/重复条目或不可重定义的外来绑定先预检,不先给前面的条目写入元数据。外部任意 setter/Proxy 抛错的事务边界,以及同时跨活动实例共享同一条目对象,仍需随管理器和资源阶段核对,当前不宣称已覆盖。
setting/layout.ts 将尺寸计算与 DOM 测量分开,面板宽度不超过播放器扣除 padding 后的空间,高度限制在实际控件上方,可滚动访问嵌套内容。padding 从 bottom 的已计算样式读取,支持 CSS 单位解析;控件中心按播放器缩放换算。正常右侧溢出时保持旧 CSS right 锚点,移动端/旋转分支保留原定位策略。动态尺寸观察、长文本、旋转/移动端和完整旧新组合仍需继续验证。
## 首批验证与待办
新增 7 项模型与 3 项布局单元测试;类型正反例覆盖保留自定义字段、数字计数器和 nullable 查找。浏览器新旧对照覆盖条目返回/绑定、移动条目、名称冲突、320x180 的超宽嵌套面板及真实点击最后一项。初轮 24 项三浏览器通过并已检查旧新截图:旧面板裁切在容器外,候选可滚动选择。最终锚点兼容调整后,24 项专项复跑通过;yarn ci:check 共 187 项通过(158 单元、4 工程、25 基线),112 个生产 TS 文件严格检查。见 [阶段证据](../baselines/setting-model-layout-validation.json)。尚未完成整个 CORE-14 或安装包回归。
首批结束时计划迁移 manager/types、selection、render 与内置设置(第二批完成情况见下文),建立条目/面板作用域,处理删除子树、缓存面板更新、过期异步回调和 mounted 定时器。补充失败与重入、默认高亮、长文本、实际窄尺寸变化、旧插件交互,之后构建并验证安装 UMD/legacy、公开类型消费和最终文档。BASE-DOM-02 此时仍 open。
package.json 将 setting-model.test.js 与 setting-layout.test.js 纳入 test:unit;无新增依赖、锁文件或版本变更。任务未完成,尚不提交本任务,不推送或发布。
## 第二批:渲染、回调与条目资源
render.ts 保留 div/类名、item 的动态 html/icon/tooltip/switch/range 描述符、数据写入和条目身份;管理器入口继续转发 createHeader/createItem/render。selection.ts 保留原 callback this、item、DOM 与 Event,range 的同步值写入和选择 default 标志;按实际写入对象隔离异步结果,兄弟 selector 共用父项代次,更新/删除关闭旧条目作用域。原始失败用 console.warn 报告,不吞掉原异常身份。没有取消用户自己的异步 SDK 工作。
resources.ts 管理条目作用域、DOM 事件与内置 Emitter 订阅。删除/更新父项时递归释放子项并移除其 selector 数组对应的缓存面板;清理失败仍尝试其余事件。注册中重入销毁会释放刚返回的监听器。延迟 mounted 绑定本次条目与 DOM,移除/更新取消旧定时器;mounted 的同步失败或返回 Promise 拒绝会报告,不把其返回值当 disposer。
playbackRate/aspectRatio/flip/subtitleOffset 四个内置设置已迁移 TS;订阅随条目关闭。数值/字符串断言仅对应内置工厂自己创建的 selector 值,不改变运行时转换。6 项资源单元用例与新旧浏览器用例覆盖乱序、更新后写入、拒绝身份、子树缓存/监听器、延迟 mounted、订阅重复及移除。最终同步导航异常保护已通过单元及专项复验;设置专项 60 项三浏览器通过,CI 193 项通过(164 单元、4 工程、25 基线),120 个生产 TS 文件严格检查。见 [第二批阶段证据](../baselines/setting-resources-validation.json),该记录明确为 partial。
尚待:管理器转 TS 与专用继承类型边界;add/update/渲染失败时的对象、DOM、面板和监听器回滚;子面板 header 的独立失败清理;跨实例共享/复用和非扩展对象预检;动态尺寸观察、长文本、switch/range 与重入/销毁专项;完整核心/旧插件与最终 UMD/legacy 安装产物。不能用当前已通过专项替代这些剩余验收。
## 第三批:管理器、根事件与失败处理
index.ts 保留实际 Component 原型继承,用最小构造类型视图表达 Setting 自己的数组缓存及返回约定,没有通过双重断言伪装成通用组件。新增管理器类型正反例,验证 add 保留输入自定义字段、find 可为 null、remove 为 void。
events.ts 接管 blur/focus/resize 和尺寸观察,实例关闭后解绑。ResizeObserver 观察播放器与控件;MutationObserver 观察播放器 class/style,并在缺少 ResizeObserver 时补充控件变化。没有 ResizeObserver 的环境仍依赖已有 window/core resize 信号发现外部容器尺寸变化,不能宣称自动发现所有祖先 CSS 修改。
最初动态布局测试三浏览器均失败:宽度已收缩到 300px,但面板高度仍为 124px,目标为 78px。补上 style 观察后仍失败,证实只增加观察不够;设置面板 `transition: all` 使 getComputedStyle(bottom) 在回调时返回过渡起点。现在额外监听面板自身 bottom 的 transitionend,过渡结束后重算,保持现有动画。6 项原生观察/回退专项通过,随后完整设置专项 72 项通过;销毁后晚到观察与 resize 均不调用管理器。
第三批 yarn ci:check 194 项通过(165 单元、4 工程、25 基线),122 个生产 TS 文件严格检查;设置专项 72 项为当前工作区构建,三浏览器无重试、跳过或未处理异常。源码、报告、候选构建及失败记录指纹见 [第三批阶段证据](../baselines/setting-manager-validation.json),仍明确为 partial。
formatTree 新增非扩展对象和不可写自动名称预检,重复名称保持原 ArtPlayerError。add 失败移除本次追加位置;格式化成功后才释放新条目的部分渲染资源,避免无效共享子树触及已有监听器。remove 即使清理失败也继续移除原 DOM 并恢复根面板,再汇总异常。资源树释放有已访问集合,避免外部后续构造循环导致无限递归。
浏览器失败注入覆盖重复名称、同一对象/共享子项、不可扩展对象、html getter 抛错、渲染阶段抛错后的 mounted 取消、再次添加和旧条目可用性。移除失败测试的首版直接向 $events 塞入任意函数,但真实 Events.remove 只处理登记过的监听器,导致三项断言失败;现改为真实 art.proxy 登记与 event-removal 钩子注入,验证原错误身份、后续清理、DOM 删除和兄弟条目均正常。保留失败日志,不以假函数改变生产事件约定。
仍未解决的边界:update 的原对象/描述符/监听器事务恢复、面板创建失败后的缓存与 header 独立清理、重入时的归属以及跨活动实例共享条目。任意 getter/setter/Proxy 的外部副作用不能承诺通用回滚;失败后的新输入可能保留已安装的隐藏元数据。长文本、switch/range、移动端/旋转、完整旧插件组合和最终安装包仍需验收。前两批阶段记录保留当时源码路径与指纹,不改写为最新完成证据。
## 第四批:面板作用域与新面板渲染恢复
panels.ts 管理按选项数组缓存的面板作用域,条目作用域挂到所属面板;返回按钮由面板持有,并仍登记在父项的公开 $events 数组中。失败、删除子树或销毁时释放其监听器、延迟 mounted、缓存和 DOM。清理返回按钮时从原事件数组移除对应登记,不影响父项其他监听器,也不重复移除已被父项释放的监听器。
checkpoint.ts 不调用用户 getter,保存条目的原属性描述符及直接传入 html/icon/tooltip 的节点位置。新面板创建若在 header、条目或 resize 阶段抛错,释放部分面板,恢复原输入节点和描述符、之前活动面板以及 height/width/left/right,调用者仍收到原始错误。归属检查避免恢复已经由别的面板接管的条目;创建 header 时重入删除父项或销毁实例,后续条目不会继续创建。该阶段没有把这些保证扩展为所有 update 或任意用户重入均已解决。
新增 15 项三浏览器测试,覆盖三种失败阶段、失败后正常重试、原 DOM 节点身份与位置、mounted 不重复、父项监听器数量、返回按钮和删除后的迟到点击,以及 header 创建中的 remove/destroy 重入。设置专项共 87 项通过。初次测试在回滚后等待定时器才比较 style,与正常 ResizeObserver 后续布局混在一起,九项失败;分开记录同步回滚和下一轮布局后,证实同步恢复正确。布局失败注入钩子随后也需在同步调用结束后撤销,避免测试自己的异常污染异步观察器。保留两轮失败报告和前后样式附件,未放宽未处理错误检查。
第四批 CI 194 项通过(165 单元、4 工程、25 基线),124 个生产 TS 文件严格检查。完整工作区 UMD 三浏览器 429 项通过,含设置 87 项以及现有播放、切源、章节插件、全屏、生命周期等检查;无重试、跳过或未处理异常。专项与完整运行的候选构建指纹一致,见 [第四批阶段证据](../baselines/setting-panels-validation.json)。这是当前工作区构建证据,不能替代最终安装包和 legacy 验收。
仍需完成:已缓存面板切换及 update 的失败/重入恢复、跨实例条目复用、长文本与 switch/range、移动端/旋转、最终安装 UMD/legacy 回归和包指纹核对。CORE-14 继续 doing,风险尚未关闭,未提交本任务。
## 第五批:设置项 update 事务与重入
update.ts 保留原条目身份和返回值,在赋值前暂停旧子树,渲染成功后才释放旧资源。resources.ts 让暂停的作用域暂存旧监听器,公开 $events 数组身份不变,新一代登记写入原数组;activity.ts 阻止暂停期间的旧回调及异步写入。回滚恢复原监听器,不重新登记,也不重复执行已经运行的 mounted;成功则释放旧代,新代继续工作,销毁时两代都释放。
checkpoint.ts 保存框架管理的 switch/range 闭包状态,恢复 range 原数组引用、内容和原生 input 的边界、步进及值;DOM 检查点恢复实际旧节点及其属性,不用克隆节点替代原节点。赋值、整树校验、条目渲染或最终 render 失败时恢复这些受管理状态,调用者仍收到原错误。提交后若旧资源清理异常,保留已经成功的新行并报告清理错误,不假装已释放的旧资源还能恢复。
按 Reflect.ownKeys 的顺序读取可枚举自有字段,保留通常的 Object.assign 读写顺序,同时在每个用户读取/写入之后检查当前操作。重入新 update、remove、destroy 或后代 update 会终止旧操作;删除祖先先取消其后代的未完成更新。渲染持有自己的操作/作用域,旧 getter 在新行完成后继续返回或抛错,也不能覆盖新句柄或清理新行。任意自定义 setter/Proxy 的外部副作用,以及任意覆写方法在返回前自行发起的额外操作,不能承诺通用回滚。
4 项新增资源单元检查覆盖恢复原登记、只提交新代、销毁两代及清理异常仍恢复旧监听器。新增独立 setting-update.spec.js,覆盖四个失败阶段的 range/DOM/监听器身份、失败后重试、switch 显示状态、同项和父子重入、祖先删除,以及 icon/switch/range 渲染 getter 的重入。此前 87 项设置专项和第一轮新增 27 项均通过;扩展重入检查后的 123 项也通过。最后补充的控制句柄检查随本轮完整运行验证,以下阶段证据以最终完整运行指纹为准,不把较早专项当作最后源码的证据。
第五批最终 CI 198 项通过(169 单元、4 工程、25 基线),126 个生产 TS 文件严格检查;工作区 UMD 完整三浏览器 471 项通过,其中设置相关 129 项,无重试、跳过或未处理异常。见 [第五批阶段证据](../baselines/setting-update-validation.json),包含最终源码/测试/构建和报告指纹。尚未运行本任务最终安装包和 legacy 验收。
仍需完成:直接切换已缓存面板时的失败恢复、跨实例共享/复用、长文本与完整 switch/range 交互、移动端/旋转、最终安装 UMD/legacy 与打包类型验收。当前公开 d.ts 未变,CORE-14 仍 doing,不关闭风险、不提交未完成任务。
## 第六批:缓存导航恢复与实例归属
render 的缓存分支现在也处理 resize 失败,恢复之前的活动面板及布局属性,保留缓存 DOM 和监听器。每次导航有独立代次;失败前若重入导航、删除面板或销毁实例,外层失败不再覆盖较新的结果。导航专项最初三项失败源于测试假定根面板与子面板尺寸必不相同,实际两者此时均为两行;活动面板和 class 断言证明重入导航正确,尺寸相等不是回滚的证据。修正该断言并保留初轮报告。
实际旧/新对照复现 BASE-DOM-08:同一条目给两台活动播放器使用,旧发布版的第二台没有条目,但第一台仍可点击;归属保护前的候选会重新绑定到第二台并释放第一台监听器。现在整树预检拒绝仍由另一活动树持有的条目,抛出 ArtPlayerError,两个树及先前监听器都不修改。没有静默克隆条目,因为那会改变 add/update 返回原对象及回调引用约定。此前这种共享本身不能正常显示两台播放器,因此登记为旧缺陷修正;同时显示时使用各自的条目对象,回调函数仍可共用。
owner 状态不直接保存播放器或 manager 对象,销毁时清空树和作用域引用,避免绑定保留旧实例。关闭中的作用域也视为非活动;旧树 remove 或实例 destroy 后,原条目/子树可交给另一实例使用,父级绑定随之更新,隐藏 getter 与 $events 数组身份保留,原实例晚到清理不会影响新实例。新增两项模型单元和 24 项归属/导航三浏览器检查,均通过;包含冲突 add 不改旧树、原对象移交、旧节点迟到点击及新回调 this。
第六批最终 CI 200 项通过(171 单元、4 工程、25 基线),126 个生产 TS 文件严格检查;工作区 UMD 完整三浏览器 495 项通过,其中设置相关 153 项,无重试、跳过或未处理异常。见 [第六批阶段证据](../baselines/setting-ownership-validation.json),保留旧/新共享对象的初始诊断和最终构建指纹。
仍需完成长文本、完整 switch/range 原生交互、移动端/旋转,以及 CORE-14 最终安装 UMD/legacy、类型消费和文件指纹验收。BASE-DOM-08 与其他 CORE-14 风险保持 open,任务保持 doing。
## 第七批:原生交互、长文本与旋转
setting-interaction.spec.js 使用真实键盘与指针事件核对 range 的 input/change 次序、每次同步值、回调 this/条目/DOM 与最终 tooltip,并用真实点击切换 switch。移动 UA/视口与触摸事件结合本地视频和实际内置 autoOrientation,验证正常与旋转状态的嵌套菜单滚动和最后一项选择;没有把这些测试标成实体手机验收。
长文本初轮在三个引擎复现控件被挤出行内,旧版与候选都存在。setting.less 让标签与提示缩短显示,给原生控件及图标保留宽度,并保留左右内容间隔;文本仍完整保留在 DOM。已检查旧/新长文本和旋转截图,新版 range/switch 可见且可通过键盘/指针操作。交互专项 18 项通过;额外 add 重入检查合入后,交互与更新两组共 66 项通过。
收尾审查补上 registration.ts:外层 add 的 getter 在内层成功 add/update 同一对象后抛错时,旧操作不能移除新行。记录新的注册归属,remove/update 使旧注册失效;保留外层原始异常及内层成功结果。最终打包验收将覆盖最新源码,不以此前局部运行替代它。
本任务的完整浏览器矩阵指仓库现有核心、历史核心及章节插件组合和契约套件;全部生态包/SDK 的专项迁移与最终发布矩阵仍由对应后续任务负责。BASE-TYPE-07 保持开放至 CORE-21 的兼容公开类型出口,CORE-14 不伪造运行时返回值来迁就旧声明。
## 最终交付证据
- yarn ci:check:200 项通过(171 单元、4 工程、25 基线),127 个生产 TS 文件严格检查;核心 122、chapter 5。
- 核心构建与 i18n 生成成功。核心/chapter 实际 tarball 仓库外安装:27 项运行时、五组类型零诊断。
- 实际安装 UMD 与 legacy 各 519 项三浏览器通过,其中各含 177 项设置相关检查;无重试、跳过、flaky 或未处理页面异常。
- [最终验证记录](../baselines/setting-validation.json) 核对全部核心源码与构建快照、三个分发格式与 tarball/docs/compiled、公开声明、包内架构文档、22 个语言文件和独立分发许可。阶段失败与原始旧行为记录仍保留。
- legacy 首次启动时,临时映射中的包路径被重复拼接,服务尚未启动即失败;已改为 Resolve-Path 得到的绝对产物路径,保留启动失败日志。最终验收核对实际映射的包内字节,不把启动失败计作通过或测试重试。
- 关闭 BASE-DOM-02/06/07/08 与 BASE-LIFE-12/13/14。BASE-TYPE-07 保留给 CORE-15/18/21 等公开类型后续责任;本任务不改变旧公开声明来掩盖实际 item/null/void 返回。
- 移动端检查采用真实引擎、触摸事件、模拟 UA/视口和实际 autoOrientation,未宣称实体设备、全部外部 SDK、远端 CI 或完整生态发布验收。
本任务独立本地提交,不推送或发布。当前 214 项中 50 完成、164 待办;下一项 CORE-15 字幕获取、解析和渲染。
+2 -2
View File
@@ -5,7 +5,7 @@
## 现状证据
- [入口](../packages/artplayer/src/index.js)顺序构造所有子系统;[player](../packages/artplayer/src/player/index.js)通过 mixin 将属性安装到 art。
- [Component](../packages/artplayer/src/utils/component.ts)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.js)还有独立的树、渲染和更新语义。
- [Component](../packages/artplayer/src/utils/component.ts)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.ts)还有独立的树、渲染和更新语义。
- [Events](../packages/artplayer/src/events/index.js)集中管理一部分 DOM 监听器,但定时器、异步请求和内部 Emitter 订阅仍由各模块管理。
- [声明](../packages/artplayer/types/artplayer.d.ts)与源码独立维护;[build-ts](../scripts/build-ts.js)进行文本拼接。
@@ -20,7 +20,7 @@
| 媒体能力 | template、proxy、player | 内部精确的能力接口与原生视频类型分离;公共 video 兼容层保留 |
| 播放流程 | play/pause/toggle/seek/url/switch/eventInit | 媒体状态、操作身份、加载恢复与 UI 响应分离 |
| UI 注册 | utils/component、control、layer、contextmenu | 保留 name/cache/update/remove;明确 DOM 和绑定的归属 |
| 设置面板 | setting/index.js | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
| 设置面板 | setting/index.ts | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 165 / doing 0 / blocked 0 / done 49 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 164 / doing 0 / blocked 0 / done 50 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -117,7 +117,7 @@
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | done |
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器;复跑 BASE-05 的 SSR import/模板、浏览器 useSSR 节点复用和非浏览器构造错误;接续 VENDOR-01/02,核对 screenfull/hint 复制版本、修改与分发通知,保留 DOM/CSS/fullscreen 行为 | H | done |
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | done |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | todo |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | done |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | todo |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | todo |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | todo |
@@ -448,6 +448,7 @@
- CORE-11: [记录](changes/2026-09-11-CORE-11-media-events.md) [记录](baselines/media-events-validation.json)
- CORE-12: [记录](changes/2026-09-11-CORE-12-template-resources.md) [记录](baselines/template-resources-validation.json)
- CORE-13: [记录](changes/2026-09-11-CORE-13-components.md) [记录](baselines/components-validation.json)
- CORE-14: [记录](changes/2026-09-11-CORE-14-setting.md) [记录](baselines/setting-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 当前实施:CORE-14 设置树、渲染与布局完成
设置目录迁移为 17 个 TS 模块,拆清树模型、渲染、布局、选择、条目/面板资源、更新事务、注册代次与根事件;保留原对象和返回约定,修复失效回调、残留订阅、失败恢复、跨实例绑定抢占及长文本控件裁切。
CI 200 项通过,核心 122 个与 chapter 5 个生产 TS 文件严格检查;实际安装 UMD 与 legacy 各 519 项三浏览器通过,其中各含 177 项设置检查。真实 tarball 27 项运行时及五组类型零诊断;源码、三个格式、公开类型、包内文档、语言及许可指纹一致。见 [交付](changes/2026-09-11-CORE-14-setting.md) 和 [最终证据](baselines/setting-validation.json)。
关闭 BASE-DOM-02/06/07/08、BASE-LIFE-12/13/14,公开返回声明差异 BASE-TYPE-07 保持开放。当前 214 项,50 完成、164 待办;下一项 CORE-15 字幕获取、解析和渲染。本任务独立本地提交,未推送或发布。
## 当前实施:CORE-13 组件注册与控件完成
Component/control/layer/contextmenu 与 qualityMix 迁移 TS,拆清注册、DOM、资源、选择生命周期、进度交互和布局;修复移除后残留订阅、失败注册与重入清理、过期选择写入、selector 重用和文本属性注入。正常回调/排序/别名/描述符保持,controls.add/update 仍返回 undefined。
+7 -1
View File
@@ -11,7 +11,7 @@
| BASE-LIFE-04 | resolved / 已复现 | Destroying an instance twice unregisters a different live instance | CORE-04 |
| BASE-LIFE-05 | resolved / 已复现 | Failed constructor leaves initialized markup/listeners until explicit probe cleanup | CORE-04 |
| BASE-DOM-01 | open / 已复现 | 控件 Tab 不可达 | CORE-23, CORE-17 |
| BASE-DOM-02 | open / 已复现 | 窄容器中控件裁切 | CORE-13, CORE-14 |
| BASE-DOM-02 | resolved / 已复现 | 窄容器中控件裁切 | CORE-13, CORE-14 |
| BASE-DEMO-01 | open / 源码/产物事实 | 旧 thumbnail 插件示例不对应当前 workspace 包 | SITE-01, EX-03 |
| BASE-TYPE-01 | resolved / 已复现 | NodeNext ESM 默认导出与声明互操作失配 | ENG-04, CORE-07, PKG-CHAPTER-04 |
| BASE-TYPE-02 | resolved / 已复现 | chapter 可选运行时参数被声明为必填 | PKG-CHAPTER-02 |
@@ -62,3 +62,9 @@
| BASE-DOM-03 | resolved / 已复现 | Reusing selector items after update redefines nonconfigurable properties | CORE-13 |
| BASE-DOM-04 | resolved / 已复现 | A component named __proto__ changes its registry prototype | CORE-13 |
| BASE-DOM-05 | resolved / 已复现 | Highlight text interpolation permits unintended HTML attributes | CORE-13 |
| BASE-DOM-06 | resolved / 已复现 | Moved setting items retain parent and option bindings from the old tree | CORE-14 |
| BASE-DOM-07 | resolved / 已复现 | Generated setting names collide with explicit names in nested branches | CORE-14 |
| BASE-LIFE-12 | resolved / 已复现 | Removed or updated setting rows retain descendant panels and builtin subscriptions | CORE-14 |
| BASE-LIFE-13 | resolved / 已复现 | Obsolete setting callback completions overwrite current UI and failures are unhandled | CORE-14 |
| BASE-LIFE-14 | resolved / 已复现 | Removed or replaced setting items still run deferred mounted callbacks | CORE-14 |
| BASE-DOM-08 | resolved / 已复现 | Sharing a live setting object leaves a second published instance blank and can steal candidate bindings | CORE-14 |
+177 -3
View File
@@ -161,7 +161,7 @@
"id": "BASE-DOM-02",
"title": "窄容器中控件裁切",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"CORE-13",
"CORE-14"
@@ -171,7 +171,18 @@
],
"compatibleResolution": "在保留自定义控件扩展入口下修正布局。",
"closureCriteria": "320px 及正常容器的设置/模式操作真实可用。",
"workspaceState": "CORE-13 final installed UMD and legacy verify wrapped controls at 640/320/240px with real 16:9 heights and observed total-height offsets, including ResizeObserver fallback. Narrow setting tree/panel interactions still require CORE-14; this finding remains open."
"workspaceState": "CORE-13 wrapped controls and CORE-14 bounded settings/native input/autoOrientation checks now pass the final installed UMD and legacy matrices; 320px layouts remain operable with long text and wrapped control rows.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-DEMO-01",
@@ -888,7 +899,8 @@
],
"compatibleResolution": "Keep legacy consumers; add explicit cue-array listener overloads, type internal implementations accurately, and resolve remaining generated/public facade contracts at CORE-21 without false intersections.",
"closureCriteria": "Actual setting/null/void and notice boolean returns, cue array callbacks and old legal type consumers all have an explicit compatible declaration strategy and package tests.",
"scopeNote": "CORE-07 provides runtime evidence and explicit cue-array overloads; old inferred scalar event callbacks and return signatures are not claimed corrected."
"scopeNote": "CORE-07 provides runtime evidence and explicit cue-array overloads; old inferred scalar event callbacks and return signatures are not claimed corrected.",
"workspaceState": "CORE-14 source manager accurately models add/update item, remove void and missing find null; installed historical public consumers still compile. Public declaration coordination remains open for CORE-15/18/21."
},
{
"id": "BASE-TYPE-08",
@@ -1132,6 +1144,168 @@
"test/component-resources.test.js"
],
"resolutionRationale": "Published/candidate behavior comparisons and both final installed artifact matrices pass. Entry ownership, transactional registration/removal, current selection guards and literal text assignment correct reproduced defects while retaining normal callbacks, return values, descriptors and public declarations. Source and shipped bytes match the tested package."
},
{
"id": "BASE-DOM-06",
"title": "Moved setting items retain parent and option bindings from the old tree",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting.spec.js",
"test/setting-model.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Preserve hidden getter flags and identity while rebinding a removed item to its current parent/panel.",
"closureCriteria": "Final Setting migration, cleanup, strict types and installed UMD/legacy regressions must pass. Source-only model results do not close this task.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-DOM-07",
"title": "Generated setting names collide with explicit names in nested branches",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting.spec.js",
"test/setting-model.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Reserve explicit names before allocating IDs; preserve normal names and monotonic counters.",
"closureCriteria": "Final Setting migration, cleanup, strict types and installed UMD/legacy regressions must pass. Source-only model results do not close this task.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-LIFE-12",
"title": "Removed or updated setting rows retain descendant panels and builtin subscriptions",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting.spec.js",
"test/setting-resources.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Own DOM/Emitter subscriptions per rendered item and release descendants and cached panels on update/removal.",
"closureCriteria": "Finish manager/resource rollback and verify strict source types, full installed UMD/legacy and builtin/legacy plugin combinations before closure.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-LIFE-13",
"title": "Obsolete setting callback completions overwrite current UI and failures are unhandled",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting.spec.js",
"test/setting-resources.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Preserve callback arguments and synchronous state; only the latest live write target may accept completion, and report the original failure.",
"closureCriteria": "Finish manager/resource rollback and verify strict source types, full installed UMD/legacy and builtin/legacy plugin combinations before closure.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-LIFE-14",
"title": "Removed or replaced setting items still run deferred mounted callbacks",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting.spec.js",
"test/setting-resources.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Bind deferred mounted to its rendered generation and cancel obsolete timers without introducing a disposer return contract.",
"closureCriteria": "Finish manager/resource rollback and verify strict source types, full installed UMD/legacy and builtin/legacy plugin combinations before closure.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
},
{
"id": "BASE-DOM-08",
"title": "Sharing a live setting object leaves a second published instance blank and can steal candidate bindings",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-14"
],
"evidence": [
"test/browser/setting-ownership.spec.js",
"test/setting-model.test.js",
"refactor/changes/2026-09-11-CORE-14-setting.md"
],
"compatibleResolution": "Reject transfer from an active owner before mutating either tree; allow original-object reuse after removal or destruction, preserving item return identity and immutable getters.",
"closureCriteria": "Retain the published sharing baseline and candidate ownership diagnosis; verify rejection, both owner lifetimes, subtree reuse, full strict checks and final installed UMD/legacy matrices before closure.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-14-setting.md",
"refactor/baselines/setting-validation.json",
"test/browser/setting.spec.js",
"test/browser/setting-update.spec.js",
"test/browser/setting-ownership.spec.js",
"test/browser/setting-interaction.spec.js",
"test/setting-model.test.js",
"test/setting-resources.test.js"
],
"resolutionRationale": "Final installed UMD and legacy each pass the complete 519-case three-engine matrix. Managed tree/DOM/resource rollback, async generation guards, scoped mounts and panel/header ownership preserve normal identities, callbacks and returns. Narrow layouts and native input/touch interactions pass; simultaneous live object sharing fails before mutation while transfer after removal/destruction preserves the original object. Source and shipped package bytes match the tested artifacts."
}
]
}
+5 -2
View File
@@ -1009,11 +1009,14 @@
"dependsOn": [
"CORE-13"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "setting model/selection/render/layout 的分批实现",
"acceptance": "不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-14-setting.md",
"baselines/setting-validation.json"
]
},
{
"id": "CORE-15",
+125
View File
@@ -0,0 +1,125 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: native setting keyboard and pointer actions retain range and switch callbacks`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.actions = []
const range = { name: 'range', html: 'Range', tooltip: 'Initial', range: [5, 0, 10, 1] }
const toggle = { name: 'toggle', html: 'Switch', switch: false }
const record = function (type, item, element, event) {
window.actions.push({ type, value: item.range?.[0] ?? item.switch, receiver: this === window.art, element: element === item.$item, trusted: event.isTrusted })
}
range.onChange = function (item, element, event) {
record.call(this, 'input', item, element, event)
return `input:${item.range[0]}`
}
range.onRange = function (item, element, event) {
record.call(this, 'change', item, element, event)
return `change:${item.range[0]}`
}
toggle.onSwitch = function (item, element, event) {
record.call(this, 'switch', item, element, event)
return !item.switch
}
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [range, toggle] })
window.art.setting.show = true
})
const range = page.locator('[data-name="range"] input')
await range.focus()
for (let index = 0; index < 3; index++)
await range.press('ArrowRight')
await page.locator('[data-name="toggle"]').click()
await expect.poll(() => page.evaluate(() => ({ range: window.art.setting.find('range').range[0], tooltip: window.art.setting.find('range').tooltip, switch: window.art.setting.find('toggle').switch }))).toEqual({ range: 8, tooltip: 'change:8', switch: true })
expect(await page.evaluate(() => window.actions)).toEqual([
...[6, 7, 8].flatMap(value => ['input', 'change'].map(type => ({ type, value, receiver: true, element: true, trusted: true }))),
{ type: 'switch', value: false, receiver: true, element: true, trusted: true },
])
await page.evaluate(() => window.art.destroy())
})
test(`${core}: long setting labels leave native controls inside the row`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const container = document.querySelector('.player')
container.style.width = '320px'
container.style.height = '240px'
window.longLabel = 'Long setting label '.repeat(20)
window.longTooltip = 'Detailed value '.repeat(20)
window.art = new window.Artplayer({ container, url: '', setting: true, settings: [
{ name: 'range', html: window.longLabel, tooltip: window.longTooltip, range: [5, 0, 10, 1], onRange() {
return window.longTooltip
} },
{ name: 'switch', html: window.longLabel, tooltip: window.longTooltip, switch: false, onSwitch(item) {
return !item.switch
} },
] })
window.art.setting.show = true
window.art.setting.render()
})
await expect(page.locator('.art-settings')).toBeVisible()
const result = await page.evaluate(() => ['range', 'switch'].map((name) => {
const item = window.art.setting.find(name)
const row = item.$item.getBoundingClientRect()
const control = (item.$range || item.$switch).getBoundingClientRect()
return { name, inside: control.left >= row.left && control.right <= row.right && control.top >= row.top && control.bottom <= row.bottom, fullLabel: item.$html.textContent === window.longLabel, fullTooltip: item.$tooltip.textContent === window.longTooltip }
}))
await testInfo.attach('setting-long-labels', { contentType: 'image/png', body: await page.locator('.player').screenshot() })
expect(result).toEqual(['range', 'switch'].map(name => ({ name, inside: core === 'candidate', fullLabel: true, fullTooltip: true })))
if (core === 'candidate') {
await page.locator('[data-name="range"] input').focus()
await page.locator('[data-name="range"] input').press('ArrowRight')
await page.locator('[data-name="switch"]').click()
await expect.poll(() => page.evaluate(() => [window.art.setting.find('range').range[0], window.art.setting.find('switch').switch])).toEqual([6, true])
}
await page.evaluate(() => window.art.destroy())
})
}
test.describe('mobile UA and real touch events (not physical-device validation)', () => {
test.use({ viewport: { width: 390, height: 844 }, hasTouch: true, userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36' })
for (const rotate of [false, true]) {
test(`candidate: nested settings remain selectable with autoOrientation=${rotate}`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
const container = document.querySelector('.player')
container.style.width = '320px'
container.style.height = '240px'
window.selected = null
const choices = Array.from({ length: 12 }, (_, index) => ({ name: `choice-${index}`, html: `Choice ${index}`, value: index }))
window.Artplayer.AUTO_ORIENTATION_TIME = 0
window.art = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true, setting: true, autoOrientation: true, fullscreenWeb: true, settings: [{ name: 'group', html: 'Choices', width: 420, selector: choices, onSelect(item, element, event) {
window.selected = { value: item.value, receiver: this === window.art, element: element === item.$item, trusted: event.isTrusted }
return item.html
} }] })
})
await expect.poll(() => page.evaluate(() => window.art.video.videoWidth)).toBeGreaterThan(0)
if (rotate) {
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await expect.poll(() => page.evaluate(() => window.art.isRotate)).toBe(true)
}
await page.evaluate(() => {
window.art.setting.show = true
})
await page.locator('[data-name="group"]').tap()
const last = page.locator('[data-name="choice-11"]')
await last.scrollIntoViewIfNeeded()
await last.tap()
await expect.poll(() => page.evaluate(() => window.selected)).toEqual({ value: 11, receiver: true, element: true, trusted: true })
const bounds = await page.evaluate(() => {
const { $player, $setting } = window.art.template
const player = $player.getBoundingClientRect()
const panel = $setting.getBoundingClientRect()
return { mobile: $player.classList.contains('art-mobile'), rotated: window.art.isRotate, inside: panel.left >= player.left - 1 && panel.top >= player.top - 1 && panel.right <= player.right + 1 && panel.bottom <= player.bottom + 1 }
})
expect(bounds).toEqual({ mobile: true, rotated: rotate, inside: true })
await testInfo.attach('setting-mobile-layout', { contentType: 'image/png', body: await page.screenshot() })
await page.evaluate(() => {
window.art.fullscreenWeb = false
window.art.destroy()
})
})
}
})
+155
View File
@@ -0,0 +1,155 @@
import { expect, test } from './fixtures.js'
for (const action of ['failure', 'navigate', 'remove', 'destroy']) {
test(`candidate: cached panel failure preserves navigation after ${action}`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate((action) => {
const first = { name: 'first', html: 'First', selector: [{ name: 'one', html: 'One' }] }
const second = { name: 'second', html: 'Second', selector: [{ name: 'two', html: 'Two' }] }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, second] })
const setting = art.setting
setting.show = true
setting.render(second.selector)
setting.render(first.selector)
const panel = setting.cache.get(first.selector)
const callbacks = [...first.$events, ...first.selector[0].$events]
const resize = setting.resize.bind(setting)
const layout = setting.$parent.style.cssText
const failure = new Error('cached layout failed')
setting.resize = () => {
setting.resize = resize
resize()
if (action === 'navigate')
setting.render()
if (action === 'remove')
setting.remove('second')
if (action === 'destroy')
art.destroy()
throw failure
}
let originalError = false
try {
setting.render(second.selector)
}
catch (error) {
originalError = error === failure
}
const result = {
originalError,
active: setting.active === first.selector ? 'first' : setting.active === setting.option ? 'root' : 'second',
panels: setting.cache.size,
firstVisible: panel.classList.contains('art-current'),
connected: panel.isConnected,
layoutRestored: setting.$parent.style.cssText === layout,
listeners: callbacks.every(callback => art.events.destroyEvents.has(callback)),
}
if (action !== 'destroy')
art.destroy()
return result
}, action)
expect(result).toEqual({
originalError: true,
active: action === 'failure' ? 'first' : action === 'destroy' ? 'second' : 'root',
panels: action === 'remove' ? 2 : action === 'destroy' ? 0 : 3,
firstVisible: action === 'failure',
connected: action !== 'destroy',
layoutRestored: action !== 'remove',
listeners: action !== 'destroy',
})
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: sharing an active setting has an explicit ownership baseline`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(async () => {
const container = document.createElement('div')
document.body.append(container)
const calls = []
const item = { name: 'shared', html: 'Shared', onClick() {
calls.push(this.id)
} }
const first = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
const row = item.$item
const firstOption = item.$option
let second
let error
try {
second = new window.Artplayer({ container, url: '', setting: true, settings: [item] })
}
catch (failure) {
error = failure.message
}
row.click()
item.$item.click()
await Promise.resolve()
const result = { error: error || null, firstOption: item.$option === firstOption, firstHandle: item.$item === row, firstCalls: calls.filter(id => id === first.id).length, secondCalls: calls.filter(id => id === second?.id).length, firstRows: first.setting.$parent.querySelectorAll('.art-setting-item').length, secondRows: second?.setting.$parent.querySelectorAll('.art-setting-item').length ?? null }
second?.destroy()
first.destroy()
container.remove()
return result
})
await testInfo.attach('setting-ownership', { contentType: 'application/json', body: JSON.stringify(result) })
expect(result).toEqual(core === 'published'
? { error: null, firstOption: true, firstHandle: true, firstCalls: 2, secondCalls: 0, firstRows: 1, secondRows: 0 }
: { error: 'Setting item [shared] already belongs to another active player', firstOption: true, firstHandle: true, firstCalls: 2, secondCalls: 0, firstRows: 1, secondRows: null })
})
}
for (const action of ['remove', 'destroy']) {
test(`candidate: a setting subtree can move to another instance after ${action}`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (action) => {
const container = document.createElement('div')
document.body.append(container)
const calls = []
const child = { name: 'child', html: 'Child', onClick() {
calls.push(this.id)
} }
const parent = { name: 'parent', html: 'Parent', selector: [child] }
const first = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
first.setting.render(parent.selector)
const oldRow = child.$item
const events = child.$events
const getter = Object.getOwnPropertyDescriptor(child, '$parents').get
const second = new window.Artplayer({ container, url: '', setting: true })
let rejected = false
try {
second.setting.add(parent)
}
catch (error) {
rejected = error.name === 'ArtPlayerError'
}
oldRow.click()
await Promise.resolve()
const protectedOwner = calls.length === 1 && calls[0] === first.id && second.setting.option.length === 0
calls.length = 0
if (action === 'remove')
first.setting.remove('parent')
else
first.destroy()
const returned = second.setting.add(parent) === parent
second.setting.render(parent.selector)
first.destroy()
oldRow.click()
child.$item.click()
await Promise.resolve()
const result = {
rejected,
protectedOwner,
returned,
getter: Object.getOwnPropertyDescriptor(child, '$parents').get === getter,
eventArray: child.$events === events,
parent: child.$parent === parent,
root: child.$parents === second.setting.option,
oldConnected: oldRow.isConnected,
newConnected: child.$item.isConnected,
newOwnerOnly: calls.length === 1 && calls[0] === second.id,
}
second.destroy()
container.remove()
return result
}, action)
expect(result).toEqual({ rejected: true, protectedOwner: true, returned: true, getter: true, eventArray: true, parent: true, root: true, oldConnected: false, newConnected: true, newOwnerOnly: true })
})
}
+307
View File
@@ -0,0 +1,307 @@
import { expect, test } from './fixtures.js'
for (const phase of ['assign', 'format', 'item', 'render']) {
test(`candidate: setting update restores the original range row after ${phase} failure`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (phase) => {
const label = document.createElement('span')
label.textContent = 'Original'
let mounts = 0
const calls = []
let art
const item = { name: 'target', html: label, tooltip: 'Original tooltip', range: [7, 0, 10, 1], mounted() {
mounts++
}, onRange(owner, element, event) {
calls.push({ value: owner.range[0], owner: owner === item, receiver: this === art, element: element === owner.$item, event: event.target === owner.$range })
return 'Range result'
} }
art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item, { name: 'existing', html: 'Existing' }] })
await new Promise(resolve => setTimeout(resolve, 0))
const row = item.$item
const input = item.$range
const range = item.range
const events = item.$events
const callbacks = [...events]
const listeners = art.events.destroyEvents.size
const failure = new Error(`failed ${phase}`)
const target = { name: 'target', html: 'Replacement', tooltip: 'Pending', range: [0, 0, 1, 1], extra: 'new' }
const createItem = art.setting.createItem.bind(art.setting)
const render = art.setting.render.bind(art.setting)
if (phase === 'assign') {
Object.defineProperty(target, 'extra', { enumerable: true, get() {
throw failure
} })
}
if (phase === 'format')
target.selector = [{ name: 'existing', html: 'Duplicate' }]
if (phase === 'item') {
art.setting.createItem = (...args) => {
createItem(...args)
throw failure
}
}
if (phase === 'render') {
art.setting.render = (...args) => {
render(...args)
throw failure
}
}
let originalError = false
try {
art.setting.update(target)
}
catch (error) {
originalError = phase === 'format' ? error.name === 'ArtPlayerError' && error.message === 'The [existing] already exists in [setting]' : error === failure
}
art.setting.createItem = createItem
art.setting.render = render
await new Promise(resolve => setTimeout(resolve, 0))
const restored = {
originalError,
row: item.$item === row && row.isConnected,
input: item.$range === input,
label: item.$html.firstChild === label,
range: item.range === range,
values: [...item.range],
native: [input.value, input.min, input.max, input.step],
tooltip: item.tooltip,
events: item.$events === events && events.length === callbacks.length && events.every((callback, index) => callback === callbacks[index]),
listeners: art.events.destroyEvents.size - listeners,
mounts,
extra: Object.hasOwn(item, 'extra'),
selector: Object.hasOwn(item, 'selector'),
}
input.dispatchEvent(new Event('change'))
await Promise.resolve()
const returned = art.setting.update({ name: 'target', html: 'Success', range: [8, 0, 20, 2] }) === item
await new Promise(resolve => setTimeout(resolve, 0))
input.dispatchEvent(new Event('change'))
item.$range.dispatchEvent(new Event('change'))
await Promise.resolve()
const result = { restored, returned, replaced: item.$item !== row && !row.isConnected, calls, mounts }
art.destroy()
return result
}, phase)
expect(result).toEqual({
restored: { originalError: true, row: true, input: true, label: true, range: true, values: [7, 0, 10, 1], native: ['7', '0', '10', '1'], tooltip: 'Original tooltip', events: true, listeners: 0, mounts: 1, extra: false, selector: false },
returned: true,
replaced: true,
calls: [
{ value: 7, owner: true, receiver: true, element: true, event: true },
{ value: 8, owner: true, receiver: true, element: true, event: true },
],
mounts: 2,
})
})
}
test('candidate: failed switch updates restore the original closure and displayed icon', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async () => {
const item = { name: 'switch', html: 'Original', switch: false, onSwitch(owner) {
return !owner.switch
} }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
const row = item.$item
const before = item.$switch.innerHTML
const failure = new Error('switch assign')
let originalError = false
try {
art.setting.update({ name: 'switch', switch: true, get html() {
throw failure
} })
}
catch (error) {
originalError = error === failure
}
const restored = { originalError, sameRow: item.$item === row, value: item.switch, icon: item.$switch.innerHTML === before }
row.click()
await Promise.resolve()
const result = { restored, clicked: item.switch }
art.destroy()
return result
})
expect(result).toEqual({ restored: { originalError: true, sameRow: true, value: false, icon: true }, clicked: true })
})
for (const action of ['update', 'remove', 'destroy', 'child-update']) {
test(`candidate: reentrant ${action} supersedes an unfinished setting update`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (action) => {
let calls = 0
const child = { name: 'child', html: 'Child', onClick() {
calls++
} }
const item = { name: 'parent', html: 'Parent', tooltip: 'Original', selector: [child] }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
art.setting.render(item.selector)
const oldRow = item.$item
const oldChild = child.$item
const target = { name: 'parent', html: 'Outer', get extra() {
oldChild.click()
if (action === 'update')
art.setting.update({ name: 'parent', html: 'Winner' })
else if (action === 'remove')
art.setting.remove('parent')
else if (action === 'destroy')
art.destroy()
else
art.setting.update({ name: 'child', html: 'Child winner' })
return 'obsolete'
}, tooltip: 'Obsolete' }
const returned = art.setting.update(target) === item
await new Promise(resolve => setTimeout(resolve, 0))
const result = {
returned,
calls,
extra: Object.hasOwn(item, 'extra'),
tooltip: item.tooltip,
html: item.html,
childHtml: child.html,
rowConnected: item.$item.isConnected,
oldRowConnected: oldRow.isConnected,
oldChildConnected: oldChild.isConnected,
childCached: art.setting.cache.has(item.selector),
}
if (action !== 'destroy')
art.destroy()
return result
}, action)
expect(result).toEqual({
returned: true,
calls: 0,
extra: false,
tooltip: 'Original',
html: action === 'update' ? 'Winner' : action === 'destroy' ? 'Outer' : 'Parent',
childHtml: action === 'child-update' ? 'Child winner' : 'Child',
rowConnected: action === 'update' || action === 'child-update',
oldRowConnected: action === 'child-update',
oldChildConnected: false,
childCached: action === 'child-update',
})
})
}
for (const throwing of [false, true]) {
test(`candidate: reentry inside a render getter retains the winning row with throwing=${throwing}`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (throwing) => {
let calls = 0
const item = { name: 'target', html: 'Original', onClick() {
calls++
} }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
let triggered = false
const failure = new Error('outer getter failed after winner')
Object.defineProperty(item, 'icon', { configurable: true, enumerable: true, get() {
if (!triggered) {
triggered = true
art.setting.update({ name: 'target', html: 'Winner' })
if (throwing)
throw failure
}
return '<span>Icon</span>'
} })
let originalError = false
try {
art.setting.update({ name: 'target', html: 'Outer' })
}
catch (error) {
originalError = error === failure
}
item.$item.click()
await Promise.resolve()
const result = { originalError, triggered, calls, html: item.html, connected: item.$item.isConnected, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length, events: item.$events.length }
art.destroy()
return result
}, throwing)
expect(result).toEqual({ originalError: throwing, triggered: true, calls: 1, html: 'Winner', connected: true, rows: 1, events: 1 })
})
}
test('candidate: removing an ancestor cancels its pending descendant update', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(() => {
const child = { name: 'child', html: 'Child' }
const parent = { name: 'parent', html: 'Parent', selector: [child] }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
art.setting.render(parent.selector)
art.setting.update({ name: 'child', html: 'Outer', get extra() {
art.setting.remove('parent')
return 'obsolete'
} })
const result = { html: child.html, extra: Object.hasOwn(child, 'extra'), connected: child.$item.isConnected, found: art.setting.find('child'), cached: art.setting.cache.has(parent.selector), events: child.$events.length }
art.destroy()
return result
})
expect(result).toEqual({ html: 'Child', extra: false, connected: false, found: null, cached: false, events: 0 })
})
for (const kind of ['switch', 'range']) {
test(`candidate: a reentrant ${kind} getter cannot replace the winning control handles`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate((kind) => {
const value = kind === 'switch' ? false : [7, 0, 10, 1]
const item = { name: 'target', html: 'Original', [kind]: value }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
let triggered = false
Object.defineProperty(item, kind, { configurable: true, enumerable: true, get() {
if (!triggered) {
triggered = true
art.setting.update({ name: 'target', html: 'Winner' })
}
return value
} })
art.setting.update({ name: 'target', html: 'Outer' })
const control = item[kind === 'switch' ? '$switch' : '$range']
const result = { triggered, html: item.html, connected: control.isConnected, owner: control.closest('.art-setting-item') === item.$item, value: item[kind], rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
art.destroy()
return result
}, kind)
expect(result).toEqual({ triggered: true, html: 'Winner', connected: true, owner: true, value: kind === 'switch' ? false : [7, 0, 10, 1], rows: 1 })
})
}
for (const action of ['add', 'update']) {
test(`candidate: a failed outer add preserves a reentrant ${action} of the same object`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (action) => {
const art = new window.Artplayer({ container: '.player', url: '', setting: true })
const failure = new Error('obsolete outer add')
let triggered = false
let html = 'Winner'
let clicks = 0
const item = { name: 'target', get html() {
if (!triggered) {
triggered = true
if (action === 'add') {
art.setting.remove('target')
art.setting.add(item)
}
else {
art.setting.update({ name: 'target', html: 'Winner' })
}
throw failure
}
return html
}, set html(value) {
html = value
}, onClick() {
clicks++
} }
let originalError = false
try {
art.setting.add(item)
}
catch (error) {
originalError = error === failure
}
item.$item.click()
await Promise.resolve()
const result = { originalError, found: art.setting.find('target') === item, html: item.html, connected: item.$item.isConnected, clicks, events: item.$events.length, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
art.destroy()
return result
}, action)
expect(result).toEqual({ originalError: true, found: true, html: 'Winner', connected: true, clicks: 1, events: 1, rows: 1 })
})
}
+545
View File
@@ -0,0 +1,545 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: builtin setting subscriptions are replaced and removed with their row`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(async () => {
const art = new window.Artplayer({ container: '.player', url: '', setting: true, playbackRate: true })
await new Promise(resolve => setTimeout(resolve, 0))
art.setting.update({ name: 'playback-rate', html: 'Speed' })
await new Promise(resolve => setTimeout(resolve, 0))
let calls = 0
const check = art.setting.check.bind(art.setting)
art.setting.check = (target) => {
calls++
check(target)
}
art.emit('video:ratechange')
const afterUpdate = calls
calls = 0
art.setting.remove('playback-rate')
art.emit('video:ratechange')
const result = { afterUpdate, afterRemove: calls }
art.destroy()
return result
})
expect(result).toEqual(core === 'candidate' ? { afterUpdate: 1, afterRemove: 0 } : { afterUpdate: 2, afterRemove: 2 })
})
test(`${core}: deferred setting mounts belong to the rendered item generation`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(async () => {
const art = new window.Artplayer({ container: '.player', url: '', setting: true })
const calls = []
art.setting.add({ name: 'removed', html: 'Removed', onClick() {}, mounted() {
calls.push('removed')
} })
art.setting.remove('removed')
art.setting.add({ name: 'updated', html: 'Before', onClick() {}, mounted() {
calls.push('before')
} })
art.setting.update({ name: 'updated', html: 'After', mounted(element, item) {
calls.push(`${element.textContent}:${this === art}:${item === art.setting.find('updated')}`)
} })
// Barrier after the previously scheduled zero-delay mounted callbacks.
await new Promise(resolve => setTimeout(resolve, 0))
art.destroy()
return calls
})
expect(result).toEqual(core === 'candidate' ? ['After:true:true'] : ['removed', 'After:true:true', 'After:true:true'])
})
test(`${core}: removed setting subtrees release child listeners and cached panels`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(() => {
let calls = 0
const child = { name: 'child', html: 'Child', onClick() {
calls++
} }
const parent = { name: 'group', html: 'Group', selector: [child] }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
art.setting.render(parent.selector)
const node = child.$item
art.setting.remove('group')
node.dispatchEvent(new MouseEvent('click', { bubbles: true }))
const result = { calls, cached: art.setting.cache.has(parent.selector), childListeners: child.$events.length, attached: node.isConnected }
art.destroy()
return result
})
expect(result).toEqual(core === 'candidate' ? { calls: 0, cached: false, childListeners: 0, attached: false } : { calls: 1, cached: true, childListeners: 1, attached: true })
})
test(`${core}: setting button results cannot overwrite an updated or newer item`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(async () => {
const pending = []
const item = { name: 'async', html: 'Async', tooltip: 'initial', onClick() {
return new Promise(resolve => pending.push(resolve))
} }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item] })
item.$item.dispatchEvent(new MouseEvent('click'))
art.setting.update({ name: 'async', tooltip: 'updated' })
pending[0]('obsolete')
await Promise.resolve()
const afterUpdate = item.tooltip
item.$item.dispatchEvent(new MouseEvent('click'))
item.$item.dispatchEvent(new MouseEvent('click'))
pending[2]('latest')
await Promise.resolve()
pending[1]('earlier')
await Promise.resolve()
const result = { afterUpdate, afterReorder: item.tooltip }
art.destroy()
return result
})
expect(result).toEqual(core === 'candidate' ? { afterUpdate: 'updated', afterReorder: 'latest' } : { afterUpdate: 'obsolete', afterReorder: 'earlier' })
})
test(`${core}: sibling setting selections share the parent result generation`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(async () => {
const pending = []
const calls = []
let art
const items = [{ name: 'a', html: 'A' }, { name: 'b', html: 'B' }]
const parent = { name: 'group', html: 'Group', selector: items, onSelect(item, node, event) {
calls.push({ name: item.name, sameThis: this === art, sameNode: node === item.$item, event: event.type })
return new Promise(resolve => pending.push(resolve))
} }
art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
art.setting.render(items)
items[0].$item.dispatchEvent(new MouseEvent('click'))
items[1].$item.dispatchEvent(new MouseEvent('click'))
pending[1]('B result')
await Promise.resolve()
pending[0]('A result')
await Promise.resolve()
const result = { tooltip: parent.tooltip, defaults: items.map(item => item.default), calls }
art.destroy()
return result
})
expect(result).toEqual({ tooltip: core === 'candidate' ? 'B result' : 'A result', defaults: [false, true], calls: [{ name: 'a', sameThis: true, sameNode: true, event: 'click' }, { name: 'b', sameThis: true, sameNode: true, event: 'click' }] })
})
test(`${core}: setting callback rejection retains its original failure identity`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const failure = new Error('setting failure')
window.settingErrors = { warnings: [], rejections: [] }
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault()
window.settingErrors.rejections.push(event.reason === failure)
})
const warn = console.warn
console.warn = (message, error) => {
window.settingErrors.warnings.push({ message, same: error === failure })
warn.call(console, message, error)
}
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [{ name: 'failure', html: 'Fail', tooltip: 'keep', onClick() {
return Promise.reject(failure)
} }] })
window.art.setting.find('failure').$item.dispatchEvent(new MouseEvent('click'))
})
await expect.poll(() => page.evaluate(() => window.settingErrors.rejections.length + window.settingErrors.warnings.length)).toBe(1)
expect(await page.evaluate(() => window.settingErrors)).toEqual(core === 'candidate' ? { warnings: [{ message: 'ArtPlayer setting callback failed:', same: true }], rejections: [] } : { warnings: [], rejections: [true] })
expect(await page.evaluate(() => window.art.setting.find('failure').tooltip)).toBe('keep')
await page.evaluate(() => window.art.destroy())
})
test(`${core}: a wide nested setting panel fits the narrow player and remains selectable`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const container = document.querySelector('.player')
container.style.width = '320px'
container.style.height = '180px'
window.art = new window.Artplayer({ container, url: '', setting: true, settings: [{ name: 'wide', html: 'Wide choices', width: 420, selector: Array.from({ length: 12 }, (_, value) => ({ name: `choice-${value}`, html: `Choice ${value}`, value })), onSelect(item) {
window.settingSelected = item.value
return item.html
} }] })
window.art.controls.show = true
})
await page.locator('.art-control-setting').click()
await page.locator('.art-setting-panel.art-current [data-name="wide"]').click()
await expect.poll(() => page.locator('.art-settings').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBe(core === 'candidate' ? 300 : 420)
await expect.poll(() => page.evaluate(() => {
const player = window.art.template.$player.getBoundingClientRect()
const panel = window.art.template.$setting.getBoundingClientRect()
return panel.left >= player.left - 1 && panel.right <= player.right + 1 && panel.top >= player.top - 1 && panel.bottom <= player.bottom + 1
})).toBe(core === 'candidate')
await page.locator('.player').screenshot({ path: testInfo.outputPath('narrow-setting.png') })
if (core === 'candidate') {
await page.locator('.art-setting-panel.art-current [data-name="choice-11"]').click()
await expect.poll(() => page.evaluate(() => window.settingSelected)).toBe(11)
await expect.poll(() => page.evaluate(() => window.art.setting.find('wide').tooltip)).toBe('Choice 11')
expect(await page.evaluate(() => window.art.setting.active === window.art.setting.option)).toBe(true)
}
await page.evaluate(() => window.art.destroy())
})
test(`${core}: setting registry retains item returns and hidden tree bindings`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(() => {
const art = new window.Artplayer({ container: '.player', url: '', setting: true })
const { add, update, remove } = art.setting
const item = { name: 'custom', html: 'Original', onClick() {} }
const added = add(item)
const originalNode = item.$item
const descriptor = Object.getOwnPropertyDescriptor(item, '$option')
const updated = update({ name: 'custom', html: 'Updated' })
const result = {
sameAdd: added === item,
sharedBase: Object.getPrototypeOf(Object.getPrototypeOf(art.setting)) === Object.getPrototypeOf(Object.getPrototypeOf(art.controls)),
sameUpdate: updated === item,
replaced: originalNode !== item.$item,
found: art.setting.find('custom') === item,
missing: art.setting.find('missing'),
html: item.html,
root: item.$option === art.setting.option,
parentAbsent: item.$parent === undefined && item.$parents === undefined,
descriptor: { enumerable: descriptor.enumerable, configurable: descriptor.configurable, getter: typeof descriptor.get, setter: descriptor.set === undefined },
removeUndefined: remove('custom') === undefined,
removed: art.setting.find('custom') === null,
}
art.destroy()
return result
})
expect(result).toEqual({ sameAdd: true, sharedBase: true, sameUpdate: true, replaced: true, found: true, missing: null, html: 'Updated', root: true, parentAbsent: true, descriptor: { enumerable: false, configurable: false, getter: 'function', setter: true }, removeUndefined: true, removed: true })
})
test(`${core}: moved setting items bind to their current parent and panel`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const child = { name: 'child', html: 'Child' }
const first = { name: 'first', html: 'First', selector: [child] }
const second = { name: 'second', html: 'Second', selector: [] }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, second] })
window.art = art
window.settingMoved = { child, first, second, getter: Object.getOwnPropertyDescriptor(child, '$parent').get, events: child.$events }
art.setting.remove('child')
second.selector.push(child)
art.setting.format()
art.setting.show = true
art.setting.resize()
})
await page.locator('.art-setting-panel.art-current [data-name="second"]').click()
const result = await page.evaluate(() => {
const { child, first, second, getter, events } = window.settingMoved
const setting = window.art.setting
return { parent: child.$parent === second ? 'second' : child.$parent === first ? 'first' : 'other', option: child.$option === second.selector, sameGetter: getter === Object.getOwnPropertyDescriptor(child, '$parent').get, sameEvents: events === child.$events, active: setting.active === second.selector, rendered: Boolean(setting.cache.get(second.selector).querySelector('[data-name="child"]')) }
})
expect(result).toEqual({ parent: core === 'candidate' ? 'second' : 'first', option: core === 'candidate', sameGetter: true, sameEvents: true, active: true, rendered: core === 'candidate' })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: automatic setting names and explicit nested names have an explicit baseline`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(() => {
const first = { html: 'Auto', onClick() {} }
const explicit = { name: 'setting-0', html: 'Explicit' }
const nested = { html: 'Nested' }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [first, { name: 'group', html: 'Group', selector: [explicit, nested] }] })
const names = []
art.setting.traverse(item => names.push(item.name))
const result = { names, unique: new Set(names).size, id: art.setting.id, explicitFound: art.setting.find('setting-0') === explicit }
art.destroy()
return result
})
expect(result).toEqual(core === 'candidate'
? { names: ['setting-1', 'group', 'setting-0', 'setting-2'], unique: 4, id: 3, explicitFound: true }
: { names: ['setting-0', 'group', 'setting-0', 'setting-1'], unique: 3, id: 2, explicitFound: true })
})
}
for (const fallback of [false, true]) {
test(`candidate: open settings follow container and control height changes with fallback=${fallback}`, async ({ page }) => {
if (fallback) {
await page.addInitScript(() => {
window.ResizeObserver = undefined
})
}
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
const choices = Array.from({ length: 12 }, (_, value) => ({ name: `choice-${value}`, html: `Choice ${value}` }))
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }], settings: [{ name: 'group', html: 'Group', width: 420, selector: choices }] })
window.art.setting.show = true
window.art.setting.render(choices)
})
await expect.poll(() => page.locator('.art-settings').evaluate(element => Math.round(element.getBoundingClientRect().width))).toBe(420)
await page.evaluate((fallback) => {
const container = document.querySelector('.player')
container.style.width = '320px'
container.style.height = '180px'
if (fallback)
window.dispatchEvent(new Event('resize'))
}, fallback)
await expect.poll(() => page.evaluate(() => {
const { $player, $setting, $controls } = window.art.template
const player = $player.getBoundingClientRect()
const panel = $setting.getBoundingClientRect()
return { width: Math.round(panel.width), height: Math.round(panel.height), controls: $controls.offsetHeight, inside: panel.top >= player.top - 1 && panel.left >= player.left - 1 && panel.right <= player.right + 1 && panel.bottom <= player.bottom + 1 }
})).toEqual({ width: 300, height: 78, controls: 92, inside: true })
const late = await page.evaluate(async () => {
const art = window.art
art.destroy(false)
let calls = 0
art.setting.resize = () => calls++
art.emit('resize')
art.template.$player.style.width = '200px'
await new Promise(resolve => setTimeout(resolve, 0))
return calls
})
expect(late).toBe(0)
})
}
test('candidate: failed setting additions leave existing rows usable and allow retry', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async () => {
let clicks = 0
let mounts = 0
const original = { name: 'original', html: 'Original', onClick() {
clicks++
} }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [original] })
const element = original.$item
const events = original.$events
const errors = []
for (const item of [original, { name: 'original', html: 'Duplicate' }, { name: 'shared', selector: [original] }, Object.preventExtensions({ name: 'sealed' })]) {
try {
art.setting.add(item)
}
catch (error) {
errors.push(error.name)
}
}
const failure = new Error('html getter failed')
const invalid = { name: 'retry', get html() {
throw failure
} }
let sameError = false
try {
art.setting.add(invalid)
}
catch (error) {
sameError = error === failure
}
const render = art.setting.render.bind(art.setting)
const renderFailure = new Error('render failed')
art.setting.render = () => {
throw renderFailure
}
let sameRenderError = false
const mounted = { name: 'mounted', html: 'Mounted', mounted() {
mounts++
} }
try {
art.setting.add(mounted)
}
catch (error) {
sameRenderError = error === renderFailure
}
art.setting.render = render
const afterFailures = {
names: art.setting.option.map(item => item.name),
rows: art.setting.$parent.querySelectorAll('.art-setting-item').length,
originalNode: original.$item === element,
originalEvents: original.$events === events,
failedMountRemoved: !mounted.$item.isConnected,
}
element.click()
const retry = { name: 'retry', html: 'Retry', onClick() {
clicks++
} }
const returned = art.setting.add(retry) === retry
retry.$item.click()
await new Promise(resolve => setTimeout(resolve, 0))
const result = { errors, sameError, sameRenderError, afterFailures, returned, clicks, mounts, retryFound: art.setting.find('retry') === retry }
art.destroy()
return result
})
expect(result).toEqual({ errors: ['Error', 'ArtPlayerError', 'Error', 'TypeError'], sameError: true, sameRenderError: true, afterFailures: { names: ['original'], rows: 1, originalNode: true, originalEvents: true, failedMountRemoved: true }, returned: true, clicks: 2, mounts: 0, retryFound: true })
})
test('candidate: setting removal finishes DOM cleanup when the event-removal hook throws', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async () => {
let clicks = 0
let laterCleanup = 0
const item = { name: 'removed', html: 'Remove', onClick() {
clicks++
} }
const sibling = { name: 'sibling', html: 'Sibling', onClick() {
clicks++
} }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [item, sibling] })
const element = item.$item
const failure = new Error('cleanup failed')
const first = art.proxy(element, 'probe-first', () => {})
const last = art.proxy(element, 'probe-last', () => {})
item.$events.push(first, last)
const remove = art.events.remove.bind(art.events)
art.events.remove = (cleanup) => {
remove(cleanup)
if (cleanup === first)
throw failure
if (cleanup === last)
laterCleanup++
}
let sameError = false
try {
art.setting.remove('removed')
}
catch (error) {
sameError = error.name === 'ResourceCleanupError' && error.errors.length === 1 && error.errors[0] === failure
}
element.click()
sibling.$item.click()
await Promise.resolve()
const result = { sameError, laterCleanup, clicks, events: item.$events.length, found: art.setting.find('removed'), connected: element.isConnected, rows: art.setting.$parent.querySelectorAll('.art-setting-item').length }
art.destroy()
return result
})
expect(result).toEqual({ sameError: true, laterCleanup: 1, clicks: 1, events: 0, found: null, connected: false, rows: 1 })
})
for (const phase of ['header', 'item', 'layout']) {
test(`candidate: failed ${phase} rendering releases its panel and restores reusable inputs`, async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate(async (phase) => {
const placeholder = document.createElement('div')
const label = document.createElement('span')
label.textContent = 'First'
placeholder.append(label)
document.body.append(placeholder)
let mounts = 0
let clicks = 0
const first = { name: 'first', html: label, mounted() {
mounts++
}, onClick() {
clicks++
} }
const second = { name: 'second', html: 'Second' }
const children = [first, second]
const parent = { name: 'parent', html: 'Parent', selector: children }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
art.setting.show = true
const setting = art.setting
const beforeEvents = art.events.destroyEvents.size
const beforeParentEvents = parent.$events.length
const beforeStyle = setting.$parent.style.cssText
const failure = new Error(`failed ${phase}`)
const createHeader = setting.createHeader.bind(setting)
const resize = setting.resize.bind(setting)
if (phase === 'header') {
setting.createHeader = (item) => {
createHeader(item)
throw failure
}
}
if (phase === 'item') {
Object.defineProperty(second, 'html', { configurable: true, get() {
throw failure
} })
}
if (phase === 'layout') {
setting.resize = () => {
resize()
throw failure
}
}
let sameError = false
try {
setting.render(children)
}
catch (error) {
sameError = error === failure
}
const afterRollback = setting.$parent.style.cssText
setting.createHeader = createHeader
setting.resize = resize
await new Promise(resolve => setTimeout(resolve, 0))
const afterTick = setting.$parent.style.cssText
const failed = {
sameError,
mounts,
rootActive: setting.active === setting.option,
cache: setting.cache.size,
panels: setting.$parent.querySelectorAll('.art-setting-panel').length,
rootVisible: setting.cache.get(setting.option).classList.contains('art-current'),
rowRestored: first.$item === undefined,
inputRestored: first.html === label,
originalParent: label.parentNode === placeholder,
listeners: art.events.destroyEvents.size - beforeEvents,
parentEvents: parent.$events.length - beforeParentEvents,
layoutRestored: afterRollback === beforeStyle,
}
if (phase === 'item')
Object.defineProperty(second, 'html', { configurable: true, enumerable: true, writable: true, value: 'Second' })
setting.render(children)
await new Promise(resolve => setTimeout(resolve, 0))
first.$item.click()
const panel = setting.cache.get(children)
const header = panel.querySelector('.art-setting-item-back')
header.click()
const returned = setting.active === setting.option
setting.remove('parent')
let late = 0
setting.render = () => late++
header.click()
first.$item.click()
const result = { layout: { beforeStyle, afterRollback, afterTick }, failed, mounts, clicks, returned, late, headerConnected: header.isConnected, cached: setting.cache.has(children), parentEvents: parent.$events.length }
art.destroy()
placeholder.remove()
return result
}, phase)
const { layout, ...state } = result
await testInfo.attach('setting-panel-layout', { contentType: 'application/json', body: JSON.stringify(layout) })
expect(state).toEqual({
failed: { sameError: true, mounts: 0, rootActive: true, cache: 1, panels: 1, rootVisible: true, rowRestored: true, inputRestored: true, originalParent: true, listeners: 0, parentEvents: 0, layoutRestored: true },
mounts: 1,
clicks: 1,
returned: true,
late: 0,
headerConnected: false,
cached: false,
parentEvents: 0,
})
})
}
for (const action of ['remove', 'destroy']) {
test(`candidate: panel creation stops after reentrant ${action}`, async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
const result = await page.evaluate((action) => {
const children = [{ name: 'child', html: 'Child' }]
const parent = { name: 'parent', html: 'Parent', selector: children }
const art = new window.Artplayer({ container: '.player', url: '', setting: true, settings: [parent] })
const setting = art.setting
const createHeader = setting.createHeader.bind(setting)
const createItem = setting.createItem.bind(setting)
let rows = 0
let header
setting.createItem = (...args) => {
rows++
createItem(...args)
}
setting.createHeader = (item) => {
createHeader(item)
header = setting.cache.get(children).querySelector('.art-setting-item-back')
if (action === 'remove')
setting.remove('parent')
else
art.destroy()
}
setting.render(children)
let late = 0
setting.render = () => late++
header.click()
const result = { rows, late, childCached: setting.cache.has(children), headerConnected: header.isConnected, events: parent.$events.length, childRendered: Boolean(children[0].$item), remainingPanels: setting.cache.size }
if (action === 'remove')
art.destroy()
return result
}, action)
expect(result).toEqual({ rows: 0, late: 0, childCached: false, headerConnected: false, events: 0, childRendered: false, remainingPanels: action === 'remove' ? 1 : 0 })
})
}
+22
View File
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { calculateSettingLayout } = await loadModules({ calculateSettingLayout: { file: 'packages/artplayer/src/setting/layout', name: 'calculateSettingLayout' } })
const standard = { containerWidth: 640, containerHeight: 360, requestedWidth: 200, rows: 4, rowHeight: 35, controlCenter: 600, bottom: 46, padding: 10 }
test('settings retain requested dimensions where they fit and anchor inside both edges', () => {
assert.deepEqual(calculateSettingLayout(standard), { width: 200, height: 140, left: 430 })
assert.deepEqual(calculateSettingLayout({ ...standard, controlCenter: 250 }), { width: 200, height: 140, left: 150 })
assert.deepEqual(calculateSettingLayout({ ...standard, controlCenter: 20 }), { width: 200, height: 140, left: 10 })
})
test('oversized nested panels fit a narrow 16:9 player above multiple control rows', () => {
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 320, containerHeight: 180, requestedWidth: 420, rows: 13, bottom: 92 }), { width: 300, height: 78, left: 10 })
})
test('hidden or undersized hosts never produce negative layout dimensions', () => {
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 0, containerHeight: 0 }), { width: 0, height: 0, left: 0 })
assert.deepEqual(calculateSettingLayout({ ...standard, containerWidth: 12, containerHeight: 30, requestedWidth: -5 }), { width: 0, height: 0, left: 6 })
})
+188
View File
@@ -0,0 +1,188 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const file = 'packages/artplayer/src/setting/model'
const { formatTree, findItem, traverseTree, treeBinding, registerTreeOwner, releaseTreeOwner } = await loadModules(Object.fromEntries(['formatTree', 'findItem', 'traverseTree', 'treeBinding', 'registerTreeOwner', 'releaseTreeOwner'].map(name => [name, { file, name }])))
test('setting format preserves node/array identity, descriptor flags and preorder names', () => {
const child = { html: 'child' }
const parent = { name: 'parent', selector: [child] }
const root = [parent, { name: 'last' }]
const owner = { id: 0 }
const names = ['reserved']
assert.equal(formatTree(owner, root, undefined, undefined, names), root)
assert.equal(owner.id, 1)
assert.equal(child.name, 'setting-0')
assert.deepEqual(names, ['reserved', 'parent', 'last'])
assert.equal(child.$parent, parent)
assert.equal(child.$parents, root)
assert.equal(child.$option, parent.selector)
assert.equal(parent.$parent, undefined)
assert.equal(parent.$parents, undefined)
assert.equal(parent.$option, root)
assert.equal(child.$formatted, true)
for (const key of ['$parent', '$parents', '$option', '$events', '$formatted']) {
const descriptor = Object.getOwnPropertyDescriptor(child, key)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
assert.equal(typeof descriptor.get, 'function')
assert.equal(descriptor.set, undefined)
}
assert.deepEqual(Object.keys(child), ['html', 'name'])
const visited = []
traverseTree(root, item => visited.push(item))
assert.deepEqual(visited, [parent, child, root[1]])
assert.equal(findItem(root, child.name), child)
assert.equal(findItem(root, 'missing'), null)
})
test('setting tree rebinding updates parent arrays without redefining immutable getters or events', () => {
const child = { name: 'child' }
const previous = { name: 'previous', selector: [child] }
const next = { name: 'next', selector: [] }
const owner = { id: 0 }
const root = [previous, next]
formatTree(owner, root)
const getter = Object.getOwnPropertyDescriptor(child, '$parent').get
const events = child.$events
const cleanup = () => {}
events.push(cleanup)
previous.selector.splice(0)
next.selector.push(child)
formatTree(owner, root)
assert.equal(child.$parent, next)
assert.equal(child.$parents, root)
assert.equal(child.$option, next.selector)
assert.equal(child.$events, events)
assert.deepEqual(events, [cleanup])
assert.equal(Object.getOwnPropertyDescriptor(child, '$parent').get, getter)
assert.equal(treeBinding(child).parent, next)
assert.equal(owner.id, 0)
})
test('generated setting names avoid explicit names across the entire tree', () => {
const root = [{}, { name: 'group', selector: [{ name: 'setting-0' }, {}] }]
const owner = { id: 0 }
formatTree(owner, root)
assert.equal(root[0].name, 'setting-1')
assert.equal(root[1].selector[1].name, 'setting-2')
assert.equal(owner.id, 3)
formatTree(owner, root)
assert.equal(owner.id, 3)
assert.equal(findItem(root, 'setting-0'), root[1].selector[0])
})
test('duplicate names and cyclic or shared items fail before names and bindings are installed', () => {
const fresh = {}
const duplicate = [fresh, { name: 'same' }, { name: 'same' }]
const owner = { id: 7 }
const names = []
assert.throws(() => formatTree(owner, duplicate, undefined, undefined, names), { name: 'ArtPlayerError', message: 'The [same] already exists in [setting]' })
assert.deepEqual(fresh, {})
assert.equal(fresh.$formatted, undefined)
assert.equal(owner.id, 7)
assert.deepEqual(names, [])
const cycle = { selector: [] }
cycle.selector.push(cycle)
assert.throws(() => formatTree(owner, [cycle]), /only once/)
assert.equal(cycle.$formatted, undefined)
assert.throws(() => formatTree(owner, [fresh, fresh]), /only once/)
assert.equal(fresh.$formatted, undefined)
})
test('invalid property bindings leave earlier entries untouched', () => {
const fresh = {}
const foreign = {}
Object.defineProperty(foreign, '$parent', { get: () => ({}) })
assert.throws(() => formatTree({ id: 0 }, [fresh, foreign]), /Cannot format setting item property/)
assert.equal(fresh.name, undefined)
assert.equal(fresh.$formatted, undefined)
assert.throws(() => treeBinding(fresh), /has not been formatted/)
})
test('formatting an already-bound frozen node does not rewrite its existing name', () => {
const child = { name: 'child' }
const root = [child]
const owner = { id: 0 }
formatTree(owner, root)
Object.freeze(child)
assert.equal(formatTree(owner, root), root)
assert.equal(child.$option, root)
})
test('nonextensible metadata and unwritable automatic names fail before binding earlier entries', () => {
const readonlyName = {}
Object.defineProperty(readonlyName, 'name', { value: '', writable: false })
const inheritedName = Object.create(Object.defineProperty({}, 'name', { value: '', writable: false }))
for (const invalid of [Object.preventExtensions({ name: 'sealed' }), readonlyName, inheritedName]) {
const fresh = {}
const owner = { id: 12 }
const names = []
assert.throws(() => formatTree(owner, [fresh, invalid], undefined, undefined, names), TypeError)
assert.deepEqual(Reflect.ownKeys(fresh), [])
assert.equal(owner.id, 12)
assert.deepEqual(names, [])
}
})
test('traversal retains mutation timing and find retains last-match behavior on raw arrays', () => {
const first = { name: 'same' }
const last = { name: 'same' }
const root = [first, last]
assert.equal(findItem(root, 'same'), last)
const child = { name: 'added' }
const visited = []
traverseTree(root, (item) => {
visited.push(item)
if (item === first)
item.selector = [child]
})
assert.deepEqual(visited, [first, child, last])
})
test('active owner conflicts are rejected before mutating either tree', () => {
const item = { name: 'shared' }
const first = { id: 0 }
const second = { id: 0 }
const scope = { closed: false }
registerTreeOwner(first, scope)
const original = [item]
formatTree(first, original)
const events = item.$events
const fresh = {}
assert.throws(() => formatTree(second, [fresh, item]), { name: 'ArtPlayerError', message: 'Setting item [shared] already belongs to another active player' })
assert.deepEqual(Reflect.ownKeys(fresh), [])
assert.equal(item.$option, original)
assert.equal(item.$events, events)
assert.equal(second.id, 0)
scope.closed = true
const replacement = [item]
formatTree(second, replacement)
assert.equal(item.$option, replacement)
assert.equal(item.$events, events)
releaseTreeOwner(first)
assert.throws(() => formatTree(first, original), /another active player/)
})
test('detaching a parent or releasing its owner allows a descendant to be reused', () => {
const child = { name: 'child' }
const parent = { name: 'parent', selector: [child] }
const root = [parent]
const first = { id: 0 }
const second = { id: 0 }
formatTree(first, root)
const getter = Object.getOwnPropertyDescriptor(child, '$parent').get
assert.throws(() => formatTree(second, [child]), /another active player/)
root.splice(0)
const replacement = [child]
formatTree(second, replacement)
assert.equal(child.$parent, undefined)
assert.equal(Object.getOwnPropertyDescriptor(child, '$parent').get, getter)
releaseTreeOwner(second)
root.push(parent)
formatTree(first, root)
assert.equal(child.$parent, parent)
assert.equal(child.$parents, root)
})
+267
View File
@@ -0,0 +1,267 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const resources = 'packages/artplayer/src/setting/resources'
const lifecycle = 'packages/artplayer/src/lifecycle/instance'
const { formatTree, bindSettingActions, ownSettingItem, releaseSettingItem, releaseSettingTree, proxySetting, settingScope, suspendSettingItem, beginLifecycle, getScope } = await loadModules({
formatTree: { file: 'packages/artplayer/src/setting/model', name: 'formatTree' },
bindSettingActions: { file: 'packages/artplayer/src/setting/selection', name: 'bindSettingActions' },
...Object.fromEntries(['ownSettingItem', 'releaseSettingItem', 'releaseSettingTree', 'proxySetting', 'settingScope', 'suspendSettingItem'].map(name => [name, { file: resources, name }])),
...Object.fromEntries(['beginLifecycle', 'getScope'].map(name => [name, { file: lifecycle, name }])),
})
function fixture(items) {
const listeners = new Set()
const art = {
events: {
remove(cleanup) {
cleanup()
listeners.delete(cleanup)
},
},
proxy(target, name, callback) {
target.addEventListener(name, callback)
const cleanup = () => target.removeEventListener(name, callback)
listeners.add(cleanup)
return cleanup
},
}
beginLifecycle(art)
formatTree({ id: 0 }, items)
return { art, listeners, setting: { art, cache: new Map(), option: items } }
}
test('setting callback results use latest operation and stop after item replacement', async () => {
const pending = []
const item = { name: 'button', tooltip: 'initial' }
const { art, setting, listeners } = fixture([item])
const target = new EventTarget()
item.onClick = function (owner, element, event) {
assert.equal(this, art)
assert.equal(owner, item)
assert.equal(element, target)
assert.equal(event.type, 'click')
return new Promise(resolve => pending.push(resolve))
}
ownSettingItem(art, item)
bindSettingActions(setting, item, target, 'button')
target.dispatchEvent(new Event('click'))
target.dispatchEvent(new Event('click'))
pending[1]('new')
await Promise.resolve()
pending[0]('old')
await Promise.resolve()
assert.equal(item.tooltip, 'new')
target.dispatchEvent(new Event('click'))
const previous = settingScope(item)
ownSettingItem(art, item)
assert.equal(previous.closed, true)
pending[2]('removed')
await Promise.resolve()
assert.equal(item.tooltip, 'new')
assert.equal(listeners.size, 0)
getScope(art).dispose()
})
test('setting callback rejection is reported with the original error and no UI write', async (t) => {
const failure = new Error('selection')
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
const item = { tooltip: 'retained', onClick() {
return Promise.reject(failure)
} }
const { art, setting } = fixture([item])
const target = new EventTarget()
ownSettingItem(art, item)
bindSettingActions(setting, item, target, 'button')
target.dispatchEvent(new Event('click'))
await Promise.resolve()
assert.deepEqual(warnings, [['ArtPlayer setting callback failed:', failure]])
assert.equal(item.tooltip, 'retained')
getScope(art).dispose()
})
test('removing a setting tree releases descendants and their panel caches', () => {
const child = { name: 'child' }
const parent = { name: 'parent', selector: [child] }
const other = { name: 'other' }
const { art, setting, listeners } = fixture([parent, other])
const target = new EventTarget()
let calls = 0
let removedPanels = 0
for (const item of [parent, child, other]) {
ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls++)
}
setting.cache.set(parent.selector, { remove() {
removedPanels++
} })
target.dispatchEvent(new Event('click'))
assert.equal(calls, 3)
releaseSettingTree(setting, parent)
assert.equal(removedPanels, 1)
assert.equal(setting.cache.has(parent.selector), false)
assert.equal(parent.$events.length, 0)
assert.equal(child.$events.length, 0)
assert.equal(other.$events.length, 1)
target.dispatchEvent(new Event('click'))
assert.equal(calls, 4)
assert.equal(listeners.size, 1)
getScope(art).dispose()
assert.equal(listeners.size, 0)
})
test('setting listener cleanup attempts every callback even when one fails', () => {
const item = {}
const { art } = fixture([item])
ownSettingItem(art, item)
const failure = new Error('cleanup')
const calls = []
item.$events.push(() => {
calls.push('first')
throw failure
}, () => {
calls.push('last')
})
assert.throws(() => releaseSettingItem(art, item), error => error.errors.length === 1 && error.errors[0] === failure)
assert.deepEqual(calls, ['first', 'last'])
assert.deepEqual(item.$events, [])
releaseSettingItem(art, item)
getScope(art).dispose()
})
test('registration that closes the owner releases the just-returned listener', () => {
const item = {}
const { art, listeners } = fixture([item])
ownSettingItem(art, item)
const proxy = art.proxy
art.proxy = (...args) => {
const result = proxy(...args)
getScope(art).dispose()
return result
}
let calls = 0
const target = new EventTarget()
proxySetting(art, item, target, 'click', () => calls++)
target.dispatchEvent(new Event('click'))
assert.equal(calls, 0)
assert.equal(listeners.size, 0)
assert.equal(item.$events.length, 0)
})
test('synchronous setting navigation errors are reported at the owned listener boundary', (t) => {
const item = {}
const { art } = fixture([item])
ownSettingItem(art, item)
const failure = new Error('navigation render')
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
const target = new EventTarget()
proxySetting(art, item, target, 'click', () => {
throw failure
})
target.dispatchEvent(new Event('click'))
assert.deepEqual(warnings, [['ArtPlayer setting callback failed:', failure]])
getScope(art).dispose()
})
test('a failed replacement resumes the original scope and event array without registering it again', () => {
const item = {}
const { art, listeners } = fixture([item])
const oldNode = new EventTarget()
const newNode = new EventTarget()
let oldCalls = 0
let newCalls = 0
const original = ownSettingItem(art, item)
proxySetting(art, item, oldNode, 'click', () => oldCalls++)
const events = item.$events
const cleanup = events[0]
const suspended = suspendSettingItem(art, item)
assert.equal(original.closed, false)
assert.equal(events.length, 0)
assert.throws(() => suspendSettingItem(art, item), /already in progress/)
oldNode.dispatchEvent(new Event('click'))
const replacement = ownSettingItem(art, item)
proxySetting(art, item, newNode, 'click', () => newCalls++)
newNode.dispatchEvent(new Event('click'))
suspended.resume()
suspended.resume()
suspended.dispose()
assert.equal(replacement.closed, true)
assert.equal(settingScope(item), original)
assert.equal(item.$events, events)
assert.deepEqual(events, [cleanup])
assert.equal(listeners.size, 1)
oldNode.dispatchEvent(new Event('click'))
newNode.dispatchEvent(new Event('click'))
assert.deepEqual([oldCalls, newCalls], [1, 1])
getScope(art).dispose()
})
test('successful replacement disposes only the suspended listeners', () => {
const item = {}
const { art, listeners } = fixture([item])
const target = new EventTarget()
const calls = []
const original = ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls.push('old'))
const suspended = suspendSettingItem(art, item)
const replacement = ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls.push('new'))
const cleanup = item.$events[0]
suspended.dispose()
suspended.resume()
assert.equal(original.closed, true)
assert.equal(replacement.closed, false)
assert.deepEqual(item.$events, [cleanup])
assert.equal(listeners.size, 1)
target.dispatchEvent(new Event('click'))
assert.deepEqual(calls, ['new'])
getScope(art).dispose()
assert.equal(listeners.size, 0)
})
test('destroying a suspended replacement releases both generations and cannot resume either', () => {
const item = {}
const { art, listeners } = fixture([item])
const target = new EventTarget()
let calls = 0
ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls++)
const suspended = suspendSettingItem(art, item)
ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls++)
getScope(art).dispose()
suspended.resume()
target.dispatchEvent(new Event('click'))
assert.equal(calls, 0)
assert.equal(item.$events.length, 0)
assert.equal(listeners.size, 0)
})
test('replacement cleanup failure still resumes the original registered listener', () => {
const item = {}
const { art, listeners } = fixture([item])
const target = new EventTarget()
let calls = 0
ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls++)
const suspended = suspendSettingItem(art, item)
ownSettingItem(art, item)
proxySetting(art, item, target, 'click', () => calls += 10)
const replacement = item.$events[0]
const failure = new Error('replacement cleanup')
const remove = art.events.remove
art.events.remove = (cleanup) => {
remove(cleanup)
if (cleanup === replacement)
throw failure
}
assert.throws(() => suspended.resume(), error => error.errors.length === 1 && error.errors[0] === failure)
target.dispatchEvent(new Event('click'))
assert.equal(calls, 1)
assert.equal(listeners.size, 1)
getScope(art).dispose()
})
+17
View File
@@ -0,0 +1,17 @@
import type Setting from '../../packages/artplayer/src/setting'
import type { SettingItem } from '../../packages/artplayer/src/setting/types'
declare const setting: Setting
const input = { name: 'custom', html: 'Custom', customValue: 42 }
const added = setting.add(input)
const value: number = added.customValue
const updated: SettingItem = setting.update(input)
const found: SettingItem | null = setting.find('absent')
const removed: void = setting.remove('custom')
// @ts-expect-error Runtime find can return null.
const definite: SettingItem = setting.find('absent')
// @ts-expect-error Setting add returns the item rather than its DOM row.
const element: HTMLDivElement = setting.add(input)
// @ts-expect-error Range values must be numeric.
setting.add({ html: 'Range', range: ['invalid'] })
void [value, updated, found, removed, definite, element]
+20
View File
@@ -0,0 +1,20 @@
import { findItem, formatTree, traverseTree, treeBinding } from '../../packages/artplayer/src/setting/model'
const item = { name: 'custom', payload: 42, selector: [{ name: 'child' }] }
const result = formatTree({ id: 0 }, [item])
const payload: number = result[0]!.payload
const binding = treeBinding(item)
const events: (() => void)[] = binding.events
const found = findItem(result, 'absent')
const nullable: typeof found | null = null
traverseTree(result, (node) => {
const name: string | undefined = node.name
void name
})
// @ts-expect-error A tree item can be missing; find must not promise a DOM node.
const element: HTMLElement = found
// @ts-expect-error Item names are strings.
formatTree({ id: 0 }, [{ name: 12 }])
// @ts-expect-error The generated-name counter is numeric.
formatTree({ id: '0' }, [item])
void [payload, events, nullable, element]