mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-11 22:06:17 -08:00
refactor(core): CORE-14 split typed settings and scope tree lifetimes
This commit is contained in:
1 parent
697817eceb
commit
64d1939daa
51 files changed
+28144
-1519
No files matched your search
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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
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)
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+8
-5
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 字幕获取、解析和渲染。
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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]
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user