refactor(core): CORE-17 type input events and own interaction lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 11:24:15 +08:00
1 parent 88aef830df
commit a842058136
72 files changed
+31156 -1219

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 n=(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 o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(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 o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={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,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(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/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+126
View File
@@ -4,6 +4,132 @@ 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.
## Keyboard input (CORE-17)
hotkey.ts retains the art/keys own fields, init/add/remove prototype methods, plain
keys-object prototype, caller-visible callback arrays and chainable registration.
input/hotkey-types.ts describes its minimal generic host and callback receiver;
input/hotkey-defaults.ts creates stable default handlers so repeated init neither
multiplies callbacks nor prevents explicitly restoring removed defaults. Public init
still enables manual mobile keyboard handling; automatic construction stays desktop-only.
input/keyboard-focus.ts resolves the event document, falls back to the player's current
ownerDocument and follows open shadow focus/composed targets. Native input, textarea,
select, inherited/plaintext editing and IME composition suppress hotkeys while preserving
the generic keydown event. Existing modifier suppression remains. This fixes typing being
intercepted after bindGlobalEvents or inside shadow roots. The global binding itself is
owned by events/globalInit.ts, described below.
The instance scope removes the one keyboard subscription and callbacks check destruction
before continuing or emitting success. Ordinary synchronous errors still propagate with
their identity. Callback dispatch intentionally retains historical live-array mutation
and this=art semantics. Own-property registration makes prototype-named keys safe without
changing the public keys prototype. Direct editing of those arrays remains observable.
Run `node --test test/hotkey.test.js test/playback.test.js` and
`yarn test:browser test/browser/hotkey.spec.js`. Browser tests exercise native typing and
play/pause/seek/volume/Escape, iframe and shadow editors, controlled IME and mobile-UA
initialization, and callback destruction. Controlled composition/mobile UA is not physical
keyboard/IME/device certification. Input and scheduling share instance ownership;
the refactor progress record tracks final installed UMD/legacy acceptance.
## Native listeners and global targets (CORE-17)
events/index.ts keeps the compatibility facade with the original bound proxy/hover
methods and destroyEvents Set. events/listener-registry.ts owns native registration,
capture snapshots, array rollback, abort and explicit cleanup. Native callback identity
is preserved: direct removeEventListener with the original callback still works. Null
options keep their historical native defaults. Duplicate callbacks retain native
deduplication; disposal is not reference counted. Native once removes the listener itself,
while its bookkeeping remains until explicit disposal or owner cleanup. Abort removes
both the listener and its owned record. Failed removals warn and remain retryable;
a target that permanently refuses removal cannot be forcibly cleaned up.
events/global-types.ts defines the minimal host, registry and source interfaces.
events/globalInit.ts stages document/window listeners, forwards only from the committed
binding, and retains the old binding if registration fails. A generation counter stops
an interrupted binding from overwriting a newer nested bind; owner destruction prevents
late activation. The original event object, forwarding names, void return and independent
document/window fallback rules remain. Manual Events.destroy still permits later reuse
while the player is alive. events/types.ts composes the typed initializer hosts without
adding runtime fields or changing initializer order.
Run `node --test test/listener-registry.test.js test/global-events.test.js` and
`yarn test:browser test/browser/listener-registry.spec.js test/browser/global-events.spec.js`.
Tests cover actual iframe targets and native once/abort/callback identity; injected
registration/removal failures exercise cleanup without asserting platform failure rates.
## Pointer interaction (CORE-17)
events/clickInit.ts retains synchronous click counting, the inclusive double-click
threshold, live constructor settings and existing desktop/mobile actions. It checks
instance closure after caller callbacks, preventing playback/fullscreen actions after
destruction. input/pointer-focus.ts owns click/contextmenu emitter subscriptions while
preserving isInput's historical INPUT-only meaning and composed-path focus checks.
input/pointer-types.ts keeps host and native registry requirements explicit.
events/hoverInit.ts and moveInit.ts retain original events and class-before-notification
ordering, with guards against stale callbacks. Emitter listeners still use their explicit
ctx argument; unlike hotkey callbacks, they do not acquire this=art automatically.
Public player methods remain non-configurable and are not replaced by these changes.
`test/pointer-events.test.js` verifies boundaries, nesting, callback failures, rejected
playback, destruction and owned focus cleanup. `test/browser/pointer-events.spec.js`
compares old/new events, controlled failure paths and actual trusted mouse input with
real media playback. Android UA coverage exercises branch logic, not a physical device.
## Touch gestures (CORE-17)
events/gestureInit.ts installs the mobile-only listeners; gesture=false still retains
progress touches and isLive skips gesture installation. input/gesture-controller.ts
owns the active touch, source snapshot and orientation, plus end/cancel/lock subscriptions.
input/gesture-direction.ts preserves the two-pixel threshold and diagonal boundaries;
gesture-types.ts describes the minimal host and drag state. Normal video gestures keep
TOUCH_MOVE_RATIO, progress gestures keep their full-width multiplier and rotated gestures
use the vertical axis. Invalid geometry or a cancelled/replaced session cannot resume
the previous drag. Callback errors retain identity; nested gestures supersede interrupted
work, and destruction prevents subsequent seek/bar/notice writes.
control/progress/position.ts now accepts a minimal position host and an optional lifetime
predicate between its bar notification and seek. Existing two-argument desktop callers
retain their default behavior. Global events add document:touchcancel with the original
Event payload; its public declaration is additive. Direct local cancellation also works
when document forwarding has been rebound. Source changes cancel through source identity
rather than depending on delayed media events.
`test/gesture.test.js` covers axes, absolute/relative ordering, reentry, invalid geometry,
source/rotation/lock/finger changes and cleanup. Browser gestures use controlled touch
payloads with actual media and DOM targets. They check requests before first play and
actual seeking after playback initializes the decoder; they do not certify physical
touch delivery, native scrolling arbitration or mobile OS cancellation behavior.
## Input scheduling and Events facade (CORE-17)
events/resizeInit.ts retains debounce coalescing, live RESIZE_TIME, normal auto-size,
aspect-ratio restoration and notice cleanup. It guards later writes after caller reentry
and listens to the owner's screen orientation by event capability rather than onchange
being non-null. events/viewInit.ts owns a leading-only throttle, captures SCROLL_TIME at
installation, reads SCROLL_GAP live and preserves synchronous nested dispatch. Its reset
timer and internal subscriptions are released at destruction. Public throttle/debounce
utilities retain their existing independent behavior.
events/viewport.ts uses the container's current ownerDocument/defaultView, preserving
existing edge and gap arithmetic after iframe adoption. events/updateInit.ts keeps RAF
opt-in, the initial playing emission and one pending owned frame; paused players do not
emit raf, and destruction inside a callback cannot schedule another frame. An explicit
destroy notification still cancels the pending frame while the owner is alive.
events/subscriptions.ts provides typed, guarded, owner-scoped emitter subscriptions;
scheduling-types.ts describes the event maps and minimal scheduling hosts. The TS Events
facade keeps its own destroyEvents/proxy/hover/bindGlobalEvents properties, prototype
methods, bound proxy/hover behavior and scalar/array disposer returns. It declares the
rebinding property without emitting an extra class field during construction.
`test/event-scheduling.test.js` verifies debounce/throttle/RAF ownership and reentry.
`test/browser/event-scheduling.spec.js` compares public facade shape, real animation
frames/playback, orientation event capability, timer cleanup and adopted iframe visibility.
Controlled orientation targets do not certify physical device orientation.
## Display modes (CORE-16, in progress)
player/autoSizeMix.ts, autoHeightMix.ts, aspectRatioMix.ts and flipMix.ts retain the
+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 n=(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 o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,n),i(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),a(s,l,c),i(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),a(l,c,d),i(l,c,d),o(l,c,d)})}}(t,e,n),t}function a(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function i(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var a=r(o);throw"string"===a?new Error(o):"error"===a?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),a="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(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 o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={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,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+578 -355
View File
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,15 @@
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 interface ProgressPositionHost {
template: { $progress: HTMLElement }
duration: number
isRotate: boolean
top: number
height: number
seek: number | string
emit: (name: 'setBar', kind: 'played', percentage: number, event: ProgressEvent) => unknown
}
export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent) {
const { $progress } = art.template
@@ -15,16 +22,18 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
return { second, time, width, percentage }
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent) {
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
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
if (active())
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active())
art.seek = second
}
}
@@ -1,65 +0,0 @@
import { includeFromEvent, isMobile, silencePromise } from '../utils'
export default function clickInit(art, events) {
const {
constructor,
template: { $player, $video },
} = art
function onDocumentClick(event) {
if (includeFromEvent(event, $player)) {
art.isInput = event.target.tagName === 'INPUT'
art.isFocus = true
art.emit('focus', event)
}
else {
art.isInput = false
art.isFocus = false
art.emit('blur', event)
}
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
let clickTimes = []
events.proxy($video, 'click', (event) => {
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isMobile) {
if (!art.isLock && MOBILE_CLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
silencePromise(art.toggle())
}
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY) {
silencePromise(art.toggle())
}
}
else {
if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -0,0 +1,45 @@
import type { ClickHost, PointerRegistry } from '../input/pointer-types'
import { pointerFocus } from '../input/pointer-focus'
import { isClosing } from '../lifecycle/instance'
import { isMobile } from '../utils/compatibility'
import { silencePromise } from '../utils/error'
export default function clickInit(art: ClickHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { constructor, template: { $video } } = art
pointerFocus(art)
let clickTimes: number[] = []
events.proxy($video, 'click', (event) => {
if (isClosing(art))
return
const now = Date.now()
clickTimes.push(now)
const { MOBILE_CLICK_PLAY, DBCLICK_TIME, MOBILE_DBCLICK_PLAY, DBCLICK_FULLSCREEN } = constructor
const clicks = clickTimes.filter(t => now - t <= DBCLICK_TIME)
switch (clicks.length) {
case 1:
art.emit('click', event)
if (isClosing(art))
return
if (!isMobile || (!art.isLock && MOBILE_CLICK_PLAY))
silencePromise(art.toggle())
clickTimes = clicks
break
case 2:
art.emit('dblclick', event)
if (isClosing(art))
return
if (isMobile) {
if (!art.isLock && MOBILE_DBCLICK_PLAY)
silencePromise(art.toggle())
}
else if (DBCLICK_FULLSCREEN) {
art.fullscreen = !art.fullscreen
}
clickTimes = []
break
default:
clickTimes = []
}
})
}
@@ -1,104 +0,0 @@
import { setCurrentTime } from '../control/progress'
import { clamp, isMobile, secondToTime } from '../utils'
function GetSlideAngle(dx, dy) {
return (Math.atan2(dy, dx) * 180) / Math.PI
}
function GetSlideDirection(startX, startY, endX, endY) {
const dy = startY - endY
const dx = endX - startX
let result = 0
if (Math.abs(dx) < 2 && Math.abs(dy) < 2) {
return result
}
const angle = GetSlideAngle(dx, dy)
if (angle >= -45 && angle < 45) {
result = 4
}
else if (angle >= 45 && angle < 135) {
result = 1
}
else if (angle >= -135 && angle < -45) {
result = 2
}
else if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135)) {
result = 3
}
return result
}
export default function gestureInit(art, events) {
if (isMobile && !art.option.isLive) {
const { $video, $progress } = art.template
let touchTarget = null
let isDragging = false
let startX = 0
let startY = 0
let startTime = 0
const onTouchStart = (event) => {
if (event.touches.length === 1 && !art.isLock) {
if (touchTarget === $progress) {
setCurrentTime(art, event)
}
isDragging = true
const { pageX, pageY } = event.touches[0]
startX = pageX
startY = pageY
startTime = art.currentTime
}
}
const onTouchMove = (event) => {
if (event.touches.length === 1 && isDragging && art.duration) {
const { pageX, pageY } = event.touches[0]
const direction = GetSlideDirection(startX, startY, pageX, pageY)
const isHorizontal = [3, 4].includes(direction)
const isVertical = [1, 2].includes(direction)
const isLegal = (isHorizontal && !art.isRotate) || (isVertical && art.isRotate)
if (isLegal) {
const ratioX = clamp((pageX - startX) / art.width, -1, 1)
const ratioY = clamp((pageY - startY) / art.height, -1, 1)
const ratio = art.isRotate ? ratioY : ratioX
const TOUCH_MOVE_RATIO = touchTarget === $video ? art.constructor.TOUCH_MOVE_RATIO : 1
const currentTime = clamp(startTime + art.duration * ratio * TOUCH_MOVE_RATIO, 0, art.duration)
art.seek = currentTime
art.emit('setBar', 'played', clamp(currentTime / art.duration, 0, 1), event)
art.notice.show = `${secondToTime(currentTime)} / ${secondToTime(art.duration)}`
}
}
}
const onTouchEnd = () => {
if (isDragging) {
startX = 0
startY = 0
startTime = 0
isDragging = false
touchTarget = null
}
}
if (art.option.gesture) {
events.proxy($video, 'touchstart', (event) => {
touchTarget = $video
onTouchStart(event)
})
events.proxy($video, 'touchmove', onTouchMove)
}
events.proxy($progress, 'touchstart', (event) => {
touchTarget = $progress
onTouchStart(event)
})
events.proxy($progress, 'touchmove', onTouchMove)
art.on('document:touchend', onTouchEnd)
}
}
@@ -0,0 +1,19 @@
import type { GestureHost } from '../input/gesture-types'
import type { PointerRegistry } from '../input/pointer-types'
import { gestureController } from '../input/gesture-controller'
import { isMobile } from '../utils/compatibility'
export default function gestureInit(art: GestureHost, events: Pick<PointerRegistry, 'proxy'>): void {
if (!isMobile || art.option.isLive)
return
const { $video, $progress } = art.template
const gesture = gestureController(art)
const bind = (target: EventTarget) => {
events.proxy(target, 'touchstart', event => gesture.start(target, event as TouchEvent))
events.proxy(target, 'touchmove', event => gesture.move(event as TouchEvent))
events.proxy(target, 'touchcancel', gesture.cancel)
}
if (art.option.gesture)
bind($video)
bind($progress)
}
@@ -0,0 +1,17 @@
import type { Disposer, Listener } from './listener-registry'
export interface GlobalEventSource {
document?: Document
window?: Window
}
export interface GlobalEventHost {
template: { $player: HTMLElement }
emit: (name: string, event: Event) => unknown
}
export interface GlobalEventRegistry {
proxy: (target: EventTarget, name: string, listener: Listener) => Disposer
remove: (dispose: Disposer) => void
bindGlobalEvents?: (source?: GlobalEventSource) => void
}
@@ -1,52 +0,0 @@
export default function globalInit(art, events) {
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = [
'resize',
'scroll',
'orientationchange',
]
const destroyEvents = []
function bindGlobalEvents(source = {}) {
for (let index = 0; index < destroyEvents.length; index++) {
events.remove(destroyEvents[index])
}
destroyEvents.length = 0
const { $player } = art.template
documentEvents.forEach((name) => {
const doc = source.document || $player.ownerDocument || document
const destroy = events.proxy(doc, name, (event) => {
art.emit(`document:${name}`, event)
})
destroyEvents.push(destroy)
})
windowEvents.forEach((name) => {
const win = source.window || $player.ownerDocument?.defaultView || window
const destroy = events.proxy(win, name, (event) => {
art.emit(`window:${name}`, event)
})
destroyEvents.push(destroy)
})
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -0,0 +1,80 @@
import type { GlobalEventHost, GlobalEventRegistry, GlobalEventSource } from './global-types'
import type { Disposer } from './listener-registry'
import { getScope, isClosing } from '../lifecycle/instance'
const documentEvents = [
'click',
'mouseup',
'keydown',
'touchend',
'touchcancel',
'touchmove',
'mousemove',
'pointerup',
'contextmenu',
'pointermove',
'visibilitychange',
'webkitfullscreenchange',
]
const windowEvents = ['resize', 'scroll', 'orientationchange']
interface Binding {
disposers: Disposer[]
}
export default function globalInit(art: GlobalEventHost, events: GlobalEventRegistry): void {
let active: Binding | undefined
let generation = 0
getScope(art).add(() => {
active = undefined
generation++
return undefined
})
const release = (binding: Binding | undefined) => {
if (binding) {
for (const dispose of binding.disposers)
events.remove(dispose)
binding.disposers.length = 0
}
}
function bindGlobalEvents(source: GlobalEventSource = {}): void {
if (isClosing(art))
return
const current = ++generation
const binding: Binding = { disposers: [] }
const cancelled = () => current !== generation || isClosing(art)
try {
const { $player } = art.template
const doc = source.document || $player.ownerDocument || document
const win = source.window || $player.ownerDocument?.defaultView || window
const register = (target: EventTarget, names: string[], prefix: string) => {
for (const name of names) {
if (cancelled())
return
binding.disposers.push(events.proxy(target, name, (event) => {
if (active === binding && !isClosing(art))
art.emit(`${prefix}:${name}`, event)
}))
}
}
register(doc, documentEvents, 'document')
register(win, windowEvents, 'window')
}
catch (error) {
release(binding)
throw error
}
if (cancelled()) {
release(binding)
return
}
const previous = active
active = binding
release(previous)
}
bindGlobalEvents()
events.bindGlobalEvents = bindGlobalEvents
}
@@ -1,17 +0,0 @@
import { addClass, removeClass } from '../utils'
export default function hoverInit(art, events) {
const { $player } = art.template
events.hover(
$player,
(event) => {
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
@@ -0,0 +1,22 @@
import type { HoverHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
import { addClass, removeClass } from '../utils/dom'
export default function hoverInit(art: HoverHost, events: Pick<PointerRegistry, 'hover'>): void {
const { $player } = art.template
events.hover(
$player,
(event) => {
if (isClosing(art))
return
addClass($player, 'art-hover')
art.emit('hover', true, event)
},
(event) => {
if (isClosing(art))
return
removeClass($player, 'art-hover')
art.emit('hover', false, event)
},
)
}
-80
View File
@@ -1,80 +0,0 @@
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
const scopes = new WeakMap()
export default class Events {
constructor(art) {
this.destroyEvents = new Set()
const scope = getScope(art)
scopes.set(this, scope)
scope.add(() => this.destroy())
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art, this)
globalInit(art, this)
updateInit(art, this)
}
proxy(target, name, callback, option = {}) {
if (Array.isArray(name)) {
return name.map(item => this.proxy(target, item, callback, option))
}
if (scopes.get(this).closed)
return () => {}
target.addEventListener(name, callback, option)
const destroy = () => target.removeEventListener(name, callback, option)
this.destroyEvents.add(destroy)
return destroy
}
hover(target, mouseenter, mouseleave) {
if (mouseenter) {
this.proxy(target, 'mouseenter', mouseenter)
}
if (mouseleave) {
this.proxy(target, 'mouseleave', mouseleave)
}
}
remove(destroyEvent) {
if (this.destroyEvents.has(destroyEvent)) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to remove event listener:', error)
}
finally {
this.destroyEvents.delete(destroyEvent)
}
}
}
destroy() {
for (const destroyEvent of this.destroyEvents) {
try {
destroyEvent()
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
this.destroyEvents.clear()
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { GlobalEventSource } from './global-types'
import type { Disposer, Listener } from './listener-registry'
import type { EventsHost } from './types'
import { getScope } from '../lifecycle/instance'
import clickInit from './clickInit'
import gestureInit from './gestureInit'
import globalInit from './globalInit'
import hoverInit from './hoverInit'
import { destroyListeners, ownListeners, proxyListener, removeListener } from './listener-registry'
import moveInit from './moveInit'
import resizeInit from './resizeInit'
import updateInit from './updateInit'
import viewInit from './viewInit'
export default class Events {
declare destroyEvents: Set<Disposer>
declare bindGlobalEvents: (source?: GlobalEventSource) => void
constructor(art: EventsHost) {
this.destroyEvents = new Set()
ownListeners(this, art)
getScope(art).add(() => {
this.destroy()
})
this.proxy = this.proxy.bind(this)
this.hover = this.hover.bind(this)
clickInit(art, this)
hoverInit(art, this)
moveInit(art, this)
resizeInit(art, this)
gestureInit(art, this)
viewInit(art)
globalInit(art, this)
updateInit(art)
}
proxy(target: EventTarget, name: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
proxy(target: EventTarget, name: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer | Disposer[]
proxy(target: EventTarget, name: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
return Array.isArray(name) ? proxyListener(this, target, name, callback, option) : proxyListener(this, target, name, callback, option)
}
hover(target: EventTarget, mouseenter?: (event: Event) => unknown, mouseleave?: (event: Event) => unknown): void {
if (mouseenter)
this.proxy(target, 'mouseenter', mouseenter)
if (mouseleave)
this.proxy(target, 'mouseleave', mouseleave)
}
remove(dispose: Disposer): void {
removeListener(this, dispose)
}
destroy(): void {
destroyListeners(this)
}
}
@@ -0,0 +1,135 @@
import type ResourceScope from '../lifecycle/scope'
import { getScope } from '../lifecycle/instance'
export type Disposer = () => void
export type Listener = EventListenerOrEventListenerObject | null
export interface ListenerRegistry {
destroyEvents: Set<Disposer>
}
interface State {
scope: ResourceScope
cleaning: boolean
}
const states = new WeakMap<ListenerRegistry, State>()
export function ownListeners(registry: ListenerRegistry, art: object): void {
states.set(registry, { scope: getScope(art), cleaning: false })
}
function stateOf(registry: ListenerRegistry): State {
const state = states.get(registry)
if (!state)
throw new Error('ArtPlayer event registry has not been initialized')
return state
}
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string[], callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer[]
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string, callback: Listener, option?: boolean | AddEventListenerOptions | null): Disposer
export function proxyListener(registry: ListenerRegistry, target: EventTarget, names: string | string[], callback: Listener, option: boolean | AddEventListenerOptions | null = {}): Disposer | Disposer[] {
if (Array.isArray(names)) {
const created: Disposer[] = []
try {
return names.map((name) => {
const dispose = proxyListener(registry, target, name, callback, option)
created.push(dispose)
return dispose
})
}
catch (error) {
for (const dispose of created.reverse()) {
try {
dispose()
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
}
throw error
}
}
const name = names
const state = stateOf(registry)
if (state.scope.closed || state.cleaning)
return () => {}
// Snapshot native dictionary values, especially capture, without wrapping the callback.
const options = typeof option === 'boolean'
? option
: {
capture: Boolean(option?.capture),
once: Boolean(option?.once),
passive: option?.passive,
signal: option?.signal,
}
const capture = typeof options === 'boolean' ? options : options.capture
const signal = typeof options === 'boolean' ? undefined : options.signal
if (state.scope.closed || state.cleaning || signal?.aborted)
return () => {}
let active = true
function clean(force = false): void {
if (!active && !force)
return
target.removeEventListener(name, callback, capture)
signal?.removeEventListener('abort', dispose)
active = false
registry.destroyEvents.delete(dispose)
}
function dispose(): void {
clean()
}
registry.destroyEvents.add(dispose)
try {
target.addEventListener(name, callback, options)
// A custom target may synchronously destroy the player before adding the listener.
if (!active || state.scope.closed || state.cleaning)
clean(true)
else if (signal?.aborted)
dispose()
else
signal?.addEventListener('abort', dispose, { once: true })
}
catch (error) {
try {
clean(true)
}
catch (cleanupError) {
console.warn('Failed to roll back event listener:', cleanupError)
}
throw error
}
return dispose
}
export function removeListener(registry: ListenerRegistry, dispose: Disposer): void {
if (!registry.destroyEvents.has(dispose))
return
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
// Retain a failed removal so a later remove/destroy can retry it.
console.warn('Failed to remove event listener:', error)
}
}
export function destroyListeners(registry: ListenerRegistry): void {
const state = stateOf(registry)
if (state.cleaning)
return
state.cleaning = true
try {
for (const dispose of registry.destroyEvents) {
try {
dispose()
registry.destroyEvents.delete(dispose)
}
catch (error) {
console.warn('Failed to destroy event listener:', error)
}
}
}
finally {
state.cleaning = false
}
}
@@ -1,7 +0,0 @@
export default function moveInit(art, events) {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
art.emit('mousemove', event)
})
}
+10
View File
@@ -0,0 +1,10 @@
import type { MoveHost, PointerRegistry } from '../input/pointer-types'
import { isClosing } from '../lifecycle/instance'
export default function moveInit(art: MoveHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { $player } = art.template
events.proxy($player, 'mousemove', (event) => {
if (!isClosing(art))
art.emit('mousemove', event)
})
}
@@ -1,29 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
export default function resizeInit(art, events) {
const { option, constructor } = art
art.on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize) {
art.autoSize()
}
art.aspectRatio = aspectRatio
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resizeFn = () => {
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
art.on('window:orientationchange', () => resizeFn())
art.on('window:resize', () => resizeFn())
if (screen && screen.orientation && screen.orientation.onchange) {
events.proxy(screen.orientation, 'change', () => resizeFn())
}
}
@@ -0,0 +1,35 @@
import type { PointerRegistry } from '../input/pointer-types'
import type { ResizeEvents, ResizeHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function resizeInit(art: ResizeHost, events: Pick<PointerRegistry, 'proxy'>): void {
const { option, constructor } = art
const on = eventSubscriptions<ResizeEvents>(art)
on('resize', () => {
const { aspectRatio, notice } = art
if (art.state === 'standard' && option.autoSize)
art.autoSize()
if (isClosing(art))
return
art.aspectRatio = aspectRatio
if (!isClosing(art))
notice.show = ''
})
const scope = getScope(art)
let cancel = () => {}
const resize = () => {
if (isClosing(art))
return
cancel()
cancel = timeout(scope, () => art.emit('resize'), constructor.RESIZE_TIME)
}
on('window:orientationchange', resize)
on('window:resize', resize)
const orientation = art.template.$player.ownerDocument.defaultView?.screen?.orientation
if (typeof orientation?.addEventListener === 'function')
events.proxy(orientation, 'change', resize)
}
@@ -0,0 +1,34 @@
import type { SubscriptionHost } from '../component/resources'
export interface ResizeEvents {
'resize': []
'window:resize': [Event]
'window:orientationchange': [Event]
}
export interface ResizeHost extends SubscriptionHost<ResizeEvents> {
template: { $player: HTMLElement }
option: { autoSize: boolean }
constructor: { RESIZE_TIME: number }
aspectRatio: string
state: string
autoSize: () => unknown
notice: { show: string }
emit: (name: 'resize') => unknown
}
export interface ViewEvents { 'window:scroll': [Event], 'view': [boolean] }
export interface ViewHost extends SubscriptionHost<ViewEvents> {
template: { $container: HTMLElement }
option: { autoMini: boolean }
constructor: { SCROLL_GAP: number, SCROLL_TIME: number }
mini: boolean
emit: (name: 'view', visible: boolean) => unknown
}
export interface RafEvents { destroy: [] }
export interface RafHost extends SubscriptionHost<RafEvents> {
constructor: { USE_RAF: boolean }
playing: boolean
emit: (name: 'raf') => unknown
}
@@ -0,0 +1,17 @@
import type { SubscriptionHost } from '../component/resources'
import { getScope, isClosing } from '../lifecycle/instance'
export function eventSubscriptions<Events extends { [Name in keyof Events]: readonly unknown[] }>(art: SubscriptionHost<Events>) {
return <Name extends keyof Events>(name: Name, callback: (...args: [...Events[Name]]) => unknown): void => {
if (isClosing(art))
return
const listener = (...args: [...Events[Name]]) => {
if (!isClosing(art))
return callback(...args)
}
art.on(name, listener)
getScope(art).add(() => {
art.off(name, listener)
})
}
}
+6
View File
@@ -0,0 +1,6 @@
import type { GestureHost } from '../input/gesture-types'
import type { ClickHost, HoverHost, MoveHost } from '../input/pointer-types'
import type { GlobalEventHost } from './global-types'
import type { RafHost, ResizeHost, ViewHost } from './scheduling-types'
export type EventsHost = ClickHost & HoverHost & MoveHost & GestureHost & GlobalEventHost & RafHost & ResizeHost & ViewHost
@@ -1,23 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
export default function updateInit(art) {
if (art.constructor.USE_RAF) {
const scope = getScope(art)
let cancel = () => {};
(function update() {
if (art.playing) {
art.emit('raf')
}
if (!art.isDestroy) {
cancel = animationFrame(scope, update)
}
})()
art.on('destroy', () => {
cancel()
})
}
}
@@ -0,0 +1,21 @@
import type { RafEvents, RafHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { animationFrame } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
export default function updateInit(art: RafHost): void {
if (!art.constructor.USE_RAF)
return
const scope = getScope(art)
let cancel = () => {}
const update = () => {
if (isClosing(art))
return
if (art.playing)
art.emit('raf')
if (!isClosing(art))
cancel = animationFrame(scope, update)
}
update()
eventSubscriptions<RafEvents>(art)('destroy', () => cancel())
}
-21
View File
@@ -1,21 +0,0 @@
import { isInViewport, throttle } from '../utils'
export default function viewInit(art) {
const {
option,
constructor,
template: { $container },
} = art
const scrollFn = throttle(() => {
art.emit('view', isInViewport($container, constructor.SCROLL_GAP))
}, constructor.SCROLL_TIME)
art.on('window:scroll', () => scrollFn())
art.on('view', (state) => {
if (option.autoMini) {
art.mini = !state
}
})
}
+29
View File
@@ -0,0 +1,29 @@
import type { ViewEvents, ViewHost } from './scheduling-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { eventSubscriptions } from './subscriptions'
import { inViewport } from './viewport'
export default function viewInit(art: ViewHost): void {
const { option, constructor, template: { $container } } = art
const delay = constructor.SCROLL_TIME
const scope = getScope(art)
const on = eventSubscriptions<ViewEvents>(art)
let waiting = false
on('window:scroll', () => {
if (waiting)
return
art.emit('view', inViewport($container, constructor.SCROLL_GAP))
if (isClosing(art))
return
// Keep the leading-only throttle and its historical synchronous reentry timing.
waiting = true
timeout(scope, () => {
waiting = false
}, delay)
})
on('view', (visible) => {
if (option.autoMini)
art.mini = !visible
})
}
+11
View File
@@ -0,0 +1,11 @@
export function inViewport(element: HTMLElement, offset: number): boolean {
const rect = element.getBoundingClientRect()
const document = element.ownerDocument
const window = document.defaultView
const height = window?.innerHeight || document.documentElement.clientHeight
const width = window?.innerWidth || document.documentElement.clientWidth
// Retain the historical edge/gap calculations while using the actual owner viewport.
const vertical = rect.top - offset <= height && rect.top + rect.height + offset >= 0
const horizontal = rect.left - offset <= width + offset && rect.left + rect.width + offset >= 0
return vertical && horizontal
}
-96
View File
@@ -1,96 +0,0 @@
import { isMobile, silencePromise } from './utils'
export default class Hotkey {
constructor(art) {
this.art = art
this.keys = {}
if (!isMobile) {
this.init()
}
}
init() {
const { constructor } = this.art
if (this.art.option.hotkey) {
this.add('Escape', () => {
if (this.art.fullscreenWeb) {
this.art.fullscreenWeb = false
}
})
this.add('Space', () => {
silencePromise(this.art.toggle())
})
this.add('ArrowLeft', () => {
this.art.backward = constructor.SEEK_STEP
})
this.add('ArrowUp', () => {
this.art.volume += constructor.VOLUME_STEP
})
this.add('ArrowRight', () => {
this.art.forward = constructor.SEEK_STEP
})
this.add('ArrowDown', () => {
this.art.volume -= constructor.VOLUME_STEP
})
}
this.art.on('document:keydown', (event) => {
if (this.art.isFocus) {
const tag = document.activeElement.tagName.toUpperCase()
const editable = document.activeElement.getAttribute('contenteditable')
if (
tag !== 'INPUT'
&& tag !== 'TEXTAREA'
&& editable !== ''
&& editable !== 'true'
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !event.shiftKey
) {
const events = this.keys[event.code]
if (events) {
event.preventDefault()
for (let index = 0; index < events.length; index++) {
events[index].call(this.art, event)
}
this.art.emit('hotkey', event)
}
}
}
this.art.emit('keydown', event)
})
}
add(key, event) {
if (this.keys[key]) {
if (!this.keys[key].includes(event)) {
this.keys[key].push(event)
}
}
else {
this.keys[key] = [event]
}
return this
}
remove(key, event) {
if (this.keys[key]) {
const index = this.keys[key].indexOf(event)
if (index !== -1) {
this.keys[key].splice(index, 1)
}
if (this.keys[key].length === 0) {
delete this.keys[key]
}
}
return this
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { HotkeyCallback, HotkeyHost } from './input/hotkey-types'
import { defaultHotkeys } from './input/hotkey-defaults'
import { acceptsHotkey } from './input/keyboard-focus'
import { getScope, isClosing } from './lifecycle/instance'
import { isMobile } from './utils/compatibility'
interface State {
defaults?: () => void
subscribed: boolean
}
const states = new WeakMap<object, State>()
export default class Hotkey<Host extends HotkeyHost> {
declare art: Host
declare keys: Record<string, HotkeyCallback<Host>[]>
constructor(art: Host) {
this.art = art
this.keys = {}
states.set(this, { subscribed: false })
if (!isMobile)
this.init()
}
init(): void {
const art = this.art
if (isClosing(art))
return
const state = states.get(this)!
if (art.option.hotkey) {
if (!state.defaults) {
const callbacks = defaultHotkeys(art)
state.defaults = () => {
for (const key of Object.keys(callbacks))
this.add(key, callbacks[key]!)
}
}
state.defaults()
}
if (state.subscribed)
return
state.subscribed = true
const onKeydown = (event: KeyboardEvent) => {
if (isClosing(art))
return
if (art.isFocus && acceptsHotkey(event, art.template.$player.ownerDocument)) {
const callbacks = Object.prototype.hasOwnProperty.call(this.keys, event.code) ? this.keys[event.code] : undefined
if (callbacks) {
event.preventDefault()
// Keep the historical live-array mutation and callback receiver contracts.
for (let index = 0; index < callbacks.length; index++) {
if (isClosing(art))
return
callbacks[index]!.call(art, event)
}
if (isClosing(art))
return
art.emit('hotkey', event)
}
}
if (!isClosing(art))
art.emit('keydown', event)
}
art.on('document:keydown', onKeydown)
getScope(art).add(() => {
state.subscribed = false
art.off('document:keydown', onKeydown)
})
}
add(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
if (!existing.includes(callback))
existing.push(callback)
}
else {
Object.defineProperty(this.keys, key, { value: [callback], enumerable: true, configurable: true, writable: true })
}
return this
}
remove(key: string, callback: HotkeyCallback<Host>): this {
const existing = Object.prototype.hasOwnProperty.call(this.keys, key) ? this.keys[key] : undefined
if (existing) {
const index = existing.indexOf(callback)
if (index !== -1)
existing.splice(index, 1)
if (existing.length === 0)
delete this.keys[key]
}
return this
}
}
@@ -0,0 +1,94 @@
import type { Drag, GestureHost } from './gesture-types'
import { setCurrentTime } from '../control/progress/position'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { clamp, secondToTime } from '../utils'
import { slideDirection } from './gesture-direction'
export function gestureController(art: GestureHost) {
let drag: Drag | undefined
const cancel = () => {
drag = undefined
}
const active = (current: Drag) => drag === current && !isClosing(art) && !art.isLock
&& !art.option.isLive && art.isRotate === current.rotated && current.sourceActive()
const onLock = (locked: boolean) => {
if (locked)
cancel()
}
art.on('document:touchend', cancel)
art.on('document:touchcancel', cancel)
art.on('lock', onLock)
getScope(art).add(() => {
cancel()
art.off('document:touchend', cancel)
art.off('document:touchcancel', cancel)
art.off('lock', onLock)
})
const start = (target: EventTarget, event: TouchEvent) => {
cancel()
const touch = event.touches[0]
if (isClosing(art) || art.isLock || art.option.isLive || event.touches.length !== 1 || !touch)
return
if (![touch.pageX, touch.pageY, touch.clientX, touch.clientY, art.currentTime].every(Number.isFinite))
return
const current: Drag = {
target,
identifier: touch.identifier,
x: touch.pageX,
y: touch.pageY,
time: art.currentTime,
rotated: art.isRotate,
sourceActive: captureSource(art),
}
drag = current
if (target === art.template.$progress) {
const size = art.isRotate ? art.height : art.template.$progress.clientWidth
if (!Number.isFinite(size) || size <= 0 || !Number.isFinite(art.duration) || art.duration <= 0) {
cancel()
return
}
try {
setCurrentTime(art, event, () => active(current))
}
catch (error) {
if (drag === current)
cancel()
throw error
}
if (active(current))
current.time = art.currentTime
}
}
const move = (event: TouchEvent) => {
const current = drag
const touch = event.touches[0]
if (!current)
return
if (!active(current) || event.touches.length !== 1 || !touch || touch.identifier !== current.identifier) {
cancel()
return
}
const size = current.rotated ? art.height : art.width
const multiplier = current.target === art.template.$video ? art.constructor.TOUCH_MOVE_RATIO : 1
if (![touch.pageX, touch.pageY, size, art.duration, multiplier].every(Number.isFinite) || size <= 0 || art.duration <= 0) {
cancel()
return
}
const direction = slideDirection(current.x, current.y, touch.pageX, touch.pageY)
if (!(current.rotated ? direction === 1 || direction === 2 : direction === 3 || direction === 4))
return
const distance = current.rotated ? touch.pageY - current.y : touch.pageX - current.x
const ratio = clamp(distance / size, -1, 1)
const time = clamp(current.time + art.duration * ratio * multiplier, 0, art.duration)
art.seek = time
if (!active(current))
return
art.emit('setBar', 'played', clamp(time / art.duration, 0, 1), event)
if (active(current))
art.notice.show = `${secondToTime(time)} / ${secondToTime(art.duration)}`
}
return { start, move, cancel }
}
@@ -0,0 +1,16 @@
export function slideDirection(startX: number, startY: number, endX: number, endY: number): 0 | 1 | 2 | 3 | 4 {
const dy = startY - endY
const dx = endX - startX
if (Math.abs(dx) < 2 && Math.abs(dy) < 2)
return 0
const angle = Math.atan2(dy, dx) * 180 / Math.PI
if (angle >= -45 && angle < 45)
return 4
if (angle >= 45 && angle < 135)
return 1
if (angle >= -135 && angle < -45)
return 2
if ((angle >= 135 && angle <= 180) || (angle >= -180 && angle < -135))
return 3
return 0
}
@@ -0,0 +1,26 @@
import type { SubscriptionHost } from '../component/resources'
import type { ProgressPositionHost } from '../control/progress/position'
export interface GestureHost extends ProgressPositionHost, SubscriptionHost<{
'document:touchend': [Event]
'document:touchcancel': [Event]
'lock': [boolean]
}> {
template: { $video: EventTarget, $progress: HTMLElement }
option: { isLive: boolean, gesture: boolean }
constructor: { TOUCH_MOVE_RATIO: number }
isLock: boolean
width: number
currentTime: number
notice: { show: string }
}
export interface Drag {
target: EventTarget
identifier: number
x: number
y: number
time: number
rotated: boolean
sourceActive: () => boolean
}
@@ -0,0 +1,27 @@
import type { HotkeyCallback, HotkeyHost } from './hotkey-types'
import { silencePromise } from '../utils/error'
export function defaultHotkeys<Host extends HotkeyHost>(art: Host): Record<string, HotkeyCallback<Host>> {
const { constructor } = art
return {
Escape: () => {
if (art.fullscreenWeb)
art.fullscreenWeb = false
},
Space: () => {
silencePromise(art.toggle())
},
ArrowLeft: () => {
art.backward = constructor.SEEK_STEP
},
ArrowUp: () => {
art.volume += constructor.VOLUME_STEP
},
ArrowRight: () => {
art.forward = constructor.SEEK_STEP
},
ArrowDown: () => {
art.volume -= constructor.VOLUME_STEP
},
}
}
@@ -0,0 +1,16 @@
import type { SubscriptionHost } from '../component/resources'
export interface HotkeyHost extends SubscriptionHost<{ 'document:keydown': [KeyboardEvent] }> {
constructor: { SEEK_STEP: number, VOLUME_STEP: number }
option: { hotkey?: boolean }
template: { $player: HTMLElement }
isFocus: boolean
fullscreenWeb: boolean
backward: number
forward: number
volume: number
toggle: () => unknown
emit: (name: 'hotkey' | 'keydown', event: KeyboardEvent) => unknown
}
export type HotkeyCallback<Host> = (this: Host, event: KeyboardEvent) => unknown
@@ -0,0 +1,30 @@
function asElement(target: EventTarget | null | undefined): HTMLElement | undefined {
if (target && (target as Node).nodeType === 1)
return target as HTMLElement
}
function eventDocument(event: KeyboardEvent, fallback: Document): Document {
const target = event.target as Node | null
return target?.nodeType === 9 ? target as Document : target?.ownerDocument || event.view?.document || fallback
}
function editable(target: HTMLElement): boolean {
const tag = target.tagName.toUpperCase()
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT')
return true
return target.isContentEditable
}
export function acceptsHotkey(event: KeyboardEvent, fallback: Document): boolean {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.isComposing || event.keyCode === 229)
return false
const doc = eventDocument(event, fallback)
let active = asElement(doc.activeElement)
while (active?.shadowRoot?.activeElement)
active = asElement(active.shadowRoot.activeElement)
if (active && editable(active))
return false
// The composed path exposes an editable descendant behind a retargeted shadow host.
const first = asElement(event.composedPath?.()[0]) || asElement(event.target)
return !first || !editable(first)
}
@@ -0,0 +1,21 @@
import type { FocusHost } from './pointer-types'
import { getScope, isClosing } from '../lifecycle/instance'
import { includeFromEvent } from '../utils/dom'
export function pointerFocus(art: FocusHost): void {
const { $player } = art.template
const onDocumentClick = (event: Event) => {
if (isClosing(art))
return
const inside = includeFromEvent(event, $player)
art.isInput = inside && (event.target as Element | null)?.tagName === 'INPUT'
art.isFocus = inside
art.emit(inside ? 'focus' : 'blur', event)
}
art.on('document:click', onDocumentClick)
art.on('document:contextmenu', onDocumentClick)
getScope(art).add(() => {
art.off('document:click', onDocumentClick)
art.off('document:contextmenu', onDocumentClick)
})
}
@@ -0,0 +1,39 @@
import type { SubscriptionHost } from '../component/resources'
import type { Disposer, Listener } from '../events/listener-registry'
export interface PointerRegistry {
proxy: (target: EventTarget, name: string, callback: Listener) => Disposer
hover: (target: EventTarget, enter?: (event: Event) => unknown, leave?: (event: Event) => unknown) => void
}
export interface FocusHost extends SubscriptionHost<{ 'document:click': [Event], 'document:contextmenu': [Event] }> {
template: { $player: HTMLElement }
isInput: boolean
isFocus: boolean
emit: (name: 'focus' | 'blur', event: Event) => unknown
}
export interface ClickHost extends FocusHost {
constructor: {
MOBILE_CLICK_PLAY: boolean
MOBILE_DBCLICK_PLAY: boolean
DBCLICK_TIME: number
DBCLICK_FULLSCREEN: boolean
}
template: { $player: HTMLElement, $video: EventTarget }
isLock: boolean
fullscreen: boolean
toggle: () => unknown
emit: (name: 'focus' | 'blur' | 'click' | 'dblclick', event: Event) => unknown
}
export interface HoverHost {
template: { $player: HTMLElement }
emit: (name: 'hover', state: boolean, event: Event) => unknown
}
export interface MoveHost {
template: { $player: HTMLElement }
emit: (name: 'mousemove', event: Event) => unknown
}
+1
View File
@@ -14,6 +14,7 @@ export interface Events {
'document:mouseup': [event: Event]
'document:keydown': [event: Event]
'document:touchend': [event: Event]
'document:touchcancel': [event: Event]
'document:touchmove': [event: Event]
'document:mousemove': [event: Event]
'document:pointerup': [event: Event]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
# CORE-17 键盘、手势、焦点与全局事件
状态:完成。以下阶段记录保留当时的进度;当前交付结论以末尾最终验收为准。
先核实公开键盘注册、焦点及跨 document 行为,保留回调 this、参数、顺序、可变 keys 数组和链式返回。后续处理原生监听器注册/释放、全局 document/window 重绑、点击/悬停/移动/触摸,以及 resize/view/RAF 调度归属。
源码观察:Hotkey 的输入判断固定读取全局 document.activeElement,重复 init 会重复订阅;Events 的重绑先删除旧监听,失败期间可能保留部分新监听;触摸只处理结束、缺少取消。尚未复现的点不登记为已确认缺陷。BASE-PERF-01 的 pending resize 已在 CORE-04 修正,本任务继续回归,不重复宣称新修复。
## 第一批:Hotkey
hotkey.ts 保留 art/keys 自有字段、init/add/remove 原型方法、keys 的普通对象原型与数组身份、链式返回、回调 this=art 和原同步事件顺序。input/hotkey-defaults.ts 管理稳定的默认回调;keyboard-focus.ts 判断事件 document 与编辑区域;hotkey-types.ts 描述最小泛型宿主。保持回调数组的旧实时遍历语义,注册/删除中途修改数组仍直接影响本次派发;不借本次迁移静默改成快照模型。
发布/候选初始基线 24 项中,15 通过、候选目标 9 失败,复现 iframe 与 Shadow DOM 输入被快捷键拦截,以及重复 init 触发两次。迁移后 24 项通过。随后补充已发布包的 prototype-named key 复现:constructor/toString/__proto__ 的注册会因继承属性报错;候选保持普通 keys 原型,用自身属性注册消除碰撞。分别登记 BASE-DOM-16/17 与 BASE-LIFE-23,当前均保持 open,等待完整 CORE-17 安装包出口。
键盘判断优先读取实际事件 document,回退到 player 当前 ownerDocument,并检查深层 shadow focus/composed target;input/textarea/select、继承或 plaintext 编辑区、IME 组合输入不触发快捷键,仍保留通用 keydown 通知。修饰键规则保持。实例 scope 清理订阅,销毁中回调不再继续后续回调或 hotkey/keydown 通知;同步回调错误仍保留身份。重复 init 复用默认回调,并可恢复显式移除的默认键;移动端默认不初始化,原手动 init 路径保留。
新增 9 项 Node 测试(与已有播放测试合跑 23 项),覆盖公开形态、默认与自定义键、重复初始化/恢复、回调上下文与数组修改、销毁、错误、修饰键/IME 和空 activeElement。类型用例保留泛型扩展与 this,拒绝错误事件/接收者类型。浏览器扩展首轮 45 项通过;最后补入移动 UA 的旧手动 init 行为,并与显示模块组合回归。未新增依赖,test:unit 加入键盘用例。
Events 注册/重绑、点击/手势/悬停以及 view/resize/RAF 仍未完成;本阶段不标记整个 CORE-17 完成,也不作完成任务提交。
本批最终 CI 278 项(249 单元、4 工程、25 基线)、168 个生产 TS 文件和五组消费者严格检查通过;键盘与显示模式组合 351 项三浏览器通过,含键盘 51 项。核心三个格式和 i18n 构建通过,docs/compiled 一致;独立重建候选并逐项核对浏览器 manifest,见 [键盘阶段证据](../baselines/hotkey-validation.json)。这不是全量源码浏览器或最终安装 UMD/legacy 验收。回退本批时恢复 hotkey.js,删除 input/hotkey 与 keyboard-focus 模块和对应测试,再通过仓库脚本重建;保留基线缺陷记录。
## 第二批:原生监听器与全局重绑
events/listener-registry.ts 管理原生监听器注册、capture 快照、数组失败回滚、abort 和清理;events/index.js 仍保留旧门面、bound proxy/hover、destroyEvents Set 与初始化顺序。返回的 disposer 直接执行后移除记录,重复执行无副作用。移除失败警告并保留记录供后续 remove/destroy 重试,其他监听器继续清理;自定义 target 永久拒绝移除仍属于明确能力边界。
保留原回调交给原生 EventTarget,因此 this、event 身份、外部 removeEventListener、原生 once/重复注册语义保持。once 触发后,记录在显式 disposer 或实例销毁时清除;不为了提前清记录包装用户回调。相同回调的重复注册不做引用计数。abort 清理记录,预先 aborted 的信号不注册。测试还发现本次迁移曾误拒绝 null options:旧包三浏览器通过、初始候选三浏览器失败,已恢复原生默认行为并保留回归用例。
原生注册初始基线 24 项中旧包 12 通过、候选目标 12 失败;迁移后配合生命周期 54 项通过。补入 null、once/abort 后,与键盘/生命周期合跑 117 项通过。新增 9 项 Node 测试覆盖原生身份、可变选项、对象监听器、取消、注册后抛错、销毁中注册、清理失败重试、手动 destroy 后复用和重复注册。确认 BASE-LIFE-24,保持 open 等待整个 CORE-17 安装包出口。
events/globalInit.ts 与 global-types.ts 拆出全局监听组及最小宿主。先注册完整新组,成功后再切换活动组并释放旧组;失败回滚新组,保留旧组可用。暂存组不转发事件,代次检查确保嵌套重绑由更新调用生效,销毁中断的注册不会晚到激活。保留原事件名称/身份、void 返回、document/window 独立回退规则(单传 document 不隐式更换 window)及成功重绑后不再接收旧目标事件。
实际 iframe 的初始基线 12 项中 9 通过、候选失败回滚目标 3 失败,证实旧实现失败后失去旧 document 并保留部分新监听。迁移后与键盘合跑 63 项通过;再补浏览器嵌套重绑/销毁检查。5 项 Node 测试覆盖 ownerDocument 变化、window 注册失败、暂存期间事件、嵌套重绑与销毁。确认 BASE-LIFE-25,保持 open;两组共 14 项 Node 测试通过。未新增依赖,已把两组测试接入 test:unit。
以上键盘阶段指纹只对应第一批历史源码,不能代表第二批。第二批最终 CI 292 项(263 单元、4 工程、25 基线)、171 个生产 TS 和五组消费者严格检查通过;键盘/原生监听器/全局重绑/生命周期组合 135 项三浏览器通过,无重试、跳过或页面错误。核心三个格式和 i18n 已重建,docs/compiled 字节一致;独立重建并逐项核对候选 manifest,见 [监听器及重绑阶段证据](../baselines/input-listeners-validation.json)。这是源码阶段的局部组合回归,不是最终安装包全量验收。
点击、悬停、移动、手势和 resize/view/RAF 仍待实施。回退本批时恢复 events/index.js/globalInit.js,删除新的 listener-registry/globalInit/global-types TS 与测试,并用仓库脚本重建。整个 CORE-17 仍为 doing,不作完成任务提交。
## 第三批:点击、焦点、悬停与移动
clickInit.ts、hoverInit.ts、moveInit.ts 迁移为 TS,pointer-types.ts 定义最小宿主/监听接口,pointer-focus.ts 独立管理文档焦点订阅。保留原始 Event、类名变化先于 hover 通知、isInput 仅识别 INPUT 的旧语义、单/双击闭区间阈值、第三次点击重置、动态静态配置与嵌套同步派发。Emitter 的 this 仍由显式 ctx 参数决定,不能套用 hotkey 的 this=art 约定。销毁移除自有焦点订阅,并阻止失效的点击/悬停/移动回调继续修改状态。
确认 BASE-LIFE-26:桌面 click 或移动 UA dblclick 回调销毁实例后,旧实现仍调用原生媒体 play。候选在用户事件回调返回后检查实例生命周期,同时在处理入口拦截已关闭实例;单元测试也验证销毁时不再进入 fullscreen 默认动作。该修正不改变正常单击播放、双击行为、用户异常身份及内部播放拒绝处理。
最初浏览器夹具错误地替换不可重定义的 toggle 属性,导致 18 项失败;修正为观察底层 video.play 后,暴露出默认 Emitter ctx 的错误测试假设。保留了两次失败报告,不把这些计为产品缺陷;第二次报告中另有 6 项候选销毁目标失败,均复现上述真实问题。最终测试显式传 ctx,保留旧不可配置属性不变。修复后 24 项三浏览器契约通过,再新增实际鼠标点击、播放/暂停、焦点和 hover 的 6 项浏览器验证通过。新增 8 项单元测试通过,包括阈值、嵌套派发、销毁、异常与自有订阅清理,已接入 test:unit,未新增依赖。
最终 CI 300 项(271 单元、4 工程、25 基线)、176 个生产 TS 与五组消费者严格检查通过。首次组合浏览器为 164 通过、1 项 WebKit 键盘定位失败;该测试设置初始 currentTime 后未等待定位完成,现补充 paused/seeking 前置条件,并断言方向键确实进入 hotkey。保留失败记录,修正测试后组合 165 项三浏览器通过,无重试/跳过/页面错误,含指针交互 30 项。核心三个格式与 i18n 重建通过,独立重建候选并核对每项 manifest、源码及 docs/compiled 指纹,见 [指针阶段证据](../baselines/pointer-validation.json)。
前两批证据仅代表各自历史源码。本批是局部源码组合回归,手势、resize/view/RAF 与 Events 门面还待迁移,整个 CORE-17 安装包出口未完成。回退本批时恢复三个 Init.js,删除 pointer-focus/pointer-types 和对应测试,通过仓库脚本重建。保持 doing,不作完成任务提交。
## 第四批:触摸手势
gestureInit.ts 负责移动端安装与选项分支;gesture-controller.ts 管理触点、源身份、方向快照和结束/取消/锁定订阅;gesture-direction.ts 保留原两像素阈值与对角边界;gesture-types.ts 描述最小宿主和当前手势。gesture=false 仍支持进度条触摸,isLive 不安装;普通视频拖动保留 TOUCH_MOVE_RATIO,进度条拖动保留全尺寸倍率,旋转时保留纵向计算。
初始 36 项三浏览器基线:21 通过、15 个候选修正目标失败。复现 touchcancel、锁定、多指、触点 identifier 改变后继续沿用旧拖动,以及 seek 回调销毁后继续发出 setBar。分别登记 BASE-LIFE-27/28。候选取消失效手势,并在用户回调后检查手势/实例/源身份;进度条触摸也不会在 setBar 回调销毁或启动新手势后继续旧 seek。失败起始回调保留异常身份并取消未完成手势。
control/progress/position.ts 改为最小宿主,并增加可选的 active 回调,供触摸调用在 setBar 与 seek 之间检查生命周期;原双参数桌面调用默认行为保持,纳入既有组件回归。新增 document:touchcancel 转发及同名公开 Event 声明,旧事件/类型保持。原生目标也监听取消,因此全局 document 被重绑后仍可释放自身手势。源切换通过既有 source 身份取消,避免依赖延迟到达的媒体事件。
12 项单元测试通过,覆盖轴/阈值、绝对/相对顺序、锁定/取消/多指/换指、源/旋转改变、无效尺寸、销毁、重入、错误和订阅清理。类型用例校验 TouchEvent 输入和新增取消事件。首轮手势+重绑浏览器为 52 通过、2 项 WebKit 用例停在触摸执行前的初始定位,最终媒体仍接近 0,未进入手势断言;保留失败记录。准备步骤显式播放/暂停以初始化解码器后,54 项通过;另外独立覆盖首次播放前的 seek 请求契约。扩展进度条绝对定位/销毁和原事件顺序后,手势 54 项三浏览器通过。
本批最终 CI 312 项(283 单元、4 工程、25 基线)、180 个生产 TS 与五组消费者严格检查通过;输入/生命周期/组件组合 303 项三浏览器通过,含 54 项手势与 84 项组件回归,无重试/跳过/页面错误。核心三个格式和 i18n 已重建,独立重建候选核对全部浏览器 manifest,源码/产物/文档副本指纹一致;公开声明差异逐字核对,仅增加 document:touchcancel。见 [手势阶段证据](../baselines/gesture-validation.json)。
浏览器触摸载荷和 Android UA 是受控输入,使用实际 DOM、媒体及定位;不将其宣称为实体手机触摸、滚动仲裁或系统取消的验收。未新增依赖,单元用例加入 test:unit。剩余调度/Events 门面和整个 CORE-17 安装包出口仍待完成;任务保持 doing。回退本批时恢复 gestureInit.js 与 position.ts,删除新的 gesture 模块和测试,撤销新增取消转发/声明后按仓库脚本重建。
## 第五批:调度与 Events 门面
resizeInit.ts 保留合并触发、动态 RESIZE_TIME、标准状态 autoSize、宽高比恢复与 notice 清理,并在重入销毁后停止后续写入。方向事件按 owner window 的 addEventListener 能力注册,不再依赖 onchange 已被赋值。viewInit.ts 拥有首发节流的复位定时器和订阅,保留构造时 SCROLL_TIME、动态 SCROLL_GAP、无尾随通知及原同步嵌套时序;viewport.ts 按 container 当前 ownerDocument 计算,但不更改原边界/容差公式。公共 throttle/debounce/isInViewport 不改。
updateInit.ts 保留 USE_RAF 开关、首次 playing 时立即通知、暂停不发 raf 和单个待执行帧;实例销毁或 raf 回调内销毁不再安排新帧,显式 destroy 通知仍可取消活跃实例的待执行帧。subscriptions.ts 以明确事件映射管理内部订阅并在实例关闭时移除;scheduling-types.ts 定义最小宿主。
events/index.ts 完成门面迁移,types.ts 组合初始化器宿主;保留 destroyEvents/proxy/hover/bindGlobalEvents 自有字段、原型方法、bound proxy/hover、注册返回和初始化顺序。bindGlobalEvents 使用 declare,不在构造开始时生成额外字段。strict 检查暴露的泛型推断问题通过显式事件映射修正,没有用 any 绕过;类型用例验证注册重载、回调 payload 和订阅边界。
初始 24 项浏览器基线为 15 通过、9 个候选目标失败。确认方向 onchange=null 时无 resize、销毁残留 scroll 节流定时器、跨 iframe 使用错误 viewport;登记 BASE-LIFE-29/30、BASE-DOM-18。首轮迁移后与生命周期组合 51 通过、3 个 viewport 用例仍失败,检查发现测试 top=150 恰好处于旧 50 像素容差边界;改为 top=250,保留闭区间规则并加入单元边界检查,不把容差规则改掉来迎合测试。补入实际 RAF/播放/销毁后,60 项三浏览器组合通过。9 项新增单元测试通过并接入 test:unit。
源码阶段 CI 321 项(292 单元、4 工程、25 基线)、188 个生产 TS 文件与五组消费者严格检查通过。实际 core/chapter tarball 安装后 27 项运行时检查、五组类型零诊断。核心三个格式及 i18n 已重建;最终安装 UMD/legacy 完整浏览器矩阵仍在验收,未将本任务标记完成。此前阶段报告仅记录当时源码,最终结论将以完整安装包指纹为准。
## 最终验收
- Hotkey、Events 门面及全部输入初始化器已迁移 TS,拆出键盘焦点/默认键、原生监听器、全局监听组、指针焦点、手势控制/方向、可见性和调度宿主。核心 183 个与 chapter 5 个生产 TS 文件严格检查通过;主播放器入口的剩余整合继续由后续任务处理。
- yarn ci:check:321 项通过(292 单元、4 工程、25 基线)。实际 core/chapter tarball 仓库外安装后 27 项运行时、五组类型零诊断。
- 最终安装 UMD 和 legacy 各完成 1119 项全量及 24 项补充三浏览器检查,分别对同一安装产物累计验证 1143 项;其中输入专项合计 243 项,既有显示模式 300 项;无重试、跳过或未处理页面错误。保留了早期失败记录与修正原因,未把早期阶段指纹混作最终证据。
- [最终证据](../baselines/input-validation.json)核对全部当前核心源码与打包快照、核心和 chapter 实际浏览器产物、全部包内文件、三个分发格式与 docs/compiled、22 个语言文件和独立分发许可。
- 保留公开字段/原型、方法绑定、回调数组和 native listener 身份、Emitter 显式 ctx、普通点击/触摸/调度时序。公开声明仅增加 document:touchcancel,其余内容与父提交逐字核对一致;五组旧/新 TS 消费者仍通过。
- 实际鼠标/键盘播放、定位、iframe 焦点/可见性和 RAF 已验证。触摸载荷、Android UA、IME、方向事件等包含受控路径;实体 iOS/Android 手势、滚动仲裁、系统取消、旋转和安全区仍交 REL-03/REVIEW-02。
- 保留原生 once/重复注册语义;once 记录在显式 disposer 或 owner 清理时释放。永久拒绝移除的自定义 target 只能警告并保留可重试记录,不承诺强制恢复外部副作用。
- 无新增依赖、锁文件或版本修改。六个新增 Node 测试文件已接入 test:unit,并补充类型与浏览器用例;既有公共类型差异仍由 CORE-21 协调。回退时恢复输入入口/初始化器与进度位置辅助函数,删除新增输入模块和用例,撤销新增取消事件,再通过仓库脚本重建,保留基线记录。
关闭本任务十一项输入/生命周期差异;BASE-PERF-01 的既有 resize 修复仅作回归验证,不重复计为新修复。控件 Tab 可达性 BASE-DOM-01 继续由 CORE-23 完成,未在本任务关闭。当前 214 项中 53 完成、161 待办,下一项 CORE-18 内置插件和提示模块。本任务独立本地提交,不推送或发布;全生态迁移和发布复盘继续按完整计划推进。
+1 -1
View File
@@ -6,7 +6,7 @@
- [入口](../packages/artplayer/src/index.js)顺序构造所有子系统;[player](../packages/artplayer/src/player/index.js)通过 mixin 将属性安装到 art。
- [Component](../packages/artplayer/src/utils/component.ts)负责控件缓存、DOM 和监听器;[Setting](../packages/artplayer/src/setting/index.ts)还有独立的树、渲染和更新语义。
- [Events](../packages/artplayer/src/events/index.js)集中管理一部分 DOM 监听器,但定时器、异步请求和内部 Emitter 订阅仍由各模块管理。
- [Events](../packages/artplayer/src/events/index.ts)已迁移为 TS,管理 DOM 监听器和全局重绑;输入调度、内部订阅与异步请求分别通过所属模块接入实例或操作作用域。
- [声明](../packages/artplayer/types/artplayer.d.ts)与源码独立维护;[build-ts](../scripts/build-ts.js)进行文本拼接。
## 目标职责
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 162 / doing 0 / blocked 0 / done 52 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 161 / doing 0 / blocked 0 / done 53 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -120,7 +120,7 @@
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | done |
| CORE-15 | artplayer<br>迁移字幕获取、解析和渲染 | CORE-11, CORE-13 | subtitle 请求/track/DOM/URL 生命周期与类型 | 过期结果、偏移、cue 数组、原生全屏及错误路径通过;接续 BASE-TYPE-07 的真实 cue 数组与旧隐式 scalar 回调声明兼容 | H | done |
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | done |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | todo |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | done |
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer;接续 BASE-TYPE-07 的 notice.show boolean getter 与历史读类型冲突 | M | todo |
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | todo |
| CORE-20 | artplayer<br>收敛核心入口与依赖方向 | CORE-14, CORE-15, CORE-18, CORE-19 | Artplayer 门面、初始化依赖与最终目录 | 所有核心自有源码迁移,公开描述符/静态接口/事件差分通过 | H | todo |
@@ -451,6 +451,7 @@
- CORE-14: [记录](changes/2026-09-11-CORE-14-setting.md) [记录](baselines/setting-validation.json)
- CORE-15: [记录](changes/2026-09-11-CORE-15-subtitle.md) [记录](baselines/subtitle-validation.json)
- CORE-16: [记录](changes/2026-09-11-CORE-16-display.md) [记录](baselines/display-validation.json)
- CORE-17: [记录](changes/2026-09-11-CORE-17-input.md) [记录](baselines/input-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 当前实施:CORE-17 输入、全局事件与调度完成
Hotkey 与 Events 全部初始化器已迁移 TS,拆清焦点、监听器、全局重绑、手势和调度;正常旧 API、回调/事件和交互保持,新增 document:touchcancel。
CI 321 项、核心 183 个与 chapter 5 个生产 TS 文件严格检查通过。实际安装 UMD 与 legacy 各通过 1119 项全量和 24 项补充三浏览器检查,对同一产物累计 1143 项,各含 243 项输入专项;真实 tarball 27 项运行时及五组类型零诊断,源码、包内文件、产物、文档副本、语言与许可指纹一致。见 [交付](changes/2026-09-11-CORE-17-input.md) 和 [最终证据](baselines/input-validation.json)。
关闭十一项输入/生命周期差异;控件 Tab 可达性 BASE-DOM-01 留 CORE-23;物理触摸/IME/方向等限制仍由 REL-03/REVIEW-02 验收。当前 214 项:53 完成、161 待办;下一项 CORE-18 内置插件和提示模块。本任务独立本地提交,不推送或发布。
## 当前实施:CORE-16 显示模式完成
全屏、video-only fallback、PiP、mini、自动尺寸、宽高比、翻转和方向已迁移 TS,拆清能力、状态、几何、DOM 和资源归属。修复重入/取消/销毁、节点和样式恢复、跨实例归属、过期事件及无效尺寸;正常旧 API、返回和事件保持。
+11
View File
@@ -84,3 +84,14 @@
| BASE-DOM-14 | resolved / 已复现 | Unready media sizing partially overwrites layout and emits NaN height | CORE-16 |
| BASE-DOM-15 | resolved / 已复现 | Auto orientation exit clears caller-owned inline dimensions and transforms | CORE-16 |
| BASE-LIFE-22 | resolved / 已复现 | Orientation lock success after fullscreen exit reactivates a cancelled player | CORE-16 |
| BASE-DOM-16 | resolved / 已复现 | Hotkeys intercept input in rebound documents and shadow roots | CORE-17 |
| BASE-DOM-17 | resolved / 已复现 | Prototype-named hotkeys fail registration through inherited keys properties | CORE-17 |
| BASE-LIFE-23 | resolved / 已复现 | Repeated Hotkey initialization accumulates keyboard subscriptions | CORE-17 |
| BASE-LIFE-24 | resolved / 已复现 | Mutable capture options and partial native registration failures leave listeners behind | CORE-17 |
| BASE-LIFE-25 | resolved / 已复现 | Failed global rebinding drops the old document and retains partial new listeners | CORE-17 |
| BASE-LIFE-26 | resolved / 已复现 | Click callbacks that destroy the player still trigger subsequent default playback actions | CORE-17 |
| BASE-LIFE-27 | resolved / 已复现 | Cancelled, locked or replaced touches continue a previous seek gesture | CORE-17 |
| BASE-LIFE-28 | resolved / 已复现 | Gesture callbacks continue seeking or updating progress after destruction | CORE-17 |
| BASE-LIFE-29 | resolved / 已复现 | Scroll throttle reset timers remain pending after player destruction | CORE-17 |
| BASE-LIFE-30 | resolved / 已复现 | Screen orientation changes are ignored when onchange is initially null | CORE-17 |
| BASE-DOM-18 | resolved / 已复现 | View visibility uses the original global viewport after container adoption into an iframe | CORE-17 |
+225
View File
@@ -1653,6 +1653,231 @@
"test/display-orientation.test.js"
],
"resolutionRationale": "Owned timers and subscriptions cancel cleanly; orientation request tokens prevent stale classes/notices and old-instance unlock of newer ArtPlayer locks. Final installed UMD/legacy each pass 900 browser cases and exact-package consumers pass. Controlled platform paths do not certify physical devices; REL-03/REVIEW-02 retains that release gate."
},
{
"id": "BASE-DOM-16",
"title": "Hotkeys intercept input in rebound documents and shadow roots",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/hotkey.spec.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Resolve the keyboard event document and deep editable focus while retaining normal shortcuts, modifiers and generic keydown notifications.",
"closureCriteria": "Final installed matrices cover real typing and playback keys, iframe/global rebinding, shadow and inherited editing, composition and source ownership.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Event-document and deep editable focus checks preserve actual iframe/shadow typing while retaining ordinary shortcuts. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-DOM-17",
"title": "Prototype-named hotkeys fail registration through inherited keys properties",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/hotkey.spec.js",
"test/hotkey.test.js"
],
"compatibleResolution": "Use own-property registration without changing the public keys object prototype, callback arrays, chainable methods or receiver contract.",
"closureCriteria": "Installed and unit regressions retain normal registration/removal, prototype-name safety, array identity, callback mutation and original errors.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Own-property registration supports prototype-named keys without changing the public keys prototype or arrays. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-23",
"title": "Repeated Hotkey initialization accumulates keyboard subscriptions",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/hotkey.spec.js",
"test/hotkey.test.js"
],
"compatibleResolution": "Own one subscription in the instance scope and reuse default callbacks, preserving explicit default restoration and stopping post-destroy dispatch.",
"closureCriteria": "Final installed matrices cover repeated init, default restoration, mobile manual initialization, real key operations and destruction during a callback.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "One scoped hotkey subscription and stable default callbacks retain opt-in initialization and restoration. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-24",
"title": "Mutable capture options and partial native registration failures leave listeners behind",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/listener-registry.spec.js",
"test/listener-registry.test.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Snapshot capture, roll back failed registration, release direct-disposer and aborted records, and preserve native callback identity, once and duplicate semantics.",
"closureCriteria": "Final installed matrices cover mutable/null options, registration failure, direct native removal, once/abort, retryable cleanup and destruction during registration.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Capture snapshots, rollback and owned disposal retain native callback identity and handle abort/failure/destruction. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-25",
"title": "Failed global rebinding drops the old document and retains partial new listeners",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/global-events.spec.js",
"test/global-events.test.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Commit a new document/window binding only after registration succeeds, keep the previous binding on failure and cancel interrupted generations without changing event identities or target fallback rules.",
"closureCriteria": "Final installed matrices cover actual iframe rebinding, document/window failure rollback, nested rebinding, destruction and keyboard focus integration.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Staged global bindings commit only on successful registration; cancelled/reentrant generations cannot overwrite newer targets. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-26",
"title": "Click callbacks that destroy the player still trigger subsequent default playback actions",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/pointer-events.spec.js",
"test/pointer-events.test.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Check instance closure before pointer handling and after click/double-click callbacks, own document focus subscriptions, and retain native events, click thresholds, nested dispatch and normal default actions.",
"closureCriteria": "Final installed matrices cover real mouse playback, desktop/mobile callback destruction, normal click pairs, focus/hover/move behavior and unit fullscreen/error/cleanup contracts.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Pointer handlers check closure after user callbacks and no longer trigger playback/fullscreen after destruction. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-27",
"title": "Cancelled, locked or replaced touches continue a previous seek gesture",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/gesture.spec.js",
"test/gesture.test.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Track the active touch, source and orientation; cancel invalidated sessions, own touch-end/cancel subscriptions and preserve ordinary drag axes, thresholds and multipliers.",
"closureCriteria": "Final installed matrices retain normal video/progress touch behavior and verify cancel, lock, multitouch, identity changes, source/rotation changes and invalid geometry; physical touch remains a release gate.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Touch/source/orientation identity and cancellation prevent invalid sessions from continuing; normal axes and multipliers remain. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-28",
"title": "Gesture callbacks continue seeking or updating progress after destruction",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/gesture.spec.js",
"test/gesture.test.js",
"refactor/changes/2026-09-11-CORE-17-input.md"
],
"compatibleResolution": "Check the current gesture after progress/seek callbacks and before subsequent writes; permit the shared position helper to check an optional caller lifetime without changing its ordinary call contract.",
"closureCriteria": "Final installed matrices and unit tests cover destruction/reentry/errors during absolute and relative seeking, with original event order and normal desktop progress behavior retained.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Gesture lifetime checks stop post-callback seek/bar/notice writes and preserve nested gesture ownership. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-29",
"title": "Scroll throttle reset timers remain pending after player destruction",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/event-scheduling.spec.js",
"test/event-scheduling.test.js"
],
"compatibleResolution": "Own the view throttle timer and emitter subscriptions while retaining leading-only dispatch, captured delay, live gap and synchronous nested behavior.",
"closureCriteria": "Final installed matrices and unit tests cover pending timer removal, view callback destruction/errors, normal scroll/mini behavior and remaining scheduling ownership.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Owned leading-throttle timers and subscriptions cancel at destruction with normal timing and synchronous errors retained. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-LIFE-30",
"title": "Screen orientation changes are ignored when onchange is initially null",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/event-scheduling.spec.js",
"test/event-scheduling.test.js"
],
"compatibleResolution": "Detect addEventListener on the owner's orientation target and retain the existing coalesced resize flow and disposal.",
"closureCriteria": "Installed matrices cover controlled orientation events with a null onchange property and resize/cleanup regressions; physical orientation remains a release gate.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "Orientation listeners use event capability on the owner target rather than requiring an assigned onchange handler. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
},
{
"id": "BASE-DOM-18",
"title": "View visibility uses the original global viewport after container adoption into an iframe",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-17"
],
"evidence": [
"test/browser/event-scheduling.spec.js",
"test/event-scheduling.test.js"
],
"compatibleResolution": "Use the current container owner document's viewport while preserving the original edge and gap arithmetic.",
"closureCriteria": "Installed matrices cover actual iframe adoption/rebinding, normal visibility and exact gap boundaries without changing public viewport utilities.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-17-input.md",
"refactor/baselines/input-validation.json"
],
"resolutionRationale": "View calculations use the current owner viewport after iframe adoption and preserve the original edge/gap boundaries. Final installed UMD/legacy each pass 1119 full-suite plus 24 supplemental cases on identical artifacts, and exact-package consumers pass. Controlled touch/IME/orientation paths do not waive physical-device release gates."
}
]
}
+5 -2
View File
@@ -1069,11 +1069,14 @@
"CORE-13",
"CORE-16"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "events/hotkey 的明确 document 与资源归属",
"acceptance": "跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-17-input.md",
"baselines/input-validation.json"
]
},
{
"id": "CORE-18",
+125
View File
@@ -0,0 +1,125 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, raf = false) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((raf) => {
window.Artplayer.SCROLL_TIME = 24731
window.Artplayer.RESIZE_TIME = 0
window.Artplayer.USE_RAF = raf
const orientation = new EventTarget()
orientation.onchange = null
Object.defineProperty(window.screen, 'orientation', { configurable: true, value: orientation })
window.createPlayer('/test/pattern.mp4')
}, raf)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: actual animation frames follow playback and stop after destruction inside raf`, async ({ page }) => {
await setup(page, core, true)
await page.evaluate(() => {
window.frameCount = 0
window.art.on('raf', () => window.frameCount++)
return window.art.play()
})
await expect.poll(() => page.evaluate(() => window.frameCount)).toBeGreaterThan(2)
await page.evaluate(() => window.art.pause())
const paused = await page.evaluate(() => window.frameCount)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await page.evaluate(() => window.frameCount)).toBe(paused)
await page.evaluate(() => {
window.art.once('raf', () => window.art.destroy())
return window.art.play()
})
await expect.poll(() => page.evaluate(() => window.art.isDestroy)).toBe(true)
const destroyed = await page.evaluate(() => window.frameCount)
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
expect(await page.evaluate(() => window.frameCount)).toBe(destroyed)
})
test(`${core}: Events keeps its public fields, prototype and detached native methods`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const events = art.events
const target = document.createElement('div')
let hits = 0
const { proxy, hover } = events
const dispose = proxy(target, 'example', () => hits++)
hover(target, () => hits++, () => hits++)
target.dispatchEvent(new Event('example'))
target.dispatchEvent(new Event('mouseenter'))
target.dispatchEvent(new Event('mouseleave'))
events.remove(dispose)
target.dispatchEvent(new Event('example'))
const result = { fields: Object.keys(events).sort(), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(events)).sort(), hits }
art.destroy()
return result
})).toEqual({ fields: ['bindGlobalEvents', 'destroyEvents', 'hover', 'proxy'], prototype: ['constructor', 'destroy', 'hover', 'proxy', 'remove'], hits: 3 })
})
test(`${core}: screen orientation dispatch works when onchange has no assigned handler`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(async () => {
const art = window.art
let resized = 0
art.on('resize', () => resized++)
window.screen.orientation.dispatchEvent(new Event('change'))
await new Promise(resolve => setTimeout(resolve, 30))
art.destroy()
return resized
})).toBe(core === 'candidate' ? 1 : 0)
})
test(`${core}: destruction releases the scroll throttle reset timer`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24731)
pending.add(timer)
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
try {
art.emit('window:scroll', new Event('scroll'))
const before = pending.size
art.destroy()
return { before, after: pending.size }
}
finally {
for (const timer of pending)
clear.call(window, timer)
window.setTimeout = set
window.clearTimeout = clear
}
})).toEqual({ before: 1, after: core === 'candidate' ? 0 : 1 })
})
test(`${core}: view visibility uses the container's current iframe viewport`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
frame.style.cssText = 'width:200px;height:100px'
document.body.append(frame)
const container = art.template.$container
container.style.cssText = 'position:absolute;top:250px;left:0;width:200px;height:100px'
frame.contentDocument.body.append(container)
art.events.bindGlobalEvents()
const seen = []
art.on('view', visible => seen.push(visible))
frame.contentWindow.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return seen
})).toEqual([core === 'published'])
})
}
+150
View File
@@ -0,0 +1,150 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, primeMedia = true) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android gesture fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.events = []
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
window.touch = (target, type, points) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', { value: points.map(([pageX, pageY, identifier = 1]) => ({ pageX, pageY, clientX: pageX, clientY: pageY, identifier })) })
target.dispatchEvent(event)
return event
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
if (!primeMedia)
return
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime > 0.1)).toBe(true)
await page.evaluate(() => window.art.pause())
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 2
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 2) < 0.1)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: a gesture before first play still issues the requested seek`, async ({ page }) => {
await setup(page, core, false)
expect(await page.evaluate(() => {
const art = window.art
const start = art.currentTime
const expected = start + art.duration * 0.1 * art.constructor.TOUCH_MOVE_RATIO
const requests = []
art.on('seek', (time, requested) => requests.push(requested))
window.touch(art.template.$video, 'touchstart', [[100, 100]])
window.touch(art.template.$video, 'touchmove', [[100 + art.width * 0.1, 100]])
art.destroy()
return { count: requests.length, valid: Math.abs(requests[0] - expected) < 0.000001 }
})).toEqual({ count: 1, valid: true })
})
test(`${core}: progress touch keeps absolute position and bar-before-seek ordering`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
const rect = progress.getBoundingClientRect()
const calls = []
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchstart')
calls.push(['bar', value, event])
})
art.on('seek', (time, requested) => calls.push(['seek', requested]))
const event = window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
window.touch(document, 'touchend', [])
return { order: calls.map(item => item[0]), percentage: calls[0][1], identity: calls[0][2] === event, requested: calls[1][1], expected: art.duration / 2 }
})
expect(result.order).toEqual(['bar', 'seek'])
expect(result.percentage).toBeCloseTo(0.5, 6)
expect(result.identity).toBe(true)
expect(result.requested).toBeCloseTo(result.expected, 6)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: destroy during absolute progress touch stops the following seek`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
const rect = progress.getBoundingClientRect()
let seeks = 0
art.on('seek', () => seeks++)
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchstart')
art.destroy()
})
window.touch(progress, 'touchstart', [[rect.left + progress.clientWidth / 2, rect.top]])
return { seeks, destroyed: art.isDestroy }
})).toEqual({ seeks: core === 'candidate' ? 0 : 1, destroyed: true })
})
test(`${core}: normal touch drag keeps seek/bar ordering and original touch identity`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const video = art.template.$video
const calls = []
const start = art.currentTime
const expected = start + art.duration * 0.2 * art.constructor.TOUCH_MOVE_RATIO
art.on('seek', (time, requested) => calls.push(['seek', requested]))
art.on('setBar', (kind, percentage, event) => {
if (event?.type === 'touchmove')
calls.push(['bar', kind, percentage, event])
})
window.touch(video, 'touchstart', [[100, 100]])
const event = window.touch(video, 'touchmove', [[100 + art.width * 0.2, 100]])
window.touch(document, 'touchend', [])
return { expected, order: calls.map(item => item[0]), requested: calls[0][1], percentage: calls[1][2], identity: calls[1][3] === event }
})
expect(result.order).toEqual(['seek', 'bar'])
expect(result.identity).toBe(true)
expect(result.requested).toBeCloseTo(result.expected, 6)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeCloseTo(result.expected, 1)
await page.evaluate(() => window.art.destroy())
})
for (const cause of ['cancel', 'lock', 'multitouch', 'identifier']) {
test(`${core}: ${cause} invalidates an in-progress gesture`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate((cause) => {
const art = window.art
const video = art.template.$video
const seeks = []
art.on('seek', (time, requested) => seeks.push(requested))
window.touch(video, 'touchstart', [[100, 100]])
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
if (cause === 'cancel')
window.touch(video, 'touchcancel', [])
if (cause === 'lock')
art.isLock = true
if (cause === 'multitouch')
window.touch(video, 'touchmove', [[150, 100], [200, 100, 2]])
window.touch(video, 'touchmove', [[100 + art.width * 0.3, 100, cause === 'identifier' ? 2 : 1]])
art.destroy()
return seeks.length
}, cause)).toBe(core === 'candidate' ? 1 : 2)
})
}
test(`${core}: destroy from a touch seek callback prevents the later progress notification`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const video = art.template.$video
let bars = 0
art.on('seek', () => art.destroy())
art.on('setBar', (kind, value, event) => {
if (event?.type === 'touchmove')
bars++
})
window.touch(video, 'touchstart', [[100, 100]])
window.touch(video, 'touchmove', [[100 + art.width * 0.1, 100]])
return { bars, destroyed: art.isDestroy }
})).toEqual({ bars: core === 'candidate' ? 0 : 1, destroyed: true })
})
}
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const action of ['rebind', 'destroy']) {
test(`candidate: ${action} during native global registration cancels the interrupted binding`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const frames = [document.createElement('iframe'), document.createElement('iframe')]
frames.forEach(frame => document.body.append(frame))
const [interrupted, newest] = frames
const target = interrupted.contentDocument
const original = target.addEventListener
target.addEventListener = function (name, ...args) {
if (name === 'click') {
if (action === 'destroy')
art.destroy()
else
art.events.bindGlobalEvents({ document: newest.contentDocument, window: newest.contentWindow })
}
return original.call(this, name, ...args)
}
const received = []
art.on('document:mouseup', event => received.push(event.target === newest.contentDocument ? 'newest' : 'stale'))
art.events.bindGlobalEvents({ document: target, window: interrupted.contentWindow })
document.dispatchEvent(new Event('mouseup'))
for (const frame of frames)
frame.contentDocument.dispatchEvent(new frame.contentWindow.Event('mouseup'))
if (!art.isDestroy)
art.destroy()
target.addEventListener = original
for (const frame of frames)
frame.remove()
return { received, remaining: art.events.destroyEvents.size }
}, action)).toEqual({ received: action === 'rebind' ? ['newest'] : [], remaining: 0 })
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: global rebinding forwards original events once and releases the previous document`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentDocument
const events = []
art.on('document:mouseup', event => events.push(event))
const first = new Event('mouseup')
document.dispatchEvent(first)
const returned = art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
document.dispatchEvent(new Event('mouseup'))
const second = new frame.contentWindow.Event('mouseup')
next.dispatchEvent(second)
art.destroy()
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
frame.remove()
return { count: events.length, identity: events[0] === first && events[1] === second, returned: typeof returned }
})).toEqual({ count: 2, identity: true, returned: 'undefined' })
})
test(`${core}: failed global rebinding keeps the previous document and removes partial new listeners`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentDocument
const original = next.addEventListener
const failure = new Error('document listener denied')
next.addEventListener = function (name, ...args) {
if (name === 'keydown')
throw failure
return original.call(this, name, ...args)
}
const received = []
art.on('document:mouseup', event => received.push(event.target === document ? 'old' : 'new'))
let sameError = false
try {
art.events.bindGlobalEvents({ document: next, window: frame.contentWindow })
}
catch (error) {
sameError = error === failure
}
next.addEventListener = original
document.dispatchEvent(new Event('mouseup'))
next.dispatchEvent(new frame.contentWindow.Event('mouseup'))
art.destroy()
frame.remove()
return { received, sameError }
})).toEqual({ received: core === 'candidate' ? ['old'] : ['new'], sameError: true })
})
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: mobile keeps keyboard initialization opt-in through the existing init method`, async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android keyboard fixture' }))
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const initial = Object.keys(art.hotkey.keys)
let hits = 0
art.hotkey.add('KeyK', () => hits++)
art.isFocus = true
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
const before = hits
art.hotkey.init()
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
const result = { initial, before, after: hits }
art.destroy()
return result
})).toEqual({ initial: [], before: 0, after: 1 })
})
test(`${core}: prototype-named hotkeys do not crash or replace the keys prototype`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const prototype = Object.getPrototypeOf(art.hotkey.keys)
const errors = []
let hits = 0
art.isFocus = true
for (const key of ['__proto__', 'constructor', 'toString']) {
try {
const callback = () => hits++
art.hotkey.add(key, callback)
document.dispatchEvent(new KeyboardEvent('keydown', { code: key, cancelable: true }))
art.hotkey.remove(key, callback)
}
catch {
errors.push(key)
}
}
const stable = Object.getPrototypeOf(art.hotkey.keys) === prototype
art.destroy()
return { errors, hits, stable }
})).toEqual({ errors: core === 'candidate' ? [] : ['__proto__', 'constructor', 'toString'], hits: core === 'candidate' ? 3 : 0, stable: true })
})
}
test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Escape retain their behavior', async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => {
window.art.isFocus = true
window.nativeHotkeys = []
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
})
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 1
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
const volume = await page.evaluate(() => {
window.art.volume = 0.5
return window.art.volume
})
await page.keyboard.press('ArrowUp')
expect(await page.evaluate(() => window.art.volume)).toBeGreaterThan(volume)
await page.evaluate(() => {
window.art.fullscreenWeb = true
})
await page.keyboard.press('Escape')
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(false)
await page.evaluate(() => window.art.destroy())
})
for (const kind of ['inherited', 'plaintext', 'select']) {
test(`candidate: ${kind} editing and IME composition do not activate player shortcuts`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((kind) => {
const art = window.art
const root = document.createElement('div')
root.innerHTML = kind === 'select' ? '<select><option>one</option></select>' : `<div contenteditable="${kind === 'plaintext' ? 'plaintext-only' : 'true'}"><span tabindex="0">edit</span></div>`
art.template.$player.append(root)
const target = root.querySelector('span,select')
target.focus()
art.isFocus = true
let hits = 0
const generic = []
art.hotkey.add('KeyK', () => hits++)
art.on('keydown', value => generic.push(value.code))
const key = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true })
target.dispatchEvent(key)
target.blur()
const composition = new KeyboardEvent('keydown', { code: 'KeyK', cancelable: true, bubbles: true, isComposing: true })
document.dispatchEvent(composition)
art.destroy()
return { hits, generic, prevented: [key.defaultPrevented, composition.defaultPrevented] }
}, kind)).toEqual({ hits: 0, generic: ['KeyK', 'KeyK'], prevented: [false, false] })
})
}
test('candidate: destroying during a key callback stops remaining keys and notifications', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const calls = []
art.isFocus = true
art.hotkey.add('KeyK', () => {
calls.push('first')
art.destroy(false)
})
art.hotkey.add('KeyK', () => calls.push('second'))
art.on('hotkey', () => calls.push('hotkey'))
art.on('keydown', () => calls.push('keydown'))
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK' }))
art.hotkey.init()
art.emit('document:keydown', new KeyboardEvent('keydown', { code: 'KeyK' }))
return calls
})).toEqual(['first'])
})
for (const core of ['published', 'candidate']) {
test(`${core}: hotkey registration retains chain, array identity, receiver and event order`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.isFocus = true
const calls = []
const event = new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true })
function callback(value) {
calls.push(['callback', this === art, value === event])
}
const add = art.hotkey.add('KeyK', callback)
const list = art.hotkey.keys.KeyK
art.hotkey.add('KeyK', callback)
art.on('hotkey', value => calls.push(['hotkey', value === event]))
art.on('keydown', value => calls.push(['keydown', value === event]))
document.dispatchEvent(event)
const count = list.length
const remove = art.hotkey.remove('KeyK', callback)
const result = { chain: add === art.hotkey && remove === art.hotkey, count, retainedArray: list.length === 0, missing: !Object.hasOwn(art.hotkey.keys, 'KeyK'), keysPrototype: Object.getPrototypeOf(art.hotkey.keys) === Object.prototype, prevented: event.defaultPrevented, calls }
art.destroy()
return result
})).toEqual({ chain: true, count: 1, retainedArray: true, missing: true, keysPrototype: true, prevented: true, calls: [['callback', true, true], ['hotkey', true], ['keydown', true]] })
})
test(`${core}: rebound document input retains typing instead of triggering a hotkey`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const frame = document.createElement('iframe')
frame.id = 'keyboard-frame'
document.body.append(frame)
const doc = frame.contentDocument
doc.body.innerHTML = '<input id="editor">'
window.art.events.bindGlobalEvents({ document: doc, window: frame.contentWindow })
window.hits = 0
window.art.hotkey.add('KeyK', () => window.hits++)
window.art.isFocus = true
})
const editor = page.frameLocator('#keyboard-frame').locator('#editor')
await editor.focus()
await editor.press('k')
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
await page.evaluate(() => window.art.destroy())
})
test(`${core}: shadow-root input retains typing instead of triggering a hotkey`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
const host = document.createElement('div')
host.id = 'shadow-editor'
window.art.template.$player.append(host)
host.attachShadow({ mode: 'open' }).innerHTML = '<input id="editor">'
window.hits = 0
window.art.hotkey.add('KeyK', () => window.hits++)
window.art.isFocus = true
})
const editor = page.locator('#shadow-editor input')
await editor.focus()
await editor.press('k')
expect(await page.evaluate(() => window.hits)).toBe(core === 'candidate' ? 0 : 1)
await expect(editor).toHaveValue(core === 'candidate' ? 'k' : '')
await page.evaluate(() => window.art.destroy())
})
test(`${core}: repeated hotkey init does not multiply the keyboard subscription`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.isFocus = true
let hits = 0
art.hotkey.add('KeyK', () => hits++)
art.hotkey.init()
document.dispatchEvent(new KeyboardEvent('keydown', { code: 'KeyK', bubbles: true, cancelable: true }))
art.destroy()
return hits
})).toBe(core === 'candidate' ? 1 : 2)
})
}
+175
View File
@@ -0,0 +1,175 @@
import { expect, test } from './fixtures.js'
async function setup(page, mobile = false) {
if (mobile)
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android closeout fixture' }))
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => {
window.Artplayer.SCROLL_TIME = 0
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, gesture: true })
window.touch = (target, name, x, y) => {
const event = new Event(name, { bubbles: true })
Object.defineProperty(event, 'touches', { value: [{ identifier: 1, pageX: x, pageY: y, clientX: x, clientY: y }] })
target.dispatchEvent(event)
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
test('candidate closeout: restoring a removed default hotkey keeps identity and real playback', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const original = art.hotkey.keys.Space[0]
art.hotkey.remove('Space', original)
const absent = !art.hotkey.keys.Space
art.hotkey.init()
art.hotkey.init()
art.isFocus = true
return { absent, count: art.hotkey.keys.Space.length, identity: art.hotkey.keys.Space[0] === original }
})).toEqual({ absent: true, count: 1, identity: true })
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test('candidate closeout: a failed native removal remains retryable and releases its record', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const remove = target.removeEventListener
const warning = console.warn
const error = new Error('temporary removal failure')
let deny = true
let warnings = 0
let hits = 0
console.warn = (message, actual) => {
if (actual === error)
warnings++
}
target.removeEventListener = function (...args) {
if (deny)
throw error
return remove.apply(this, args)
}
try {
const dispose = art.events.proxy(target, 'example', () => hits++)
art.events.remove(dispose)
const retained = art.events.destroyEvents.has(dispose)
target.dispatchEvent(new Event('example'))
deny = false
art.events.remove(dispose)
target.dispatchEvent(new Event('example'))
const released = !art.events.destroyEvents.has(dispose)
art.destroy()
return { retained, released, warnings, hits }
}
finally {
console.warn = warning
target.removeEventListener = remove
}
})).toEqual({ retained: true, released: true, warnings: 1, hits: 1 })
})
test('candidate closeout: destruction before native add returns cannot leave a late listener', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const add = target.addEventListener
let hits = 0
target.addEventListener = function (...args) {
art.destroy()
return add.apply(this, args)
}
art.events.proxy(target, 'example', () => hits++)
target.dispatchEvent(new Event('example'))
return { hits, remaining: art.events.destroyEvents.size, destroyed: art.isDestroy }
})).toEqual({ hits: 0, remaining: 0, destroyed: true })
})
test('candidate closeout: window registration failure restores the previous global binding', async ({ page }) => {
await setup(page)
expect(await page.evaluate(() => {
const art = window.art
const frame = document.createElement('iframe')
document.body.append(frame)
const next = frame.contentWindow
const add = next.addEventListener
const failure = new Error('window registration failed after add')
next.addEventListener = function (name, ...args) {
add.call(this, name, ...args)
if (name === 'scroll')
throw failure
}
const received = []
art.on('document:mouseup', event => received.push(event.target === document ? 'old-document' : 'new-document'))
art.on('window:scroll', event => received.push(event.target === window ? 'old-window' : 'new-window'))
let sameError = false
try {
art.events.bindGlobalEvents({ document: next.document, window: next })
}
catch (error) {
sameError = error === failure
}
next.addEventListener = add
next.document.dispatchEvent(new next.Event('mouseup'))
next.dispatchEvent(new next.Event('scroll'))
document.dispatchEvent(new Event('mouseup'))
window.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return { sameError, received }
})).toEqual({ sameError: true, received: ['old-document', 'old-window'] })
})
for (const cause of ['source', 'rotation', 'geometry']) {
test(`candidate closeout: ${cause} changes cancel the old gesture in the installed player`, async ({ page }) => {
await setup(page, true)
expect(await page.evaluate((cause) => {
const art = window.art
const video = art.template.$video
let seeks = 0
art.on('seek', () => seeks++)
window.touch(video, 'touchstart', 100, 100)
if (cause === 'source')
art.url = '/test/pattern.mp4?closeout=source'
if (cause === 'rotation')
art.isRotate = true
if (cause === 'geometry') {
art.template.$player.style.setProperty('width', '0px', 'important')
art.template.$player.style.setProperty('min-width', '0px', 'important')
}
const before = seeks
const width = art.width
window.touch(video, 'touchmove', cause === 'rotation' ? 100 : 140, cause === 'rotation' ? 140 : 100)
art.destroy()
return { newSeeks: seeks - before, zeroWidth: cause !== 'geometry' || width === 0 }
}, cause)).toEqual({ newSeeks: 0, zeroWidth: true })
})
}
test('candidate closeout: adopted viewport retains exact gap boundaries', async ({ page }) => {
await setup(page)
expect(await page.evaluate(async () => {
const art = window.art
const frame = document.createElement('iframe')
frame.style.cssText = 'height:100px;width:200px'
document.body.append(frame)
const container = art.template.$container
container.style.cssText = 'position:absolute;top:150px;left:0;height:20px;width:20px'
frame.contentDocument.body.append(container)
art.events.bindGlobalEvents()
art.constructor.SCROLL_GAP = 50
const states = []
art.on('view', value => states.push(value))
frame.contentWindow.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 0))
container.style.top = '151px'
frame.contentWindow.dispatchEvent(new Event('scroll'))
art.destroy()
frame.remove()
return states
})).toEqual([true, false])
})
+151
View File
@@ -0,0 +1,151 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: null options preserve the native default and callbacks can be removed directly`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
let failed = false
const callback = () => hits++
try {
const remove = art.events.proxy(target, 'example', callback, null)
target.dispatchEvent(new Event('example'))
remove()
target.dispatchEvent(new Event('example'))
}
catch {
failed = true
}
art.destroy()
return { hits, failed }
})).toEqual({ hits: 1, failed: false })
})
test(`${core}: once and abort use native event identity and permit subsequent registration`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const seen = []
const event = new Event('example')
const callback = function (received) {
seen.push(this === target && received === event)
}
const once = art.events.proxy(target, 'example', callback, { once: true })
target.dispatchEvent(event)
target.dispatchEvent(event)
once()
const controller = new AbortController()
const aborted = art.events.proxy(target, 'example', callback, { signal: controller.signal })
target.dispatchEvent(event)
controller.abort()
target.dispatchEvent(event)
aborted()
const skipped = art.events.proxy(target, 'example', callback, { signal: controller.signal })
target.dispatchEvent(event)
skipped()
art.events.proxy(target, 'example', callback)
target.dispatchEvent(event)
art.destroy()
target.dispatchEvent(event)
return seen
})).toEqual([true, true, true])
})
test(`${core}: direct event disposer preserves native callback identity and releases bookkeeping`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
const callback = () => hits++
const before = art.events.destroyEvents.size
const remove = art.events.proxy(target, 'example', callback)
target.removeEventListener('example', callback)
target.dispatchEvent(new Event('example'))
const returned = remove()
const result = { hits, returned: typeof returned, remaining: art.events.destroyEvents.size - before }
art.destroy()
return result
})).toEqual({ hits: 0, returned: 'undefined', remaining: core === 'candidate' ? 0 : 1 })
})
test(`${core}: changing capture options after registration cannot prevent cleanup`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
let hits = 0
const options = { capture: true }
const remove = art.events.proxy(target, 'example', () => hits++, options)
options.capture = false
remove()
target.dispatchEvent(new Event('example'))
art.destroy()
return hits
})).toBe(core === 'candidate' ? 0 : 1)
})
test(`${core}: partially failing array registration rolls back the listeners it created`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const original = target.addEventListener
const failure = new Error('second registration denied')
target.addEventListener = function (name, ...args) {
if (name === 'second')
throw failure
return original.call(this, name, ...args)
}
let hits = 0
let sameError = false
const before = art.events.destroyEvents.size
try {
art.events.proxy(target, ['first', 'second'], () => hits++)
}
catch (error) {
sameError = error === failure
}
target.addEventListener = original
target.dispatchEvent(new Event('first'))
const result = { hits, sameError, remaining: art.events.destroyEvents.size - before }
art.destroy()
return result
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true, remaining: core === 'candidate' ? 0 : 1 })
})
test(`${core}: registration throwing after native add does not leave an untracked listener`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const target = document.createElement('div')
const original = target.addEventListener
const failure = new Error('after native add')
target.addEventListener = function (...args) {
original.apply(this, args)
throw failure
}
let hits = 0
let sameError = false
try {
art.events.proxy(target, 'example', () => hits++)
}
catch (error) {
sameError = error === failure
}
target.addEventListener = original
target.dispatchEvent(new Event('example'))
art.destroy()
return { hits, sameError }
})).toEqual({ hits: core === 'candidate' ? 0 : 1, sameError: true })
})
}
+112
View File
@@ -0,0 +1,112 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = false) {
if (mobile)
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android pointer fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
test(`${core}: real mouse input focuses the player, hovers and toggles actual playback`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
window.art.constructor.DBCLICK_FULLSCREEN = false
window.art.constructor.DBCLICK_TIME = 0
window.pointerTrusted = []
window.art.on('click', event => window.pointerTrusted.push(event.isTrusted))
})
const video = page.locator('.art-video')
await video.click({ position: { x: 100, y: 100 } })
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
expect(await page.evaluate(() => ({ focus: window.art.isFocus, hover: window.art.template.$player.classList.contains('art-hover') }))).toEqual({ focus: true, hover: true })
await video.click({ position: { x: 100, y: 100 } })
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
expect(await page.evaluate(() => window.pointerTrusted)).toEqual([true, true])
await page.mouse.move(1, 1)
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-hover'))).toBe(false)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: click pairs preserve callback identity and playback ordering`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.constructor.DBCLICK_TIME = 60000
art.constructor.DBCLICK_FULLSCREEN = false
const calls = []
const events = [new MouseEvent('click'), new MouseEvent('click'), new MouseEvent('click')]
let current
art.template.$video.play = () => {
calls.push('play')
return Promise.resolve()
}
for (const name of ['click', 'dblclick']) {
art.on(name, function (event) {
calls.push(`${name}:${event === current}:${this === art}`)
}, art)
}
for (const event of events) {
current = event
art.template.$video.dispatchEvent(event)
}
art.destroy()
return calls
})).toEqual(['click:true:true', 'play', 'dblclick:true:true', 'click:true:true', 'play'])
})
for (const name of ['click', 'dblclick']) {
test(`${core}: destruction in ${name} stops its subsequent default action`, async ({ page }) => {
await setup(page, core, name === 'dblclick')
expect(await page.evaluate((name) => {
const art = window.art
art.constructor.DBCLICK_TIME = 60000
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.on(name, () => art.destroy())
const video = art.template.$video
video.dispatchEvent(new MouseEvent('click'))
if (name === 'dblclick')
video.dispatchEvent(new MouseEvent('click'))
return { plays, destroyed: art.isDestroy }
}, name)).toEqual({ plays: core === 'published' ? 1 : 0, destroyed: true })
})
}
test(`${core}: focus, hover and move preserve flags, classes and original events`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const { $player } = art.template
const input = document.createElement('input')
$player.append(input)
const calls = []
let expected
for (const name of ['focus', 'blur', 'mousemove'])
art.on(name, event => calls.push([name, event === expected, art.isFocus, art.isInput]))
art.on('hover', (state, event) => calls.push(['hover', state, event === expected, $player.classList.contains('art-hover')]))
expected = new MouseEvent('click', { bubbles: true })
input.dispatchEvent(expected)
expected = new MouseEvent('contextmenu', { bubbles: true })
document.body.dispatchEvent(expected)
expected = new MouseEvent('mouseenter')
$player.dispatchEvent(expected)
expected = new MouseEvent('mousemove')
$player.dispatchEvent(expected)
expected = new MouseEvent('mouseleave')
$player.dispatchEvent(expected)
art.destroy()
return calls
})).toEqual([
['focus', true, true, true],
['blur', true, false, false],
['hover', true, true, true],
['mousemove', true, false, false],
['hover', false, true, false],
])
})
}
+223
View File
@@ -0,0 +1,223 @@
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 { resizeInit, viewInit, updateInit, inViewport, Emitter, beginLifecycle, getScope } = await loadModules({
resizeInit: 'packages/artplayer/src/events/resizeInit',
viewInit: 'packages/artplayer/src/events/viewInit',
updateInit: 'packages/artplayer/src/events/updateInit',
inViewport: { file: 'packages/artplayer/src/events/viewport', name: 'inViewport' },
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(t) {
const timers = new Map()
const frames = new Map()
let id = 0
t.mock.method(globalThis, 'setTimeout', (callback, delay) => {
const key = ++id
timers.set(key, { callback, delay })
return key
})
t.mock.method(globalThis, 'clearTimeout', key => timers.delete(key))
for (const [name, value] of Object.entries({
requestAnimationFrame(callback) {
const key = ++id
frames.set(key, callback)
return key
},
cancelAnimationFrame: key => frames.delete(key),
})) {
const before = Object.getOwnPropertyDescriptor(globalThis, name)
Object.defineProperty(globalThis, name, { configurable: true, value })
t.after(() => before ? Object.defineProperty(globalThis, name, before) : Reflect.deleteProperty(globalThis, name))
}
const orientation = new EventTarget()
orientation.onchange = null
const document = { defaultView: { innerHeight: 100, innerWidth: 200, screen: { orientation } }, documentElement: { clientHeight: 80, clientWidth: 160 } }
const rect = { top: 0, left: 0, width: 20, height: 20 }
const container = { ownerDocument: document, getBoundingClientRect: () => rect }
const calls = []
const art = Object.assign(new Emitter(), {
template: { $player: { ownerDocument: document }, $container: container },
constructor: { RESIZE_TIME: 10, SCROLL_TIME: 20, SCROLL_GAP: 0, USE_RAF: true },
option: { autoSize: true, autoMini: true },
aspectRatio: '16:9',
state: 'standard',
autoSize: () => calls.push('autoSize'),
mini: false,
playing: false,
notice: { show: 'before' },
})
beginLifecycle(art)
const scope = getScope(art)
const registry = { proxy(target, name, callback) {
target.addEventListener(name, callback)
scope.add(() => {
target.removeEventListener(name, callback)
})
} }
const flushTimers = () => {
const pending = [...timers.values()]
timers.clear()
for (const timer of pending)
timer.callback()
}
const frame = () => {
const [key, callback] = frames.entries().next().value
frames.delete(key)
callback(0)
}
return { art, scope, registry, calls, timers, frames, frame, flushTimers, orientation, rect, document }
}
test('resize coalesces with live delay, preserves normal effects and cancels on destruction', (t) => {
const f = fixture(t)
resizeInit(f.art, f.registry)
f.art.on('resize', () => f.calls.push('resize'))
f.art.emit('window:resize', new Event('resize'))
f.art.emit('window:orientationchange', new Event('orientationchange'))
assert.equal(f.timers.size, 1)
assert.equal([...f.timers.values()][0].delay, 10)
f.art.constructor.RESIZE_TIME = 30
f.art.emit('window:resize', new Event('resize'))
assert.equal([...f.timers.values()][0].delay, 30)
f.flushTimers()
assert.deepEqual(f.calls, ['autoSize', 'resize'])
assert.equal(f.art.aspectRatio, '16:9')
assert.equal(f.art.notice.show, '')
f.art.emit('window:resize', new Event('resize'))
f.scope.dispose()
assert.equal(f.timers.size, 0)
f.art.emit('window:resize', new Event('resize'))
assert.equal(f.timers.size, 0)
})
test('orientation event capability does not depend on onchange being assigned', (t) => {
const f = fixture(t)
resizeInit(f.art, f.registry)
f.orientation.dispatchEvent(new Event('change'))
assert.equal(f.timers.size, 1)
f.flushTimers()
assert.deepEqual(f.calls, ['autoSize'])
f.scope.dispose()
f.orientation.dispatchEvent(new Event('change'))
assert.equal(f.timers.size, 0)
})
test('destroy inside auto-size prevents later ratio and notice writes', (t) => {
const f = fixture(t)
let ratioWrites = 0
Object.defineProperty(f.art, 'aspectRatio', { get: () => '16:9', set: () => ratioWrites++ })
f.art.autoSize = () => f.scope.dispose()
resizeInit(f.art, f.registry)
f.art.emit('resize')
assert.equal(ratioWrites, 0)
assert.equal(f.art.notice.show, 'before')
})
test('view preserves leading-only throttle, initial delay and live gap while cleaning its timer', (t) => {
const f = fixture(t)
const seen = []
viewInit(f.art)
f.art.on('view', value => seen.push(value))
f.art.constructor.SCROLL_TIME = 99
f.art.emit('window:scroll', new Event('scroll'))
f.art.emit('window:scroll', new Event('scroll'))
assert.deepEqual(seen, [true])
assert.equal([...f.timers.values()][0].delay, 20)
f.rect.top = 130
f.flushTimers()
assert.deepEqual(seen, [true])
f.art.emit('window:scroll', new Event('scroll'))
assert.deepEqual(seen, [true, false])
assert.equal(f.art.mini, true)
f.flushTimers()
f.art.constructor.SCROLL_GAP = 50
f.art.emit('window:scroll', new Event('scroll'))
assert.deepEqual(seen, [true, false, true])
f.scope.dispose()
assert.equal(f.timers.size, 0)
f.art.emit('window:scroll', new Event('scroll'))
assert.deepEqual(seen, [true, false, true])
})
test('view callback failure remains synchronous and does not lock the throttle', (t) => {
const f = fixture(t)
viewInit(f.art)
const error = new Error('view failed')
f.art.once('view', () => {
throw error
})
assert.throws(() => f.art.emit('window:scroll', new Event('scroll')), actual => actual === error)
assert.equal(f.timers.size, 0)
f.art.emit('window:scroll', new Event('scroll'))
assert.equal(f.timers.size, 1)
f.scope.dispose()
})
test('nested leading view dispatch keeps its timing and destroy prevents reset timers', (t) => {
const f = fixture(t)
viewInit(f.art)
let views = 0
f.art.on('view', () => views++)
f.art.once('view', () => f.art.emit('window:scroll', new Event('scroll')))
f.art.emit('window:scroll', new Event('scroll'))
assert.equal(views, 2)
assert.equal(f.timers.size, 2)
f.flushTimers()
f.art.once('view', () => f.scope.dispose())
f.art.emit('window:scroll', new Event('scroll'))
assert.equal(f.timers.size, 0)
})
test('RAF keeps the initial playing emission and one frame while cancelling reentrant destruction', (t) => {
const f = fixture(t)
let frames = 0
f.art.playing = true
f.art.on('raf', () => frames++)
updateInit(f.art)
assert.equal(frames, 1)
assert.equal(f.frames.size, 1)
f.art.playing = false
f.frame()
assert.equal(frames, 1)
assert.equal(f.frames.size, 1)
f.art.playing = true
f.art.once('raf', () => f.scope.dispose())
f.frame()
assert.equal(frames, 2)
assert.equal(f.frames.size, 0)
})
test('RAF remains opt-in and explicit destroy notification can cancel an idle frame', (t) => {
const f = fixture(t)
f.art.constructor.USE_RAF = false
updateInit(f.art)
assert.equal(f.frames.size, 0)
f.art.constructor.USE_RAF = true
updateInit(f.art)
assert.equal(f.frames.size, 1)
f.art.emit('destroy')
assert.equal(f.frames.size, 0)
f.scope.dispose()
})
test('viewport follows owner document changes and preserves edge/gap arithmetic', (t) => {
const f = fixture(t)
const container = f.art.template.$container
f.rect.top = 150
assert.equal(inViewport(container, 50), true)
f.rect.top = 151
assert.equal(inViewport(container, 50), false)
container.ownerDocument = { defaultView: null, documentElement: { clientHeight: 200, clientWidth: 200 } }
assert.equal(inViewport(container, 0), true)
f.rect.left = 301
assert.equal(inViewport(container, 50), false)
f.rect.left = 300
assert.equal(inViewport(container, 50), true)
f.scope.dispose()
})
+215
View File
@@ -0,0 +1,215 @@
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 savedAgent = Object.getOwnPropertyDescriptor(globalThis, 'CUSTOM_USER_AGENT')
Object.defineProperty(globalThis, 'CUSTOM_USER_AGENT', { configurable: true, value: 'ArtPlayer Android gesture unit fixture' })
let modules
try {
modules = await loadModules({
gestureController: { file: 'packages/artplayer/src/input/gesture-controller', name: 'gestureController' },
slideDirection: { file: 'packages/artplayer/src/input/gesture-direction', name: 'slideDirection' },
gestureInit: 'packages/artplayer/src/events/gestureInit',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
})
}
finally {
if (savedAgent)
Object.defineProperty(globalThis, 'CUSTOM_USER_AGENT', savedAgent)
else
delete globalThis.CUSTOM_USER_AGENT
}
const { gestureController, slideDirection, gestureInit, Emitter, beginLifecycle, getScope, beginSource } = modules
function touch(x, y, identifier = 1, count = 1) {
return { touches: Array.from({ length: count }, () => ({ pageX: x, pageY: y, clientX: x, clientY: y, identifier })) }
}
function fixture() {
const calls = []
const art = Object.assign(new Emitter(), {
template: { $video: {}, $progress: { clientWidth: 100, getBoundingClientRect: () => ({ left: 10 }) } },
option: { isLive: false, gesture: true },
constructor: { TOUCH_MOVE_RATIO: 0.5 },
currentTime: 20,
duration: 100,
width: 200,
height: 100,
top: 10,
isRotate: false,
isLock: false,
notice: { show: '' },
})
// eslint-disable-next-line accessor-pairs -- The real seek API is write-only.
Object.defineProperty(art, 'seek', {
set(time) {
art.currentTime = time
calls.push(['seek', time])
art.emit('seek', time)
},
})
art.on('setBar', (kind, value, event) => calls.push(['bar', kind, value, event]))
beginLifecycle(art)
const gesture = gestureController(art)
return { art, gesture, calls, dispose: () => getScope(art).dispose() }
}
test('gesture directions retain the two-pixel threshold and exact diagonal boundaries', () => {
for (const [x, y, expected] of [[1, 1, 0], [2, 0, 4], [0, 2, 2], [-2, 0, 3], [0, -2, 1], [2, 2, 4], [2, -2, 1], [-2, -2, 3], [-2, 2, 2]])
assert.equal(slideDirection(0, 0, x, y), expected)
assert.equal(slideDirection(0, 0, Number.NaN, 0), 0)
})
test('video dragging retains seek/bar/notice values and clamps against duration', () => {
const f = fixture()
const event = touch(40, 0)
f.gesture.start(f.art.template.$video, touch(0, 0))
f.gesture.move(event)
assert.deepEqual(f.calls, [['seek', 30], ['bar', 'played', 0.3, event]])
assert.equal(f.art.notice.show, '00:30 / 01:40')
f.art.constructor.TOUCH_MOVE_RATIO = 2
f.gesture.move(touch(1000, 0))
assert.equal(f.art.currentTime, 100)
f.dispose()
})
test('progress start retains bar-before-seek and its full-width drag multiplier', () => {
const f = fixture()
const event = touch(60, 20)
f.gesture.start(f.art.template.$progress, event)
assert.deepEqual(f.calls, [['bar', 'played', 0.5, event], ['seek', 50]])
f.gesture.move(touch(100, 20))
assert.equal(f.art.currentTime, 70)
f.dispose()
})
test('rotation uses the vertical axis for absolute progress and subsequent drag', () => {
const f = fixture()
f.art.isRotate = true
f.gesture.start(f.art.template.$progress, touch(60, 60))
assert.equal(f.art.currentTime, 50)
f.gesture.move(touch(90, 60))
assert.equal(f.art.currentTime, 50)
f.gesture.move(touch(60, 80))
assert.equal(f.art.currentTime, 70)
f.dispose()
})
test('cancel, lock, multitouch, changed finger, source and rotation cannot resume the old drag', () => {
for (const reason of ['cancel', 'lock', 'multitouch', 'identifier', 'source', 'rotation']) {
const f = fixture()
f.gesture.start(f.art.template.$video, touch(0, 0))
if (reason === 'cancel')
f.art.emit('document:touchcancel', {})
if (reason === 'lock') {
f.art.isLock = true
f.art.emit('lock', true)
f.art.isLock = false
}
if (reason === 'multitouch')
f.gesture.move(touch(10, 0, 1, 2))
if (reason === 'identifier')
f.gesture.move(touch(10, 0, 2))
if (reason === 'source')
beginSource(f.art)
if (reason === 'rotation')
f.art.isRotate = true
f.gesture.move(touch(40, 0))
assert.equal(f.art.currentTime, 20, reason)
f.dispose()
}
})
test('non-finite or zero geometry cancels movement instead of producing invalid positions', () => {
for (const [key, value] of [['width', 0], ['width', Infinity], ['duration', Infinity], ['duration', 0]]) {
const f = fixture()
f.gesture.start(f.art.template.$video, touch(0, 0))
const before = f.art[key]
f.art[key] = value
f.gesture.move(touch(40, 0))
f.art[key] = before
f.gesture.move(touch(60, 0))
assert.deepEqual(f.calls, [], key)
f.dispose()
}
const f = fixture()
f.art.template.$progress.clientWidth = 0
f.gesture.start(f.art.template.$progress, touch(60, 0))
assert.deepEqual(f.calls, [])
f.dispose()
})
test('destroy inside absolute progress notification prevents the following seek', () => {
const f = fixture()
f.art.on('setBar', f.dispose)
f.gesture.start(f.art.template.$progress, touch(60, 0))
assert.equal(f.art.currentTime, 20)
assert.equal(f.calls.length, 1)
f.gesture.move(touch(80, 0))
assert.equal(f.calls.length, 1)
})
test('destroy inside relative seek prevents bar and notice updates', () => {
const f = fixture()
f.art.on('seek', f.dispose)
f.gesture.start(f.art.template.$video, touch(0, 0))
f.gesture.move(touch(40, 0))
assert.deepEqual(f.calls, [['seek', 30]])
assert.equal(f.art.notice.show, '')
})
test('nested touch start supersedes the interrupted absolute progress transaction', () => {
const f = fixture()
f.art.once('setBar', () => f.gesture.start(f.art.template.$video, touch(0, 0, 2)))
f.gesture.start(f.art.template.$progress, touch(60, 0))
assert.equal(f.art.currentTime, 20)
f.gesture.move(touch(40, 0, 2))
assert.equal(f.art.currentTime, 30)
f.dispose()
})
test('failed progress start retains the caller error and cancels the unfinished drag', () => {
const f = fixture()
const error = new Error('progress callback failed')
f.art.once('setBar', () => {
throw error
})
assert.throws(() => f.gesture.start(f.art.template.$progress, touch(60, 0)), thrown => thrown === error)
f.gesture.move(touch(80, 0))
assert.equal(f.art.currentTime, 20)
f.dispose()
})
test('touch end and owner disposal remove internal subscriptions but preserve external listeners', () => {
const f = fixture()
let external = 0
f.art.on('document:touchend', () => external++)
f.gesture.start(f.art.template.$video, touch(0, 0))
f.art.emit('document:touchend', {})
f.gesture.move(touch(40, 0))
assert.equal(f.art.currentTime, 20)
f.dispose()
f.art.emit('document:touchend', {})
assert.equal(external, 2)
assert.equal(f.art.e['document:touchcancel'], undefined)
assert.equal(f.art.e.lock, undefined)
})
test('mobile gesture=false still installs progress interactions, while live mode installs none', () => {
const f = fixture()
f.art.option.gesture = false
const registrations = []
const events = { proxy: (target, name) => registrations.push([target, name]) }
gestureInit(f.art, events)
assert.deepEqual(registrations.map(item => item[1]), ['touchstart', 'touchmove', 'touchcancel'])
assert(registrations.every(item => item[0] === f.art.template.$progress))
f.art.option.isLive = true
registrations.length = 0
gestureInit(f.art, events)
assert.deepEqual(registrations, [])
f.dispose()
})
+133
View File
@@ -0,0 +1,133 @@
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 { globalInit, ownListeners, proxyListener, removeListener, destroyListeners, beginLifecycle, getScope } = await loadModules({
globalInit: 'packages/artplayer/src/events/globalInit',
...Object.fromEntries(['ownListeners', 'proxyListener', 'removeListener', 'destroyListeners'].map(name => [name, { file: 'packages/artplayer/src/events/listener-registry', name }])),
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function source() {
const window = new EventTarget()
const document = Object.assign(new EventTarget(), { defaultView: window })
return { document, window }
}
function fixture() {
const initial = source()
const received = []
const art = {
template: { $player: { ownerDocument: initial.document } },
emit: (...args) => received.push(args),
}
beginLifecycle(art)
const registry = { destroyEvents: new Set() }
ownListeners(registry, art)
registry.proxy = (...args) => proxyListener(registry, ...args)
registry.remove = dispose => removeListener(registry, dispose)
const scope = getScope(art)
scope.add(() => {
destroyListeners(registry)
return undefined
})
globalInit(art, registry)
return { initial, art, registry, scope, received }
}
test('global rebinding selects current owner targets, keeps event identity and original window fallback', () => {
const f = fixture()
const next = source()
const event = new Event('mouseup')
const scroll = new Event('scroll')
f.registry.bindGlobalEvents({ document: next.document })
f.initial.document.dispatchEvent(new Event('mouseup'))
next.document.dispatchEvent(event)
next.window.dispatchEvent(new Event('scroll'))
f.initial.window.dispatchEvent(scroll)
assert.deepEqual(f.received, [['document:mouseup', event], ['window:scroll', scroll]])
f.art.template.$player.ownerDocument = next.document
f.registry.bindGlobalEvents()
f.initial.window.dispatchEvent(new Event('scroll'))
next.window.dispatchEvent(scroll)
assert.equal(f.received.length, 3)
f.scope.dispose()
next.document.dispatchEvent(event)
assert.equal(f.received.length, 3)
assert.equal(f.registry.destroyEvents.size, 0)
})
test('window registration failure rolls back the staged document while old listeners keep working', () => {
const f = fixture()
const next = source()
const failure = new Error('window failure')
next.window.addEventListener = () => {
throw failure
}
assert.throws(() => f.registry.bindGlobalEvents(next), error => error === failure)
const event = new Event('mouseup')
next.document.dispatchEvent(new Event('mouseup'))
f.initial.document.dispatchEvent(event)
assert.deepEqual(f.received, [['document:mouseup', event]])
assert.equal(f.registry.destroyEvents.size, 15)
f.scope.dispose()
})
test('staged listeners do not forward before commit while the previous binding remains active', () => {
const f = fixture()
const next = source()
const original = next.window.addEventListener
const oldEvent = new Event('mouseup')
next.window.addEventListener = function (name, ...args) {
if (name === 'resize') {
next.document.dispatchEvent(new Event('mouseup'))
f.initial.document.dispatchEvent(oldEvent)
}
return original.call(this, name, ...args)
}
f.registry.bindGlobalEvents(next)
assert.deepEqual(f.received, [['document:mouseup', oldEvent]])
const nextEvent = new Event('mouseup')
next.document.dispatchEvent(nextEvent)
f.initial.document.dispatchEvent(new Event('mouseup'))
assert.deepEqual(f.received, [['document:mouseup', oldEvent], ['document:mouseup', nextEvent]])
f.scope.dispose()
})
test('a newer reentrant global binding wins and the interrupted registration is released', () => {
const f = fixture()
const interrupted = source()
const newest = source()
const original = interrupted.document.addEventListener
interrupted.document.addEventListener = function (name, ...args) {
if (name === 'click')
f.registry.bindGlobalEvents(newest)
return original.call(this, name, ...args)
}
f.registry.bindGlobalEvents(interrupted)
f.initial.document.dispatchEvent(new Event('mouseup'))
interrupted.document.dispatchEvent(new Event('mouseup'))
const event = new Event('mouseup')
newest.document.dispatchEvent(event)
assert.deepEqual(f.received, [['document:mouseup', event]])
assert.equal(f.registry.destroyEvents.size, 15)
f.scope.dispose()
})
test('destroy during rebinding clears staged and old listeners and later bind remains inert', () => {
const f = fixture()
const next = source()
const original = next.document.addEventListener
next.document.addEventListener = function (...args) {
f.scope.dispose()
return original.apply(this, args)
}
f.registry.bindGlobalEvents(next)
assert.equal(f.registry.destroyEvents.size, 0)
f.registry.bindGlobalEvents()
f.initial.document.dispatchEvent(new Event('mouseup'))
next.document.dispatchEvent(new Event('mouseup'))
assert.deepEqual(f.received, [])
})
+168
View File
@@ -0,0 +1,168 @@
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 { Hotkey, Emitter, beginLifecycle, getScope } = await loadModules({
Hotkey: 'packages/artplayer/src/hotkey',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture(enabled = true) {
const document = { activeElement: { nodeType: 1, tagName: 'BODY', isContentEditable: false } }
const art = Object.assign(new Emitter(), {
template: { $player: { ownerDocument: document } },
constructor: { SEEK_STEP: 5, VOLUME_STEP: 0.1 },
option: { hotkey: enabled },
isFocus: true,
fullscreenWeb: true,
volume: 0.5,
toggle: () => undefined,
})
beginLifecycle(art)
const hotkey = new Hotkey(art)
function dispatch(code = 'KeyK', options = {}) {
const event = Object.assign(new Event('keydown', { cancelable: true }), { code, ...options })
art.emit('document:keydown', event)
return event
}
return { art, hotkey, document, dispatch, dispose: () => getScope(art).dispose() }
}
test('hotkeys preserve own art/keys fields, public prototype and registration identity', () => {
const f = fixture()
assert.deepEqual(Object.keys(f.hotkey), ['art', 'keys'])
assert.deepEqual(Object.getOwnPropertyNames(Hotkey.prototype), ['constructor', 'init', 'add', 'remove'])
assert.equal(Object.getPrototypeOf(f.hotkey.keys), Object.prototype)
for (const key of ['__proto__', 'constructor', 'toString']) {
const callback = () => {}
assert.equal(f.hotkey.add(key, callback), f.hotkey)
const array = f.hotkey.keys[key]
f.hotkey.add(key, callback)
assert.equal(array.length, 1)
assert.equal(f.hotkey.remove(key, callback), f.hotkey)
assert.deepEqual(array, [])
assert.equal(Object.hasOwn(f.hotkey.keys, key), false)
}
assert.equal(Object.getPrototypeOf(f.hotkey.keys), Object.prototype)
f.dispose()
})
test('default hotkeys preserve live constants, operations and inactive focus behavior', () => {
const f = fixture()
f.dispatch('Escape')
assert.equal(f.art.fullscreenWeb, false)
f.art.constructor.SEEK_STEP = 9
f.dispatch('ArrowLeft')
f.dispatch('ArrowRight')
assert.equal(f.art.backward, 9)
assert.equal(f.art.forward, 9)
f.dispatch('ArrowUp')
assert.equal(f.art.volume, 0.6)
f.art.isFocus = false
assert.equal(f.dispatch('ArrowDown').defaultPrevented, false)
assert.equal(f.art.volume, 0.6)
f.dispose()
})
test('default disabling still permits custom keys and later initialization installs defaults once', () => {
const f = fixture(false)
assert.deepEqual(Object.keys(f.hotkey.keys), [])
let hits = 0
f.hotkey.add('KeyK', () => hits++)
f.dispatch()
f.art.option.hotkey = true
f.hotkey.init()
f.hotkey.init()
f.dispatch()
assert.equal(hits, 2)
assert.equal(f.hotkey.keys.Space.length, 1)
f.dispose()
})
test('explicit reinitialization restores removed defaults without adding duplicate callbacks', () => {
const f = fixture()
const callback = f.hotkey.keys.ArrowRight[0]
f.hotkey.remove('ArrowRight', callback)
assert.equal(f.hotkey.keys.ArrowRight, undefined)
f.hotkey.init()
f.hotkey.init()
assert.deepEqual(f.hotkey.keys.ArrowRight, [callback])
assert.equal(f.hotkey.keys.Space.length, 1)
f.dispatch('ArrowRight')
assert.equal(f.art.forward, 5)
f.dispose()
})
test('key dispatch retains callback receiver, event order and live-array removal behavior', () => {
const f = fixture()
const calls = []
function first(event) {
calls.push(['first', this === f.art, event.defaultPrevented])
f.hotkey.remove('KeyK', first)
}
f.hotkey.add('KeyK', first)
f.hotkey.add('KeyK', () => calls.push('second'))
f.art.on('hotkey', () => calls.push('hotkey'))
f.art.on('keydown', () => calls.push('keydown'))
f.dispatch()
f.dispatch()
assert.deepEqual(calls, [['first', true, true], 'hotkey', 'keydown', 'second', 'hotkey', 'keydown'])
f.dispose()
})
test('destroy during a hotkey stops later callbacks and public notifications', () => {
const f = fixture()
const calls = []
f.hotkey.add('KeyK', () => {
calls.push('first')
f.dispose()
})
f.hotkey.add('KeyK', () => calls.push('second'))
f.art.on('hotkey', () => calls.push('hotkey'))
f.art.on('keydown', () => calls.push('keydown'))
f.dispatch()
f.hotkey.init()
f.dispatch()
assert.deepEqual(calls, ['first'])
assert.equal(f.art.e['document:keydown'], undefined)
})
test('synchronous hotkey errors retain identity and do not emit success notifications', () => {
const f = fixture()
const error = new Error('callback failed')
f.hotkey.add('KeyK', () => {
throw error
})
let downstream = 0
f.art.on('hotkey', () => downstream++)
f.art.on('keydown', () => downstream++)
assert.throws(() => f.dispatch(), value => value === error)
assert.equal(downstream, 0)
f.dispose()
})
test('modifiers and composition suppress hotkeys but preserve generic keydown delivery', () => {
const f = fixture()
let keys = 0
let generic = 0
f.hotkey.add('KeyK', () => keys++)
f.art.on('keydown', () => generic++)
for (const options of [{ altKey: true }, { ctrlKey: true }, { metaKey: true }, { shiftKey: true }, { isComposing: true }, { keyCode: 229 }])
assert.equal(f.dispatch('KeyK', options).defaultPrevented, false)
assert.equal(keys, 0)
assert.equal(generic, 6)
f.dispose()
})
test('an absent active element still permits a focused player key without throwing', () => {
const f = fixture()
f.document.activeElement = null
let hits = 0
f.hotkey.add('KeyK', () => hits++)
assert.equal(f.dispatch().defaultPrevented, true)
assert.equal(hits, 1)
f.dispose()
})
+201
View File
@@ -0,0 +1,201 @@
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 { ownListeners, proxyListener, removeListener, destroyListeners, beginLifecycle, getScope } = await loadModules({
...Object.fromEntries(['ownListeners', 'proxyListener', 'removeListener', 'destroyListeners'].map(name => [name, { file: 'packages/artplayer/src/events/listener-registry', name }])),
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const art = {}
beginLifecycle(art)
const registry = { destroyEvents: new Set() }
ownListeners(registry, art)
const scope = getScope(art)
scope.add(() => {
destroyListeners(registry)
return undefined
})
return { registry, scope, target: new EventTarget() }
}
test('native callbacks keep identity, receiver and event; direct disposal is idempotent', () => {
const { registry, target, scope } = fixture()
const received = []
function callback(event) {
received.push([this, event])
}
const dispose = proxyListener(registry, target, 'example', callback)
const event = new Event('example')
target.dispatchEvent(event)
assert.deepEqual(received, [[target, event]])
target.removeEventListener('example', callback)
target.dispatchEvent(event)
assert.equal(received.length, 1)
assert.equal(dispose(), undefined)
assert.equal(dispose(), undefined)
assert.equal(registry.destroyEvents.size, 0)
scope.dispose()
})
test('capture is read once and removal uses the registration snapshot', () => {
const { registry, scope } = fixture()
const calls = []
const target = {
addEventListener: (...args) => calls.push(['add', ...args]),
removeEventListener: (...args) => calls.push(['remove', ...args]),
}
let reads = 0
const callback = () => {}
const options = {
get capture() { return ++reads === 1 },
once: true,
passive: false,
}
proxyListener(registry, target, 'example', callback, options)()
assert.equal(reads, 1)
assert.deepEqual(calls, [
['add', 'example', callback, { capture: true, once: true, passive: false, signal: undefined }],
['remove', 'example', callback, true],
])
scope.dispose()
})
test('once and object listeners use native dispatch; explicit disposal clears their records', () => {
const { registry, target, scope } = fixture()
const listener = {
hits: 0,
handleEvent() { this.hits++ },
}
const dispose = proxyListener(registry, target, 'example', listener, { once: true })
target.dispatchEvent(new Event('example'))
target.dispatchEvent(new Event('example'))
assert.equal(listener.hits, 1)
assert.equal(registry.destroyEvents.has(dispose), true)
dispose()
assert.equal(registry.destroyEvents.size, 0)
scope.dispose()
})
test('aborted signals skip registration and later abort clears the owned record', () => {
const { registry, target, scope } = fixture()
let hits = 0
const controller = new AbortController()
proxyListener(registry, target, 'example', () => hits++, { signal: controller.signal })
target.dispatchEvent(new Event('example'))
controller.abort()
target.dispatchEvent(new Event('example'))
assert.equal(hits, 1)
assert.equal(registry.destroyEvents.size, 0)
proxyListener(registry, target, 'example', () => hits++, { signal: controller.signal })
target.dispatchEvent(new Event('example'))
assert.equal(hits, 1)
assert.equal(registry.destroyEvents.size, 0)
scope.dispose()
})
test('registration failure after native add rolls back an entire array and keeps error identity', () => {
const { registry, target, scope } = fixture()
const original = target.addEventListener
const failure = new Error('registration failed')
target.addEventListener = function (name, ...args) {
original.call(this, name, ...args)
if (name === 'second')
throw failure
}
let hits = 0
assert.throws(() => proxyListener(registry, target, ['first', 'second'], () => hits++), error => error === failure)
target.dispatchEvent(new Event('first'))
target.dispatchEvent(new Event('second'))
assert.equal(hits, 0)
assert.equal(registry.destroyEvents.size, 0)
scope.dispose()
})
test('destruction during native registration removes a listener added after cleanup', () => {
const { registry, target, scope } = fixture()
const original = target.addEventListener
target.addEventListener = function (...args) {
scope.dispose()
original.apply(this, args)
}
let hits = 0
proxyListener(registry, target, 'example', () => hits++)
target.dispatchEvent(new Event('example'))
assert.equal(hits, 0)
assert.equal(registry.destroyEvents.size, 0)
assert.equal(scope.closed, true)
})
test('failed removal retains a retryable record and does not prevent other cleanup', (t) => {
const { registry, target, scope } = fixture()
const failure = new Error('temporary removal failure')
const warnings = []
t.mock.method(console, 'warn', (...args) => warnings.push(args))
const original = target.removeEventListener
let denied = true
target.removeEventListener = function (name, ...args) {
if (denied && name === 'first')
throw failure
original.call(this, name, ...args)
}
const calls = []
const first = proxyListener(registry, target, 'first', () => calls.push('first'))
proxyListener(registry, target, 'second', () => calls.push('second'))
removeListener(registry, first)
assert.equal(registry.destroyEvents.size, 2)
destroyListeners(registry)
assert.equal(registry.destroyEvents.size, 1)
target.dispatchEvent(new Event('first'))
target.dispatchEvent(new Event('second'))
assert.deepEqual(calls, ['first'])
assert.deepEqual(warnings.map(item => item[1]), [failure, failure])
denied = false
destroyListeners(registry)
assert.equal(registry.destroyEvents.size, 0)
target.dispatchEvent(new Event('first'))
assert.deepEqual(calls, ['first'])
scope.dispose()
})
test('manual destroy suppresses reentrant registrations but allows reuse until owner closes', () => {
const { registry, target, scope } = fixture()
const original = target.removeEventListener
let hits = 0
target.removeEventListener = function (...args) {
destroyListeners(registry)
proxyListener(registry, target, 'reentrant', () => hits++)
original.apply(this, args)
}
proxyListener(registry, target, 'first', () => hits++)
destroyListeners(registry)
target.dispatchEvent(new Event('reentrant'))
assert.equal(hits, 0)
assert.equal(registry.destroyEvents.size, 0)
proxyListener(registry, target, 'next', () => hits++)
target.dispatchEvent(new Event('next'))
assert.equal(hits, 1)
scope.dispose()
proxyListener(registry, target, 'closed', () => hits++)
target.dispatchEvent(new Event('closed'))
assert.equal(hits, 1)
assert.equal(registry.destroyEvents.size, 0)
})
test('duplicate registrations preserve native callback deduplication without reference counting', () => {
const { registry, target, scope } = fixture()
let hits = 0
const callback = () => hits++
const first = proxyListener(registry, target, 'example', callback)
proxyListener(registry, target, 'example', callback)
target.dispatchEvent(new Event('example'))
assert.equal(hits, 1)
first()
target.dispatchEvent(new Event('example'))
assert.equal(hits, 1)
scope.dispose()
assert.equal(registry.destroyEvents.size, 0)
})
+161
View File
@@ -0,0 +1,161 @@
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 { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { clickInit, hoverInit, moveInit, Emitter, beginLifecycle, getScope } = await loadModules({
clickInit: 'packages/artplayer/src/events/clickInit',
hoverInit: 'packages/artplayer/src/events/hoverInit',
moveInit: 'packages/artplayer/src/events/moveInit',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
function fixture() {
const classes = new Set()
const $player = { classList: { add: name => classes.add(name), remove: name => classes.delete(name) } }
const $video = {}
const calls = []
const callbacks = new Map()
const art = Object.assign(new Emitter(), {
template: { $player, $video },
constructor: { DBCLICK_TIME: 300, DBCLICK_FULLSCREEN: true, MOBILE_CLICK_PLAY: false, MOBILE_DBCLICK_PLAY: true },
isFocus: false,
isInput: false,
isLock: false,
fullscreen: false,
toggle: () => calls.push('toggle'),
})
beginLifecycle(art)
const events = {
proxy(target, name, callback) {
callbacks.set(name, callback)
return () => callbacks.delete(name)
},
hover(target, enter, leave) {
callbacks.set('mouseenter', enter)
callbacks.set('mouseleave', leave)
},
}
clickInit(art, events)
hoverInit(art, events)
moveInit(art, events)
return { art, classes, calls, callbacks, dispose: () => getScope(art).dispose() }
}
test('click grouping keeps the inclusive threshold, third-click reset and live settings', (t) => {
const f = fixture()
let now = 0
t.mock.method(Date, 'now', () => now)
f.art.on('click', () => f.calls.push('click'))
f.art.on('dblclick', () => f.calls.push('dblclick'))
const click = f.callbacks.get('click')
click(new Event('click'))
now = 300
click(new Event('click'))
assert.equal(f.art.fullscreen, true)
now = 301
click(new Event('click'))
now = 602
click(new Event('click'))
f.art.constructor.DBCLICK_FULLSCREEN = false
click(new Event('click'))
assert.equal(f.art.fullscreen, true)
assert.deepEqual(f.calls, ['click', 'toggle', 'dblclick', 'click', 'toggle', 'click', 'toggle', 'dblclick'])
f.dispose()
})
for (const name of ['click', 'dblclick']) {
test(`destruction during ${name} stops the remaining playback or fullscreen action`, () => {
const f = fixture()
f.art.constructor.DBCLICK_TIME = 60000
f.art.on(name, f.dispose)
f.callbacks.get('click')(new Event('click'))
if (name === 'dblclick')
f.callbacks.get('click')(new Event('click'))
assert.equal(f.art.fullscreen, false)
assert.deepEqual(f.calls, name === 'click' ? [] : ['toggle'])
f.callbacks.get('click')(new Event('click'))
assert.deepEqual(f.calls, name === 'click' ? [] : ['toggle'])
})
}
test('click callback errors retain identity and stop the default action', () => {
const f = fixture()
const failure = new Error('caller failed')
const callback = () => {
throw failure
}
f.art.on('click', callback)
assert.throws(() => f.callbacks.get('click')(new Event('click')), error => error === failure)
assert.deepEqual(f.calls, [])
f.dispose()
})
test('click-triggered playback rejection remains handled internally', async () => {
const f = fixture()
f.art.toggle = () => Promise.reject(new DOMException('interrupted', 'AbortError'))
f.callbacks.get('click')(new Event('click'))
await setImmediate()
f.dispose()
})
test('nested clicks preserve the historical synchronous grouping and event identity', (t) => {
const f = fixture()
t.mock.method(Date, 'now', () => 100)
const first = new Event('click')
const nested = new Event('click')
f.art.on('click', (event) => {
assert.equal(event, first)
f.calls.push('click')
f.callbacks.get('click')(nested)
})
f.art.on('dblclick', event => f.calls.push(event === nested ? 'nested' : 'next'))
f.callbacks.get('click')(first)
assert.equal(f.art.fullscreen, true)
f.callbacks.get('click')(new Event('click'))
assert.equal(f.art.fullscreen, false)
assert.deepEqual(f.calls, ['click', 'nested', 'toggle', 'next'])
f.dispose()
})
test('focus flags retain input-only semantics and disposal removes only owned subscriptions', () => {
const f = fixture()
const events = []
f.art.on('focus', event => events.push(event))
const input = { tagName: 'INPUT' }
const event = { target: input, composedPath: () => [input, f.art.template.$player] }
f.art.emit('document:click', event)
assert.equal(f.art.isInput, true)
assert.equal(f.art.isFocus, true)
assert.equal(events[0], event)
input.tagName = 'TEXTAREA'
f.art.emit('document:contextmenu', event)
assert.equal(f.art.isInput, false)
let external = 0
f.art.on('document:click', () => external++)
f.dispose()
f.art.emit('document:click', { target: null, composedPath: () => [] })
assert.equal(f.art.isFocus, true)
assert.equal(external, 1)
assert.equal(events.length, 2)
})
test('hover and move preserve class-before-event ordering and reject stale callbacks', () => {
const f = fixture()
const states = []
const event = new Event('mousemove')
f.art.on('hover', (state, original) => states.push([state, original, f.classes.has('art-hover')]))
f.art.on('mousemove', original => states.push(['move', original]))
f.callbacks.get('mouseenter')(event)
f.callbacks.get('mousemove')(event)
f.callbacks.get('mouseleave')(event)
assert.deepEqual(states, [[true, event, true], ['move', event], [false, event, false]])
f.dispose()
f.callbacks.get('mouseenter')(event)
f.callbacks.get('mousemove')(event)
assert.equal(f.classes.size, 0)
assert.equal(states.length, 3)
})
+29
View File
@@ -0,0 +1,29 @@
import type { GlobalEventSource } from '../../packages/artplayer/src/events/global-types'
import type { ViewEvents, ViewHost } from '../../packages/artplayer/src/events/scheduling-types'
import type { EventsHost } from '../../packages/artplayer/src/events/types'
import Events from '../../packages/artplayer/src/events'
import { eventSubscriptions } from '../../packages/artplayer/src/events/subscriptions'
declare const host: EventsHost
declare const view: ViewHost
declare const source: GlobalEventSource
const events = new Events(host)
const target = document.createElement('button')
const dispose: () => void = events.proxy(target, 'click', event => event.preventDefault(), null)
const disposers: (() => void)[] = events.proxy(target, ['click', 'mouseenter'], { handleEvent: event => event.preventDefault() })
events.remove(dispose)
disposers.forEach(remove => remove())
events.bindGlobalEvents(source)
// @ts-expect-error Native registration requires a string name or array.
events.proxy(target, 123, () => {})
// @ts-expect-error Public hover supplies an Event, not a narrower keyboard event.
events.hover(target, (event: KeyboardEvent) => event.code)
const on = eventSubscriptions<ViewEvents>(view)
on('view', (visible) => {
const value: boolean = visible
return value
})
// @ts-expect-error View payloads are boolean.
on('view', (visible: string) => visible)
// @ts-expect-error The subscription factory only accepts its declared event map.
on('resize', () => {})
+16
View File
@@ -0,0 +1,16 @@
import type { GestureHost } from '../../packages/artplayer/src/input/gesture-types'
import type Artplayer from '../../packages/artplayer/types/artplayer'
import { gestureController } from '../../packages/artplayer/src/input/gesture-controller'
declare const art: Artplayer
declare const host: GestureHost
const gesture = gestureController(host)
gesture.start(host.template.$video, new TouchEvent('touchstart'))
gesture.move(new TouchEvent('touchmove'))
gesture.cancel()
// @ts-expect-error Gesture input requires touch coordinates and identifiers.
gesture.move(new MouseEvent('mousemove'))
art.on('document:touchcancel', (event) => {
const original: Event = event
return original
})
+18
View File
@@ -0,0 +1,18 @@
import type { HotkeyHost } from '../../packages/artplayer/src/input/hotkey-types'
import Hotkey from '../../packages/artplayer/src/hotkey'
declare const host: HotkeyHost & { extension: string }
const keys = new Hotkey(host)
keys.add('KeyK', function (event) {
const extension: string = this.extension
const code: string = event.code
return { extension, code }
}).remove('KeyK', () => {})
const same: typeof host = keys.art
// @ts-expect-error Keyboard callbacks receive a keyboard event.
keys.add('KeyK', (event: MouseEvent) => event.clientX)
// @ts-expect-error The callback receiver preserves the host extension type.
keys.add('KeyK', function (this: { extension: number }) {
return this.extension
})
export { same }