refactor(core): CORE-13 type component registries and own control lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 04:54:32 +08:00
1 parent 9eb7c945ca
commit 697817eceb
68 files changed
+10100 -1925

No files matched your search

+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)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(a=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 a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(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),n(s,l,c),i(s,l,c),a(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),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=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 a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,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
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js",
"test: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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+71 -3
View File
@@ -4,6 +4,74 @@ 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.
## Components and controls (CORE-13)
The public registries remain Component-based. utils/component.ts owns add/update/remove,
cache and dynamic name aliases; component/dom.ts owns insertion and known template
queries; component/types.ts defines the minimal generic host and callback shapes.
component/resources.ts owns each entry's child ResourceScope, DOM proxies and guarded
Emitter subscriptions. control/resources.ts specializes the shared subscriptions with
UIEvents; it does not create a second event bus. Builtins depend on these capabilities,
not the full player class. CORE-20 still has to integrate the constructor facade.
Control index.ts preserves routing and visibility behavior. builtins.ts preserves
installation order and option/platform conditions. selector.ts owns item binding,
selection rendering and asynchronous completion; progress.ts keeps the factory and
its historical helper exports while progress/position.ts, view.ts and interactions.ts
separate pointer math, rendering and drag ownership. The remaining simple controls
stay individual factories. layer.ts retains the generic registry; contextmenu/index.ts
owns positioning and root listeners, while its item factories own their own updates.
Preserve the observable registry rules: option factories are bare calls with art;
click/mounted/beforeUnmount use art as this; index 0 still follows the old index-or-id
rule; equal indexes insert before their predecessor. update mutates the cached option
before remove, so the newly supplied beforeUnmount runs on the old node. Component
add/update may return a div; Control add/update still return undefined. Source types
model that distinction; historical public declaration conflicts remain CORE-21 work.
A name of `__proto__` now creates a normal own DOM alias without replacing the registry
prototype. Other historical aliases and shadowing behavior are not broadly renamed.
Entry ownership starts before rendering. Failed mounting releases owned effects and
removes its DOM/cache/alias without masking the original exception or deleting a
successful reentrant replacement. beforeUnmount failure retains the entry for retry;
recursive removal of that same entry is ignored. Already detached DOM can be removed.
Builtin DOM/Emitter listeners, progress drag handlers and tip timers end on entry
removal/update or root destruction; closed handlers are inert even in an Emitter
snapshot. Whole-player destruction retains its existing cache/alias and user-hook
policy: it does not newly call every beforeUnmount. User mounted hook return values
remain ignored. Arbitrary user listeners and third-party SDK work still need explicit
user cleanup; internal scopes cannot infer their ownership.
Selector items keep their non-enumerable, non-configurable getter bindings. A WeakMap
allows the same items to bind to a replacement after the prior entry closes; sharing
items across simultaneously active controls remains invalid. Each click keeps original
item/node/Event/this arguments and synchronous default flags. Only the latest active
selection may write a delayed title; removal invalidates pending writes. Background
clicks are ignored and owned callback failures are warned with their original value.
component/selection.ts links a click's active state to the builtin quality callback,
so late source-switch completion cannot overwrite a newer or removed quality notice.
Direct callback invocations retain their Promise result and original rejection.
Deferred builtin quality installation reports errors without an unhandled Promise.
This does not cancel arbitrary user onSelect work or change switchQuality's public
settlement contract. Highlight text uses dataset assignment rather than interpolation
into HTML attributes; normal text and marker positions retain their behavior.
controls.less allows groups to wrap inside narrow players. Individual control height
continues to use --art-control-height; control/layout.ts observes total layout height
through offsetHeight and records --art-controls-height for subtitle/panel offsets.
ResizeObserver is preferred; the fallback combines core resize and MutationObserver.
Both observers and subscriptions are owned by the bottom controls scope. Without
ResizeObserver, unrelated parent CSS resizing is not independently detected until a
core resize or control mutation occurs. The 640/320/240px tests include real 16:9
heights and verify all buttons remain visible. This does not promise arbitrary custom
minimum widths will fit. Narrow setting-tree/panel behavior remains CORE-14 work.
Use test/component-resources.test.js for ownership, reentry and quality settlement,
test/types/components.ts for source contracts, and test/browser/components.spec.js
for published/candidate registries, real pointer/contextmenu controls, asynchronous
selectors, failures, actual layout and local-media progress. Run the full installed
UMD and legacy browser matrices after building, not only the source fixture suite.
## Template and public resources
`src/template.ts` owns container checks, reservation, proxy replacement and destruction.
@@ -29,7 +97,7 @@ uses the actual HTMLElement shape. This mismatch remains tracked for CORE-21.
`i18n/index.ts` owns selected language, deep updates and key fallback. Standalone
language modules keep default exports and artplayer-i18n-* aliases through publish.ts.
Only own language/message keys participate in lookup; absent prototype names now return
the requested string. Explicit custom constructor/toString/**proto** messages remain
the requested string. Explicit custom constructor/toString/`__proto__` messages remain
supported. Empty translations still fall back. build:i18n excludes built-in zh-cn and
helper modules, retaining exactly eleven UMD/ESM pairs and their historical globals.
@@ -182,7 +250,7 @@ throw stops that dispatch and propagates unchanged. Off accepts either a once wr
or its original callback; duplicate registrations of a callback are removed together.
The once wrapper unsubscribes before invoking user code and now records consumption,
so a nested dispatch cannot execute a captured copy twice. This fixes a demonstrated
old defect without changing ordinary snapshot behavior. Event names such as **proto**
old defect without changing ordinary snapshot behavior. Event names such as `__proto__`
and toString use own properties rather than inherited Object.prototype values.
Shared contracts in test/contracts/emitter.js run against published, workspace and
@@ -472,5 +540,5 @@ core files in docs/compiled when committing a shippable core change.
For new utility behavior, extend the same old/new contract tests. Clearly separate
intentional defect corrections from preserved behavior. For timers or Blob URLs,
also identify the owner and verify cleanup in the consuming module. Remaining
constructor, playback, UI, Component and capability migrations are recorded
constructor, playback, setting, input and capability migrations are recorded
in refactor/tasks.json and should extend this map as they land.
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)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(a=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 a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(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),n(s,l,c),i(s,l,c),a(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),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=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 a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),r.exports);const n=t(o),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var u,h={exports:{}};var f=(u||(u=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),h.exports);const m=p(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,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
+743 -536
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
import type { ComponentHost, EntryOption } from './types'
import { addClass, append, setStyles, tooltip } from '../utils/dom'
export function renderEntry<Host extends ComponentHost>(element: HTMLDivElement, parent: HTMLElement, kind: string, name: string, id: number, option: EntryOption<Host>): void {
addClass(element, `art-${kind}`)
addClass(element, `art-${kind}-${name}`)
const children = Array.from(parent.children) as HTMLElement[]
element.dataset.index = String(option.index || id)
const next = children.find(child => Number(child.dataset.index) >= Number(element.dataset.index))
if (next)
next.insertAdjacentElement('beforebegin', element)
else
append(parent, element)
if (option.html)
append(element, option.html)
if (option.style)
setStyles(element, option.style)
if (option.tooltip)
tooltip(element, option.tooltip)
}
// Builtin renderers append known element markup or existing icon wrappers.
export function appendElement(parent: Element, child: string | Element): HTMLElement {
return append(parent, child) as HTMLElement
}
export function queryElement(selector: string, parent: ParentNode): HTMLElement {
return parent.querySelector<HTMLElement>(selector)!
}
@@ -0,0 +1,54 @@
import type ResourceScope from '../lifecycle/scope'
import type { ComponentHost } from './types'
import { getScope } from '../lifecycle/instance'
const scopes = new WeakMap<HTMLElement, ResourceScope>()
export function ownEntry(art: ComponentHost, element: HTMLElement): ResourceScope {
const scope = getScope(art).child()
scopes.set(element, scope)
return scope
}
export function entryScope(element: HTMLElement): ResourceScope {
const scope = scopes.get(element)
if (!scope)
throw new Error('ArtPlayer component has not been registered')
return scope
}
export function releaseEntry(element: HTMLElement): void {
scopes.get(element)?.dispose()
}
export function proxyEntry<Name extends keyof HTMLElementEventMap>(art: ComponentHost, element: HTMLElement, target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): () => void {
const scope = entryScope(element)
if (scope.closed)
return () => {}
const cleanup = art.events.proxy(target, name, (event) => {
if (!scope.closed)
return callback(event as HTMLElementEventMap[Name])
})
return scope.add(() => {
art.events.remove(cleanup)
})
}
export interface SubscriptionHost<Events extends { [Name in keyof Events]: readonly unknown[] }> {
on: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
off: <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown) => unknown
}
export function subscribeEntry<Events extends { [Name in keyof Events]: readonly unknown[] }, Name extends keyof Events>(art: SubscriptionHost<Events>, element: HTMLElement, name: Name, callback: (...args: [...Events[Name]]) => unknown): void {
const scope = entryScope(element)
if (scope.closed)
return
const guarded = (...args: [...Events[Name]]) => {
if (!scope.closed)
return callback(...args)
}
art.on(name, guarded)
scope.add(() => {
art.off(name, guarded)
})
}
@@ -0,0 +1,15 @@
import type { Cleanup } from '../lifecycle/scope'
const selections = new WeakMap<Event, () => boolean>()
export function trackSelection(event: Event, active: () => boolean): Cleanup {
selections.set(event, active)
return () => {
if (selections.get(event) === active)
selections.delete(event)
}
}
export function captureSelection(event?: Event): () => boolean {
return (event && selections.get(event)) || (() => true)
}
+44
View File
@@ -0,0 +1,44 @@
import type Component from '../utils/component'
export type EventCleanup = () => void
export interface ComponentHost {
template: { $player: HTMLElement }
emit: (name: string, ...args: unknown[]) => unknown
events: {
proxy: (target: EventTarget, name: string, callback: (event: Event) => unknown) => EventCleanup
remove: (cleanup: EventCleanup) => void
}
}
export interface SelectorItem {
html: string | HTMLElement | number
value?: string | number
default?: boolean
readonly $control_option?: SelectorItem[]
readonly $control_item?: HTMLDivElement
readonly $control_value?: HTMLDivElement
}
export interface EntryOption<Host extends ComponentHost> {
html?: string | HTMLElement | number
disable?: boolean
name?: string
index?: number
style?: Partial<CSSStyleDeclaration>
tooltip?: string | number
position?: string
selector?: SelectorItem[]
click?: (this: Host, component: Component<Host>, event: Event) => unknown
mounted?: (this: Host, element: HTMLDivElement) => unknown
beforeUnmount?: (this: Host, element: HTMLDivElement) => unknown
onSelect?: (this: Host, item: SelectorItem, element: HTMLDivElement, event: Event) => unknown
}
export type EntryInput<Host extends ComponentHost> = EntryOption<Host> | ((art: Host) => EntryOption<Host>)
export interface Entry<Host extends ComponentHost> {
$ref: HTMLDivElement
events: EventCleanup[]
option: EntryOption<Host>
}
@@ -1,6 +1,8 @@
import { inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
export default function aspectRatio(option) {
export default function aspectRatio(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -15,19 +17,20 @@ export default function aspectRatio(option) {
...option,
html: `${i18n.get('Aspect Ratio')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.aspectRatio = value
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="default"]', $panel)
if ($default) {
inverseClass($default, 'art-current')
}
art.on('aspectRatio', (value) => {
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
on('aspectRatio', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
}
@@ -1,9 +0,0 @@
export default function close(option) {
return art => ({
...option,
html: art.i18n.get('Close'),
click: (contextmenu) => {
contextmenu.show = false
},
})
}
@@ -0,0 +1,11 @@
import type { ContextmenuFactory, ContextmenuOption } from './types'
export default function close(option: ContextmenuOption): ContextmenuFactory {
return art => ({
...option,
html: art.i18n.get('Close'),
click: (contextmenu) => {
contextmenu.show = false
},
})
}
@@ -1,6 +1,8 @@
import { capitalize, inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { capitalize, inverseClass, query } from '../utils'
export default function flip(option) {
export default function flip(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -13,19 +15,20 @@ export default function flip(option) {
...option,
html: `${i18n.get('Video Flip')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.flip = value.toLowerCase()
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="normal"]', $panel)
if ($default) {
inverseClass($default, 'art-current')
}
art.on('flip', (value) => {
const $current = queryAll('span', $panel).find(item => item.dataset.value === value)
on('flip', (value) => {
const $current = Array.from($panel.querySelectorAll('span')).find(item => item.dataset.value === value)
if ($current) {
inverseClass($current, 'art-current')
}
@@ -1,3 +1,6 @@
import type { ContextmenuHost } from './types'
import { ownEntry } from '../component/resources'
import { controlEvents } from '../control/resources'
import { getRect, includeFromEvent, isMobile, setStyles } from '../utils'
import Component from '../utils/component'
import aspectRatio from './aspectRatio'
@@ -7,24 +10,25 @@ import info from './info'
import playbackRate from './playbackRate'
import version from './version'
export default class Contextmenu extends Component {
constructor(art) {
export default class Contextmenu extends Component<ContextmenuHost> {
constructor(art: ContextmenuHost) {
super(art)
this.name = 'contextmenu'
this.$parent = art.template.$contextmenu
ownEntry(art, this.$parent)
if (!isMobile) {
this.init()
}
}
init() {
init(): void {
const {
option,
proxy,
template: { $player, $contextmenu },
} = this.art
const { on, proxy } = controlEvents(this.art, $contextmenu)
if (option.playbackRate) {
this.add(
@@ -75,7 +79,7 @@ export default class Contextmenu extends Component {
)
for (let index = 0; index < option.contextmenu.length; index++) {
this.add(option.contextmenu[index])
this.add(option.contextmenu[index]!)
}
proxy($player, 'contextmenu', (event) => {
@@ -112,7 +116,7 @@ export default class Contextmenu extends Component {
}
})
this.art.on('blur', () => {
on('blur', () => {
this.show = false
})
}
@@ -1,4 +1,6 @@
export default function info(option) {
import type { ContextmenuFactory, ContextmenuOption } from './types'
export default function info(option: ContextmenuOption): ContextmenuFactory {
return art => ({
...option,
html: art.i18n.get('Video Info'),
@@ -1,6 +1,8 @@
import { inverseClass, query, queryAll } from '../utils'
import type { ContextmenuFactory, ContextmenuOption } from './types'
import { controlEvents } from '../control/resources'
import { inverseClass, query } from '../utils'
export default function playbackRate(option) {
export default function playbackRate(option: ContextmenuOption): ContextmenuFactory {
return (art) => {
const {
i18n,
@@ -15,18 +17,19 @@ export default function playbackRate(option) {
...option,
html: `${i18n.get('Play Speed')}: ${html}`,
click: (contextmenu, event) => {
const { value } = event.target.dataset
const value = event.target instanceof HTMLElement ? event.target.dataset.value : undefined
if (value) {
art.playbackRate = Number(value)
contextmenu.show = false
}
},
mounted: ($panel) => {
const { on } = controlEvents(art, $panel)
const $default = query('[data-value="1"]', $panel)
if ($default)
inverseClass($default, 'art-current')
art.on('video:ratechange', () => {
const $current = queryAll('span', $panel).find(
on('video:ratechange', () => {
const $current = Array.from($panel.querySelectorAll('span')).find(
item => Number(item.dataset.value) === art.playbackRate,
)
if ($current) {
@@ -0,0 +1,17 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
import type { UIEvents } from '../control/types'
export interface ContextmenuHost extends ComponentHost, SubscriptionHost<UIEvents> {
template: { $player: HTMLDivElement, $contextmenu: HTMLDivElement }
option: { playbackRate: boolean, aspectRatio: boolean, flip: boolean, contextmenu: EntryInput<ContextmenuHost>[] }
constructor: { CONTEXTMENU: boolean, PLAYBACK_RATE: number[], ASPECT_RATIO: string[], FLIP: string[] }
i18n: { get: (key: string) => string }
info: { show: boolean }
playbackRate: number
aspectRatio: string
flip: string
}
export type ContextmenuOption = EntryOption<ContextmenuHost>
export type ContextmenuFactory = (art: ContextmenuHost) => ContextmenuOption
@@ -1,6 +1,7 @@
import type { ContextmenuOption } from './types'
import { version } from '../../package.json'
export default function (option) {
export default function (option: ContextmenuOption): ContextmenuOption {
return {
...option,
html: `<a href="https://artplayer.org" target="_blank" style="width:100%;">ArtPlayer ${version}</a>`,
-13
View File
@@ -1,13 +0,0 @@
import { append } from '../utils'
export default function airplay(option) {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+16
View File
@@ -0,0 +1,16 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function airplay(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('AirPlay'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.airplay)
proxy($control, 'click', () => art.airplay())
},
})
}
+135
View File
@@ -0,0 +1,135 @@
import type { EntryInput } from '../component/types'
import type { ControlHost } from './types'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { isMobile } from '../utils'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export function installControls(controls: { art: ControlHost, add: (option: EntryInput<ControlHost>) => undefined }): void {
const { option } = controls.art
if (!option.isLive) {
controls.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
controls.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
controls.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
controls.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
controls.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(controls.art)).then((active) => {
if (!active || getScope(controls.art).closed)
return
controls.art.quality = option.quality
}).catch((error) => {
console.warn('ArtPlayer quality initialization failed:', error)
})
}
if (option.screenshot && !isMobile) {
controls.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
controls.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
controls.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && 'WebKitPlaybackTargetAvailabilityEvent' in window && window.WebKitPlaybackTargetAvailabilityEvent) {
controls.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
controls.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
controls.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
controls.add(option.controls[index]!)
}
}
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreen(option) {
export default function fullscreen(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenOn = append($control, icons.fullscreenOn)
const $fullscreenOff = append($control, icons.fullscreenOff)
const $fullscreenOn = appendElement($control, icons.fullscreenOn)
const $fullscreenOff = appendElement($control, icons.fullscreenOff)
setStyle($fullscreenOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreen = !art.fullscreen
})
art.on('fullscreen', (state) => {
on('fullscreen', (state) => {
if (state) {
tooltip($control, i18n.get('Exit Fullscreen'))
setStyle($fullscreenOn, 'display', 'none')
@@ -1,21 +1,25 @@
import { append, setStyle, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function fullscreenWeb(option) {
export default function fullscreenWeb(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Web Fullscreen'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $fullscreenWebOn = append($control, icons.fullscreenWebOn)
const $fullscreenWebOff = append($control, icons.fullscreenWebOff)
const $fullscreenWebOn = appendElement($control, icons.fullscreenWebOn)
const $fullscreenWebOff = appendElement($control, icons.fullscreenWebOff)
setStyle($fullscreenWebOff, 'display', 'none')
proxy($control, 'click', () => {
art.fullscreenWeb = !art.fullscreenWeb
})
art.on('fullscreenWeb', (value) => {
on('fullscreenWeb', (value) => {
if (value) {
tooltip($control, i18n.get('Exit Web Fullscreen'))
setStyle($fullscreenWebOn, 'display', 'none')
-290
View File
@@ -1,290 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import {
addClass,
append,
createElement,
def,
errorHandle,
getComposedPath,
includeFromEvent,
inverseClass,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import airplay from './airplay'
import fullscreen from './fullscreen'
import fullscreenWeb from './fullscreenWeb'
import pip from './pip'
import playAndPause from './playAndPause'
import progress from './progress'
import screenshot from './screenshot'
import setting from './setting'
import time from './time'
import volume from './volume'
export default class Control extends Component {
constructor(art) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
art.on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
art.on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
art.on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
art.on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
art.on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
}
init() {
const { option } = this.art
if (!option.isLive) {
this.add(
progress({
name: 'progress',
position: 'top',
index: 10,
}),
)
}
this.add({
name: 'thumbnails',
position: 'top',
index: 20,
})
this.add(
playAndPause({
name: 'playAndPause',
position: 'left',
index: 10,
}),
)
this.add(
volume({
name: 'volume',
position: 'left',
index: 20,
}),
)
if (!option.isLive) {
this.add(
time({
name: 'time',
position: 'left',
index: 30,
}),
)
}
if (option.quality.length) {
wait(getScope(this.art)).then((active) => {
if (!active || getScope(this.art).closed)
return
this.art.quality = option.quality
})
}
if (option.screenshot && !isMobile) {
this.add(
screenshot({
name: 'screenshot',
position: 'right',
index: 20,
}),
)
}
if (option.setting) {
this.add(
setting({
name: 'setting',
position: 'right',
index: 30,
}),
)
}
if (option.pip) {
this.add(
pip({
name: 'pip',
position: 'right',
index: 40,
}),
)
}
if (option.airplay && window.WebKitPlaybackTargetAvailabilityEvent) {
this.add(
airplay({
name: 'airplay',
position: 'right',
index: 50,
}),
)
}
if (option.fullscreenWeb) {
this.add(
fullscreenWeb({
name: 'fullscreenWeb',
position: 'right',
index: 60,
}),
)
}
if (option.fullscreen) {
this.add(
fullscreen({
name: 'fullscreen',
position: 'right',
index: 70,
}),
)
}
for (let index = 0; index < option.controls.length; index++) {
this.add(option.controls[index])
}
}
add(getOption) {
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target) {
if (!target) {
return
}
target.$control_value.innerHTML = target.html
for (let index = 0; index < target.$control_option.length; index++) {
const item = target.$control_option[index]
item.default = item === target
if (item.default) {
inverseClass(item.$control_item, 'art-current')
}
}
}
selector(option, $ref, events) {
const { proxy } = this.art.events
addClass($ref, 'art-control-selector')
const $value = createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = createElement('div')
addClass($list, 'art-selector-list')
append($ref, $list)
for (let index = 0; index < option.selector.length; index++) {
const item = option.selector[index]
const $item = createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = index
$item.dataset.value = item.value
$item.innerHTML = item.html
append($list, $item)
def(item, '$control_option', {
get: () => option.selector,
})
def(item, '$control_item', {
get: () => $item,
})
def(item, '$control_value', {
get: () => $value,
})
}
const event = proxy($list, 'click', async (event) => {
const path = getComposedPath(event)
const item = option.selector.find(
item => item.$control_item === path.find($item => item.$control_item === $item),
)
this.check(item)
if (option.onSelect) {
$value.innerHTML = await option.onSelect.call(this.art, item, item.$control_item, event)
}
})
events.push(event)
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { EntryInput, EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { ownEntry } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
import {
addClass,
errorHandle,
includeFromEvent,
isMobile,
removeClass,
} from '../utils'
import Component from '../utils/component'
import { installControls } from './builtins'
import { observeControlLayout } from './layout'
import { controlEvents } from './resources'
import { checkSelector, renderSelector } from './selector'
export default class Control extends Component<ControlHost> {
declare isHover: boolean
declare timer: number
constructor(art: ControlHost) {
super(art)
this.isHover = false
this.name = 'control'
this.timer = Date.now()
const { constructor } = art
const { $player, $bottom } = this.art.template
ownEntry(art, $bottom)
const { on } = controlEvents(art, $bottom)
on('mousemove', () => {
if (!isMobile) {
this.show = true
}
})
on('click', () => {
if (isMobile) {
this.toggle()
}
else {
this.show = true
}
})
on('document:mousemove', (event) => {
this.isHover = includeFromEvent(event, $bottom)
})
on('video:timeupdate', () => {
if (
!art.setting.show
&& !this.isHover
&& !art.isInput
&& art.playing
&& this.show
&& Date.now() - this.timer >= constructor.CONTROL_HIDE_TIME
) {
this.show = false
}
})
on('control', (state) => {
if (state) {
removeClass($player, 'art-hide-cursor')
addClass($player, 'art-hover')
this.timer = Date.now()
}
else {
addClass($player, 'art-hide-cursor')
removeClass($player, 'art-hover')
}
})
this.init()
if (!getScope(art).closed)
observeControlLayout(art)
}
init(): void {
installControls(this)
}
override add(getOption: EntryInput<ControlHost>): undefined {
if (isClosing(this.art))
return
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
const { $progress, $controlsLeft, $controlsRight } = this.art.template
switch (option.position) {
case 'top':
this.$parent = $progress
break
case 'left':
this.$parent = $controlsLeft
break
case 'right':
this.$parent = $controlsRight
break
default:
errorHandle(false, `Control option.position must one of 'top', 'left', 'right'`)
break
}
super.add(option)
}
check(target?: SelectorItem): void {
checkSelector(target)
}
override selector(option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
renderSelector(this.art, target => this.check(target), option, $ref, events)
}
}
+33
View File
@@ -0,0 +1,33 @@
import type { ControlHost } from './types'
import { entryScope } from '../component/resources'
import { controlEvents } from './resources'
export function observeControlLayout(art: ControlHost): void {
const { $bottom, $controls, $player } = art.template
const scope = entryScope($bottom)
const { on } = controlEvents(art, $bottom)
const update = () => {
if (scope.closed)
return
// Layout height is unaffected by the player's rotation/scale transform.
const height = $controls.offsetHeight
if (height > 0 && $player.style.getPropertyValue('--art-controls-height') !== `${height}px`)
$player.style.setProperty('--art-controls-height', `${height}px`)
}
on('resize', update)
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(update)
observer.observe($controls)
scope.add(() => {
observer.disconnect()
})
}
else if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(update)
observer.observe($controls, { childList: true, subtree: true, attributes: true, characterData: true })
scope.add(() => {
observer.disconnect()
})
}
update()
}
-21
View File
@@ -1,21 +0,0 @@
import { append, tooltip } from '../utils'
export default function pip(option) {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
art.on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function pip(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.pip)
proxy($control, 'click', () => {
art.pip = !art.pip
})
on('pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
}
@@ -1,13 +1,17 @@
import { append, setStyle, silencePromise, tooltip } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { setStyle, silencePromise, tooltip } from '../utils'
import { controlEvents } from './resources'
export default function playAndPause(option) {
export default function playAndPause(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons, i18n } = art
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
const $play = append($control, icons.play)
const $pause = append($control, icons.pause)
const $play = appendElement($control, icons.play)
const $pause = appendElement($control, icons.pause)
tooltip($play, i18n.get('Play'))
tooltip($pause, i18n.get('Pause'))
@@ -36,11 +40,11 @@ export default function playAndPause(option) {
showPlay()
}
art.on('video:playing', () => {
on('video:playing', () => {
showPause()
})
art.on('video:pause', () => {
on('video:pause', () => {
showPlay()
})
},
-208
View File
@@ -1,208 +0,0 @@
import { addClass, append, clamp, getRect, includeFromEvent, isMobile, query, removeClass, secondToTime, setStyle } from '../utils'
export function getPosFromEvent(art, event) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? event.touches[0].clientX : event.clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art, event) {
if (art.isRotate) {
const percentage = (event.touches[0].clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
export default function progress(options) {
return (art) => {
const { icons, option, proxy } = art
const { $player, $progress } = art.template
return {
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
let tipTimer = null
let isDragging = false
const $hover = query('.art-progress-hover', $control)
const $loaded = query('.art-progress-loaded', $control)
const $played = query('.art-progress-played', $control)
const $highlight = query('.art-progress-highlight', $control)
const $indicator = query('.art-progress-indicator', $control)
const $tip = query('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event) {
const { width } = getPosFromEvent(art, event)
const { text } = event.target.dataset
$tip.textContent = text
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event, touch) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const html = `<span data-text="${item.text}" data-time="${item.time}" style="left: ${left}%"></span>`
append($highlight, html)
}
}
function setBar(type, percentage, event) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event, $highlight)) {
showHighlight(event)
}
else {
showTime(event)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event, { width, time })
clearTimeout(tipTimer)
tipTimer = setTimeout(() => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
art.on('setBar', setBar)
art.on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
art.on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
art.on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
art.on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
art.on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
if (!isMobile) {
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
art.on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
art.on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
},
}
}
}
@@ -0,0 +1,25 @@
import type { ControlFactory, ControlOption } from './types'
import { installProgressInteractions } from './progress/interactions'
import { mountProgressView } from './progress/view'
export { getPosFromEvent, setCurrentTime } from './progress/position'
export default function progress(options: ControlOption): ControlFactory {
return art => ({
...options,
html: `
<div class="art-control-progress-inner">
<div class="art-progress-hover"></div>
<div class="art-progress-loaded"></div>
<div class="art-progress-played"></div>
<div class="art-progress-highlight"></div>
<div class="art-progress-indicator"></div>
<div class="art-progress-tip">00:00</div>
</div>
`,
mounted: ($control) => {
mountProgressView(art, $control)
installProgressInteractions(art, $control)
},
})
}
@@ -0,0 +1,45 @@
import type { ControlHost } from '../types'
import { isMobile } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent, setCurrentTime } from './position'
export function installProgressInteractions(art: ControlHost, $control: HTMLDivElement): void {
const { $progress } = art.template
const $indicator = $control.querySelector('.art-progress-indicator')
const { on, proxy } = controlEvents(art, $control)
if (!isMobile) {
let isDragging = false
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
}
})
proxy($progress, 'mousemove', (event) => {
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
art.emit('setBar', 'hover', 0, event)
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
})
on('document:mousemove', (event) => {
if (isDragging) {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
})
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
})
}
}
@@ -0,0 +1,30 @@
import type { ControlHost } from '../types'
import { clamp, getRect, isMobile, secondToTime } from '../../utils'
export type ProgressEvent = MouseEvent | TouchEvent
type ProgressPositionHost = Pick<ControlHost, 'template' | 'duration' | 'isRotate' | 'top' | 'height' | 'emit' | 'seek'>
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
const { left } = getRect($progress)
const eventLeft = isMobile ? (event as TouchEvent).touches[0]!.clientX : (event as MouseEvent).clientX
const width = clamp(eventLeft - left, 0, $progress.clientWidth)
const second = (width / $progress.clientWidth) * art.duration
const time = secondToTime(second)
const percentage = clamp(width / $progress.clientWidth, 0, 1)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
if (art.isRotate) {
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
const second = percentage * art.duration
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
}
}
@@ -0,0 +1,141 @@
import type { ControlHost } from '../types'
import type { ProgressEvent } from './position'
import { queryElement } from '../../component/dom'
import { entryScope } from '../../component/resources'
import { timeout } from '../../lifecycle/resources'
import { addClass, append, clamp, includeFromEvent, isMobile, removeClass, secondToTime, setStyle } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent } from './position'
export function mountProgressView(art: ControlHost, $control: HTMLDivElement): void {
const { icons, option } = art
const { $player } = art.template
const scope = entryScope($control)
const { on } = controlEvents(art, $control)
let cancelTip = () => {}
const $hover = queryElement('.art-progress-hover', $control)
const $loaded = queryElement('.art-progress-loaded', $control)
const $played = queryElement('.art-progress-played', $control)
const $highlight = queryElement('.art-progress-highlight', $control)
const $indicator = queryElement('.art-progress-indicator', $control)
const $tip = queryElement('.art-progress-tip', $control)
if (icons.indicator) {
append($indicator, icons.indicator)
}
else {
setStyle($indicator, 'backgroundColor', 'var(--art-theme)')
}
function showHighlight(event: ProgressEvent) {
const { width } = getPosFromEvent(art, event)
const text = event.target instanceof HTMLElement ? event.target.dataset.text : undefined
$tip.textContent = text ?? ''
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function showTime(event: ProgressEvent, touch?: { width: number, time: string }) {
const { width, time } = touch || getPosFromEvent(art, event)
$tip.textContent = time || '00:00'
const tipWidth = $tip.clientWidth
if (width <= tipWidth / 2) {
setStyle($tip, 'left', 0)
}
else if (width > $control.clientWidth - tipWidth / 2) {
setStyle($tip, 'left', `${$control.clientWidth - tipWidth}px`)
}
else {
setStyle($tip, 'left', `${width - tipWidth / 2}px`)
}
}
function updateHighlight() {
$highlight.textContent = ''
for (let index = 0; index < option.highlight.length; index++) {
const item = option.highlight[index]!
const left = (clamp(item.time, 0, art.duration) / art.duration) * 100
const marker = document.createElement('span')
marker.dataset.text = item.text
marker.dataset.time = String(item.time)
marker.style.left = `${left}%`
append($highlight, marker)
}
}
function setBar(type: string, percentage: number, event?: ProgressEvent) {
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'loaded') {
setStyle($loaded, 'width', `${percentage * 100}%`)
}
if (type === 'hover') {
setStyle($hover, 'width', `${percentage * 100}%`)
if (includeFromEvent(event!, $highlight)) {
showHighlight(event!)
}
else {
showTime(event!)
}
if (percentage === 0) {
removeClass($player, 'art-progress-hover')
}
else {
addClass($player, 'art-progress-hover')
}
}
if (type === 'played') {
setStyle($played, 'width', `${percentage * 100}%`)
setStyle($indicator, 'left', `${percentage * 100}%`)
}
if (isMobileDragging) {
addClass($player, 'art-progress-hover')
const width = $control.clientWidth * percentage
const time = secondToTime(percentage * art.duration)
showTime(event!, { width, time })
cancelTip()
cancelTip = timeout(scope, () => {
removeClass($player, 'art-progress-hover')
}, 500)
}
}
on('setBar', setBar)
on('video:loadedmetadata', updateHighlight)
if (art.constructor.USE_RAF) {
on('raf', () => {
art.emit('setBar', 'played', art.played)
art.emit('setBar', 'loaded', art.loaded)
})
}
else {
on('video:timeupdate', () => {
art.emit('setBar', 'played', art.played)
})
on('video:progress', () => {
art.emit('setBar', 'loaded', art.loaded)
})
on('video:ended', () => {
art.emit('setBar', 'played', 1)
})
}
art.emit('setBar', 'loaded', art.loaded || 0)
}
@@ -0,0 +1,13 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type { UIEvents } from './types'
import { proxyEntry, subscribeEntry } from '../component/resources'
export function controlEvents(art: ComponentHost & SubscriptionHost<UIEvents>, element: HTMLElement) {
return {
on: <Name extends keyof UIEvents>(name: Name, callback: (...args: [...UIEvents[Name]]) => unknown): void => {
subscribeEntry<UIEvents, Name>(art, element, name, callback)
},
proxy: <Name extends keyof HTMLElementEventMap>(target: EventTarget, name: Name, callback: (event: HTMLElementEventMap[Name]) => unknown): (() => void) => proxyEntry(art, element, target, name, callback),
}
}
@@ -1,16 +0,0 @@
import { append } from '../utils'
export default function screenshot(option) {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy, icons } = art
append($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,19 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { controlEvents } from './resources'
export default function screenshot(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Screenshot'),
mounted: ($control) => {
const { proxy } = controlEvents(art, $control)
const { icons } = art
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
})
},
})
}
@@ -0,0 +1,96 @@
import type { EntryOption, EventCleanup, SelectorItem } from '../component/types'
import type { ControlHost } from './types'
import { appendElement } from '../component/dom'
import { entryScope } from '../component/resources'
import { trackSelection } from '../component/selection'
import { addClass, append, def, errorHandle, getComposedPath, inverseClass } from '../utils'
interface Binding {
option: EntryOption<ControlHost>
item: HTMLDivElement
value: HTMLDivElement
owner: HTMLDivElement
}
const bindings = new WeakMap<SelectorItem, Binding>()
function bind(item: SelectorItem, binding: Binding): void {
const previous = bindings.get(item)
errorHandle(!previous || entryScope(previous.owner).closed, 'Cannot share selector items between active controls')
if (!previous) {
def(item, '$control_option', { get: () => bindings.get(item)!.option.selector })
def(item, '$control_item', { get: () => bindings.get(item)!.item })
def(item, '$control_value', { get: () => bindings.get(item)!.value })
}
bindings.set(item, binding)
}
// WebIDL keeps legacy numeric/Element coercion and null-to-empty innerHTML behavior.
function setHTML(element: HTMLElement, value: unknown): void {
element.innerHTML = value as string
}
export function checkSelector(target?: SelectorItem): void {
if (!target)
return
setHTML(target.$control_value!, target.html)
for (let index = 0; index < target.$control_option!.length; index++) {
const item = target.$control_option![index]!
item.default = item === target
if (item.default)
inverseClass(item.$control_item, 'art-current')
}
}
export function renderSelector(art: ControlHost, check: (target?: SelectorItem) => void, option: EntryOption<ControlHost>, $ref: HTMLDivElement, events: EventCleanup[]): void {
const { proxy } = art.events
const scope = entryScope($ref)
const selector = option.selector!
addClass($ref, 'art-control-selector')
const $value = document.createElement('div')
addClass($value, 'art-selector-value')
append($value, option.html)
$ref.textContent = ''
append($ref, $value)
const $list = appendElement($ref, '<div class="art-selector-list"></div>')
for (let index = 0; index < selector.length; index++) {
const item = selector[index]!
const $item = document.createElement('div')
addClass($item, 'art-selector-item')
if (item.default)
addClass($item, 'art-current')
$item.dataset.index = String(index)
$item.dataset.value = String(item.value)
setHTML($item, item.html)
append($list, $item)
bind(item, { option, item: $item, value: $value, owner: $ref })
}
let generation = 0
const event = proxy($list, 'click', async (event) => {
if (scope.closed)
return
const path = getComposedPath(event)
const item = option.selector!.find(item => path.includes(item.$control_item))
if (!item)
return
const current = ++generation
const active = () => !scope.closed && current === generation
const release = scope.add(trackSelection(event, active))
try {
check(item)
if (scope.closed)
return
if (option.onSelect) {
const value = await option.onSelect.call(art, item, item.$control_item!, event)
if (active())
setHTML($value, value)
}
}
catch (error) {
console.warn('ArtPlayer selector failed:', error)
}
finally {
release()
}
})
events.push(event)
}
-22
View File
@@ -1,22 +0,0 @@
import { append, tooltip } from '../utils'
export default function setting(option) {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { proxy, icons, i18n } = art
append($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
art.on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
+26
View File
@@ -0,0 +1,26 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { tooltip } from '../utils'
import { controlEvents } from './resources'
export default function setting(option: ControlOption): ControlFactory {
return art => ({
...option,
tooltip: art.i18n.get('Show Setting'),
mounted: ($control) => {
const { on, proxy } = controlEvents(art, $control)
const { icons, i18n } = art
appendElement($control, icons.setting)
proxy($control, 'click', () => {
art.setting.toggle()
art.setting.resize()
})
on('setting', (value) => {
tooltip($control, i18n.get(value ? 'Hide Setting' : 'Show Setting'))
})
},
})
}
@@ -1,6 +1,8 @@
import type { ControlFactory, ControlOption } from './types'
import { isMobile, secondToTime } from '../utils'
import { controlEvents } from './resources'
export default function time(option) {
export default function time(option: ControlOption): ControlFactory {
return art => ({
...option,
style: isMobile
@@ -13,6 +15,7 @@ export default function time(option) {
padding: '0 10px',
},
mounted: ($control) => {
const { on } = controlEvents(art, $control)
function getTime() {
const newTime = `${secondToTime(art.currentTime)} / ${secondToTime(art.duration)}`
if (newTime !== $control.textContent) {
@@ -22,9 +25,9 @@ export default function time(option) {
getTime()
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress']
const events = ['video:loadedmetadata', 'video:timeupdate', 'video:progress'] as const
for (let index = 0; index < events.length; index++) {
art.on(events[index], getTime)
on(events[index]!, getTime)
}
},
})
+82
View File
@@ -0,0 +1,82 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost, EntryInput, EntryOption } from '../component/types'
export interface UIEvents {
'mousemove': [MouseEvent]
'click': [MouseEvent]
'document:mousemove': [MouseEvent]
'document:mouseup': [MouseEvent]
'video:timeupdate': [Event]
'video:loadedmetadata': [Event]
'video:progress': [Event]
'video:ended': [Event]
'video:playing': [Event]
'video:pause': [Event]
'video:volumechange': [Event]
'video:ratechange': [Event]
'setBar': [type: string, percentage: number, event?: MouseEvent | TouchEvent]
'raf': []
'control': [boolean]
'fullscreen': [boolean]
'fullscreenWeb': [boolean]
'pip': [boolean]
'setting': [boolean]
'aspectRatio': [string]
'flip': [string]
'blur': []
'resize': []
}
export interface QualityItem { html: string, url: string, default?: boolean }
type IconName = 'play' | 'pause' | 'volume' | 'volumeClose' | 'fullscreenOn' | 'fullscreenOff' | 'fullscreenWebOn' | 'fullscreenWebOff' | 'pip' | 'setting' | 'screenshot' | 'airplay'
export interface ControlHost extends ComponentHost, SubscriptionHost<UIEvents> {
template: {
$player: HTMLDivElement
$bottom: HTMLDivElement
$controls: HTMLDivElement
$progress: HTMLDivElement
$controlsLeft: HTMLDivElement
$controlsRight: HTMLDivElement
}
option: {
isLive: boolean
quality: QualityItem[]
screenshot: boolean
setting: boolean
pip: boolean
airplay: boolean
fullscreenWeb: boolean
fullscreen: boolean
controls: EntryInput<ControlHost>[]
highlight: { time: number, text: string }[]
}
icons: Record<IconName, HTMLElement> & { indicator?: HTMLElement }
i18n: { get: (key: string) => string }
constructor: { CONTROL_HIDE_TIME: number, USE_RAF: boolean }
setting: { show: boolean, toggle: () => void, resize: () => void }
isInput: boolean
isRotate: boolean
playing: boolean
muted: boolean
volume: number
currentTime: number
duration: number
played: number
loaded: number
top: number
height: number
fullscreen: boolean
fullscreenWeb: boolean
pip: boolean
set seek(second: number)
set quality(value: QualityItem[])
play: () => unknown
pause: () => unknown
screenshot: () => unknown
airplay: () => unknown
}
export type ControlOption = EntryOption<ControlHost>
export type ControlFactory = (art: ControlHost) => ControlOption
@@ -1,22 +1,26 @@
import { append, getRect, isMobile, setStyle } from '../utils'
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { getRect, isMobile, setStyle } from '../utils'
import { controlEvents } from './resources'
export default function volume(option) {
export default function volume(option: ControlOption): ControlFactory {
return art => ({
...option,
mounted: ($control) => {
const { proxy, icons } = art
const { on, proxy } = controlEvents(art, $control)
const { icons } = art
const $volume = append($control, icons.volume)
const $close = append($control, icons.volumeClose)
const $panel = append($control, '<div class="art-volume-panel"></div>')
const $inner = append($panel, '<div class="art-volume-inner"></div>')
const $value = append($inner, `<div class="art-volume-val"></div>`)
const $slider = append($inner, `<div class="art-volume-slider"></div>`)
const $handle = append($slider, `<div class="art-volume-handle"></div>`)
const $loaded = append($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = append($slider, `<div class="art-volume-indicator"></div>`)
const $volume = appendElement($control, icons.volume)
const $close = appendElement($control, icons.volumeClose)
const $panel = appendElement($control, '<div class="art-volume-panel"></div>')
const $inner = appendElement($panel, '<div class="art-volume-inner"></div>')
const $value = appendElement($inner, `<div class="art-volume-val"></div>`)
const $slider = appendElement($inner, `<div class="art-volume-slider"></div>`)
const $handle = appendElement($slider, `<div class="art-volume-handle"></div>`)
const $loaded = appendElement($handle, `<div class="art-volume-loaded"></div>`)
const $indicator = appendElement($slider, `<div class="art-volume-indicator"></div>`)
function getVolumeFromEvent(event) {
function getVolumeFromEvent(event: MouseEvent) {
const { top, height } = getRect($slider)
return 1 - (event.clientY - top) / height
}
@@ -27,7 +31,7 @@ export default function volume(option) {
setStyle($close, 'display', 'flex')
setStyle($indicator, 'top', '100%')
setStyle($loaded, 'top', '100%')
$value.textContent = 0
$value.textContent = '0'
}
else {
const percentage = art.volume * 100
@@ -35,12 +39,12 @@ export default function volume(option) {
setStyle($close, 'display', 'none')
setStyle($indicator, 'top', `${100 - percentage}%`)
setStyle($loaded, 'top', `${100 - percentage}%`)
$value.textContent = Math.floor(percentage)
$value.textContent = String(Math.floor(percentage))
}
}
update()
art.on('video:volumechange', update)
on('video:volumechange', update)
proxy($volume, 'click', () => {
art.muted = true
@@ -61,14 +65,14 @@ export default function volume(option) {
art.volume = getVolumeFromEvent(event)
})
art.on('document:mousemove', (event) => {
on('document:mousemove', (event) => {
if (isDragging) {
art.muted = false
art.volume = getVolumeFromEvent(event)
}
})
art.on('document:mouseup', () => {
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
-19
View File
@@ -1,19 +0,0 @@
import Component from './utils/component'
export default class Layer extends Component {
constructor(art) {
super(art)
const {
option,
template: { $layer },
} = art
this.name = 'layer'
this.$parent = $layer
for (let index = 0; index < option.layers.length; index++) {
this.add(option.layers[index])
}
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { ComponentHost, EntryInput } from './component/types'
import Component from './utils/component'
export interface LayerHost extends ComponentHost {
option: { layers: EntryInput<LayerHost>[] }
template: { $player: HTMLElement, $layer: HTMLElement }
}
export default class Layer extends Component<LayerHost> {
constructor(art: LayerHost) {
super(art)
const { option, template: { $layer } } = art
this.name = 'layer'
this.$parent = $layer
for (let index = 0; index < option.layers.length; index++)
this.add(option.layers[index]!)
}
}
@@ -1,25 +0,0 @@
import { def } from '../utils'
export default function qualityMix(art) {
def(art, 'quality', {
set(quality) {
const { controls, notice, i18n } = art
const qualityDefault = quality.find(item => item.default) || quality[0]
controls.update({
name: 'quality',
position: 'right',
index: 10,
style: {
marginRight: '10px',
},
html: qualityDefault?.html || '',
selector: quality,
async onSelect(item) {
await art.switchQuality(item.url)
notice.show = `${i18n.get('Switch Video')}: ${item.html}`
return item.html
},
})
},
})
}
@@ -0,0 +1,40 @@
import type { ComponentHost, EntryOption } from '../component/types'
import type { QualityItem } from '../control/types'
import { captureSelection } from '../component/selection'
import { isClosing } from '../lifecycle/instance'
import { def } from '../utils'
export interface QualityHost extends ComponentHost {
controls: { update: (option: EntryOption<QualityHost>) => unknown }
notice: { set show(message: string) }
i18n: { get: (key: string) => string }
switchQuality: (url: string) => Promise<unknown>
}
export default function qualityMix(art: QualityHost): void {
def(art, 'quality', {
set(quality: QualityItem[]) {
const { controls, notice, i18n } = art
const qualityDefault = quality.find(item => item.default) || quality[0]
controls.update({
name: 'quality',
position: 'right',
index: 10,
style: { marginRight: '10px' },
html: qualityDefault?.html || '',
selector: quality,
async onSelect(item, _element, event) {
const active = captureSelection(event)
// This callback is installed with the quality array above.
await art.switchQuality((item as QualityItem).url)
if (!isClosing(art) && active()) {
const message = `${i18n.get('Switch Video')}: ${item.html}`
if (!isClosing(art) && active())
notice.show = message
}
return item.html
},
})
},
})
}
+9 -2
View File
@@ -6,12 +6,17 @@
display: flex;
align-items: center;
justify-content: space-between;
height: var(--art-control-height);
flex-wrap: wrap;
flex-shrink: 0;
height: auto;
min-height: var(--art-control-height);
.art-controls-left,
.art-controls-right {
display: flex;
height: 100%;
flex-wrap: wrap;
max-width: 100%;
min-height: var(--art-control-height);
}
.art-controls-center {
@@ -23,11 +28,13 @@
.art-controls-right {
justify-content: flex-end;
margin-left: auto;
}
.art-control {
.art-center();
flex-shrink: 0;
max-width: 100%;
cursor: pointer;
white-space: nowrap;
opacity: var(--art-control-opacity);
+7 -2
View File
@@ -20,7 +20,7 @@
padding: 10px;
line-height: 1;
left: var(--art-padding);
bottom: calc(var(--art-control-height) + var(--art-bottom-gap) + 10px);
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + 10px);
background-color: var(--art-widget-background);
.art-auto-playback-close {
@@ -55,7 +55,12 @@
.art-controls,
.art-progress {
transform: translateY(calc(var(--art-control-height) + var(--art-bottom-gap) + var(--art-progress-height) / 4));
transform: translateY(
calc(
var(--art-controls-height, var(--art-control-height)) + var(--art-bottom-gap) + var(--art-progress-height) /
4
)
);
}
.art-progress-indicator {
+1 -1
View File
@@ -10,7 +10,7 @@
border-radius: var(--art-border-radius);
max-height: var(--art-settings-max-height);
right: var(--art-padding);
bottom: var(--art-control-height);
bottom: var(--art-controls-height, var(--art-control-height));
transition: all var(--art-transition-duration) ease;
background-color: var(--art-widget-background);
+1 -1
View File
@@ -31,7 +31,7 @@
&.art-control-show {
.art-subtitle {
bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));
bottom: calc(var(--art-controls-height, var(--art-control-height)) + var(--art-subtitle-bottom));
}
}
}
-120
View File
@@ -1,120 +0,0 @@
import validator from 'option-validator'
import { ComponentOption } from '../scheme'
import { addClass, append, createElement, hasClass, remove, removeClass, setStyles, tooltip } from './dom'
import { errorHandle } from './error'
export default class Component {
constructor(art) {
this.id = 0
this.art = art
this.cache = new Map()
this.add = this.add.bind(this)
this.remove = this.remove.bind(this)
this.update = this.update.bind(this)
}
get show() {
return hasClass(this.art.template.$player, `art-${this.name}-show`)
}
set show(value) {
const { $player } = this.art.template
const className = `art-${this.name}-show`
if (value) {
addClass($player, className)
}
else {
removeClass($player, className)
}
this.art.emit(this.name, value)
}
toggle() {
this.show = !this.show
}
add(getOption) {
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
option.html = option.html || ''
validator(option, ComponentOption)
if (!this.$parent || !this.name || option.disable)
return
const name = option.name || `${this.name}${this.id}`
errorHandle(!this.cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
this.id += 1
const $ref = createElement('div')
addClass($ref, `art-${this.name}`)
addClass($ref, `art-${this.name}-${name}`)
const childs = Array.from(this.$parent.children)
$ref.dataset.index = option.index || this.id
const nextChild = childs.find(item => Number(item.dataset.index) >= Number($ref.dataset.index))
if (nextChild) {
nextChild.insertAdjacentElement('beforebegin', $ref)
}
else {
append(this.$parent, $ref)
}
if (option.html) {
append($ref, option.html)
}
if (option.style) {
setStyles($ref, option.style)
}
if (option.tooltip) {
tooltip($ref, option.tooltip)
}
const events = []
if (option.click) {
const destroyEvent = this.art.events.proxy($ref, 'click', (event) => {
event.preventDefault()
option.click.call(this.art, this, event)
})
events.push(destroyEvent)
}
if (option.selector && ['left', 'right'].includes(option.position)) {
this.selector(option, $ref, events)
}
this[name] = $ref
this.cache.set(name, { $ref, events, option })
if (option.mounted) {
option.mounted.call(this.art, $ref)
}
return $ref
}
remove(name) {
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
const item = this.cache.get(name)
if (item.option.beforeUnmount) {
item.option.beforeUnmount.call(this.art, item.$ref)
}
for (const event of item.events) {
this.art.events.remove(event)
}
this.cache.delete(name)
delete this[name]
remove(item.$ref)
}
update(option) {
if (this.cache.has(option.name)) {
const item = this.cache.get(option.name)
option = Object.assign(item.option, option)
this.remove(option.name)
}
return this.add(option)
}
}
+158
View File
@@ -0,0 +1,158 @@
import type { ComponentHost, Entry, EntryInput, EntryOption, EventCleanup } from '../component/types'
import validator from 'option-validator'
import { renderEntry } from '../component/dom'
import { ownEntry, releaseEntry } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { ComponentOption } from '../scheme'
import { addClass, hasClass, removeClass } from './dom'
import { errorHandle } from './error'
const removing = new WeakSet<object>()
export default class Component<Host extends ComponentHost = ComponentHost> {
declare id: number
declare art: Host
declare cache: Map<string, Entry<Host>>
declare name?: string
declare $parent?: HTMLElement
selector?(option: EntryOption<Host>, element: HTMLDivElement, events: EventCleanup[]): void
constructor(art: Host) {
this.id = 0
this.art = art
this.cache = new Map()
this.add = this.add.bind(this)
this.remove = this.remove.bind(this)
this.update = this.update.bind(this)
}
get show(): boolean {
return hasClass(this.art.template.$player, `art-${this.name}-show`)
}
set show(value: boolean) {
const { $player } = this.art.template
const className = `art-${this.name}-show`
if (value)
addClass($player, className)
else
removeClass($player, className)
this.art.emit(this.name!, value)
}
toggle(): void {
this.show = !this.show
}
add(getOption: EntryInput<Host>): HTMLDivElement | undefined {
if (isClosing(this.art))
return
const option = typeof getOption === 'function' ? getOption(this.art) : getOption
if (isClosing(this.art))
return
option.html = option.html || ''
validator(option, ComponentOption)
if (!this.$parent || !this.name || option.disable)
return
const name = option.name || `${this.name}${this.id}`
const cache = this.cache
errorHandle(!cache.has(name), `Can't add an existing [${name}] to the [${this.name}]`)
this.id += 1
const $ref = document.createElement('div')
const scope = ownEntry(this.art, $ref)
const events: EventCleanup[] = []
const previous = Object.getOwnPropertyDescriptor(this, name)
let aliased = false
scope.add(() => {
for (const event of events)
this.art.events.remove(event)
})
try {
if (scope.closed)
return
renderEntry($ref, this.$parent, this.name, name, this.id, option)
if (scope.closed)
return
if (option.click) {
const cleanup = this.art.events.proxy($ref, 'click', (event) => {
if (scope.closed)
return
event.preventDefault()
option.click!.call(this.art, this, event)
})
events.push(cleanup)
}
if (option.selector && ['left', 'right'].includes(option.position!))
this.selector!(option, $ref, events)
if (scope.closed)
return
assignAlias(this, name, $ref)
aliased = true
cache.set(name, { $ref, events, option })
if (option.mounted)
option.mounted.call(this.art, $ref)
return $ref
}
catch (error) {
try {
releaseEntry($ref)
}
catch (cleanupError) {
console.warn('ArtPlayer component cleanup failed:', cleanupError)
}
if (cache.get(name)?.$ref === $ref)
cache.delete(name)
if (aliased && (this as unknown as Record<string, unknown>)[name] === $ref) {
if (previous)
Object.defineProperty(this, name, previous)
else
delete (this as unknown as Record<string, unknown>)[name]
}
$ref.remove()
throw error
}
}
remove(name: string): void {
errorHandle(this.cache.has(name), `Can't find [${name}] from the [${this.name}]`)
const item = this.cache.get(name)!
if (removing.has(item))
return
removing.add(item)
try {
if (item.option.beforeUnmount)
item.option.beforeUnmount.call(this.art, item.$ref)
try {
releaseEntry(item.$ref)
}
finally {
if (this.cache.get(name) === item) {
this.cache.delete(name)
delete (this as unknown as Record<string, unknown>)[name]
}
item.$ref.remove()
}
}
finally {
removing.delete(item)
}
}
update(option: EntryOption<Host>): ReturnType<this['add']> {
if (this.cache.has(option.name!)) {
const item = this.cache.get(option.name!)!
option = Object.assign(item.option, option)
this.remove(option.name!)
}
// add is polymorphic: Control intentionally returns undefined.
return this.add(option) as ReturnType<this['add']>
}
}
function assignAlias(owner: object, name: string, value: HTMLElement): void {
const aliases = owner as Record<string, unknown>
if (name === '__proto__')
Object.defineProperty(aliases, name, { value, enumerable: true, configurable: true, writable: true })
else
aliases[name] = value
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
# CORE-13 组件注册与控件
状态:完成;源码、严格类型、构建、安装包与本地三浏览器验收通过。
## 职责与兼容
Component、control、layer、contextmenu 和 qualityMix 迁移 TS。注册器、DOM、条目资源、选择生命周期、内置装配、布局观察、进度计算/视图/交互按实际职责拆分;简单控件保留独立工厂。共享 typed UIEvents 和最小宿主不引入第二套事件总线;主构造门面的整合仍交 CORE-20。包内 ARCHITECTURE.md 给出实际地图、修改入口与边界。
保持裸工厂调用、回调 this、原始 Event 和参数、索引排序、HTML 数字转换、更新时原配置对象合并、同步 mounted/beforeUnmount、属性别名及缓存条目形状。Component add/update 可返回 div;controls.add/update 继续返回 undefined。公开 d.ts 不在此轮收紧,BASE-TYPE-04 与只写 quality 属性的 BASE-TYPE-08 仍交 CORE-21。
条目资源在渲染前登记,remove/update 释放内置 DOM 与 Emitter 订阅、拖动和提示定时器;整实例销毁释放作用域,但不新增逐条 beforeUnmount 调用或清空历史 cache/alias。自定义 mounted 返回值仍忽略,用户自行创建的监听器/SDK 工作需自行 beforeUnmount 清理,没有凭空引入 disposer 协议。
## 缺陷与修正
- BASE-LIFE-09:旧控件移除后时间/音量/菜单仍响应事件,拖动仍可 seek。候选按条目释放并在 Emitter 快照内检查关闭状态,用户自己的订阅保留。
- BASE-LIFE-10:旧 mounted 抛错残留 DOM/cache/监听器,beforeUnmount 重入重复执行并抛错,已脱离 DOM 的条目移除也抛错。候选回滚失败条目且保留成功的重入替换,递归移除同条目幂等;beforeUnmount 自身抛错仍保留条目可重试并传播原异常。
- BASE-LIFE-11:旧 selector 背景点击及异步拒绝产生未处理异常,乱序和移除后结果仍覆盖标题。候选忽略无项点击,报告原始失败,只接受当前选择的完成;内置清晰度通知也绑定选择生命周期,包括通知文本准备期间重入。第三方 onSelect 自有异步副作用不会被自动取消。
- BASE-DOM-03:旧 selector 数组用于更新后因不可配置属性重复定义而抛错。候选保持 getter 标志,通过 WeakMap 重绑已关闭条目;同时共享到两个活动控件仍非法。
- BASE-DOM-04:旧 `__proto__` 名称别名会改变 registry 原型。候选为它定义普通 own 属性,仍可按名称读 DOM,不再改原型。
- BASE-DOM-05:旧 highlight.text 可通过引号注入 DOM 属性;候选使用 dataset 按文本写入。没有声称移除用户明确支持的 HTML 控件内容。
- BASE-DOM-02 部分:窄容器控件换行,所有按钮保持可见;新增 --art-controls-height 记录总高度,字幕/面板跟随,旧 --art-control-height 保持单行单位高度。CORE-14 仍需处理窄设置树/面板,因此此风险保持 open。
## 验证与过程
源码组件资源 8 项 Node 测试、根与核心 TS 检查通过;源码专项三浏览器 84 项通过,最终安装包 UMD 与 legacy 同套完整矩阵各 342 项通过。新增用例覆盖真实右键/点击、视频进度、旧新错误对照、回调/别名/返回值、乱序、清理和尺寸变化。ResizeObserver 不可用分支单独测试;没有将 UA 覆盖当实体设备证据。
首次窄容器用例增加实际 16:9 高度后暴露候选控件组仍 height:100% 的裁切:57 通过、3 失败。修正遗漏的子组 CSS,再验证 640/320/240px,保留原失败报告。之后观察器用例 81 通过、3 失败,原因是字幕仍在既有 CSS transition 中;改为有界轮询最终计算样式,未扩大超时或移除动画,随后 84 项通过。
本任务未新增依赖或修改锁文件;package.json 将组件资源测试加入既有 test:unit,确保 CI 执行。没有版本变更、推送或发布。原型名/生命周期/选择器修复为有旧新复现的缺陷修正,不静默改正常 API。回退需一起回退 TS 模块、样式、生成产物、测试和对应记录;不能仅回退组件基类而保留依赖其条目作用域的控件。
完整安装 UMD 首轮 341 通过、1 项 WebKit 切源位置失败。补充原生 seeking/seeked/loadedmetadata/canplay 轨迹后复现:初始 seek 已结束但实际落在接近 0 秒,切源保留的正是这个实际位置;旧测试仅等 seeking=false,没有证明初始 3 秒前提。诊断中精确等于 3 的临时断言也错误拒绝毫秒偏差,已改用原有 0.05 秒精度。曾尝试等待 buffered/seekable 均覆盖 3 秒,但本地 WebKit 实际播放后仍报告 buffered=[]、seekable=[[0,8]]、readyState=4,这个附加假设不成立,保留失败报告并撤回。最终用例通过真实播放/暂停手势启动媒体,再确认 seekable 目标并设置初始位置,在切换前后断言位置,保留范围与媒体轨迹;没有修改播放器逻辑、重复 seek、扩大超时或放宽原切换断言。专项每引擎重复 10 次用于确认该前提修正,再重跑完整矩阵。
## 最终交付证据
- yarn ci:check:177 项通过(148 单元、4 工程、25 基线),110 个生产 TS 文件严格检查;其中核心 105、chapter 5。
- yarn build artplayer 与 yarn build:i18n 成功;实际核心/chapter tarball 仓库外安装消费:27 项运行时通过、五组类型零诊断。
- 最终安装 UMD 和 legacy 各 342 项三浏览器通过,包含本任务 84 项;无跳过、重试、flaky 或未处理页面异常。旧缺陷测试显式收集并断言预期拒绝,没有把它们冒充候选成功行为。
- [验证记录](../baselines/components-validation.json) 校验全部核心源码与打包构建快照一致,三个格式产物与 tarball 及 docs/compiled 指纹一致,公开声明、包内架构文档、22 个语言文件和完整许可一致;记录实际引擎版本、逐项结果与早期失败原因。
- 关闭 BASE-LIFE-09/10/11 和 BASE-DOM-03/04/05;BASE-DOM-02 保持 open,窄设置面板交 CORE-14。没有远端 CI、实体设备或全生态发布验收声明。
本任务独立本地提交;当前总任务 214 项,49 完成、165 待办。下一项 CORE-14 设置树、渲染、选择与布局。
+17 -17
View File
@@ -5,28 +5,28 @@
## 现状证据
- [入口](../packages/artplayer/src/index.js)顺序构造所有子系统;[player](../packages/artplayer/src/player/index.js)通过 mixin 将属性安装到 art。
- [Component](../packages/artplayer/src/utils/component.js)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.js)还有独立的树、渲染和更新语义。
- [Component](../packages/artplayer/src/utils/component.ts)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.js)还有独立的树、渲染和更新语义。
- [Events](../packages/artplayer/src/events/index.js)集中管理一部分 DOM 监听器,但定时器、异步请求和内部 Emitter 订阅仍由各模块管理。
- [声明](../packages/artplayer/types/artplayer.d.ts)与源码独立维护;[build-ts](../scripts/build-ts.js)进行文本拼接。
## 目标职责
| 领域 | 现有文件范围 | 目标与迁移方式 |
| --- | --- | --- |
| 公共门面 | src/index.js、player/*Mix.js | 保留原签名和属性描述符;委托内部服务,不一次性换继承结构 |
| 配置 | option、scheme、config、默认值 | 区分用户输入与归一化配置;保持默认值、合并语义和错误时机 |
| 生命周期 | constructor、destroy、Events、各模块清理 | 实例资源作用域与操作资源作用域,清理恰好一次、可重入、失败后继续释放 |
| 内部事件 | utils/emitter.js | typed event map、未知事件兼容扩展、原调用顺序和 ctx |
| 媒体能力 | 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 | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
| 无障碍 | template、control、setting、显示模式 | 明确键盘导航、可访问名称、焦点进入退出和字幕能力,保留旧 DOM/CSS 与热键契约 |
| 内置功能 | plugins/*、info/notice/loading/mask | 逐项登记定时器和订阅,不能因内部插件迁移改变默认启用条件 |
| 资源 | i18n、icons、style、libs | 保持子路径、图标键、CSS 类和样式注入;记录 vendored 依赖来源 |
| 领域 | 现有文件范围 | 目标与迁移方式 |
| -------- | -------------------------------------------- | ---------------------------------------------------------------------------- |
| 公共门面 | src/index.js、player/*Mix.js | 保留原签名和属性描述符;委托内部服务,不一次性换继承结构 |
| 配置 | option、scheme、config、默认值 | 区分用户输入与归一化配置;保持默认值、合并语义和错误时机 |
| 生命周期 | constructor、destroy、Events、各模块清理 | 实例资源作用域与操作资源作用域,清理恰好一次、可重入、失败后继续释放 |
| 内部事件 | utils/emitter.js | typed event map、未知事件兼容扩展、原调用顺序和 ctx |
| 媒体能力 | 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 | 分开树整理、选择状态、渲染、布局、绑定;共享能共享的逻辑,不强行统一返回类型 |
| 字幕 | subtitle.js、utils/subtitle.js | 获取、解析、track/DOM 渲染、取消和对象 URL 回收分开 |
| 显示模式 | fullscreen/fullscreenWeb/pip/mini/auto* | 显式恢复位置和样式,保持互斥顺序、用户手势与移动端语义 |
| 输入 | events、hotkey | 鼠标/触控/键盘/焦点归属清晰,跨 document 重新绑定和清理 |
| 无障碍 | template、control、setting、显示模式 | 明确键盘导航、可访问名称、焦点进入退出和字幕能力,保留旧 DOM/CSS 与热键契约 |
| 内置功能 | plugins/*、info/notice/loading/mask | 逐项登记定时器和订阅,不能因内部插件迁移改变默认启用条件 |
| 资源 | i18n、icons、style、libs | 保持子路径、图标键、CSS 类和样式注入;记录 vendored 依赖来源 |
## 生命周期设计约束
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 166 / doing 0 / blocked 0 / done 48 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 165 / doing 0 / blocked 0 / done 49 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -116,7 +116,7 @@
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过;接续 CORE-07 / BASE-TYPE-04 的 toggle void 与实际 Promise 分支冲突,不吞拒绝 | H | done |
| 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 | todo |
| 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-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 |
@@ -447,6 +447,7 @@
- CORE-10: [记录](changes/2026-09-11-CORE-10-playback-properties.md) [记录](baselines/playback-properties-validation.json)
- 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)
- 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)
+17 -9
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 当前实施:CORE-13 组件注册与控件完成
Component/control/layer/contextmenu 与 qualityMix 迁移 TS,拆清注册、DOM、资源、选择生命周期、进度交互和布局;修复移除后残留订阅、失败注册与重入清理、过期选择写入、selector 重用和文本属性注入。正常回调/排序/别名/描述符保持,controls.add/update 仍返回 undefined。
177 项 Node/工程/基线通过,核心及 chapter 共 110 个生产 TS 文件严格检查;最终安装 UMD 和 legacy 各 342 项三浏览器通过,实际 tarball 27 项运行时与五组类型零诊断。见 [交付](changes/2026-09-11-CORE-13-components.md) 和 [指纹及结果](baselines/components-validation.json)。关闭 BASE-LIFE-09/10/11、BASE-DOM-03/04/05;BASE-DOM-02 的窄设置面板留 CORE-14,公开返回值与只写属性声明仍交 CORE-21。
当前分支 codex/compatible-modernization,214 项中 49 完成、165 待办;核心 105 个 TS 文件、chapter 5 个。下一项 CORE-14 设置树、选择、渲染与布局;CORE-13 按单任务本地提交,未推送或发布。
## 当前实施:CORE-12 模板及公开资源入口完成
模板、图标、语言和样式入口迁移 TS,保留 SSR/DOM/proxy 身份及语言分发。169 项 Node/工程/基线通过,最终安装 UMD 与 legacy 各 258 项三浏览器通过;打包 27 项运行时及五组类型零诊断。复制资源固定上游内容、全部适配和实际分发许可已核对,关闭 VENDOR-01/02 与 BASE-I18N-01;公开模板/图标类型差异 BASE-TYPE-09 交 CORE-21。见 [交付](changes/2026-09-11-CORE-12-template-resources.md)。当前 214 项,48 完成、166 待办;核心 71 个 TS 文件、chapter 5 个。下一项 CORE-13 组件注册与控件。
@@ -241,15 +249,15 @@ BASE-01 已提交 `1d705b30`。BASE-HARNESS-01 完成发布包采集页面/本
以下来自建立计划前的同一会话评估,属于初步诊断,不代替 BASE 阶段的已发布产物基线与完整浏览器测试:
| 检查 | 结果与限制 |
| --- | --- |
| `node --test test/playback.test.js test/dash-control.test.js` | Node 25.2.1,19 项通过;这是 mock/单元测试 |
| 核心 src/types、test、scripts/*.js 的 ESLint(不加 --fix) | 通过;没有把插件模板占位符作为普通源码 lint |
| 三个 TS 样例,TS 5.9.3、strict、types: [] | 通过;未隔离环境类型时存在第三方 DOM/WebCodecs/markdown 声明冲突 |
| 使用项目构建配置 write:false 进行 ESM/现代 UMD/legacy UMD 内存构建 | 成功;不是完整 npm pack 或全部发布构建;未写产物 |
| 销毁源码隔离探针 | 重复 destroy 可误删其他实例注册项;需要正式回归用例 |
| 连续切源源码隔离探针 | 一次 canplay 可结算两次切源;尚未真实浏览器复现影响 |
| `bun test ./test/playback.test.js ./test/dash-control.test.js` | Bun 1.3.14:1 通过、18 失败,mock.fn 不兼容;未来版本需重测 |
| 检查 | 结果与限制 |
| ------------------------------------------------------------------ | ---------------------------------------------------------------- |
| `node --test test/playback.test.js test/dash-control.test.js` | Node 25.2.1,19 项通过;这是 mock/单元测试 |
| 核心 src/types、test、scripts/*.js 的 ESLint(不加 --fix) | 通过;没有把插件模板占位符作为普通源码 lint |
| 三个 TS 样例,TS 5.9.3、strict、types: [] | 通过;未隔离环境类型时存在第三方 DOM/WebCodecs/markdown 声明冲突 |
| 使用项目构建配置 write:false 进行 ESM/现代 UMD/legacy UMD 内存构建 | 成功;不是完整 npm pack 或全部发布构建;未写产物 |
| 销毁源码隔离探针 | 重复 destroy 可误删其他实例注册项;需要正式回归用例 |
| 连续切源源码隔离探针 | 一次 canplay 可结算两次切源;尚未真实浏览器复现影响 |
| `bun test ./test/playback.test.js ./test/dash-control.test.js` | Bun 1.3.14:1 通过、18 失败,mock.fn 不兼容;未来版本需重测 |
## 清点中新增的待核对项
+6
View File
@@ -56,3 +56,9 @@
| BASE-LIFE-08 | resolved / 已复现 | Ready continues after a control callback destroys the instance | CORE-11 |
| BASE-I18N-01 | resolved / 已复现 | Missing prototype-named translations return inherited functions | CORE-12 |
| BASE-TYPE-09 | open / 源码/产物事实 | Template and icon declarations claim incompatible DOM and static shapes | CORE-21 |
| BASE-LIFE-09 | resolved / 已复现 | Removed builtin controls retain subscriptions and progress drag effects | CORE-13 |
| BASE-LIFE-10 | resolved / 已复现 | Component mounting and removal failures leave partial entries or recurse | CORE-13 |
| BASE-LIFE-11 | resolved / 已复现 | Selector failures are unhandled and obsolete completion overwrites current UI | CORE-13 |
| 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 |
+140 -1
View File
@@ -170,7 +170,8 @@
"refactor/baselines/dom.json"
],
"compatibleResolution": "在保留自定义控件扩展入口下修正布局。",
"closureCriteria": "320px 及正常容器的设置/模式操作真实可用。"
"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."
},
{
"id": "BASE-DEMO-01",
@@ -993,6 +994,144 @@
],
"compatibleResolution": "Keep old legal declarations accepted, expose accurate template and icon views without inventing runtime properties.",
"closureCriteria": "Old consumers and accurate template/media/icon view consumers pass all supported type modes and runtime shape checks."
},
{
"id": "BASE-LIFE-09",
"title": "Removed builtin controls retain subscriptions and progress drag effects",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Keep per-entry owned listeners, timers and drag callbacks inert after removal while preserving user subscriptions.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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-LIFE-10",
"title": "Component mounting and removal failures leave partial entries or recurse",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Rollback failed registration, preserve successful reentrant replacement and original errors, retain failed beforeUnmount for retry.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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-LIFE-11",
"title": "Selector failures are unhandled and obsolete completion overwrites current UI",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Keep callback arguments and default flags; report owned failures and gate titles and builtin quality notices by active selection.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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-03",
"title": "Reusing selector items after update redefines nonconfigurable properties",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Keep getter descriptors and rebind only after the previous entry closes.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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-04",
"title": "A component named __proto__ changes its registry prototype",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Preserve named DOM access with a normal own property without changing registry prototype.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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-05",
"title": "Highlight text interpolation permits unintended HTML attributes",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-13"
],
"evidence": [
"test/browser/components.spec.js",
"test/component-resources.test.js",
"refactor/changes/2026-09-11-CORE-13-components.md"
],
"compatibleResolution": "Assign highlight text through dataset while retaining marker positions and intentional control HTML APIs.",
"closureCriteria": "Published/candidate comparison, final installed UMD and legacy browser matrices, strict types and package verification must pass; original failure evidence remains recorded.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-13-components.md",
"refactor/baselines/components-validation.json",
"test/browser/components.spec.js",
"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."
}
]
}
+5 -2
View File
@@ -990,11 +990,14 @@
"CORE-12",
"CORE-08"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "Component/control/layer/contextmenu 的资源与 TS 边界",
"acceptance": "add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-13-components.md",
"baselines/components-validation.json"
]
},
{
"id": "CORE-14",
+378
View File
@@ -0,0 +1,378 @@
import { expect, test } from './fixtures.js'
for (const fallback of [false, true]) {
test(`candidate: control layout follows resize and removal with observer 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(() => {
window.art = new window.Artplayer({ container: '.player', url: '', setting: true, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
})
const heights = () => page.evaluate(() => ({ actual: window.art.template.$controls.offsetHeight, recorded: Number.parseFloat(window.art.template.$player.style.getPropertyValue('--art-controls-height')) }))
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
await page.evaluate(() => {
window.art.template.$container.style.width = '320px'
window.dispatchEvent(new Event('resize'))
})
await expect.poll(heights).toEqual({ actual: 92, recorded: 92 })
await expect.poll(() => page.evaluate(() => Number.parseFloat(getComputedStyle(window.art.template.$subtitle).bottom))).toBeGreaterThanOrEqual(92)
await page.evaluate(() => window.art.controls.remove('probe'))
await expect.poll(heights).toEqual({ actual: 46, recorded: 46 })
await page.evaluate(() => {
window.art.destroy(false)
window.art.template.$controls.style.display = 'none'
window.art.emit('resize')
})
await expect.poll(heights).toEqual({ actual: 0, recorded: 46 })
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: selector background clicks and rejection ownership are explicit`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
const failure = new Error('selection failure')
window.selectionReports = { warnings: [], rejections: [] }
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault()
window.selectionReports.rejections.push({ name: event.reason.name, same: event.reason === failure })
})
const original = console.warn
console.warn = (message, error) => {
window.selectionReports.warnings.push({ message, same: error === failure })
original.call(console, message, error)
}
const art = new window.Artplayer({ container: '.player', url: '' })
window.art = art
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector: [{ html: 'A' }], onSelect() {
return Promise.reject(failure)
} })
art.controls.selector.querySelector('.art-selector-list').dispatchEvent(new MouseEvent('click', { bubbles: true }))
})
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.selectionReports.rejections.length)).toBe(1)
await page.evaluate(() => window.art.controls.selector.querySelector('.art-selector-item').dispatchEvent(new MouseEvent('click', { bubbles: true })))
await expect.poll(() => page.evaluate(() => window.selectionReports.warnings.length + window.selectionReports.rejections.length)).toBe(core === 'published' ? 2 : 1)
const reports = await page.evaluate(() => window.selectionReports)
expect(reports).toEqual(core === 'published'
? { warnings: [], rejections: [{ name: 'TypeError', same: false }, { name: 'Error', same: true }] }
: { warnings: [{ message: 'ArtPlayer selector failed:', same: true }], rejections: [] })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: beforeUnmount errors remain retryable and detached nodes can be removed`, 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: '' })
const failure = new Error('before unmount')
const option = { name: 'entry', html: 'entry', beforeUnmount() {
throw failure
} }
const element = art.layers.add(option)
let same = false
try {
art.layers.remove('entry')
}
catch (error) { same = error === failure }
const retained = art.layers.cache.has('entry') && element.isConnected
option.beforeUnmount = () => {}
element.remove()
let error = null
try {
art.layers.remove('entry')
}
catch (caught) { error = caught.name }
const result = { same, retained, error, removed: !art.layers.cache.has('entry') }
art.destroy()
return result
})
expect(result).toEqual({ same: true, retained: true, error: core === 'published' ? 'TypeError' : null, removed: true })
})
test(`${core}: failed mounted reentry preserves its successful replacement`, 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: '' })
const failure = new Error('outer failure')
let same = false
try {
art.layers.add({ name: 'entry', html: 'outer', mounted() {
art.layers.remove('entry')
art.layers.add({ name: 'entry', html: 'replacement' })
throw failure
} })
}
catch (error) { same = error === failure }
const result = { same, value: art.layers.entry.textContent, attached: art.layers.entry.isConnected, entries: art.layers.cache.size }
art.destroy()
return result
})
expect(result).toEqual({ same: true, value: 'replacement', attached: true, entries: 1 })
})
test(`${core}: context menu builtin selection and owned updates preserve state`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', playbackRate: true, aspectRatio: true, flip: true })
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
for (const [name, value] of [['playbackRate', '1.5'], ['aspectRatio', '4:3'], ['flip', 'horizontal']]) {
await page.locator('.art-video-player').click({ button: 'right', position: { x: 100, y: 80 } })
await page.locator(`.art-contextmenu-${name} [data-value="${value}"]`).click()
await expect.poll(() => page.evaluate(name => window.art.contextmenu[name].querySelector('.art-current')?.dataset.value, name)).toBe(value)
expect(await page.evaluate(() => window.art.contextmenu.show)).toBe(false)
}
const result = await page.evaluate(() => {
const { art } = window
const menu = art.contextmenu.flip
art.contextmenu.remove('flip')
art.flip = 'normal'
const result = { rate: art.playbackRate, aspect: art.aspectRatio, changedAfterRemove: menu.querySelector('[data-value="normal"]').classList.contains('art-current') }
art.destroy()
return result
})
expect(result).toEqual({ rate: 1.5, aspect: '4:3', changedAfterRemove: core === 'published' })
})
test(`${core}: a prototype-named component keeps the registry prototype intact`, 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: '' })
const prototype = Object.getPrototypeOf(art.layers)
const element = art.layers.add({ name: '__proto__', html: 'named' })
const alias = Reflect.get(art.layers, '__proto__') === element
art.layers.remove('__proto__')
const result = { alias, preserved: Object.getPrototypeOf(art.layers) === prototype }
art.destroy()
return result
})
expect(result).toEqual({ alias: true, preserved: core === 'candidate' })
})
test(`${core}: component factories, ordering, bound methods and update hooks retain contracts`, 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: '' })
const { add, update, remove } = art.layers
const calls = []
const first = { name: 'first', index: 10, html: 'first', mounted(element) {
calls.push(['mount', this === art, element.textContent])
} }
add(first)
add({ name: 'equal', index: 10, html: 'equal' })
let factory
add((owner) => {
factory = owner === art
return { name: 'zero', index: 0, html: 42 }
})
const disabled = { html: 0, disable: true }
const before = art.layers.id
const disabledResult = add(disabled)
const order = Array.from(art.template.$layer.children, node => node.textContent)
const updated = update({ name: 'first', html: 'changed', beforeUnmount(element) {
calls.push(['unmount', this === art, element.textContent, first.html])
} })
const controlResult = art.controls.add({ name: 'probe', position: 'right', html: 'probe', click(component, event) {
calls.push(['click', this === art, component === art.controls, event.defaultPrevented])
} })
art.controls.probe.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
const controlUpdate = art.controls.update({ name: 'probe', html: 'updated' })
const result = { factory, order, disabled: disabledResult === undefined && art.layers.id === before + 1 && disabled.html === '', updated: updated === art.layers.first, controlReturns: [controlResult === undefined, controlUpdate === undefined], calls }
remove('first')
art.destroy()
return result
})
expect(result.factory).toBe(true)
expect(result.order).toEqual(['42', 'equal', 'first'])
expect(result.disabled).toBe(true)
expect(result.updated).toBe(true)
expect(result.controlReturns).toEqual([true, true])
expect(result.calls.slice(0, 4)).toEqual([['mount', true, 'first'], ['unmount', true, 'first', 'changed'], ['mount', true, 'changed'], ['click', true, true, true]])
})
test(`${core}: removed builtin time and volume controls stop owned effects`, 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: '' })
const time = art.controls.time
const before = time.textContent
const icon = art.controls.volume.querySelector('.art-icon-volume')
art.controls.remove('time')
art.controls.remove('volume')
art.video.currentTime = 12
art.emit('video:timeupdate', new Event('timeupdate'))
art.muted = false
icon.dispatchEvent(new MouseEvent('click'))
const result = { changed: time.textContent !== before, muted: art.muted }
art.destroy()
return result
})
expect(result).toEqual({ changed: core === 'published', muted: core === 'published' })
})
test(`${core}: selector items can be reused by an update without changing getter flags`, 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: '' })
const selector = [{ html: 'A', value: 'a', default: true }, { html: 'B', value: 'b' }]
art.controls.add({ name: 'selector', position: 'right', html: 'A', selector })
const oldNode = selector[0].$control_item
let error = null
try {
art.controls.update({ name: 'selector', html: 'updated' })
}
catch (caught) { error = caught.name }
const descriptor = Object.getOwnPropertyDescriptor(selector[0], '$control_item')
const result = { error, changed: selector[0].$control_item !== oldNode, flags: [descriptor.enumerable, descriptor.configurable, typeof descriptor.get], options: selector[0].$control_option === selector }
art.destroy()
return result
})
expect(result).toEqual({ error: core === 'candidate' ? null : 'TypeError', changed: core === 'candidate', flags: [false, false, 'function'], options: true })
})
test(`${core}: async selector results cannot overwrite a newer choice or removed entry`, 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: '' })
const selector = [{ html: 'A', value: 'a' }, { html: 'B', value: 'b' }]
const pending = []
const calls = []
art.controls.add({ name: 'selector', position: 'right', html: 'initial', selector, onSelect(item, node, event) {
calls.push([this === art, selector.includes(item), item.$control_item === node, event.type])
return new Promise(resolve => pending.push(resolve))
} })
const click = item => item.$control_item.dispatchEvent(new MouseEvent('click', { bubbles: true }))
click(selector[0])
click(selector[1])
pending[1]('new')
await Promise.resolve()
pending[0]('old')
await Promise.resolve()
const value = selector[0].$control_value
const raced = value.innerHTML
click(selector[0])
const beforeRemove = value.innerHTML
art.controls.remove('selector')
pending[2]('removed')
await Promise.resolve()
const result = { raced, changedAfterRemove: value.innerHTML !== beforeRemove, flags: selector.map(item => item.default), calls }
art.destroy()
return result
})
expect(result.raced).toBe(core === 'candidate' ? 'new' : 'old')
expect(result.changedAfterRemove).toBe(core === 'published')
expect(result.flags).toEqual([true, false])
expect(result.calls).toEqual(Array.from({ length: 3 }, () => [true, true, true, 'click']))
})
test(`${core}: a failed mounted hook rolls back its DOM alias and listeners`, 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: '' })
const failure = new Error('mounted failure')
let element
let sameError = false
let clicks = 0
try {
art.layers.add({ name: 'failed', html: 'failed', click() {
clicks++
}, mounted(node) {
element = node
throw failure
} })
}
catch (error) { sameError = error === failure }
element.dispatchEvent(new MouseEvent('click'))
const result = { sameError, clicks, cached: art.layers.cache.has('failed'), alias: art.layers.failed === element, attached: art.template.$layer.contains(element) }
art.destroy()
return result
})
expect(result).toEqual({ sameError: true, clicks: core === 'candidate' ? 0 : 1, cached: core === 'published', alias: core === 'published', attached: core === 'published' })
})
test(`${core}: reentrant beforeUnmount removes only once`, 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: '' })
let calls = 0
art.layers.add({ name: 'reentrant', html: 'reentrant', beforeUnmount() {
calls++
if (calls === 1)
art.layers.remove('reentrant')
} })
let error = null
try {
art.layers.remove('reentrant')
}
catch (caught) { error = caught.name }
const result = { calls, error, cached: art.layers.cache.has('reentrant') }
art.destroy()
return result
})
expect(result).toEqual({ calls: core === 'candidate' ? 1 : 2, error: core === 'candidate' ? null : 'TypeError', cached: false })
})
test(`${core}: narrow controls stay inside the player without hiding buttons`, async ({ page }, testInfo) => {
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, fullscreenWeb: true, controls: [{ name: 'probe', position: 'right', html: 'Probe control' }] })
const container = art.template.$container
const measure = (width) => {
container.style.width = `${width}px`
container.style.height = `${width * 9 / 16}px`
const bounds = container.getBoundingClientRect()
const controls = Array.from(container.querySelectorAll('.art-controls .art-control'))
return { count: controls.length, hidden: controls.filter(node => node.getClientRects().length === 0).length, overflow: controls.filter((node) => {
const box = node.getBoundingClientRect()
return box.left < bounds.left - 0.75 || box.right > bounds.right + 0.75 || box.top < bounds.top - 0.75 || box.bottom > bounds.bottom + 0.75
}).length }
}
const wide = measure(640)
const narrow = measure(320)
const smaller = measure(240)
measure(320)
window.layoutArt = art
return { wide, narrow, smaller }
})
await page.locator('.player').screenshot({ path: testInfo.outputPath('narrow-controls.png') })
await page.evaluate(() => window.layoutArt.destroy())
expect(result.wide.overflow).toBe(0)
expect(result.narrow.count).toBe(result.wide.count)
expect(result.narrow.hidden).toBe(0)
expect(result.smaller.hidden).toBe(0)
if (core === 'candidate') {
expect(result.narrow.overflow).toBe(0)
expect(result.smaller.overflow).toBe(0)
}
else {
expect(result.narrow.overflow).toBeGreaterThan(0)
expect(result.smaller.overflow).toBeGreaterThan(0)
}
})
test(`${core}: progress markers treat text as data and removed drags cannot seek`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.createPlayer('/test/pattern.mp4')
window.art.option.highlight = [{ time: 1, text: 'label" data-injected="yes' }]
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(() => {
const art = window.art
const marker = art.controls.progress.querySelector('.art-progress-highlight span')
const text = marker.dataset.text
const injected = marker.hasAttribute('data-injected')
let seeks = 0
art.on('seek', () => seeks++)
art.template.$progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
art.controls.remove('progress')
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100 }))
art.destroy()
return { text, injected, seeks }
})
expect(result).toEqual({ text: core === 'candidate' ? 'label" data-injected="yes' : 'label', injected: core === 'published', seeks: core === 'candidate' ? 0 : 1 })
})
}
+28 -2
View File
@@ -56,11 +56,32 @@ for (const core of ['published', 'candidate']) {
})
}
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }) => {
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
// A real gesture starts WebKit's media pipeline before the paused seek setup.
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused)).toBe(true)
await testInfo.attach('initial-media-ranges', {
body: JSON.stringify(await page.evaluate(() => {
const { video } = window.art
const ranges = value => Array.from({ length: value.length }, (_, index) => [value.start(index), value.end(index)])
return { buffered: ranges(video.buffered), seekable: ranges(video.seekable), readyState: video.readyState, duration: video.duration, currentTime: video.currentTime }
})),
contentType: 'application/json',
})
await expect.poll(() => page.evaluate(() => {
const ranges = window.art.video.seekable
return Array.from({ length: ranges.length }, (_, index) => index).some(index => ranges.start(index) <= 3 && ranges.end(index) >= 3)
})).toBe(true)
await page.evaluate(() => {
window.switchMediaEvents = []
for (const name of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'pause', 'playing']) {
window.art.video.addEventListener(name, () => window.switchMediaEvents.push({ name, time: window.art.currentTime, seeking: window.art.video.seeking, source: window.art.video.currentSrc }))
}
window.art.currentTime = 3
})
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
@@ -76,11 +97,16 @@ test('candidate: quality switch restores position/rate and preserves real custom
art.aspectRatio = '16:9'
const restart = []
art.on('restart', url => restart.push(url))
const beforeSwitch = { time: art.currentTime, seeking: art.video.seeking, paused: art.video.paused }
await art.switchQuality('/test/pattern.mp4?custom=next')
const result = { calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
const result = { beforeSwitch, mediaEvents: window.switchMediaEvents, calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
art.destroy()
return result
})
await testInfo.attach('quality-switch-media-state', { body: JSON.stringify(result), contentType: 'application/json' })
expect(result.beforeSwitch.time).toBeCloseTo(3, 1)
expect(result.beforeSwitch.seeking).toBe(false)
expect(result.beforeSwitch.paused).toBe(true)
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
expect(result.time).toBeCloseTo(3, 1)
+221
View File
@@ -0,0 +1,221 @@
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/component/resources'
const lifecycle = 'packages/artplayer/src/lifecycle/instance'
const { Emitter, Control, installControls, qualityMix, trackSelection, captureSelection, beginLifecycle, destroyInstance, getScope, ownEntry, entryScope, releaseEntry, proxyEntry, subscribeEntry } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
Control: 'packages/artplayer/src/control/index',
installControls: { file: 'packages/artplayer/src/control/builtins', name: 'installControls' },
qualityMix: 'packages/artplayer/src/player/qualityMix',
...Object.fromEntries(['trackSelection', 'captureSelection'].map(name => [name, { file: 'packages/artplayer/src/component/selection', name }])),
...Object.fromEntries(['beginLifecycle', 'destroyInstance', 'getScope'].map(name => [name, { file: lifecycle, name }])),
...Object.fromEntries(['ownEntry', 'entryScope', 'releaseEntry', 'proxyEntry', 'subscribeEntry'].map(name => [name, { file: resources, name }])),
})
test('deferred builtin quality initialization reports its original failure', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art } = fixture()
const failure = new Error('quality option getter')
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
let reads = 0
art.option = {
controls: [],
get quality() {
if (reads++ === 0)
return [{ html: 'A', url: 'a' }]
throw failure
},
}
installControls({ art, add() {} })
t.mock.timers.tick(0)
await Promise.resolve()
await Promise.resolve()
assert.deepEqual(warnings, [['ArtPlayer quality initialization failed:', failure]])
getScope(art).dispose()
})
test('control factories do not run during the reset portion of instance destruction', () => {
const { art } = fixture()
art.isDestroy = false
art.template = { $video: {}, destroy() {} }
let factories = 0
art.reset = () => {
assert.equal(art.isDestroy, false)
assert.equal(getScope(art).closed, false)
const result = Control.prototype.add.call({ art }, () => {
factories++
return { html: 'late' }
})
assert.equal(result, undefined)
}
destroyInstance(art, [art], true, true)
assert.equal(factories, 0)
assert.equal(art.isDestroy, true)
})
test('quality callbacks keep return values while obsolete and removed selections stop notices', async () => {
const { art, element } = fixture()
let option
art.controls = { update(value) {
option = value
} }
art.notice = { show: '' }
art.i18n = { get: key => key }
const pending = []
art.switchQuality = url => new Promise(resolve => pending.push({ url, resolve }))
qualityMix(art)
const quality = [{ html: 'A', url: 'a' }, { html: 'B', url: 'b' }]
art.quality = quality
let generation = 0
const run = (item) => {
const current = ++generation
const scope = entryScope(element)
const event = new Event('click')
const release = scope.add(trackSelection(event, () => !scope.closed && current === generation))
return option.onSelect.call(art, item, element, event).finally(release)
}
const first = run(quality[0])
const second = run(quality[1])
pending[1].resolve()
assert.equal(await second, 'B')
assert.equal(art.notice.show, 'Switch Video: B')
pending[0].resolve()
assert.equal(await first, 'A')
assert.equal(art.notice.show, 'Switch Video: B')
const removed = run(quality[0])
releaseEntry(element)
pending[2].resolve()
assert.equal(await removed, 'A')
assert.equal(art.notice.show, 'Switch Video: B')
ownEntry(art, element)
art.i18n.get = (key) => {
releaseEntry(element)
return key
}
const reentrant = run(quality[0])
pending[3].resolve()
assert.equal(await reentrant, 'A')
assert.equal(art.notice.show, 'Switch Video: B')
assert.deepEqual(pending.map(item => item.url), ['a', 'b', 'a', 'a'])
})
test('quality rejection identity and captured selection closure survive disposal', async () => {
const { art, element } = fixture()
let option
const failure = new Error('quality failed')
art.controls = { update(value) {
option = value
} }
art.notice = { show: '' }
art.i18n = { get: key => key }
art.switchQuality = () => Promise.reject(failure)
qualityMix(art)
const quality = [{ html: 'A', url: 'a' }]
art.quality = quality
const event = new Event('click')
const scope = entryScope(element)
scope.add(trackSelection(event, () => !scope.closed))
const active = captureSelection(event)
const result = option.onSelect.call(art, quality[0], element, event)
releaseEntry(element)
assert.equal(active(), false)
await assert.rejects(result, error => error === failure)
assert.equal(art.notice.show, '')
})
function fixture() {
const art = new Emitter()
const registered = new Set()
art.events = {
proxy(target, name, callback) {
target.addEventListener(name, callback)
const cleanup = () => target.removeEventListener(name, callback)
registered.add(cleanup)
return cleanup
},
remove(cleanup) {
cleanup()
registered.delete(cleanup)
},
}
beginLifecycle(art)
const element = new EventTarget()
ownEntry(art, element)
return { art, element, registered }
}
test('entry disposal removes DOM listeners and preserves unrelated entries', () => {
const { art, element, registered } = fixture()
const second = new EventTarget()
ownEntry(art, second)
const events = []
proxyEntry(art, element, element, 'click', event => events.push(event))
proxyEntry(art, second, second, 'click', event => events.push(event))
const original = new Event('click')
element.dispatchEvent(original)
assert.equal(events[0], original)
releaseEntry(element)
element.dispatchEvent(new Event('click'))
second.dispatchEvent(new Event('click'))
assert.equal(events.length, 2)
assert.equal(registered.size, 1)
assert.equal(entryScope(element).closed, true)
assert.equal(getScope(art).closed, false)
getScope(art).dispose()
assert.equal(registered.size, 0)
})
test('entry listeners stop within an existing emitter snapshot while user listeners survive', () => {
const { art, element } = fixture()
const calls = []
art.on('update', () => {
calls.push('first')
releaseEntry(element)
})
subscribeEntry(art, element, 'update', () => calls.push('owned'))
art.on('update', () => calls.push('last'))
art.emit('update')
art.emit('update')
assert.deepEqual(calls, ['first', 'last', 'first', 'last'])
assert.equal(art.e.update.length, 2)
})
test('root closure stops all entry resources and late registrations remain inert', () => {
const { art, element, registered } = fixture()
let calls = 0
proxyEntry(art, element, element, 'click', () => calls++)
subscribeEntry(art, element, 'update', () => calls++)
getScope(art).dispose()
proxyEntry(art, element, element, 'click', () => calls++)
subscribeEntry(art, element, 'update', () => calls++)
element.dispatchEvent(new Event('click'))
art.emit('update')
releaseEntry(element)
assert.equal(calls, 0)
assert.equal(registered.size, 0)
})
test('cleanup failure does not prevent releasing other component resources', () => {
const { art, element } = fixture()
const failures = [new Error('first'), new Error('second')]
const attempts = []
art.events.remove = () => {
const error = failures[attempts.length]
attempts.push(error)
throw error
}
proxyEntry(art, element, element, 'click', () => {})
proxyEntry(art, element, element, 'change', () => {})
assert.throws(() => releaseEntry(element), (error) => {
assert.deepEqual(error.errors, failures)
return true
})
assert.deepEqual(attempts, failures)
assert.equal(entryScope(element).closed, true)
releaseEntry(element)
assert.equal(attempts.length, 2)
})
+22
View File
@@ -0,0 +1,22 @@
import type { ComponentHost } from '../../packages/artplayer/src/component/types'
import type { ControlHost } from '../../packages/artplayer/src/control/types'
import Control from '../../packages/artplayer/src/control'
import Component from '../../packages/artplayer/src/utils/component'
declare const host: ComponentHost & { extra: number }
const component = new Component(host)
const added: HTMLDivElement | undefined = component.add({ html: 42, mounted(element) {
const extra: number = this.extra
const div: HTMLDivElement = element
void [extra, div]
} })
const updated: HTMLDivElement | undefined = component.update({ name: 'entry', html: 'new' })
declare const controlHost: ControlHost
const control = new Control(controlHost)
const empty: undefined = control.add({ position: 'right', html: 'entry' })
const alsoEmpty: undefined = control.update({ name: 'entry', html: 'new' })
// @ts-expect-error Control.add has always returned undefined at runtime.
const invented: HTMLDivElement = control.add({ position: 'left', html: 'entry' })
// @ts-expect-error Component indexes are numbers.
component.add({ index: 'first' })
void [added, updated, empty, alsoEmpty, invented]