refactor(core): CORE-18 type builtins and own prompt lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 12:31:01 +08:00
1 parent a842058136
commit 98a1800032
55 files changed
+26511 -650

No files matched your search

+4 -4
View File
@@ -184,9 +184,9 @@ Controls and setting entries are managed through component registries.
Relevant implementation:
- `packages/artplayer/src/control/index.js`
- `packages/artplayer/src/setting/index.js`
- `packages/artplayer/src/utils/component.js`
- `packages/artplayer/src/control/index.ts`
- `packages/artplayer/src/setting/index.ts`
- `packages/artplayer/src/utils/component.ts`
Important behavior:
@@ -323,6 +323,6 @@ Start with the nearest sibling implementation instead of inventing a new pattern
- control/setting UI: `artplayer-plugin-hls-control`, `artplayer-plugin-dash-control`
- proxy behavior: `artplayer-proxy-canvas`, `artplayer-proxy-mediabunny`
- component lifecycle: `packages/artplayer/src/utils/component.js`
- component lifecycle: `packages/artplayer/src/utils/component.ts`
If a change spans source, examples, and packaging, make all three consistent in the same pass.
+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 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
!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
+397 -143
View File
@@ -361,13 +361,18 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$4 = /* @__PURE__ */ new WeakMap();
const states$6 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$4.set(owner, { scope: new ResourceScope(), destroying: false });
const state2 = { scope: new ResourceScope(), finalizers: new ResourceScope(), destroying: false };
states$6.set(owner, state2);
state2.scope.add(() => {
if (!state2.destroying)
state2.finalizers.dispose();
});
}
function stateOf$1(owner) {
const state2 = states$4.get(owner);
const state2 = states$6.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -375,6 +380,9 @@ function stateOf$1(owner) {
function getScope(owner) {
return stateOf$1(owner).scope;
}
function getFinalizationScope(owner) {
return stateOf$1(owner).finalizers;
}
function isClosing(owner) {
const state2 = stateOf$1(owner);
return state2.destroying || state2.scope.closed;
@@ -422,6 +430,7 @@ function destroyInstance(owner, instances2, removeHtml, removeSource, failed = f
state2.releaseContainer = void 0;
}
attempt(() => owner.emit("destroy"));
attempt(() => state2.finalizers.dispose());
if (failed && state2.rollback)
attempt(state2.rollback);
state2.rollback = void 0;
@@ -2536,12 +2545,12 @@ function hoverInit(art, events) {
}
);
}
const states$3 = /* @__PURE__ */ new WeakMap();
const states$5 = /* @__PURE__ */ new WeakMap();
function ownListeners(registry, art) {
states$3.set(registry, { scope: getScope(art), cleaning: false });
states$5.set(registry, { scope: getScope(art), cleaning: false });
}
function stateOf(registry) {
const state2 = states$3.get(registry);
const state2 = states$5.get(registry);
if (!state2)
throw new Error("ArtPlayer event registry has not been initialized");
return state2;
@@ -2823,12 +2832,12 @@ function acceptsHotkey(event, fallback) {
const first = asElement(event.composedPath?.()[0]) || asElement(event.target);
return !first || !editable(first);
}
const states$2 = /* @__PURE__ */ new WeakMap();
const states$4 = /* @__PURE__ */ new WeakMap();
class Hotkey {
constructor(art) {
this.art = art;
this.keys = {};
states$2.set(this, { subscribed: false });
states$4.set(this, { subscribed: false });
if (!isMobile)
this.init();
}
@@ -2836,7 +2845,7 @@ class Hotkey {
const art = this.art;
if (isClosing(art))
return;
const state2 = states$2.get(this);
const state2 = states$4.get(this);
if (art.option.hotkey) {
if (!state2.defaults) {
const callbacks = defaultHotkeys(art);
@@ -3028,40 +3037,85 @@ class Icons {
}
}
}
function pollInfo(art, scope, close2) {
const active2 = () => !scope.closed && !isClosing(art);
const fail = (error2) => {
try {
scope.dispose();
} catch (cleanupError) {
console.warn("Failed to release info polling resources:", cleanupError);
}
throw error2;
};
try {
const { proxy, constructor, template: { $infoPanel, $infoClose, $video } } = art;
const cleanup = proxy($infoClose, "click", () => {
if (active2())
close2();
});
scope.add(() => {
art.events.remove(cleanup);
});
const items = Array.from($infoPanel.querySelectorAll("[data-video]"));
const destroy = () => scope.dispose();
art.on("destroy", destroy);
scope.add(() => {
art.off("destroy", destroy);
});
const loop = () => {
try {
for (const item of items) {
if (!active2())
return;
const value = Reflect.get($video, item.dataset.video ?? "undefined");
if (!active2())
return;
const raw = typeof value === "number" ? value.toFixed(2) : value;
if (!active2())
return;
if (item.textContent !== raw) {
const text = raw == null ? null : `${raw}`;
if (!active2())
return;
item.textContent = text;
}
}
if (active2())
timeout(scope, loop, constructor.INFO_LOOP_TIME);
} catch (error2) {
fail(error2);
}
};
if (active2())
loop();
} catch (error2) {
fail(error2);
}
}
const states$3 = /* @__PURE__ */ new WeakMap();
class Info extends Component {
constructor(art) {
super(art);
this.name = "info";
if (!isMobile) {
states$3.set(this, { revision: 0 });
if (!isMobile)
this.init();
}
}
init() {
const {
proxy,
constructor,
template: { $infoPanel, $infoClose, $video }
} = this.art;
proxy($infoClose, "click", () => {
const state2 = states$3.get(this);
const revision = ++state2.revision;
state2.scope?.dispose();
if (isClosing(this.art) || state2.revision !== revision)
return;
const scope = getScope(this.art).child();
state2.scope = scope;
scope.add(() => {
if (state2.scope === scope)
state2.scope = void 0;
});
pollInfo(this.art, scope, () => {
this.show = false;
});
let cancel = () => {
};
const $types = queryAll("[data-video]", $infoPanel) || [];
this.art.on("destroy", () => cancel());
const scope = getScope(this.art);
function loop() {
for (let index = 0; index < $types.length; index++) {
const item = $types[index];
const value = $video[item.dataset.video];
const textContent = typeof value === "number" ? value.toFixed(2) : value;
if (item.textContent !== textContent) {
item.textContent = textContent;
}
}
cancel = timeout(scope, loop, constructor.INFO_LOOP_TIME);
}
loop();
}
}
class Layer extends Component {
@@ -3078,7 +3132,9 @@ class Loading extends Component {
constructor(art) {
super(art);
this.name = "loading";
append(art.template.$loading, art.icons.loading);
const icon = art.icons.loading;
if (!isClosing(art))
appendElement(art.template.$loading, icon);
}
}
class Mask extends Component {
@@ -3086,44 +3142,103 @@ class Mask extends Component {
super(art);
this.name = "mask";
const { template, icons, events } = art;
const $state = append(template.$state, icons.state);
const $error = append(template.$state, icons.error);
const stateIcon = icons.state;
if (isClosing(art))
return;
const $state = appendElement(template.$state, stateIcon);
const errorIcon = icons.error;
if (isClosing(art))
return;
const $error = appendElement(template.$state, errorIcon);
setStyle($error, "display", "none");
art.on("destroy", () => {
let terminalEventHandled = false;
const destroy = () => {
setStyle($state, "display", "none");
setStyle($error, "display", null);
if (isClosing(art))
terminalEventHandled = true;
};
art.on("destroy", destroy);
getFinalizationScope(art).add(() => {
try {
if (!terminalEventHandled)
destroy();
} finally {
art.off("destroy", destroy);
}
});
events.proxy(template.$state, "click", () => silencePromise(art.play()));
if (!isClosing(art)) {
events.proxy(template.$state, "click", () => {
if (!isClosing(art))
silencePromise(art.play());
});
}
}
}
const states$2 = /* @__PURE__ */ new WeakMap();
function cancelNotice(notice) {
const state2 = states$2.get(notice);
state2.revision++;
state2.pending = void 0;
if (notice.timer !== null) {
clearTimeout(notice.timer);
notice.timer = null;
}
}
class Notice {
constructor(art) {
this.art = art;
this.timer = null;
getScope(art).add(() => this.destroy());
art.on("destroy", () => this.destroy());
states$2.set(this, { revision: 0 });
const destroy = () => this.destroy();
art.on("destroy", destroy);
getScope(art).add(() => {
art.off("destroy", destroy);
});
getScope(art).add(() => {
this.destroy();
});
}
destroy() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
cancelNotice(this);
}
set show(msg) {
if (getScope(this.art).closed)
if (isClosing(this.art))
return;
const state2 = states$2.get(this);
state2.revision++;
const {
constructor,
template: { $player, $noticeInner }
} = this.art;
if (msg) {
cancelNotice(this);
const revision = state2.revision;
const active2 = () => !isClosing(this.art) && state2.revision === revision;
$noticeInner.textContent = msg instanceof Error ? msg.message.trim() : msg;
if (!active2())
return;
addClass($player, "art-notice-show");
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (!active2())
return;
const delay = constructor.NOTICE_TIME;
if (!active2())
return;
const pending2 = {};
state2.pending = pending2;
const timer = setTimeout(() => {
if (isClosing(this.art) || state2.pending !== pending2)
return;
state2.pending = void 0;
const before = state2.revision;
$noticeInner.textContent = "";
removeClass($player, "art-notice-show");
}, constructor.NOTICE_TIME);
if (!isClosing(this.art) && state2.revision === before)
removeClass($player, "art-notice-show");
}, delay);
if (active2())
this.timer = timer;
else
clearTimeout(timer);
} else {
removeClass($player, "art-notice-show");
}
@@ -5451,16 +5566,31 @@ function autoOrientation(art) {
}
};
}
function autoPlayback(art) {
const {
i18n,
icons,
storage,
constructor,
proxy,
template: { $poster }
} = art;
const $autoPlayback = art.layers.add({
function readTimes(storage) {
return storage.get("times") || {};
}
function installPlaybackRecords(art) {
const { storage, constructor } = art;
eventSubscriptions(art)("video:timeupdate", () => {
if (!art.playing)
return;
const times = readTimes(storage);
const keys2 = Object.keys(times);
const max = constructor.AUTO_PLAYBACK_MAX;
const key = art.option.id || art.option.url;
const time2 = art.currentTime;
if (isClosing(art))
return;
if (keys2.length > max)
delete times[keys2[0]];
times[key] = time2;
if (!isClosing(art))
storage.set("times", times);
});
}
function installResumePrompt(art) {
const { i18n, icons, storage, constructor, template: { $poster } } = art;
const element = art.layers.add({
name: "auto-playback",
html: `
<div class="art-auto-playback-close"></div>
@@ -5468,95 +5598,204 @@ function autoPlayback(art) {
<div class="art-auto-playback-jump"></div>
`
});
const $last = query(".art-auto-playback-last", $autoPlayback);
const $jump = query(".art-auto-playback-jump", $autoPlayback);
const $close = query(".art-auto-playback-close", $autoPlayback);
append($close, icons.close);
let timer = null;
art.on("video:timeupdate", () => {
if (art.playing) {
const times = storage.get("times") || {};
const keys2 = Object.keys(times);
if (keys2.length > constructor.AUTO_PLAYBACK_MAX) {
delete times[keys2[0]];
if (!element || isClosing(art))
return () => {
};
const $last = queryElement(".art-auto-playback-last", element);
const $jump = queryElement(".art-auto-playback-jump", element);
const $close = queryElement(".art-auto-playback-close", element);
appendElement($close, icons.close);
const owner = entryScope(element);
let current2;
let revision = 0;
const init = () => {
const generation = ++revision;
current2?.dispose();
if (owner.closed || isClosing(art) || revision !== generation)
return;
const scope = owner.child();
current2 = scope;
const active2 = () => current2 === scope && !scope.closed && !isClosing(art);
scope.add(() => {
if (current2 === scope) {
current2 = void 0;
setStyle(element, "display", "none");
}
times[art.option.id || art.option.url] = art.currentTime;
storage.set("times", times);
}
});
function init() {
const times = storage.get("times") || {};
});
const releaseSource = getSourceScope(art).add(() => {
scope.dispose();
});
scope.add(() => {
releaseSource();
});
const times = readTimes(storage);
const currentTime = times[art.option.id || art.option.url];
clearTimeout(timer);
setStyle($autoPlayback, "display", "none");
if (currentTime && currentTime >= constructor.AUTO_PLAYBACK_MIN) {
setStyle($autoPlayback, "display", "flex");
$last.textContent = `${i18n.get("Last Seen")} ${secondToTime(currentTime)}`;
$jump.textContent = i18n.get("Jump Play");
proxy($close, "click", () => {
setStyle($autoPlayback, "display", "none");
if (!active2())
return;
setStyle(element, "display", "none");
if (!active2() || !currentTime || !(currentTime >= constructor.AUTO_PLAYBACK_MIN))
return;
if (!active2())
return;
setStyle(element, "display", "flex");
if (!active2())
return;
const lastText = `${i18n.get("Last Seen")} ${secondToTime(currentTime)}`;
if (!active2())
return;
$last.textContent = lastText;
if (!active2())
return;
const jumpText = i18n.get("Jump Play");
if (!active2())
return;
$jump.textContent = jumpText;
if (!active2())
return;
const bind2 = (target, callback) => {
const cleanup = art.events.proxy(target, "click", () => {
if (active2())
callback();
});
proxy($jump, "click", () => {
art.seek = currentTime;
silencePromise(art.play());
setStyle($poster, "display", "none");
setStyle($autoPlayback, "display", "none");
scope.add(() => {
art.events.remove(cleanup);
});
art.once("video:timeupdate", () => {
timer = setTimeout(() => {
setStyle($autoPlayback, "display", "none");
}, constructor.AUTO_PLAYBACK_TIMEOUT);
});
}
}
art.on("ready", init);
art.on("restart", init);
};
bind2($close, () => {
setStyle(element, "display", "none");
});
if (!active2())
return;
bind2($jump, () => {
art.seek = currentTime;
if (!active2())
return;
silencePromise(art.play());
if (!active2())
return;
setStyle($poster, "display", "none");
if (active2())
setStyle(element, "display", "none");
});
if (!active2())
return;
let releaseUpdate = () => {
};
let fired = false;
const update = () => {
if (fired || !active2())
return;
fired = true;
releaseUpdate();
timeout(scope, () => {
if (active2())
setStyle(element, "display", "none");
}, constructor.AUTO_PLAYBACK_TIMEOUT);
};
art.on("video:timeupdate", update);
releaseUpdate = scope.add(() => {
art.off("video:timeupdate", update);
});
if (fired)
releaseUpdate();
};
return () => {
subscribeEntry(art, element, "ready", init);
subscribeEntry(art, element, "restart", init);
};
}
function autoPlayback(art) {
const { storage } = art;
const installPrompt = installResumePrompt(art);
installPlaybackRecords(art);
installPrompt();
return {
name: "auto-playback",
get times() {
return storage.get("times") || {};
return readTimes(storage);
},
clear() {
return storage.del("times");
},
delete(id2) {
const times = storage.get("times") || {};
const times = readTimes(storage);
delete times[id2];
storage.set("times", times);
return times;
}
};
}
function longPress(art) {
let current2;
let generation = 0;
const hasActivePress = () => current2?.active;
const stop = () => {
generation++;
current2?.scope.dispose();
};
const start = (event) => {
const revision = ++generation;
current2?.scope.dispose();
if (revision !== generation || isClosing(art) || event.touches.length !== 1 || !art.playing || art.isLock)
return;
const press = { scope: getSourceScope(art).child(), active: false, previousRate: 1 };
current2 = press;
press.scope.add(() => {
if (current2 !== press)
return;
current2 = void 0;
if (press.active) {
try {
art.playbackRate = press.previousRate;
} finally {
if (!hasActivePress())
removeClass(art.template.$player, "art-fast-forward");
}
}
});
timeout(press.scope, () => {
if (current2 !== press || isClosing(art) || !art.playing || art.isLock) {
press.scope.dispose();
return;
}
try {
press.previousRate = art.playbackRate;
if (current2 !== press || press.scope.closed)
return;
press.active = true;
const rate = art.constructor.FAST_FORWARD_VALUE;
if (current2 !== press || press.scope.closed || isClosing(art))
return;
art.playbackRate = rate;
if (current2 === press && !press.scope.closed && !isClosing(art))
addClass(art.template.$player, "art-fast-forward");
} catch (error2) {
try {
press.scope.dispose();
} catch (cleanupError) {
console.warn("Failed to restore fast-forward playback rate:", cleanupError);
}
throw error2;
}
}, art.constructor.FAST_FORWARD_TIME);
};
return { start, stop };
}
function fastForward(art) {
const {
constructor,
proxy,
template: { $player, $video }
} = art;
let timer = null;
let isPress = false;
let lastPlaybackRate = 1;
const onStart = (event) => {
if (event.touches.length === 1 && art.playing && !art.isLock) {
timer = setTimeout(() => {
isPress = true;
lastPlaybackRate = art.playbackRate;
art.playbackRate = constructor.FAST_FORWARD_VALUE;
addClass($player, "art-fast-forward");
}, constructor.FAST_FORWARD_TIME);
}
};
const onStop = () => {
clearTimeout(timer);
if (isPress) {
isPress = false;
art.playbackRate = lastPlaybackRate;
removeClass($player, "art-fast-forward");
}
};
proxy($video, "touchstart", onStart);
art.on("document:touchmove", onStop);
art.on("document:touchend", onStop);
const { proxy, template: { $player, $video } } = art;
const press = longPress(art);
const subscribe = eventSubscriptions(art);
proxy($video, "touchstart", (event) => press.start(event));
proxy($video, "touchcancel", press.stop);
subscribe("document:touchmove", press.stop);
subscribe("document:touchend", press.stop);
subscribe("document:touchcancel", press.stop);
subscribe("video:pause", press.stop);
subscribe("destroy", press.stop);
subscribe("lock", (locked) => {
if (locked)
press.stop();
});
return {
name: "fastForward",
get state() {
@@ -5574,29 +5813,44 @@ function lock(art) {
return hasClass($player, "art-lock");
}
function setLock() {
if (isClosing(art))
return;
addClass($player, "art-lock");
if (isClosing(art))
return;
art.isLock = true;
art.emit("lock", true);
if (!isClosing(art))
art.emit("lock", true);
}
function setUnlock() {
if (isClosing(art))
return;
removeClass($player, "art-lock");
if (isClosing(art))
return;
art.isLock = false;
art.emit("lock", false);
if (!isClosing(art))
art.emit("lock", false);
}
layers.add({
name: "lock",
mounted($el) {
const $lock = append($el, icons.lock);
const $unlock = append($el, icons.unlock);
const scope = entryScope($el);
if (scope.closed || isClosing(art))
return;
const $lock = appendElement($el, icons.lock);
if (scope.closed || isClosing(art))
return;
const $unlock = appendElement($el, icons.unlock);
if (scope.closed || isClosing(art))
return;
setStyle($lock, "display", "none");
art.on("lock", (state2) => {
if (state2) {
setStyle($lock, "display", "inline-flex");
setStyle($unlock, "display", "none");
} else {
setStyle($lock, "display", "none");
setStyle($unlock, "display", "inline-flex");
}
subscribeEntry(art, $el, "lock", (state2) => {
if (isClosing(art))
return;
setStyle($lock, "display", state2 ? "inline-flex" : "none");
if (!scope.closed && !isClosing(art))
setStyle($unlock, "display", state2 ? "none" : "inline-flex");
});
},
click() {
@@ -5622,7 +5876,7 @@ function lock(art) {
};
}
function miniProgressBar(art) {
art.on("control", (state2) => {
eventSubscriptions(art)("control", (state2) => {
if (state2) {
removeClass(art.template.$player, "art-mini-progress-bar");
} else {
+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/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: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/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+65 -1
View File
@@ -4,6 +4,69 @@ 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.
## Builtins and prompts (CORE-18)
`src/notice.ts` owns the notice timer and DOM visibility. Its minimal host, private
WeakMap generations and closure checks prevent expired or reentrant writes from
overwriting newer messages. Public art/timer fields and destroy/show methods remain.
False/empty assignment only hides the notice; the old pending expiry still clears
its text. Manual notice.destroy cancels the timer without hiding or permanently
disabling a live instance. The source getter is boolean; the legacy public message
read type remains tracked by BASE-TYPE-07 for compatible coordination in CORE-21.
`src/plugins/fastForward.ts` wires the unchanged plugin result and input events.
`src/input/long-press.ts` owns one press at a time in the current source scope,
including its timer and previous playback rate. Touch move/end/cancel, lock, pause,
source disposal and player destruction release the press. The native media element
still performs its own defaultPlaybackRate reset on load/source replacement.
Resource cleanup must not create a second press or overwrite a newer reentrant one.
The typed builtin factory cast is confined to the existing JS constructor boundary;
CORE-20 must revisit it when the full constructor host becomes typed.
Run `test/notice.test.js` and `test/fast-forward.test.js` through the Node runner;
their matching `test/types` fixtures check source inference and retained consumers.
The matching browser specs compare frozen releases with candidate builds using
actual video playback and controlled DOM touch events. They do not certify physical
touch devices.
`src/plugins/autoPlayback.ts` keeps the resume plugin's name/times/clear/delete
surface. `auto-playback/records.ts` writes the existing times member of storage
using option.id or option.url. Keep its historical strict `length > max` pruning
and single-oldest deletion; changing the retention policy is a separate behavior
decision. Playback recording remains instance-owned when the prompt layer is removed.
`auto-playback/prompt.ts` prepares the original DOM before recording is subscribed,
then installs ready/restart handlers. Each prompt owns its click listeners, one
first-timeupdate subscription and timer under its layer, and is also cancelled by
source disposal. Restart keeps only the latest resume target. Close only hides the
prompt; seek/play precede poster/prompt hiding unless reentry invalidates that work.
Extend `test/auto-playback.test.js`, its type fixture and browser spec for storage,
restart, source/layer lifecycle, or actual resume playback changes.
`src/plugins/lock.ts` preserves class -> isLock -> lock event ordering, including
notifications for repeated assignments. Its icon subscription belongs to the lock
layer; removing that layer does not disable a live plugin's state API. Destruction
does disable further writes through retained setters/clicks. `miniProgressBar.ts`
keeps its name-only result and control-driven class, with an instance-owned
subscription. Both stay in one file because their responsibilities remain small.
`test/builtin-layers.test.js` and its browser/type fixtures cover these contracts
and the mobile/desktop, live/VOD builtin installation matrix. User-agent fixtures
exercise the branch conditions, not physical mobile capability certification.
`src/info.ts` owns a single initialization generation without adding public fields;
`src/info/poll.ts` reads the current initialization's panel/media targets, converts
media values as the native textContent setter does, and owns its timer and close
listener. Reinitializing replaces that work; mobile construction still waits for an
explicit init. Polling continues while the panel is hidden to preserve old behavior.
`src/loading.ts` retains its simple icon insertion and Component facade.
`src/mask.ts` keeps its original destroy listener position so earlier/later consumer
observers see the same icon states. The internal lifecycle finalization scope runs
after destroy event dispatch (even on error) and performs any missing terminal
display/cleanup. Direct root-scope disposal releases finalizers after root resources.
Use this phase only for work whose original event order must remain observable;
ordinary timers/listeners still belong in the root or feature scope.
`test/prompt-components.test.js` and its browser/type fixtures cover these modules,
including getter/DOM reentry, conversion, listener order and throwing observers.
## Keyboard input (CORE-17)
hotkey.ts retains the art/keys own fields, init/add/remove prototype methods, plain
@@ -546,7 +609,8 @@ minimal option hosts and internal sync/Promise return types. builtins.ts install
five existing builtins in their original order and reads each condition at its turn.
It keeps the mobile/live exclusions, including no fastForward for live media. The
constructor captures option once before builtin installation, then traverses the
same live user-plugin array. Builtin implementations remain JS until CORE-18.
same live user-plugin array. All five builtin implementations are now TypeScript;
their minimum-host factory casts remain confined to this constructor boundary until CORE-20.
registration.ts owns result naming and non-enumerable/non-writable/non-configurable
registry properties. Names still prefer result.name, then factory.name, then 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 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
!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
+397 -143
View File
@@ -361,13 +361,18 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$4 = /* @__PURE__ */ new WeakMap();
const states$6 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$4.set(owner, { scope: new ResourceScope(), destroying: false });
const state2 = { scope: new ResourceScope(), finalizers: new ResourceScope(), destroying: false };
states$6.set(owner, state2);
state2.scope.add(() => {
if (!state2.destroying)
state2.finalizers.dispose();
});
}
function stateOf$1(owner) {
const state2 = states$4.get(owner);
const state2 = states$6.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -375,6 +380,9 @@ function stateOf$1(owner) {
function getScope(owner) {
return stateOf$1(owner).scope;
}
function getFinalizationScope(owner) {
return stateOf$1(owner).finalizers;
}
function isClosing(owner) {
const state2 = stateOf$1(owner);
return state2.destroying || state2.scope.closed;
@@ -422,6 +430,7 @@ function destroyInstance(owner, instances2, removeHtml, removeSource, failed = f
state2.releaseContainer = void 0;
}
attempt(() => owner.emit("destroy"));
attempt(() => state2.finalizers.dispose());
if (failed && state2.rollback)
attempt(state2.rollback);
state2.rollback = void 0;
@@ -2536,12 +2545,12 @@ function hoverInit(art, events) {
}
);
}
const states$3 = /* @__PURE__ */ new WeakMap();
const states$5 = /* @__PURE__ */ new WeakMap();
function ownListeners(registry, art) {
states$3.set(registry, { scope: getScope(art), cleaning: false });
states$5.set(registry, { scope: getScope(art), cleaning: false });
}
function stateOf(registry) {
const state2 = states$3.get(registry);
const state2 = states$5.get(registry);
if (!state2)
throw new Error("ArtPlayer event registry has not been initialized");
return state2;
@@ -2823,12 +2832,12 @@ function acceptsHotkey(event, fallback) {
const first = asElement(event.composedPath?.()[0]) || asElement(event.target);
return !first || !editable(first);
}
const states$2 = /* @__PURE__ */ new WeakMap();
const states$4 = /* @__PURE__ */ new WeakMap();
class Hotkey {
constructor(art) {
this.art = art;
this.keys = {};
states$2.set(this, { subscribed: false });
states$4.set(this, { subscribed: false });
if (!isMobile)
this.init();
}
@@ -2836,7 +2845,7 @@ class Hotkey {
const art = this.art;
if (isClosing(art))
return;
const state2 = states$2.get(this);
const state2 = states$4.get(this);
if (art.option.hotkey) {
if (!state2.defaults) {
const callbacks = defaultHotkeys(art);
@@ -3028,40 +3037,85 @@ class Icons {
}
}
}
function pollInfo(art, scope, close2) {
const active2 = () => !scope.closed && !isClosing(art);
const fail = (error2) => {
try {
scope.dispose();
} catch (cleanupError) {
console.warn("Failed to release info polling resources:", cleanupError);
}
throw error2;
};
try {
const { proxy, constructor, template: { $infoPanel, $infoClose, $video } } = art;
const cleanup = proxy($infoClose, "click", () => {
if (active2())
close2();
});
scope.add(() => {
art.events.remove(cleanup);
});
const items = Array.from($infoPanel.querySelectorAll("[data-video]"));
const destroy = () => scope.dispose();
art.on("destroy", destroy);
scope.add(() => {
art.off("destroy", destroy);
});
const loop = () => {
try {
for (const item of items) {
if (!active2())
return;
const value = Reflect.get($video, item.dataset.video ?? "undefined");
if (!active2())
return;
const raw = typeof value === "number" ? value.toFixed(2) : value;
if (!active2())
return;
if (item.textContent !== raw) {
const text = raw == null ? null : `${raw}`;
if (!active2())
return;
item.textContent = text;
}
}
if (active2())
timeout(scope, loop, constructor.INFO_LOOP_TIME);
} catch (error2) {
fail(error2);
}
};
if (active2())
loop();
} catch (error2) {
fail(error2);
}
}
const states$3 = /* @__PURE__ */ new WeakMap();
class Info extends Component {
constructor(art) {
super(art);
this.name = "info";
if (!isMobile) {
states$3.set(this, { revision: 0 });
if (!isMobile)
this.init();
}
}
init() {
const {
proxy,
constructor,
template: { $infoPanel, $infoClose, $video }
} = this.art;
proxy($infoClose, "click", () => {
const state2 = states$3.get(this);
const revision = ++state2.revision;
state2.scope?.dispose();
if (isClosing(this.art) || state2.revision !== revision)
return;
const scope = getScope(this.art).child();
state2.scope = scope;
scope.add(() => {
if (state2.scope === scope)
state2.scope = void 0;
});
pollInfo(this.art, scope, () => {
this.show = false;
});
let cancel = () => {
};
const $types = queryAll("[data-video]", $infoPanel) || [];
this.art.on("destroy", () => cancel());
const scope = getScope(this.art);
function loop() {
for (let index = 0; index < $types.length; index++) {
const item = $types[index];
const value = $video[item.dataset.video];
const textContent = typeof value === "number" ? value.toFixed(2) : value;
if (item.textContent !== textContent) {
item.textContent = textContent;
}
}
cancel = timeout(scope, loop, constructor.INFO_LOOP_TIME);
}
loop();
}
}
class Layer extends Component {
@@ -3078,7 +3132,9 @@ class Loading extends Component {
constructor(art) {
super(art);
this.name = "loading";
append(art.template.$loading, art.icons.loading);
const icon = art.icons.loading;
if (!isClosing(art))
appendElement(art.template.$loading, icon);
}
}
class Mask extends Component {
@@ -3086,44 +3142,103 @@ class Mask extends Component {
super(art);
this.name = "mask";
const { template, icons, events } = art;
const $state = append(template.$state, icons.state);
const $error = append(template.$state, icons.error);
const stateIcon = icons.state;
if (isClosing(art))
return;
const $state = appendElement(template.$state, stateIcon);
const errorIcon = icons.error;
if (isClosing(art))
return;
const $error = appendElement(template.$state, errorIcon);
setStyle($error, "display", "none");
art.on("destroy", () => {
let terminalEventHandled = false;
const destroy = () => {
setStyle($state, "display", "none");
setStyle($error, "display", null);
if (isClosing(art))
terminalEventHandled = true;
};
art.on("destroy", destroy);
getFinalizationScope(art).add(() => {
try {
if (!terminalEventHandled)
destroy();
} finally {
art.off("destroy", destroy);
}
});
events.proxy(template.$state, "click", () => silencePromise(art.play()));
if (!isClosing(art)) {
events.proxy(template.$state, "click", () => {
if (!isClosing(art))
silencePromise(art.play());
});
}
}
}
const states$2 = /* @__PURE__ */ new WeakMap();
function cancelNotice(notice) {
const state2 = states$2.get(notice);
state2.revision++;
state2.pending = void 0;
if (notice.timer !== null) {
clearTimeout(notice.timer);
notice.timer = null;
}
}
class Notice {
constructor(art) {
this.art = art;
this.timer = null;
getScope(art).add(() => this.destroy());
art.on("destroy", () => this.destroy());
states$2.set(this, { revision: 0 });
const destroy = () => this.destroy();
art.on("destroy", destroy);
getScope(art).add(() => {
art.off("destroy", destroy);
});
getScope(art).add(() => {
this.destroy();
});
}
destroy() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
cancelNotice(this);
}
set show(msg) {
if (getScope(this.art).closed)
if (isClosing(this.art))
return;
const state2 = states$2.get(this);
state2.revision++;
const {
constructor,
template: { $player, $noticeInner }
} = this.art;
if (msg) {
cancelNotice(this);
const revision = state2.revision;
const active2 = () => !isClosing(this.art) && state2.revision === revision;
$noticeInner.textContent = msg instanceof Error ? msg.message.trim() : msg;
if (!active2())
return;
addClass($player, "art-notice-show");
clearTimeout(this.timer);
this.timer = setTimeout(() => {
if (!active2())
return;
const delay = constructor.NOTICE_TIME;
if (!active2())
return;
const pending2 = {};
state2.pending = pending2;
const timer = setTimeout(() => {
if (isClosing(this.art) || state2.pending !== pending2)
return;
state2.pending = void 0;
const before = state2.revision;
$noticeInner.textContent = "";
removeClass($player, "art-notice-show");
}, constructor.NOTICE_TIME);
if (!isClosing(this.art) && state2.revision === before)
removeClass($player, "art-notice-show");
}, delay);
if (active2())
this.timer = timer;
else
clearTimeout(timer);
} else {
removeClass($player, "art-notice-show");
}
@@ -5451,16 +5566,31 @@ function autoOrientation(art) {
}
};
}
function autoPlayback(art) {
const {
i18n,
icons,
storage,
constructor,
proxy,
template: { $poster }
} = art;
const $autoPlayback = art.layers.add({
function readTimes(storage) {
return storage.get("times") || {};
}
function installPlaybackRecords(art) {
const { storage, constructor } = art;
eventSubscriptions(art)("video:timeupdate", () => {
if (!art.playing)
return;
const times = readTimes(storage);
const keys2 = Object.keys(times);
const max = constructor.AUTO_PLAYBACK_MAX;
const key = art.option.id || art.option.url;
const time2 = art.currentTime;
if (isClosing(art))
return;
if (keys2.length > max)
delete times[keys2[0]];
times[key] = time2;
if (!isClosing(art))
storage.set("times", times);
});
}
function installResumePrompt(art) {
const { i18n, icons, storage, constructor, template: { $poster } } = art;
const element = art.layers.add({
name: "auto-playback",
html: `
<div class="art-auto-playback-close"></div>
@@ -5468,95 +5598,204 @@ function autoPlayback(art) {
<div class="art-auto-playback-jump"></div>
`
});
const $last = query(".art-auto-playback-last", $autoPlayback);
const $jump = query(".art-auto-playback-jump", $autoPlayback);
const $close = query(".art-auto-playback-close", $autoPlayback);
append($close, icons.close);
let timer = null;
art.on("video:timeupdate", () => {
if (art.playing) {
const times = storage.get("times") || {};
const keys2 = Object.keys(times);
if (keys2.length > constructor.AUTO_PLAYBACK_MAX) {
delete times[keys2[0]];
if (!element || isClosing(art))
return () => {
};
const $last = queryElement(".art-auto-playback-last", element);
const $jump = queryElement(".art-auto-playback-jump", element);
const $close = queryElement(".art-auto-playback-close", element);
appendElement($close, icons.close);
const owner = entryScope(element);
let current2;
let revision = 0;
const init = () => {
const generation = ++revision;
current2?.dispose();
if (owner.closed || isClosing(art) || revision !== generation)
return;
const scope = owner.child();
current2 = scope;
const active2 = () => current2 === scope && !scope.closed && !isClosing(art);
scope.add(() => {
if (current2 === scope) {
current2 = void 0;
setStyle(element, "display", "none");
}
times[art.option.id || art.option.url] = art.currentTime;
storage.set("times", times);
}
});
function init() {
const times = storage.get("times") || {};
});
const releaseSource = getSourceScope(art).add(() => {
scope.dispose();
});
scope.add(() => {
releaseSource();
});
const times = readTimes(storage);
const currentTime = times[art.option.id || art.option.url];
clearTimeout(timer);
setStyle($autoPlayback, "display", "none");
if (currentTime && currentTime >= constructor.AUTO_PLAYBACK_MIN) {
setStyle($autoPlayback, "display", "flex");
$last.textContent = `${i18n.get("Last Seen")} ${secondToTime(currentTime)}`;
$jump.textContent = i18n.get("Jump Play");
proxy($close, "click", () => {
setStyle($autoPlayback, "display", "none");
if (!active2())
return;
setStyle(element, "display", "none");
if (!active2() || !currentTime || !(currentTime >= constructor.AUTO_PLAYBACK_MIN))
return;
if (!active2())
return;
setStyle(element, "display", "flex");
if (!active2())
return;
const lastText = `${i18n.get("Last Seen")} ${secondToTime(currentTime)}`;
if (!active2())
return;
$last.textContent = lastText;
if (!active2())
return;
const jumpText = i18n.get("Jump Play");
if (!active2())
return;
$jump.textContent = jumpText;
if (!active2())
return;
const bind2 = (target, callback) => {
const cleanup = art.events.proxy(target, "click", () => {
if (active2())
callback();
});
proxy($jump, "click", () => {
art.seek = currentTime;
silencePromise(art.play());
setStyle($poster, "display", "none");
setStyle($autoPlayback, "display", "none");
scope.add(() => {
art.events.remove(cleanup);
});
art.once("video:timeupdate", () => {
timer = setTimeout(() => {
setStyle($autoPlayback, "display", "none");
}, constructor.AUTO_PLAYBACK_TIMEOUT);
});
}
}
art.on("ready", init);
art.on("restart", init);
};
bind2($close, () => {
setStyle(element, "display", "none");
});
if (!active2())
return;
bind2($jump, () => {
art.seek = currentTime;
if (!active2())
return;
silencePromise(art.play());
if (!active2())
return;
setStyle($poster, "display", "none");
if (active2())
setStyle(element, "display", "none");
});
if (!active2())
return;
let releaseUpdate = () => {
};
let fired = false;
const update = () => {
if (fired || !active2())
return;
fired = true;
releaseUpdate();
timeout(scope, () => {
if (active2())
setStyle(element, "display", "none");
}, constructor.AUTO_PLAYBACK_TIMEOUT);
};
art.on("video:timeupdate", update);
releaseUpdate = scope.add(() => {
art.off("video:timeupdate", update);
});
if (fired)
releaseUpdate();
};
return () => {
subscribeEntry(art, element, "ready", init);
subscribeEntry(art, element, "restart", init);
};
}
function autoPlayback(art) {
const { storage } = art;
const installPrompt = installResumePrompt(art);
installPlaybackRecords(art);
installPrompt();
return {
name: "auto-playback",
get times() {
return storage.get("times") || {};
return readTimes(storage);
},
clear() {
return storage.del("times");
},
delete(id2) {
const times = storage.get("times") || {};
const times = readTimes(storage);
delete times[id2];
storage.set("times", times);
return times;
}
};
}
function longPress(art) {
let current2;
let generation = 0;
const hasActivePress = () => current2?.active;
const stop = () => {
generation++;
current2?.scope.dispose();
};
const start = (event) => {
const revision = ++generation;
current2?.scope.dispose();
if (revision !== generation || isClosing(art) || event.touches.length !== 1 || !art.playing || art.isLock)
return;
const press = { scope: getSourceScope(art).child(), active: false, previousRate: 1 };
current2 = press;
press.scope.add(() => {
if (current2 !== press)
return;
current2 = void 0;
if (press.active) {
try {
art.playbackRate = press.previousRate;
} finally {
if (!hasActivePress())
removeClass(art.template.$player, "art-fast-forward");
}
}
});
timeout(press.scope, () => {
if (current2 !== press || isClosing(art) || !art.playing || art.isLock) {
press.scope.dispose();
return;
}
try {
press.previousRate = art.playbackRate;
if (current2 !== press || press.scope.closed)
return;
press.active = true;
const rate = art.constructor.FAST_FORWARD_VALUE;
if (current2 !== press || press.scope.closed || isClosing(art))
return;
art.playbackRate = rate;
if (current2 === press && !press.scope.closed && !isClosing(art))
addClass(art.template.$player, "art-fast-forward");
} catch (error2) {
try {
press.scope.dispose();
} catch (cleanupError) {
console.warn("Failed to restore fast-forward playback rate:", cleanupError);
}
throw error2;
}
}, art.constructor.FAST_FORWARD_TIME);
};
return { start, stop };
}
function fastForward(art) {
const {
constructor,
proxy,
template: { $player, $video }
} = art;
let timer = null;
let isPress = false;
let lastPlaybackRate = 1;
const onStart = (event) => {
if (event.touches.length === 1 && art.playing && !art.isLock) {
timer = setTimeout(() => {
isPress = true;
lastPlaybackRate = art.playbackRate;
art.playbackRate = constructor.FAST_FORWARD_VALUE;
addClass($player, "art-fast-forward");
}, constructor.FAST_FORWARD_TIME);
}
};
const onStop = () => {
clearTimeout(timer);
if (isPress) {
isPress = false;
art.playbackRate = lastPlaybackRate;
removeClass($player, "art-fast-forward");
}
};
proxy($video, "touchstart", onStart);
art.on("document:touchmove", onStop);
art.on("document:touchend", onStop);
const { proxy, template: { $player, $video } } = art;
const press = longPress(art);
const subscribe = eventSubscriptions(art);
proxy($video, "touchstart", (event) => press.start(event));
proxy($video, "touchcancel", press.stop);
subscribe("document:touchmove", press.stop);
subscribe("document:touchend", press.stop);
subscribe("document:touchcancel", press.stop);
subscribe("video:pause", press.stop);
subscribe("destroy", press.stop);
subscribe("lock", (locked) => {
if (locked)
press.stop();
});
return {
name: "fastForward",
get state() {
@@ -5574,29 +5813,44 @@ function lock(art) {
return hasClass($player, "art-lock");
}
function setLock() {
if (isClosing(art))
return;
addClass($player, "art-lock");
if (isClosing(art))
return;
art.isLock = true;
art.emit("lock", true);
if (!isClosing(art))
art.emit("lock", true);
}
function setUnlock() {
if (isClosing(art))
return;
removeClass($player, "art-lock");
if (isClosing(art))
return;
art.isLock = false;
art.emit("lock", false);
if (!isClosing(art))
art.emit("lock", false);
}
layers.add({
name: "lock",
mounted($el) {
const $lock = append($el, icons.lock);
const $unlock = append($el, icons.unlock);
const scope = entryScope($el);
if (scope.closed || isClosing(art))
return;
const $lock = appendElement($el, icons.lock);
if (scope.closed || isClosing(art))
return;
const $unlock = appendElement($el, icons.unlock);
if (scope.closed || isClosing(art))
return;
setStyle($lock, "display", "none");
art.on("lock", (state2) => {
if (state2) {
setStyle($lock, "display", "inline-flex");
setStyle($unlock, "display", "none");
} else {
setStyle($lock, "display", "none");
setStyle($unlock, "display", "inline-flex");
}
subscribeEntry(art, $el, "lock", (state2) => {
if (isClosing(art))
return;
setStyle($lock, "display", state2 ? "inline-flex" : "none");
if (!scope.closed && !isClosing(art))
setStyle($unlock, "display", state2 ? "none" : "inline-flex");
});
},
click() {
@@ -5622,7 +5876,7 @@ function lock(art) {
};
}
function miniProgressBar(art) {
art.on("control", (state2) => {
eventSubscriptions(art)("control", (state2) => {
if (state2) {
removeClass(art.template.$player, "art-mini-progress-bar");
} else {
-46
View File
@@ -1,46 +0,0 @@
import { getScope } from './lifecycle/instance'
import { timeout } from './lifecycle/resources'
import { isMobile, queryAll } from './utils'
import Component from './utils/component'
export default class Info extends Component {
constructor(art) {
super(art)
this.name = 'info'
if (!isMobile) {
this.init()
}
}
init() {
const {
proxy,
constructor,
template: { $infoPanel, $infoClose, $video },
} = this.art
proxy($infoClose, 'click', () => {
this.show = false
})
let cancel = () => {}
const $types = queryAll('[data-video]', $infoPanel) || []
this.art.on('destroy', () => cancel())
const scope = getScope(this.art)
function loop() {
for (let index = 0; index < $types.length; index++) {
const item = $types[index]
const value = $video[item.dataset.video]
const textContent = typeof value === 'number' ? value.toFixed(2) : value
if (item.textContent !== textContent) {
item.textContent = textContent
}
}
cancel = timeout(scope, loop, constructor.INFO_LOOP_TIME)
}
loop()
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { InfoHost } from './info/poll'
import type ResourceScope from './lifecycle/scope'
import { pollInfo } from './info/poll'
import { getScope, isClosing } from './lifecycle/instance'
import { isMobile } from './utils'
import Component from './utils/component'
const states = new WeakMap<Info, { scope?: ResourceScope, revision: number }>()
export default class Info extends Component<InfoHost> {
constructor(art: InfoHost) {
super(art)
this.name = 'info'
states.set(this, { revision: 0 })
if (!isMobile)
this.init()
}
init(): void {
const state = states.get(this)!
const revision = ++state.revision
state.scope?.dispose()
if (isClosing(this.art) || state.revision !== revision)
return
const scope = getScope(this.art).child()
state.scope = scope
scope.add(() => {
if (state.scope === scope)
state.scope = undefined
})
pollInfo(this.art, scope, () => {
this.show = false
})
}
}
+71
View File
@@ -0,0 +1,71 @@
import type { SubscriptionHost } from '../component/resources'
import type { ComponentHost } from '../component/types'
import type ResourceScope from '../lifecycle/scope'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
export interface InfoHost extends ComponentHost, SubscriptionHost<{ destroy: [] }> {
template: { $player: HTMLElement, $infoPanel: HTMLElement, $infoClose: HTMLElement, $video: object }
constructor: { INFO_LOOP_TIME: number }
proxy: ComponentHost['events']['proxy']
}
export function pollInfo(art: InfoHost, scope: ResourceScope, close: () => void): void {
const active = () => !scope.closed && !isClosing(art)
const fail = (error: unknown): never => {
try {
scope.dispose()
}
catch (cleanupError) {
console.warn('Failed to release info polling resources:', cleanupError)
}
throw error
}
try {
const { proxy, constructor, template: { $infoPanel, $infoClose, $video } } = art
const cleanup = proxy($infoClose, 'click', () => {
if (active())
close()
})
scope.add(() => {
art.events.remove(cleanup)
})
const items = Array.from($infoPanel.querySelectorAll<HTMLElement>('[data-video]'))
const destroy = () => scope.dispose()
art.on('destroy', destroy)
scope.add(() => {
art.off('destroy', destroy)
})
const loop = () => {
try {
for (const item of items) {
if (!active())
return
const value: unknown = Reflect.get($video, item.dataset.video ?? 'undefined')
if (!active())
return
const raw = typeof value === 'number' ? value.toFixed(2) : value
if (!active())
return
if (item.textContent !== raw) {
// Match the DOM nullable-string conversion, including Symbol errors.
const text = raw == null ? null : `${raw}`
if (!active())
return
item.textContent = text
}
}
if (active())
timeout(scope, loop, constructor.INFO_LOOP_TIME)
}
catch (error) {
fail(error)
}
}
if (active())
loop()
}
catch (error) {
fail(error)
}
}
@@ -0,0 +1,80 @@
import type ResourceScope from '../lifecycle/scope'
import { isClosing } from '../lifecycle/instance'
import { timeout } from '../lifecycle/resources'
import { getSourceScope } from '../source/operation'
import { addClass, removeClass } from '../utils'
export interface LongPressHost {
template: { $player: HTMLElement }
constructor: { FAST_FORWARD_TIME: number, FAST_FORWARD_VALUE: number }
playing: boolean
isLock: boolean
playbackRate: number
}
interface Press {
scope: ResourceScope
active: boolean
previousRate: number
}
export function longPress(art: LongPressHost) {
let current: Press | undefined
let generation = 0
const hasActivePress = () => current?.active
const stop = () => {
generation++
current?.scope.dispose()
}
const start = (event: TouchEvent) => {
const revision = ++generation
current?.scope.dispose()
if (revision !== generation || isClosing(art) || event.touches.length !== 1 || !art.playing || art.isLock)
return
const press: Press = { scope: getSourceScope(art).child(), active: false, previousRate: 1 }
current = press
press.scope.add(() => {
if (current !== press)
return
current = undefined
if (press.active) {
try {
art.playbackRate = press.previousRate
}
finally {
// A callback may have already started and activated a newer press.
if (!hasActivePress())
removeClass(art.template.$player, 'art-fast-forward')
}
}
})
timeout(press.scope, () => {
if (current !== press || isClosing(art) || !art.playing || art.isLock) {
press.scope.dispose()
return
}
try {
press.previousRate = art.playbackRate
if (current !== press || press.scope.closed)
return
press.active = true
const rate = art.constructor.FAST_FORWARD_VALUE
if (current !== press || press.scope.closed || isClosing(art))
return
art.playbackRate = rate
if (current === press && !press.scope.closed && !isClosing(art))
addClass(art.template.$player, 'art-fast-forward')
}
catch (error) {
try {
press.scope.dispose()
}
catch (cleanupError) {
console.warn('Failed to restore fast-forward playback rate:', cleanupError)
}
throw error
}
}, art.constructor.FAST_FORWARD_TIME)
}
return { start, stop }
}
+13 -1
View File
@@ -9,6 +9,7 @@ interface LifecycleOwner {
interface State {
scope: ResourceScope
finalizers: ResourceScope
destroying: boolean
rollback?: () => void
releaseContainer?: () => void
@@ -18,7 +19,13 @@ const states = new WeakMap<object, State>()
const containers = new WeakMap<Element, object>()
export function beginLifecycle(owner: object): void {
states.set(owner, { scope: new ResourceScope(), destroying: false })
const state: State = { scope: new ResourceScope(), finalizers: new ResourceScope(), destroying: false }
states.set(owner, state)
state.scope.add(() => {
// Direct scope disposal has no destroy-event phase to wait for.
if (!state.destroying)
state.finalizers.dispose()
})
}
function stateOf(owner: object): State {
@@ -32,6 +39,10 @@ export function getScope(owner: object): ResourceScope {
return stateOf(owner).scope
}
export function getFinalizationScope(owner: object): ResourceScope {
return stateOf(owner).finalizers
}
export function isClosing(owner: object): boolean {
const state = stateOf(owner)
return state.destroying || state.scope.closed
@@ -83,6 +94,7 @@ export function destroyInstance(owner: LifecycleOwner, instances: LifecycleOwner
state.releaseContainer = undefined
}
attempt(() => owner.emit('destroy'))
attempt(() => state.finalizers.dispose())
if (failed && state.rollback)
attempt(state.rollback)
state.rollback = undefined
-10
View File
@@ -1,10 +0,0 @@
import { append } from './utils'
import Component from './utils/component'
export default class Loading extends Component {
constructor(art) {
super(art)
this.name = 'loading'
append(art.template.$loading, art.icons.loading)
}
}
+19
View File
@@ -0,0 +1,19 @@
import type { ComponentHost } from './component/types'
import { appendElement } from './component/dom'
import { isClosing } from './lifecycle/instance'
import Component from './utils/component'
export interface LoadingHost extends ComponentHost {
template: { $player: HTMLElement, $loading: HTMLElement }
icons: { loading: string | Element }
}
export default class Loading extends Component<LoadingHost> {
constructor(art: LoadingHost) {
super(art)
this.name = 'loading'
const icon = art.icons.loading
if (!isClosing(art))
appendElement(art.template.$loading, icon)
}
}
-23
View File
@@ -1,23 +0,0 @@
import { append, setStyle, silencePromise } from './utils'
import Component from './utils/component'
export default class Mask extends Component {
constructor(art) {
super(art)
this.name = 'mask'
const { template, icons, events } = art
const $state = append(template.$state, icons.state)
const $error = append(template.$state, icons.error)
setStyle($error, 'display', 'none')
art.on('destroy', () => {
setStyle($state, 'display', 'none')
setStyle($error, 'display', null)
})
events.proxy(template.$state, 'click', () => silencePromise(art.play()))
}
}
+57
View File
@@ -0,0 +1,57 @@
import type { SubscriptionHost } from './component/resources'
import type { ComponentHost } from './component/types'
import { appendElement } from './component/dom'
import { getFinalizationScope, isClosing } from './lifecycle/instance'
import { setStyle, silencePromise } from './utils'
import Component from './utils/component'
export interface MaskHost extends ComponentHost, SubscriptionHost<{ destroy: [] }> {
template: { $player: HTMLElement, $state: HTMLElement }
icons: { state: string | Element, error: string | Element }
play: () => unknown
}
export default class Mask extends Component<MaskHost> {
constructor(art: MaskHost) {
super(art)
this.name = 'mask'
const { template, icons, events } = art
const stateIcon = icons.state
if (isClosing(art))
return
const $state = appendElement(template.$state, stateIcon)
const errorIcon = icons.error
if (isClosing(art))
return
const $error = appendElement(template.$state, errorIcon)
setStyle($error, 'display', 'none')
let terminalEventHandled = false
const destroy = () => {
setStyle($state, 'display', 'none')
setStyle($error, 'display', null)
if (isClosing(art))
terminalEventHandled = true
}
art.on('destroy', destroy)
getFinalizationScope(art).add(() => {
try {
if (!terminalEventHandled)
destroy()
}
finally {
art.off('destroy', destroy)
}
})
if (!isClosing(art)) {
events.proxy(template.$state, 'click', () => {
if (!isClosing(art))
silencePromise(art.play())
})
}
}
}
-48
View File
@@ -1,48 +0,0 @@
import { getScope } from './lifecycle/instance'
import { addClass, removeClass } from './utils'
export default class Notice {
constructor(art) {
this.art = art
this.timer = null
getScope(art).add(() => this.destroy())
art.on('destroy', () => this.destroy())
}
destroy() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
set show(msg) {
if (getScope(this.art).closed)
return
const {
constructor,
template: { $player, $noticeInner },
} = this.art
if (msg) {
$noticeInner.textContent = msg instanceof Error ? msg.message.trim() : msg
addClass($player, 'art-notice-show')
clearTimeout(this.timer)
this.timer = setTimeout(() => {
$noticeInner.textContent = ''
removeClass($player, 'art-notice-show')
}, constructor.NOTICE_TIME)
}
else {
removeClass($player, 'art-notice-show')
}
}
get show() {
const {
template: { $player },
} = this.art
return $player.classList.contains('art-notice-show')
}
}
+99
View File
@@ -0,0 +1,99 @@
import type { SubscriptionHost } from './component/resources'
import { getScope, isClosing } from './lifecycle/instance'
import { addClass, removeClass } from './utils'
interface NoticeHost extends SubscriptionHost<{ destroy: [] }> {
constructor: { NOTICE_TIME: number }
template: { $player: HTMLElement, $noticeInner: HTMLElement }
}
interface NoticeState {
revision: number
pending?: object
}
const states = new WeakMap<Notice, NoticeState>()
function cancelNotice(notice: Notice): void {
const state = states.get(notice)!
state.revision++
state.pending = undefined
if (notice.timer !== null) {
clearTimeout(notice.timer)
notice.timer = null
}
}
export default class Notice {
declare art: NoticeHost
declare timer: ReturnType<typeof setTimeout> | null
constructor(art: NoticeHost) {
this.art = art
this.timer = null
states.set(this, { revision: 0 })
const destroy = () => this.destroy()
art.on('destroy', destroy)
getScope(art).add(() => {
art.off('destroy', destroy)
})
getScope(art).add(() => {
this.destroy()
})
}
destroy(): void {
cancelNotice(this)
}
set show(msg: string | Error | false) {
if (isClosing(this.art))
return
const state = states.get(this)!
state.revision++
const {
constructor,
template: { $player, $noticeInner },
} = this.art
if (msg) {
cancelNotice(this)
const revision = state.revision
const active = () => !isClosing(this.art) && state.revision === revision
$noticeInner.textContent = msg instanceof Error ? msg.message.trim() : msg
if (!active())
return
addClass($player, 'art-notice-show')
if (!active())
return
const delay = constructor.NOTICE_TIME
if (!active())
return
const pending = {}
state.pending = pending
const timer = setTimeout(() => {
if (isClosing(this.art) || state.pending !== pending)
return
state.pending = undefined
const before = state.revision
$noticeInner.textContent = ''
if (!isClosing(this.art) && state.revision === before)
removeClass($player, 'art-notice-show')
}, delay)
if (active())
this.timer = timer
else
clearTimeout(timer)
}
else {
removeClass($player, 'art-notice-show')
}
}
get show(): boolean {
const {
template: { $player },
} = this.art
return $player.classList.contains('art-notice-show')
}
}
@@ -0,0 +1,144 @@
import type { SubscriptionHost } from '../../component/resources'
import type { ComponentHost } from '../../component/types'
import type ResourceScope from '../../lifecycle/scope'
import type { PlaybackStorage } from './records'
import { appendElement, queryElement } from '../../component/dom'
import { entryScope, subscribeEntry } from '../../component/resources'
import { isClosing } from '../../lifecycle/instance'
import { timeout } from '../../lifecycle/resources'
import { getSourceScope } from '../../source/operation'
import { secondToTime, setStyle, silencePromise } from '../../utils'
import { readTimes } from './records'
interface PromptEvents {
'ready': []
'restart': [string]
'video:timeupdate': [Event]
}
export interface ResumePromptHost extends ComponentHost, SubscriptionHost<PromptEvents> {
template: { $player: HTMLElement, $poster: HTMLElement }
constructor: { AUTO_PLAYBACK_MIN: number, AUTO_PLAYBACK_TIMEOUT: number }
layers: { add: (option: { name: string, html: string }) => HTMLDivElement | undefined }
icons: { close: string | Element }
i18n: { get: (key: string) => string }
storage: PlaybackStorage
option: { id?: string, url: string }
seek: number
play: () => unknown
}
export function installResumePrompt(art: ResumePromptHost): () => void {
const { i18n, icons, storage, constructor, template: { $poster } } = art
const element = art.layers.add({
name: 'auto-playback',
html: `
<div class="art-auto-playback-close"></div>
<div class="art-auto-playback-last"></div>
<div class="art-auto-playback-jump"></div>
`,
})
if (!element || isClosing(art))
return () => {}
const $last = queryElement('.art-auto-playback-last', element)
const $jump = queryElement('.art-auto-playback-jump', element)
const $close = queryElement('.art-auto-playback-close', element)
appendElement($close, icons.close)
const owner = entryScope(element)
let current: ResourceScope | undefined
let revision = 0
const init = () => {
const generation = ++revision
current?.dispose()
if (owner.closed || isClosing(art) || revision !== generation)
return
const scope = owner.child()
current = scope
const active = () => current === scope && !scope.closed && !isClosing(art)
scope.add(() => {
if (current === scope) {
current = undefined
setStyle(element, 'display', 'none')
}
})
const releaseSource = getSourceScope(art).add(() => {
scope.dispose()
})
scope.add(() => {
releaseSource()
})
const times = readTimes(storage)
const currentTime = times[art.option.id || art.option.url]
if (!active())
return
setStyle(element, 'display', 'none')
if (!active() || !currentTime || !(currentTime >= constructor.AUTO_PLAYBACK_MIN))
return
if (!active())
return
setStyle(element, 'display', 'flex')
if (!active())
return
const lastText = `${i18n.get('Last Seen')} ${secondToTime(currentTime)}`
if (!active())
return
$last.textContent = lastText
if (!active())
return
const jumpText = i18n.get('Jump Play')
if (!active())
return
$jump.textContent = jumpText
if (!active())
return
const bind = (target: HTMLElement, callback: () => void) => {
const cleanup = art.events.proxy(target, 'click', () => {
if (active())
callback()
})
scope.add(() => {
art.events.remove(cleanup)
})
}
bind($close, () => {
setStyle(element, 'display', 'none')
})
if (!active())
return
bind($jump, () => {
art.seek = currentTime
if (!active())
return
silencePromise(art.play())
if (!active())
return
setStyle($poster, 'display', 'none')
if (active())
setStyle(element, 'display', 'none')
})
if (!active())
return
let releaseUpdate = () => {}
let fired = false
const update = () => {
if (fired || !active())
return
fired = true
releaseUpdate()
timeout(scope, () => {
if (active())
setStyle(element, 'display', 'none')
}, constructor.AUTO_PLAYBACK_TIMEOUT)
}
art.on('video:timeupdate', update)
releaseUpdate = scope.add(() => {
art.off('video:timeupdate', update)
})
if (fired)
releaseUpdate()
}
return () => {
subscribeEntry<PromptEvents, 'ready'>(art, element, 'ready', init)
subscribeEntry<PromptEvents, 'restart'>(art, element, 'restart', init)
}
}
@@ -0,0 +1,44 @@
import type { SubscriptionHost } from '../../component/resources'
import { eventSubscriptions } from '../../events/subscriptions'
import { isClosing } from '../../lifecycle/instance'
export type PlaybackTimes = Record<string, number>
export interface PlaybackStorage {
get: (key: 'times') => PlaybackTimes | null | undefined
set: (key: 'times', value: PlaybackTimes) => void
del: (key: 'times') => void
}
export interface PlaybackRecordHost extends SubscriptionHost<{ 'video:timeupdate': [Event] }> {
storage: PlaybackStorage
constructor: { AUTO_PLAYBACK_MAX: number }
option: { id?: string, url: string }
playing: boolean
currentTime: number
}
export function readTimes(storage: PlaybackStorage): PlaybackTimes {
return storage.get('times') || {}
}
export function installPlaybackRecords(art: PlaybackRecordHost): void {
const { storage, constructor } = art
eventSubscriptions<{ 'video:timeupdate': [Event] }>(art)('video:timeupdate', () => {
if (!art.playing)
return
const times = readTimes(storage)
const keys = Object.keys(times)
const max = constructor.AUTO_PLAYBACK_MAX
const key = art.option.id || art.option.url
const time = art.currentTime
if (isClosing(art))
return
// Preserve the historical strict threshold and one-oldest-entry pruning.
if (keys.length > max)
delete times[keys[0]!]
times[key] = time
if (!isClosing(art))
storage.set('times', times)
})
}
@@ -1,91 +0,0 @@
import { append, query, secondToTime, setStyle, silencePromise } from '../utils'
export default function autoPlayback(art) {
const {
i18n,
icons,
storage,
constructor,
proxy,
template: { $poster },
} = art
const $autoPlayback = art.layers.add({
name: 'auto-playback',
html: `
<div class="art-auto-playback-close"></div>
<div class="art-auto-playback-last"></div>
<div class="art-auto-playback-jump"></div>
`,
})
const $last = query('.art-auto-playback-last', $autoPlayback)
const $jump = query('.art-auto-playback-jump', $autoPlayback)
const $close = query('.art-auto-playback-close', $autoPlayback)
append($close, icons.close)
let timer = null
art.on('video:timeupdate', () => {
if (art.playing) {
const times = storage.get('times') || {}
const keys = Object.keys(times)
if (keys.length > constructor.AUTO_PLAYBACK_MAX) {
delete times[keys[0]]
}
times[art.option.id || art.option.url] = art.currentTime
storage.set('times', times)
}
})
function init() {
const times = storage.get('times') || {}
const currentTime = times[art.option.id || art.option.url]
clearTimeout(timer)
setStyle($autoPlayback, 'display', 'none')
if (currentTime && currentTime >= constructor.AUTO_PLAYBACK_MIN) {
setStyle($autoPlayback, 'display', 'flex')
$last.textContent = `${i18n.get('Last Seen')} ${secondToTime(currentTime)}`
$jump.textContent = i18n.get('Jump Play')
proxy($close, 'click', () => {
setStyle($autoPlayback, 'display', 'none')
})
proxy($jump, 'click', () => {
art.seek = currentTime
silencePromise(art.play())
setStyle($poster, 'display', 'none')
setStyle($autoPlayback, 'display', 'none')
})
art.once('video:timeupdate', () => {
timer = setTimeout(() => {
setStyle($autoPlayback, 'display', 'none')
}, constructor.AUTO_PLAYBACK_TIMEOUT)
})
}
}
art.on('ready', init)
art.on('restart', init)
return {
name: 'auto-playback',
get times() {
return storage.get('times') || {}
},
clear() {
return storage.del('times')
},
delete(id) {
const times = storage.get('times') || {}
delete times[id]
storage.set('times', times)
return times
},
}
}
@@ -0,0 +1,29 @@
import type { ResumePromptHost } from './auto-playback/prompt'
import type { PlaybackRecordHost } from './auto-playback/records'
import { installResumePrompt } from './auto-playback/prompt'
import { installPlaybackRecords, readTimes } from './auto-playback/records'
export type AutoPlaybackHost = ResumePromptHost & PlaybackRecordHost
export default function autoPlayback(art: AutoPlaybackHost) {
const { storage } = art
// Keep time recording before the prompt's first-update timer subscription.
const installPrompt = installResumePrompt(art)
installPlaybackRecords(art)
installPrompt()
return {
name: 'auto-playback',
get times() {
return readTimes(storage)
},
clear() {
return storage.del('times')
},
delete(id: string) {
const times = readTimes(storage)
delete times[id]
storage.set('times', times)
return times
},
}
}
+4 -4
View File
@@ -10,15 +10,15 @@ import miniProgressBar from './miniProgressBar'
export default function installBuiltins<Host extends PluginHost<Host>>(registry: BuiltinRegistry<Host>, option: PluginHost<Host>['option']): void {
// Read each condition at its original turn; earlier plugins may change options.
if (!isClosing(registry.art) && option.miniProgressBar && !option.isLive)
registry.add(miniProgressBar)
registry.add(miniProgressBar as unknown as PluginFactory<Host>)
if (!isClosing(registry.art) && option.lock && isMobile)
registry.add(lock)
registry.add(lock as unknown as PluginFactory<Host>)
if (!isClosing(registry.art) && option.autoPlayback && !option.isLive)
registry.add(autoPlayback)
registry.add(autoPlayback as unknown as PluginFactory<Host>)
if (!isClosing(registry.art) && option.autoOrientation && isMobile) {
// The JS constructor supplies the complete host before builtin installation.
registry.add(autoOrientation as unknown as PluginFactory<Host>)
}
if (!isClosing(registry.art) && option.fastForward && isMobile && !option.isLive)
registry.add(fastForward)
registry.add(fastForward as unknown as PluginFactory<Host>)
}
@@ -1,44 +0,0 @@
import { addClass, hasClass, removeClass } from '../utils'
export default function fastForward(art) {
const {
constructor,
proxy,
template: { $player, $video },
} = art
let timer = null
let isPress = false
let lastPlaybackRate = 1
const onStart = (event) => {
if (event.touches.length === 1 && art.playing && !art.isLock) {
timer = setTimeout(() => {
isPress = true
lastPlaybackRate = art.playbackRate
art.playbackRate = constructor.FAST_FORWARD_VALUE
addClass($player, 'art-fast-forward')
}, constructor.FAST_FORWARD_TIME)
}
}
const onStop = () => {
clearTimeout(timer)
if (isPress) {
isPress = false
art.playbackRate = lastPlaybackRate
removeClass($player, 'art-fast-forward')
}
}
proxy($video, 'touchstart', onStart)
art.on('document:touchmove', onStop)
art.on('document:touchend', onStop)
return {
name: 'fastForward',
get state() {
return hasClass($player, 'art-fast-forward')
},
}
}
@@ -0,0 +1,43 @@
import type { SubscriptionHost } from '../component/resources'
import type { LongPressHost } from '../input/long-press'
import type { PointerRegistry } from '../input/pointer-types'
import { eventSubscriptions } from '../events/subscriptions'
import { longPress } from '../input/long-press'
import { hasClass } from '../utils'
interface PressEvents {
'document:touchmove': [Event]
'document:touchend': [Event]
'document:touchcancel': [Event]
'video:pause': [Event]
'lock': [boolean]
'destroy': []
}
export interface FastForwardHost extends LongPressHost, SubscriptionHost<PressEvents> {
template: { $player: HTMLElement, $video: EventTarget }
proxy: PointerRegistry['proxy']
}
export default function fastForward(art: FastForwardHost): { name: string, readonly state: boolean } {
const { proxy, template: { $player, $video } } = art
const press = longPress(art)
const subscribe = eventSubscriptions<PressEvents>(art)
proxy($video, 'touchstart', event => press.start(event as TouchEvent))
proxy($video, 'touchcancel', press.stop)
subscribe('document:touchmove', press.stop)
subscribe('document:touchend', press.stop)
subscribe('document:touchcancel', press.stop)
subscribe('video:pause', press.stop)
subscribe('destroy', press.stop)
subscribe('lock', (locked) => {
if (locked)
press.stop()
})
return {
name: 'fastForward',
get state() {
return hasClass($player, 'art-fast-forward')
},
}
}
-68
View File
@@ -1,68 +0,0 @@
import { addClass, append, hasClass, removeClass, setStyle } from '../utils'
export default function lock(art) {
const {
layers,
icons,
template: { $player },
} = art
function getState() {
return hasClass($player, 'art-lock')
}
function setLock() {
addClass($player, 'art-lock')
art.isLock = true
art.emit('lock', true)
}
function setUnlock() {
removeClass($player, 'art-lock')
art.isLock = false
art.emit('lock', false)
}
layers.add({
name: 'lock',
mounted($el) {
const $lock = append($el, icons.lock)
const $unlock = append($el, icons.unlock)
setStyle($lock, 'display', 'none')
art.on('lock', (state) => {
if (state) {
setStyle($lock, 'display', 'inline-flex')
setStyle($unlock, 'display', 'none')
}
else {
setStyle($lock, 'display', 'none')
setStyle($unlock, 'display', 'inline-flex')
}
})
},
click() {
if (getState()) {
setUnlock()
}
else {
setLock()
}
},
})
return {
name: 'lock',
get state() {
return getState()
},
set state(value) {
if (value) {
setLock()
}
else {
setUnlock()
}
},
}
}
+94
View File
@@ -0,0 +1,94 @@
import type { SubscriptionHost } from '../component/resources'
import { appendElement } from '../component/dom'
import { entryScope, subscribeEntry } from '../component/resources'
import { isClosing } from '../lifecycle/instance'
import { addClass, hasClass, removeClass, setStyle } from '../utils'
export interface LockHost extends SubscriptionHost<{ lock: [boolean] }> {
template: { $player: HTMLElement }
icons: { lock: string | Element, unlock: string | Element }
isLock: boolean
emit: (name: 'lock', state: boolean) => unknown
layers: { add: (option: { name: string, mounted: (element: HTMLDivElement) => void, click: () => void }) => unknown }
}
export default function lock(art: LockHost): { name: string, state: boolean } {
const {
layers,
icons,
template: { $player },
} = art
function getState() {
return hasClass($player, 'art-lock')
}
function setLock() {
if (isClosing(art))
return
addClass($player, 'art-lock')
if (isClosing(art))
return
art.isLock = true
if (!isClosing(art))
art.emit('lock', true)
}
function setUnlock() {
if (isClosing(art))
return
removeClass($player, 'art-lock')
if (isClosing(art))
return
art.isLock = false
if (!isClosing(art))
art.emit('lock', false)
}
layers.add({
name: 'lock',
mounted($el) {
const scope = entryScope($el)
if (scope.closed || isClosing(art))
return
const $lock = appendElement($el, icons.lock)
if (scope.closed || isClosing(art))
return
const $unlock = appendElement($el, icons.unlock)
if (scope.closed || isClosing(art))
return
setStyle($lock, 'display', 'none')
subscribeEntry<{ lock: [boolean] }, 'lock'>(art, $el, 'lock', (state) => {
if (isClosing(art))
return
setStyle($lock, 'display', state ? 'inline-flex' : 'none')
if (!scope.closed && !isClosing(art))
setStyle($unlock, 'display', state ? 'none' : 'inline-flex')
})
},
click() {
if (getState()) {
setUnlock()
}
else {
setLock()
}
},
})
return {
name: 'lock',
get state() {
return getState()
},
set state(value: boolean) {
if (value) {
setLock()
}
else {
setUnlock()
}
},
}
}
@@ -1,14 +0,0 @@
import { addClass, removeClass } from '../utils'
export default function miniProgressBar(art) {
art.on('control', (state) => {
if (state) {
removeClass(art.template.$player, 'art-mini-progress-bar')
}
else {
addClass(art.template.$player, 'art-mini-progress-bar')
}
})
return { name: 'mini-progress-bar' }
}
@@ -0,0 +1,20 @@
import type { SubscriptionHost } from '../component/resources'
import { eventSubscriptions } from '../events/subscriptions'
import { addClass, removeClass } from '../utils'
export interface MiniProgressHost extends SubscriptionHost<{ control: [boolean] }> {
template: { $player: HTMLElement }
}
export default function miniProgressBar(art: MiniProgressHost): { name: string } {
eventSubscriptions<{ control: [boolean] }>(art)('control', (state) => {
if (state) {
removeClass(art.template.$player, 'art-mini-progress-bar')
}
else {
addClass(art.template.$player, 'art-mini-progress-bar')
}
})
return { name: 'mini-progress-bar' }
}
+141
View File
@@ -0,0 +1,141 @@
{
"task": "CORE-18",
"date": "2026-09-11",
"status": "partial",
"parentCommit": "a842058136997b94db93155dc6db06eab72604bb",
"node": "v24.21.0",
"scope": "Adds autoPlayback, lock and miniProgressBar. Info/loading/mask and installed-package exit remain outstanding.",
"fingerprints": {
"packages/artplayer/src/notice.ts": "cd13f8cb7147088a02ee4583d4edc2120848a039d07878dd996dcb54781db66e",
"packages/artplayer/src/input/long-press.ts": "110cf16cf9dae845597377ebbc43249e103cbc0ece56f89e44ec6ecc7f3819b0",
"packages/artplayer/src/plugins/fastForward.ts": "74c03e3fcfe2f87dcb0ef1a5da5410cb96322509fd07e32930128584c54f62b5",
"packages/artplayer/src/plugins/builtins.ts": "1f45028b00dd82e622cc6387b1ee73e39e6ab85ca0d15117ace11b5f02dee37f",
"packages/artplayer/src/plugins/autoPlayback.ts": "e349010813bbcd53503383b707c5f6e6e60d8ade6442d020fe6ba07a128c3122",
"packages/artplayer/src/plugins/auto-playback/records.ts": "731df519f264c602e5128c66f943b40e8645c403dc880652dea8f171574adc78",
"packages/artplayer/src/plugins/auto-playback/prompt.ts": "cdcfd8542d49c2be61a31c74f2ff5ff0cdd1f829858b9bf5b497f2dde85c2002",
"packages/artplayer/src/plugins/lock.ts": "9a08cec3a92006be8a9d2dba8d850bc4f8f38d586a50de86d725d0d8929130fd",
"packages/artplayer/src/plugins/miniProgressBar.ts": "21b4850fcaf6b0e82d5e62ca7aca60c179c578f7ff6d5606e6025628ff4b1cc7",
"test/auto-playback.test.js": "2d61bffa99bf7acab58f263fb60cb50fef8b1d450d3ea22a4f1eef8066b162a0",
"test/builtin-layers.test.js": "0ff767aadbfc7c142dc03e64891dd000b651e812509488f474dcf01ca5d0df05",
"test/types/auto-playback.ts": "3af10afc38be6eb0d6a8c9f82f76b6b046ab0b0439a22c1318fab5557a2157e4",
"test/types/builtin-layers.ts": "4bf0c661011e15bb3f620e0e4bf7b51ca381c420dda261fcd59988cafd2e53c8",
"test/browser/auto-playback.spec.js": "39d9258fb808ef42dfbd38836572335a5c759d99cc1b0ecc1b635a2d7cb3b0fe",
"test/browser/builtin-layers.spec.js": "4efa0ebe413cbf0ca9b06074ce73ead337136bc477c4f8fda6665be0504a31eb",
"package.json": "b51633ce5b1d55b8b9b91a2169a627c27f59c1cee0b00225b53a736802d35cc3"
},
"ci": {
"tests": 359,
"unit": 330,
"engineering": 4,
"baseline": 25,
"productionTs": 196,
"publicTypesUnchanged": true,
"log": "refactor/.cache/core18-auto-layers-ci-final.log",
"sha256": "9b6ab1f59cdba15a19833c001fd9eb292cd5160620aa06a437e7037d2944f42a"
},
"browser": {
"tests": 90,
"retries": 0,
"skipped": 0,
"unexpectedPageErrors": 0,
"knownRejections": [
{
"browser": "chromium",
"core": "published",
"rejections": [
{
"name": "Error",
"message": "controlled play rejection",
"same": true
},
{
"name": "Error",
"message": "controlled play rejection",
"same": true
}
]
},
{
"browser": "chromium",
"core": "candidate",
"rejections": []
},
{
"browser": "firefox",
"core": "published",
"rejections": [
{
"name": "Error",
"message": "controlled play rejection",
"same": true
},
{
"name": "Error",
"message": "controlled play rejection",
"same": true
}
]
},
{
"browser": "firefox",
"core": "candidate",
"rejections": []
},
{
"browser": "webkit",
"core": "published",
"rejections": [
{
"name": "Error",
"message": "controlled play rejection",
"same": true
},
{
"name": "Error",
"message": "controlled play rejection",
"same": true
}
]
},
{
"browser": "webkit",
"core": "candidate",
"rejections": []
}
],
"report": "refactor/.cache/core18-auto-layers-browser-final.json",
"sha256": "21c6ffce5aba6519f8dfd88b9678bf06db4d1880e12139887a99e13df8d82c88",
"browsers": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"candidate": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "6dc05603a158ec31a52629f7a0c370b8befbe82258aad26c90f8cd880169dd98",
"bytes": 316545
},
"limitation": "Workspace-built UMD, not installed-package or legacy acceptance. Simulated mobile conditions do not certify physical devices."
},
"initialReproduction": {
"autoPlayback": {
"passed": 2,
"failed": 6
},
"builtinLayers": {
"passed": 1,
"failed": 4
},
"node": "24.21.0"
},
"remaining": [
"info",
"loading",
"mask",
"all prompt integration",
"final build/i18n/package and installed browser matrices"
]
}
@@ -0,0 +1,77 @@
{
"task": "CORE-18",
"date": "2026-09-11",
"status": "partial",
"parentCommit": "a842058136997b94db93155dc6db06eab72604bb",
"node": "v24.21.0",
"scope": "Notice and fastForward only; other builtins/prompts and installed-package exit remain outstanding.",
"fingerprints": {
"packages/artplayer/src/notice.ts": "cd13f8cb7147088a02ee4583d4edc2120848a039d07878dd996dcb54781db66e",
"packages/artplayer/src/input/long-press.ts": "110cf16cf9dae845597377ebbc43249e103cbc0ece56f89e44ec6ecc7f3819b0",
"packages/artplayer/src/plugins/fastForward.ts": "74c03e3fcfe2f87dcb0ef1a5da5410cb96322509fd07e32930128584c54f62b5",
"packages/artplayer/src/plugins/builtins.ts": "b3848611b329a059df684e1f3d515a98e6f051aef9d3a7e0408693794c65c6ab",
"test/notice.test.js": "8d454d731cde5bf5d47f6573503fa7782de68b3c1b09ef4298a5fa1bd4b60855",
"test/fast-forward.test.js": "81172dc0fadbf62cf88904fb7a54d131f848602a0fdfe3be93a37fcf4ca7b389",
"test/types/notice.ts": "f896ea15c1dc1f71e7018e3a00e7276f415b9ff58fb6058d8767cac9438a047a",
"test/types/fast-forward.ts": "bd724069d82a78b8f9264f73ac3d7862f27b2bb161936a49763812e5d5966246",
"test/browser/notice.spec.js": "56b2d2977e19fd798f3867e9819e631bcb99e51496387a9c67791b60a41048a6",
"test/browser/fast-forward.spec.js": "5b884fae1cd48cb592c2004a8149890aed4741ac0a92b1e415e47b94411bb368",
"test/browser/event-scheduling.spec.js": "4ebd5dab23ca8e49580bd1dc3234e612cbae36b735c7c9401d168059eae11c5c",
"package.json": "6c772a8d39b30444c27bd4d12896050052392f65d99c7e41665cae00ab5adb6a"
},
"ci": {
"tests": 340,
"unit": 311,
"engineering": 4,
"baseline": 25,
"productionTs": 191,
"publicTypesUnchanged": true,
"log": "refactor/.cache/core18-partial-ci.log",
"sha256": "b9384ab43b6dd4bedf2cf14d738b190b84c709e1534c9260c9beaf2576a07d20"
},
"browser": {
"tests": 96,
"retries": 0,
"skipped": 0,
"pageErrors": 0,
"report": "refactor/.cache/core18-prompts-input-browser.json",
"sha256": "f8557584b8f9b21a714c3a7e0e5d809bef0d88614f84ee5f539060c053db58e4",
"browsers": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"candidate": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "ad326102f632ec856d8ed9a14f522a6b32c058c56ed82fe3e6204e44abd21376",
"bytes": 313815
},
"limitation": "Workspace-built UMD, not installed-package/legacy acceptance. Controlled touch events do not certify physical devices."
},
"initialReproduction": {
"notice": {
"passed": 3,
"failed": 3,
"node": "25.2.1"
},
"fastForward": {
"passed": 3,
"failed": 8,
"node": "24.21.0"
},
"source": "Before respective migration in this task; see ignored raw logs and committed regression tests."
},
"remaining": [
"autoPlayback",
"lock",
"miniProgressBar",
"info",
"loading",
"mask",
"autoOrientation/builtin integration",
"final build/i18n/package and installed browser matrices"
]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,70 @@
# CORE-18 内置插件与提示模块
状态:完成;父提交 `a8420581`。以下分批过程保留历史结果,最终验收见末节。
## 第一批:notice 与 fastForward
- `src/notice.ts` 使用最小宿主类型,公开字段仍只有 art/timer,原型仍为 constructor/destroy/show。内部请求代次放在 WeakMap;取消、重入与销毁不再让旧提示覆盖新提示。功能规模较小,状态与 DOM 操作保留在一个模块。
- `src/plugins/fastForward.ts` 负责插件接口、原生触摸和实例事件订阅;`src/input/long-press.ts` 负责按压状态、延时、原播放速率和资源释放。每次按压归属于当前 source scope,重复开始、取消、锁定、暂停、切源、销毁都会释放旧工作。
- 保留 notice 的 Error 消息 trim、字符串空白、false/空字符串仅隐藏而保留旧文本/待执行清空计时器,以及显式 notice.destroy 仅取消计时器、存活实例仍可复用的行为。
- 保留 fastForward 的名称、只读 state、默认安装条件、单指/播放/未锁定条件、实时 FAST_FORWARD_TIME/VALUE 以及松手恢复原速率。新增 touchcancel 等清理是生命周期缺陷修正。
- notice 的真实 getter 为 boolean,历史公开声明却是 string/Error/false。本批源类型与正反例明确实际行为,旧声明保持;BASE-TYPE-07 继续开放,由 CORE-21 提供兼容的精确公共视图,不能简单用 boolean 替换旧消费者读类型。
- builtins 的 fastForward 类型断言与已有 autoOrientation 相同,只位于尚未迁移的构造器到最小宿主的接合处;CORE-20 收敛入口时需核对,不向内部传播 any。
## 已复现与测试过程
notice 初次六项 Node 复现为三通过、三失败,显示写入中销毁后仍创建 timer、过期回调覆盖新消息、到期清空中重入的新消息被隐藏。初次复现使用宿主 Node 25.2.1;后续执行切回固定 Node 24.21.0。真实旧发布包在 Chromium/Firefox/WebKit 均复现 DOM 写入中的销毁和重入缺陷;候选通过。
fastForward 初次十一项固定 Node 复现为三通过、八失败,包含重复定时器、缺少取消与生命周期防护。后续扩展源切换、回调时状态变化及恢复速率时重入的回归。
notice 三浏览器专项 33 项通过,涵盖旧包/候选的接口、DOM、自然提示到期与真实视频播放,及候选过期回调防护。fastForward 第一轮 27 项中六项失败:测试误把切源/原生 load 后的默认速率重置当成必须保持 1.5。检查实际 reset() 调用 video.load(),补入旧包/候选原生换源重置对照;检查实际恢复 setter 收到 1.5,而随后原生换源保持默认 1。待触发按压销毁后则明确断言 resetRate=1 且迟到回调不再修改它。修正后专项 33 项通过,没有增加等待时间或重试。
测试文件:`test/notice.test.js`、`test/fast-forward.test.js`,对应 `test/types/` 正反例和 `test/browser/notice.spec.js`、`test/browser/fast-forward.spec.js`。模拟触摸使用真实 DOM 事件与实际视频,物理设备仍由 REL-03/REVIEW-02 验收。
第一批最终源码已通过 `yarn ci:check`:311 项单元、4 项工程、25 项基线,共 340 项;191 个生产 TS 文件(核心 186、chapter 5)及五组新旧消费者模式通过。notice/fastForward/event-scheduling 三组源码构建的浏览器专项合跑 96 项通过,Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6,无重试、跳过和未处理页面错误。精确指纹及日志信息见 [部分阶段证据](../baselines/builtin-prompts-partial.json)。本结果明确不代表安装包或 legacy 出口验收,后续源码变化须重新验证对应范围。
## 剩余与交接
所有目标模块已迁移,autoOrientation 沿用 CORE-16 实现并通过组合矩阵。完整任务出口已通过;BASE-TYPE-07 的兼容精确公共视图仍交 CORE-21,移动真机与多轮发布复盘仍由 REL-03/REVIEW-02 验收。下一任务 CORE-19。
没有增加依赖,使用既有 Yarn/Node/TypeScript/Playwright 工具。最终生产产物已由构建生成并核对安装包;本任务按独立本地 commit 交付,不推送、不发布。分批阶段的 doing 状态属于历史记录。
回退以父提交为比较基准,只撤销本任务源文件、测试、任务/架构/风险文档与最终生成产物,保留此前 CORE-17 交付。
## 第二批:autoPlayback
`plugins/autoPlayback.ts` 保留 name/times/clear/delete 结果;`auto-playback/records.ts` 负责 playing 时写入 times,`auto-playback/prompt.ts` 负责提示 DOM、ready/restart、点击和首个 timeupdate 后的超时。存储仍使用 artplayer_settings 的 times 成员,优先 option.id、回退 option.url;严格保留旧版的 keys.length > AUTO_PLAYBACK_MAX 时仅删除最早一项,不能在 TS 化时悄悄改为固定最大长度。
记录订阅属于实例;移除提示层仍继续记忆播放位置。每一轮提示属于图层且连接当前 source scope;重启、切源、图层移除、实例销毁均取消旧点击/首帧订阅/定时器。点击顺序仍为 seek、play、隐藏 poster/提示;用户回调若切源或销毁,则停止失效的后续操作。提示 HTML、类名、翻译、阈值和超时读取方式保持。新增内部类型,没有修改现有公共声明或增加依赖。
迁移前固定 Node 的八项复现为二通过、六失败;迁移后扩展了旧 timeout、seek 中 restart、翻译/存储回调销毁、NaN 阈值和内部播放拒绝。首轮三浏览器 39 项通过,明确证实旧包连续 restart 后一次点击执行三次 seek/play,而候选只使用最新保存时间响应一次;也覆盖真正的 seek/play/持续记录、本地存储信封、清理和销毁重入。新增关闭按钮与播放拒绝兼容用例后继续专项验证,完整结果见对应阶段证据,不覆盖或重写第一批的历史指纹。
## 第三批:lock / miniProgressBar
两个简单内置插件分别保留单文件并迁移 TS,使用最小宿主类型。lock 保留 name/state 结果、class → isLock → lock 事件顺序和重复相同赋值仍通知的行为。图层移除时只取消图标订阅,存活实例的插件 state 仍可读写;销毁后则忽略旧引用上的 setter/click。miniProgressBar 仍根据 control 事件切换原类名,仅返回 name,订阅归实例所有。没有为了拆文件创建多层转发。
迁移前五项 Node 复现为一通过、四失败;迁移后五项通过。浏览器增加旧包/候选、桌面/模拟移动、直播/点播的内置插件启用矩阵,以及 lock 重复事件、图标、图层移除、mini 控件同步和销毁后引用的验证。这里的移动条件用 userAgent 固定,只证明现有分支及 DOM 行为,不证明真实移动设备能力。
第二/三批第一次合跑 90 项有 15 项失败:12 项图标测试错误读取 art.icons getter 新建的节点,改为从实际挂载的 lock 图层取节点;其余 3 项准确暴露旧发布包在重复注册的续播点击中产生两个未处理播放拒绝。旧包专用捕获器只匹配本测试的 Error 对象,断言每个浏览器恰好两次并附带 known-resume-rejections 证据,不忽略其他异常;候选不安装捕获器且要求零异常。当前分支在本任务前已使用 silencePromise,这部分是保留既有修复,不能宣称是本批新修正的生产缺陷。
修正测试后合跑 90 项通过,无重试或跳过,候选与非预期页面错误为零。最终 `yarn ci:check` 通过 330 项单元、4 项工程、25 项基线,共 359 项;196 个生产 TS 文件(核心 191、chapter 5)及五组消费者类型检查通过。新增自动续播单元 14 项、图层单元 5 项已加入 test:unit。嵌套的浏览器/类型文件另行 lint 通过;精确源码、报告和日志指纹见 [续播与内置图层部分证据](../baselines/auto-layers-partial.json)。尚未生成本任务最终产物或执行安装包出口,CORE-18 继续 doing。
## 第四批:info / loading / mask
info.ts 保留 Component 字段与 init 原型方法;info/poll.ts 管理动态媒体字段、原 DOM 文本转换、关闭监听与循环。每次 init 先释放上一轮,WeakMap 保存内部代次;支持手动 destroy 事件停止轮询、存活时 init 重启,拒绝实际销毁后的写入。固定数值保留 toFixed(2),空值、布尔、对象、NaN/Infinity 与 Symbol 错误用真实 DOM 对照验证。
loading.ts 仍只负责 loading 图标与继承的组件行为。mask.ts 保留原生点击内部播放处理和 state/error 图标。最初尝试在根 scope 清理时切换 mask,随后新增的观察顺序测试揭示它会让早于 mask 注册的 destroy 监听器看到提前变化的图标;已修正。lifecycle/instance.ts 新增内部 finalization scope:普通销毁在原 destroy 事件之后释放,直接根 scope.dispose 则在根资源清理完毕后释放。mask 正常仍由原注册位置的事件回调显示终止图标,最终阶段只补偿未执行/失败的回调并取消订阅,避免改变旧观察顺序或重复正常 DOM 写入。较早的用户 destroy 回调抛错时,清理完成后仍抛出原错误。
迁移前八项专项为二通过、六失败;扩展后包含十二项单元与生命周期关联测试。初始 mask 顺序修复前的十二项中一项失败,修复后联合 instance-lifecycle/resource-scope 共二十六项通过。完整源码与安装包出口结果在任务收尾时追加。
info/mask 源码浏览器专项 45 项通过;其中新增观察顺序用例最初遗漏 option.layers 的必需 html 字段,导致九项初始化失败,补齐合法输入后通过,没有改变播放器验证规则。最终兼容复核还发现 notice setter 不应通过可被消费者覆盖的 public destroy 方法取消旧 timer;七项 notice 单元中新增覆盖测试曾失败,改用内部 cancelNotice 后修正,并加入旧包/候选的真实浏览器回归。该修正发生在首轮打包之后,先前 run-7Jpsrp 不作为最终候选,必须重新打包验证。
## 最终任务验收
固定 Node 24.21.0 / Yarn 1.22.22。完整 CI 372 项通过(343 单元、4 工程、25 基线),200 个生产 TS 文件(核心 195、chapter 5)及五组新旧类型消费模式通过。实际 tarball 安装为 run-4YqfAy,27 项运行时与五组类型零诊断;工作区源文件、包内全部文件、UMD/legacy/ESM、docs 副本、22 个语言产物与许可指纹一致。
安装的 UMD 和 legacy 各完整执行 1350 项三浏览器测试,各含 207 项本任务专项与 300 项显示模式回归,无重试或跳过。Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6;原生桌面 fullscreen 与可用引擎 PiP 播放继续验证。精确匹配并记录旧包的已知续播播放拒绝;候选和非预期页面错误为零。完整报告、源码与测试指纹见 [最终证据](../baselines/builtins-prompts-validation.json)。
公开声明未改;类型冲突仍按兼容策略交 CORE-21,不能把旧消息读类型改成 boolean 并宣称无影响。关闭 BASE-LIFE-31/32/33/34/35;BASE-PERF-01 已有修复通过相关全量回归。无新增依赖或 lockfile 变化;新增五组单元文件共 51 项并接入既有命令,浏览器/类型与包内架构同步维护。
当前 214 项:54 完成、160 待办,下一项 CORE-19。本任务独立本地提交,不推送、不发布,也不代表完整重构或发布复盘已完成。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 161 / doing 0 / blocked 0 / done 53 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 160 / doing 0 / blocked 0 / done 54 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -121,7 +121,7 @@
| 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 | 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-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 | done |
| 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 |
| CORE-21 | artplayer<br>生成核心声明并校验包内容 | CORE-20, ENG-07 | 由 TS 生成的既有入口声明与三种发布产物 | 内部类型不泄漏,旧 JS/TS 消费者与 i18n 通过;逐项完成 CORE-07 留下的 BASE-TYPE-04/05/07 声明方案,旧合法消费者与实际返回分别留证;不能以虚假的交叉返回类型或改变运行时来掩盖冲突,未决项阻止核心声明出口;处理 CORE-10 / BASE-TYPE-08 的 setter-only 属性虚构 getter 声明,并复核 PlaybackControls 精确视图与旧 toggle 声明共存策略 | H | todo |
@@ -452,6 +452,7 @@
- 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)
- CORE-18: [记录](changes/2026-09-11-CORE-18-builtins-prompts.md) [记录](baselines/builtins-prompts-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-18 内置插件与提示模块完成
notice、autoPlayback、fastForward、lock、miniProgressBar、info、loading、mask 已迁移 TS,拆清记录、提示、长按与轮询归属;autoOrientation 组合复验通过。修复重复响应、陈旧回调和清理,同时保持公开结果、存储格式、DOM 与 destroy 观察顺序。
CI 372 项、200 个生产 TS 严格检查、实际 tarball 27 项运行时与五组类型通过;安装 UMD/legacy 各 1350 项三浏览器全量通过,各含 207 项本任务专项。源码、包文件、三个格式、语言、文档副本和许可指纹一致。见 [交付记录](changes/2026-09-11-CORE-18-builtins-prompts.md) 和 [最终证据](baselines/builtins-prompts-validation.json)。
关闭五项生命周期差异;BASE-TYPE-07 精确公共视图继续交 CORE-21,物理设备与多轮复盘仍保留发布门槛。当前 214 项:54 完成、160 待办;下一项 CORE-19。本任务独立本地提交,无推送或发布。
## 当前实施:CORE-17 输入、全局事件与调度完成
Hotkey 与 Events 全部初始化器已迁移 TS,拆清焦点、监听器、全局重绑、手势和调度;正常旧 API、回调/事件和交互保持,新增 document:touchcancel。
+5
View File
@@ -95,3 +95,8 @@
| 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 |
| BASE-LIFE-31 | resolved / 已复现 | Notice reentrant writes can schedule after destruction or erase a newer message | CORE-18 |
| BASE-LIFE-32 | resolved / 已复现 | Fast-forward repeated starts and missing cancellation retain timers or accelerated playback | CORE-18 |
| BASE-LIFE-33 | resolved / 已复现 | Auto-playback restarts retain obsolete resume clicks, subscriptions and timers | CORE-18 |
| BASE-LIFE-34 | resolved / 已复现 | Lock icon and miniature progress subscriptions outlive their UI or player | CORE-18 |
| BASE-LIFE-35 | resolved / 已复现 | Info repeated initialization retains polling and close callbacks; destroyed reads continue writing | CORE-18 |
+121 -1
View File
@@ -900,7 +900,7 @@
"compatibleResolution": "Keep legacy consumers; add explicit cue-array listener overloads, type internal implementations accurately, and resolve remaining generated/public facade contracts at CORE-21 without false intersections.",
"closureCriteria": "Actual setting/null/void and notice boolean returns, cue array callbacks and old legal type consumers all have an explicit compatible declaration strategy and package tests.",
"scopeNote": "CORE-07 provides runtime evidence and explicit cue-array overloads; old inferred scalar event callbacks and return signatures are not claimed corrected.",
"workspaceState": "CORE-14 source models item/null/void setting returns; CORE-15 source models cue arrays and string/null/undefined subtitle results. Historical public consumers and explicit array listener overloads still compile; scalar inference and public return facade coordination remain CORE-18/21."
"workspaceState": "CORE-14 source models item/null/void setting returns; CORE-15 source models cue arrays and string/null/undefined subtitle results. Historical public consumers and explicit array listener overloads still compile; scalar inference and public return facade coordination remain CORE-18/21. CORE-18 notice.ts now models a boolean getter and string/Error/false writes; source and retained public consumer fixtures are in test/types/notice.ts. Public precise-view coordination remains CORE-21."
},
{
"id": "BASE-TYPE-08",
@@ -1878,6 +1878,126 @@
"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."
},
{
"id": "BASE-LIFE-31",
"title": "Notice reentrant writes can schedule after destruction or erase a newer message",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-18"
],
"evidence": [
"test/notice.test.js",
"test/browser/notice.spec.js",
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md"
],
"compatibleResolution": "Preserve public fields and normal hide/expiry behavior; own timer generations and reject stale writes.",
"closureCriteria": "Installed UMD and legacy verify normal notices, reentrant writes and destruction without late timers or stale DOM effects.",
"workspaceState": "Final CORE-18 source, production outputs and installed UMD/legacy artifacts match; ownership and compatible behavior are verified.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md",
"refactor/baselines/builtins-prompts-validation.json",
"test/notice.test.js",
"test/browser/notice.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
},
{
"id": "BASE-LIFE-32",
"title": "Fast-forward repeated starts and missing cancellation retain timers or accelerated playback",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-18"
],
"evidence": [
"test/fast-forward.test.js",
"test/browser/fast-forward.spec.js",
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md"
],
"compatibleResolution": "Own a single press in the current source; restore its prior rate and clean subscriptions while preserving native load reset behavior.",
"closureCriteria": "Installed UMD and legacy verify touch and cancellation, pause/lock/source/destroy and rate restoration; physical touch limitations remain documented.",
"workspaceState": "Final CORE-18 source, production outputs and installed UMD/legacy artifacts match; ownership and compatible behavior are verified.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md",
"refactor/baselines/builtins-prompts-validation.json",
"test/fast-forward.test.js",
"test/browser/fast-forward.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
},
{
"id": "BASE-LIFE-33",
"title": "Auto-playback restarts retain obsolete resume clicks, subscriptions and timers",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-18"
],
"evidence": [
"test/auto-playback.test.js",
"test/browser/auto-playback.spec.js",
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md"
],
"compatibleResolution": "Keep storage and public plugin contracts; bind one prompt generation to the layer and source, independently of instance-owned time recording.",
"closureCriteria": "Installed UMD and legacy prove single current resume action, actual playback/storage, close/rejection and restart/source/remove/destroy cleanup.",
"workspaceState": "Final CORE-18 source, production outputs and installed UMD/legacy artifacts match; ownership and compatible behavior are verified.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md",
"refactor/baselines/builtins-prompts-validation.json",
"test/auto-playback.test.js",
"test/browser/auto-playback.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
},
{
"id": "BASE-LIFE-34",
"title": "Lock icon and miniature progress subscriptions outlive their UI or player",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-18"
],
"evidence": [
"test/builtin-layers.test.js",
"test/browser/builtin-layers.spec.js",
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md"
],
"compatibleResolution": "Scope icon subscriptions to their layer and miniature progress to the player; retain live lock setters after UI removal while suppressing post-destroy mutations.",
"closureCriteria": "Installed UMD and legacy verify builtin activation matrix, state/event/icon contracts, layer removal and retained setters/events after destruction.",
"workspaceState": "Final CORE-18 source, production outputs and installed UMD/legacy artifacts match; ownership and compatible behavior are verified.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md",
"refactor/baselines/builtins-prompts-validation.json",
"test/builtin-layers.test.js",
"test/browser/builtin-layers.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
},
{
"id": "BASE-LIFE-35",
"title": "Info repeated initialization retains polling and close callbacks; destroyed reads continue writing",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-18"
],
"evidence": [
"test/prompt-components.test.js",
"test/browser/prompt-components.spec.js",
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md"
],
"compatibleResolution": "Own one info initialization generation; preserve mask destroy observer ordering and finalize its resources after event dispatch, including failure paths.",
"closureCriteria": "Installed UMD and legacy verify info initial/repeated/mobile/manual/failed init and DOM conversion, plus mask playback, destroy ordering and throwing-observer finalization.",
"workspaceState": "Final CORE-18 source, production outputs and installed UMD/legacy artifacts match; ownership and compatible behavior are verified.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-18-builtins-prompts.md",
"refactor/baselines/builtins-prompts-validation.json",
"test/prompt-components.test.js",
"test/browser/prompt-components.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
}
]
}
+5 -2
View File
@@ -1090,11 +1090,14 @@
"CORE-16",
"CORE-17"
],
"status": "todo",
"status": "done",
"risk": "M",
"deliverable": "autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask",
"acceptance": "默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer;接续 BASE-TYPE-07 的 notice.show boolean getter 与历史读类型冲突",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-18-builtins-prompts.md",
"baselines/builtins-prompts-validation.json"
]
},
{
"id": "CORE-19",
+319
View File
@@ -0,0 +1,319 @@
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 { autoPlayback, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry } = await loadModules({
autoPlayback: 'packages/artplayer/src/plugins/autoPlayback',
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' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
})
function fixture(t, stored = { first: 12 }) {
class Element {
style = {}
textContent = ''
children = new Map()
querySelector(name) {
if (!this.children.has(name))
this.children.set(name, new Element())
return this.children.get(name)
}
insertAdjacentHTML() {
this.lastElementChild = new Element()
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
let id = 0
const timers = new Map()
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))
const listeners = new Set()
const calls = []
const layer = new Element()
let records = stored
const storage = {
get(key) {
assert.equal(key, 'times')
return records
},
set(key, value) {
assert.equal(key, 'times')
records = value
calls.push(['save', value])
},
del(key) {
assert.equal(key, 'times')
records = undefined
},
}
const art = Object.assign(new Emitter(), {
constructor: { AUTO_PLAYBACK_MAX: 2, AUTO_PLAYBACK_MIN: 5, AUTO_PLAYBACK_TIMEOUT: 100 },
option: { id: 'first', url: 'first-url' },
template: { $player: new Element(), $poster: new Element() },
i18n: { get: key => key },
icons: { close: '<svg></svg>' },
storage,
currentTime: 3,
playing: false,
play() {
calls.push(['play'])
return Promise.resolve()
},
layers: { add(option) {
assert.equal(option.name, 'auto-playback')
ownEntry(art, layer)
return layer
} },
})
Object.defineProperty(art, 'seek', {
configurable: true,
get: () => art.currentTime,
set: value => calls.push(['seek', value]),
})
art.events = {
proxy(target, name, callback) {
const listener = { target, name, callback }
listeners.add(listener)
const cleanup = () => listeners.delete(listener)
getScope(art).add(() => {
cleanup()
})
return cleanup
},
remove: cleanup => cleanup(),
}
art.proxy = art.events.proxy
beginLifecycle(art)
beginSource(art)
const plugin = autoPlayback(art)
const jump = layer.querySelector('.art-auto-playback-jump')
const close = layer.querySelector('.art-auto-playback-close')
const last = layer.querySelector('.art-auto-playback-last')
const click = (target = jump) => {
for (const listener of [...listeners]) {
if (listener.target === target && listener.name === 'click')
listener.callback(new Event('click'))
}
}
const flush = () => {
const pending = [...timers.values()]
timers.clear()
for (const timer of pending)
timer.callback()
}
return { art, plugin, storage, layer, last, jump, close, click, flush, timers, calls, listeners, scope: getScope(art) }
}
test('autoPlayback retains result names, storage key, ID precedence, pruning and management methods', (t) => {
const f = fixture(t, { a: 1, b: 2, c: 3 })
assert.deepEqual(Object.keys(f.plugin), ['name', 'times', 'clear', 'delete'])
assert.equal(f.plugin.name, 'auto-playback')
f.art.playing = true
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { b: 2, c: 3, first: 3 })
f.art.option.id = ''
f.art.currentTime = 9
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { 'c': 3, 'first': 3, 'first-url': 9 })
assert.deepEqual(f.plugin.delete('first'), { 'c': 3, 'first-url': 9 })
assert.equal(f.plugin.clear(), undefined)
assert.deepEqual(f.plugin.times, {})
f.scope.dispose()
})
test('resume prompt preserves threshold, text, seek/play order and first-update timeout', (t) => {
const f = fixture(t, { first: 4 })
f.art.emit('ready')
assert.equal(f.layer.style.display, 'none')
f.storage.set('times', { first: 5 })
f.art.emit('restart')
assert.equal(f.layer.style.display, 'flex')
assert.equal(f.last.textContent, 'Last Seen 00:05')
assert.equal(f.jump.textContent, 'Jump Play')
assert.equal(f.timers.size, 0)
f.click()
assert.deepEqual(f.calls.slice(-2), [['seek', 5], ['play']])
assert.equal(f.art.template.$poster.style.display, 'none')
assert.equal(f.layer.style.display, 'none')
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
assert.equal([...f.timers.values()][0].delay, 100)
f.flush()
assert.equal(f.layer.style.display, 'none')
f.scope.dispose()
})
test('restarts replace click targets and only the latest resume time is played', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.storage.set('times', { first: 18 })
f.art.emit('restart')
f.storage.set('times', { first: 24 })
f.art.emit('restart')
f.click()
assert.deepEqual(f.calls.filter(call => call[0] === 'seek' || call[0] === 'play'), [['seek', 24], ['play']])
assert.equal(f.listeners.size, 2)
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('restarting below threshold cancels old clicks and pending first-update subscription', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.storage.set('times', { first: 1 })
f.art.emit('restart')
f.click()
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('removing the layer releases prompt resources but keeps playing-time storage', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.art.emit('video:timeupdate')
releaseEntry(f.layer)
f.click()
f.art.emit('restart')
assert.equal(f.calls.some(call => call[0] === 'seek'), false)
assert.equal(f.timers.size, 0)
assert.equal(f.listeners.size, 0)
f.art.playing = true
f.art.emit('video:timeupdate')
assert.deepEqual(f.plugin.times, { first: 3 })
f.scope.dispose()
})
test('source replacement hides stale prompts before restart and cancels their pending work', (t) => {
const f = fixture(t)
f.art.emit('ready')
beginSource(f.art)
assert.equal(f.layer.style.display, 'none')
f.click()
f.art.emit('video:timeupdate')
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('destroy during seek stops play and subsequent DOM writes', (t) => {
const f = fixture(t)
f.art.emit('ready')
Object.defineProperty(f.art, 'seek', {
get: () => 0,
set() {
f.scope.dispose()
},
})
f.click()
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.art.template.$poster.style.display, undefined)
assert.equal(f.timers.size, 0)
})
test('destruction stops recording and first-update scheduling', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.scope.dispose()
f.art.playing = true
f.art.emit('video:timeupdate')
f.art.emit('restart')
assert.equal(f.calls.length, 0)
assert.equal(f.timers.size, 0)
})
test('old timeout cannot hide a newer prompt and live timeout constants remain effective', (t) => {
const f = fixture(t)
f.art.emit('ready')
f.art.emit('video:timeupdate')
const stale = [...f.timers.values()][0].callback
f.storage.set('times', { first: 18 })
f.art.emit('restart')
stale()
assert.equal(f.layer.style.display, 'flex')
f.art.constructor.AUTO_PLAYBACK_TIMEOUT = 200
f.art.emit('video:timeupdate')
assert.equal(f.timers.size, 1)
assert.equal([...f.timers.values()][0].delay, 200)
f.scope.dispose()
})
test('a restart inside seek retains the new prompt and stops the old play continuation', (t) => {
const f = fixture(t)
f.art.emit('ready')
Object.defineProperty(f.art, 'seek', {
get: () => 0,
set() {
f.storage.set('times', { first: 25 })
f.art.emit('restart')
},
})
f.click()
assert.equal(f.calls.some(call => call[0] === 'play'), false)
assert.equal(f.last.textContent, 'Last Seen 00:25')
assert.equal(f.layer.style.display, 'flex')
assert.equal(f.listeners.size, 2)
f.scope.dispose()
})
test('destroying in localization callbacks prevents later DOM writes', (t) => {
const f = fixture(t)
f.art.i18n.get = () => {
f.scope.dispose()
return 'late'
}
f.art.emit('ready')
assert.equal(f.last.textContent, '')
assert.equal(f.jump.textContent, '')
assert.equal(f.listeners.size, 0)
})
test('destroying in storage callbacks prevents later record writes', (t) => {
const f = fixture(t)
f.storage.get = () => {
f.scope.dispose()
return { first: 12 }
}
f.art.playing = true
f.art.emit('video:timeupdate')
assert.equal(f.calls.length, 0)
})
test('invalid minimum retains the original comparison rather than showing a prompt', (t) => {
const f = fixture(t)
f.art.constructor.AUTO_PLAYBACK_MIN = Number.NaN
f.art.emit('ready')
assert.equal(f.layer.style.display, 'none')
assert.equal(f.listeners.size, 0)
f.scope.dispose()
})
test('resume close and internally rejected playback preserve the existing UI contract', async (t) => {
const f = fixture(t)
f.art.emit('ready')
f.click(f.close)
assert.equal(f.layer.style.display, 'none')
assert.equal(f.calls.length, 0)
f.art.emit('restart')
f.art.play = () => Promise.reject(new Error('play refused'))
f.click()
await Promise.resolve()
assert.equal(f.layer.style.display, 'none')
assert.equal(f.art.template.$poster.style.display, 'none')
f.scope.dispose()
})
+181
View File
@@ -0,0 +1,181 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, time = 1) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((time) => {
window.Artplayer.AUTO_PLAYBACK_MIN = 0.5
window.Artplayer.AUTO_PLAYBACK_TIMEOUT = 24735
localStorage.setItem('artplayer_settings', JSON.stringify({ volume: 0.4, times: { example: time } }))
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', id: 'example', autoPlayback: true, muted: true })
window.resume = window.art.plugins['auto-playback']
window.promptLayer = window.art.layers['auto-playback']
}, time)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect.poll(() => page.evaluate(() => window.promptLayer.style.display)).toBe('flex')
}
for (const core of ['published', 'candidate']) {
test(`${core}: resume close and playback rejection preserve the UI contract`, async ({ page }, testInfo) => {
await setup(page, core)
await page.locator('.art-auto-playback-close').click()
expect(await page.evaluate(() => window.promptLayer.style.display)).toBe('none')
await page.evaluate((published) => {
window.art.emit('restart', window.art.url)
const failure = new Error('controlled play rejection')
window.resumeRejections = []
if (published) {
window.addEventListener('unhandledrejection', (event) => {
if (event.reason === failure) {
event.preventDefault()
window.resumeRejections.push({ name: event.reason.name, message: event.reason.message, same: true })
}
})
}
window.art.template.$video.play = () => Promise.reject(failure)
}, core === 'published')
await page.locator('.art-auto-playback-jump').click()
expect(await page.evaluate(() => ({ layer: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ layer: 'none', poster: 'none' })
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.resumeRejections.length)).toBe(2)
const rejections = await page.evaluate(() => window.resumeRejections)
expect(rejections).toEqual(core === 'published' ? Array.from({ length: 2 }, () => ({ name: 'Error', message: 'controlled play rejection', same: true })) : [])
await testInfo.attach('known-resume-rejections', { contentType: 'application/json', body: JSON.stringify({ core, rejections }) })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: resume API retains storage envelope, ID lookup and deletion`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const plugin = window.resume
const before = plugin.times
const removed = plugin.delete('example')
const cleared = plugin.clear()
const envelope = JSON.parse(localStorage.getItem('artplayer_settings'))
window.art.destroy()
return { keys: Object.keys(plugin), name: plugin.name, before, removed, cleared, times: plugin.times, volume: envelope.volume, removedTimes: !Object.hasOwn(envelope, 'times') }
})).toEqual({ keys: ['name', 'times', 'clear', 'delete'], name: 'auto-playback', before: { example: 1 }, removed: {}, cleared: undefined, times: {}, volume: 0.4, removedTimes: true })
})
test(`${core}: resume button seeks and plays actual video`, async ({ page }) => {
await setup(page, core)
await page.locator('.art-auto-playback-jump').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
expect(await page.evaluate(() => ({ prompt: window.promptLayer.style.display, poster: window.art.template.$poster.style.display }))).toEqual({ prompt: 'none', poster: 'none' })
await expect.poll(() => page.evaluate(() => window.resume.times.example)).toBeGreaterThan(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: repeated restart leaves one effective click for the current resume target`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(() => {
const art = window.art
const seeks = []
let plays = 0
art.on('seek', (_time, requested) => seeks.push(requested))
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 1.5 })
art.emit('restart', art.url)
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const label = window.promptLayer.querySelector('.art-auto-playback-last').textContent
art.destroy()
return { seeks, plays, label }
})
expect(result.seeks).toEqual(core === 'candidate' ? [2] : [1, 1.5, 2])
expect(result.plays).toBe(core === 'candidate' ? 1 : 3)
expect(result.label).toBe('Last Seen 00:02')
})
test(`${core}: restart below threshold invalidates the old prompt click`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.storage.set('times', { example: 0.1 })
art.emit('restart', art.url)
window.promptLayer.querySelector('.art-auto-playback-jump').click()
const result = { hidden: window.promptLayer.style.display, plays }
art.destroy()
return result
})).toEqual({ hidden: 'none', plays: core === 'candidate' ? 0 : 1 })
})
}
for (const action of ['restart', 'source', 'remove', 'destroy']) {
test(`candidate: ${action} cancels obsolete resume timers and subscriptions`, async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate((action) => {
const art = window.art
const layer = window.promptLayer
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24735) {
callbacks.push(callback)
pending.add(timer)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.emit('video:timeupdate', new Event('timeupdate'))
const scheduled = pending.size
if (action === 'restart') {
art.storage.set('times', { example: 2 })
art.emit('restart', art.url)
}
else if (action === 'source') {
art.url = '/test/pattern.mp4?resume=2'
}
else if (action === 'remove') {
art.layers.remove('auto-playback')
}
else {
art.destroy(false)
}
for (const callback of callbacks)
callback()
if (action !== 'restart')
layer.querySelector('.art-auto-playback-jump').click()
const result = { scheduled, remaining: pending.size, display: layer.style.display, plays }
window.setTimeout = set
window.clearTimeout = clear
art.destroy()
return result
}, action)).toEqual({ scheduled: 1, remaining: 0, display: action === 'restart' ? 'flex' : 'none', plays: 0 })
})
}
test('candidate: destruction inside seek prevents resume playback continuation', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
let plays = 0
art.template.$video.play = () => {
plays++
return Promise.resolve()
}
art.on('seek', () => art.destroy(false))
window.promptLayer.querySelector('.art-auto-playback-jump').click()
return { destroyed: art.isDestroy, plays }
})).toEqual({ destroyed: true, plays: 0 })
})
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = true, live = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android builtin fixture' : 'ArtPlayer Desktop builtin fixture' }), mobile)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((live) => {
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
isLive: live,
miniProgressBar: true,
lock: true,
autoPlayback: true,
autoOrientation: true,
fastForward: true,
})
}, live)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const mobile of [true, false]) {
for (const live of [true, false]) {
test(`${core}: builtin availability keeps mobile=${mobile} live=${live} conditions`, async ({ page }) => {
await setup(page, core, mobile, live)
expect(await page.evaluate(() => {
const names = ['mini-progress-bar', 'lock', 'auto-playback', 'autoOrientation', 'fastForward']
const result = names.map(name => Boolean(window.art.plugins[name]))
window.art.destroy()
return result
})).toEqual([!live, mobile, !live, mobile, mobile && !live])
})
}
}
test(`${core}: lock keeps public state, repeated events, DOM classes and icon switching`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const plugin = art.plugins.lock
const events = []
art.on('lock', state => events.push([state, art.isLock, plugin.state]))
plugin.state = true
plugin.state = true
art.layers.lock.click()
const result = {
keys: Object.keys(plugin),
events,
lock: art.layers.lock.querySelector('.art-icon-lock').style.display,
unlock: art.layers.lock.querySelector('.art-icon-unlock').style.display,
}
art.destroy()
return result
})).toEqual({ keys: ['name', 'state'], events: [[true, true, true], [true, true, true], [false, false, false]], lock: 'none', unlock: 'inline-flex' })
})
test(`${core}: removed lock icons stop observing the still-usable plugin state API`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const icon = art.layers.lock.querySelector('.art-icon-lock')
art.layers.remove('lock')
art.plugins.lock.state = true
const result = { flag: art.isLock, state: art.plugins.lock.state, icon: icon.style.display }
art.destroy()
return result
})).toEqual({ flag: true, state: true, icon: core === 'candidate' ? 'none' : 'inline-flex' })
})
test(`${core}: miniature progress follows the control event without adding public state`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const player = art.template.$player
art.emit('control', false)
const hiddenControls = player.classList.contains('art-mini-progress-bar')
art.emit('control', true)
const shownControls = player.classList.contains('art-mini-progress-bar')
const keys = Object.keys(art.plugins['mini-progress-bar'])
art.destroy()
return { hiddenControls, shownControls, keys }
})).toEqual({ hiddenControls: true, shownControls: false, keys: ['name'] })
})
}
test('candidate: retained builtin setters and events do not mutate a destroyed player', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.emit('control', true)
art.destroy(false)
let events = 0
art.on('lock', () => events++)
art.plugins.lock.state = true
art.emit('control', false)
return { lock: art.isLock, state: art.plugins.lock.state, mini: art.template.$player.classList.contains('art-mini-progress-bar'), events }
})).toEqual({ lock: false, state: false, mini: false, events: 0 })
})
+132
View File
@@ -0,0 +1,132 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android long-press fixture' }))
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => {
window.Artplayer.FAST_FORWARD_TIME = 20
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, fastForward: true })
window.touch = (type, count = 1) => {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier, pageX: 50, pageY: 50, clientX: 50, clientY: 50 })) })
window.art.template.$video.dispatchEvent(event)
}
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.art.playbackRate = 1.5
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: native source reset retains its default playback rate behavior`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => {
window.art.url = '/test/pattern.mp4?reset=1'
})
await expect.poll(() => page.evaluate(() => window.art.playbackRate)).toBe(1)
expect(await page.evaluate(() => window.art.template.$video.defaultPlaybackRate)).toBe(1)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: long press accelerates real playback and restores its prior rate`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => Object.keys(window.art.plugins.fastForward))).toEqual(['name', 'state'])
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
expect(await page.evaluate(() => window.art.playbackRate)).toBe(3)
await page.evaluate(() => window.touch('touchend', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: 1.5 })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: touchcancel releases an active long press`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate(() => window.touch('touchcancel', 0))
expect(await page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: core === 'published', rate: core === 'published' ? 3 : 1.5 })
await page.evaluate(() => {
window.touch('touchend', 0)
window.art.destroy()
})
})
}
for (const action of ['lock', 'pause', 'source', 'destroy']) {
test(`candidate: ${action} restores the active long-press rate`, async ({ page }) => {
await setup(page, 'candidate')
await page.evaluate(() => window.touch('touchstart'))
await expect.poll(() => page.evaluate(() => window.art.plugins.fastForward.state)).toBe(true)
await page.evaluate((action) => {
const art = window.art
if (action === 'lock') {
art.isLock = true
art.emit('lock', true)
}
else if (action === 'pause') {
art.pause()
}
else if (action === 'source') {
const video = art.template.$video
const rate = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate')
window.restoredRates = []
Object.defineProperty(video, 'playbackRate', {
configurable: true,
get() {
return rate.get.call(this)
},
set(value) {
window.restoredRates.push(value)
rate.set.call(this, value)
},
})
art.url = '/test/pattern.mp4?replacement=1'
}
else {
art.destroy(false)
}
}, action)
await expect.poll(() => page.evaluate(() => ({ state: window.art.plugins.fastForward.state, rate: window.art.playbackRate }))).toEqual({ state: false, rate: action === 'source' ? 1 : 1.5 })
if (action === 'source')
expect(await page.evaluate(() => window.restoredRates)).toEqual([1.5])
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: repeated pending touches own a single timer which destruction cancels', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
art.constructor.FAST_FORWARD_TIME = 24734
const set = window.setTimeout
const clear = window.clearTimeout
const pending = new Set()
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
const timer = set.call(this, callback, delay, ...args)
if (delay === 24734) {
pending.add(timer)
callbacks.push(callback)
}
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
window.touch('touchstart')
window.touch('touchstart')
const scheduled = pending.size
art.destroy(false)
const resetRate = art.playbackRate
for (const callback of callbacks)
callback()
const result = { scheduled, remaining: pending.size, state: art.plugins.fastForward.state, resetRate, rate: art.playbackRate }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ scheduled: 1, remaining: 0, state: false, resetRate: 1, rate: 1 })
})
+168
View File
@@ -0,0 +1,168 @@
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)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 24733
window.art.notice.destroy()
})
}
for (const core of ['published', 'candidate']) {
test(`${core}: notice updates do not call a consumer override of destroy`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
const original = notice.destroy
let calls = 0
notice.destroy = function () {
calls++
return original.call(this)
}
notice.show = 'first'
notice.show = 'second'
const implicit = calls
notice.destroy()
const explicit = calls
window.art.destroy()
return { implicit, explicit }
})).toEqual({ implicit: 0, explicit: 1 })
})
test(`${core}: notice preserves public shape, Error text and false hide semantics`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
notice.show = new Error(' trimmed ')
const visible = notice.show
const text = art.template.$noticeInner.textContent
const timer = notice.timer
notice.show = false
const result = {
fields: Object.keys(notice),
prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(notice)),
visible,
text,
hidden: !notice.show,
retained: art.template.$noticeInner.textContent,
sameTimer: timer === notice.timer,
}
notice.destroy()
art.destroy()
return result
})).toEqual({ fields: ['art', 'timer'], prototype: ['constructor', 'destroy', 'show'], visible: true, text: 'trimmed', hidden: true, retained: 'trimmed', sameTimer: true })
})
test(`${core}: notice expires naturally during real playback`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => {
window.Artplayer.NOTICE_TIME = 20
window.art.notice.show = 'transient'
})
await expect.poll(() => page.evaluate(() => ({ visible: window.art.notice.show, text: window.art.template.$noticeInner.textContent }))).toEqual({ visible: false, text: '' })
expect(await page.evaluate(() => window.art.playing)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
test(`${core}: manual notice destroy permits reuse and does not hide its DOM`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const notice = window.art.notice
notice.show = 'visible'
notice.destroy()
const visible = notice.show
const timer = notice.timer
notice.show = 'reused'
const reused = window.art.template.$noticeInner.textContent
notice.destroy()
window.art.destroy()
return { visible, timer, reused }
})).toEqual({ visible: true, timer: null, reused: 'reused' })
})
test(`${core}: destruction during notice text assignment cannot schedule a late timer`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const notice = art.notice
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
art.destroy(false)
},
})
notice.show = 'destroy during write'
const result = { destroyed: art.isDestroy, timer: notice.timer !== null, visible: notice.show }
notice.destroy()
return result
})).toEqual({ destroyed: true, timer: core === 'published', visible: core === 'published' })
})
test(`${core}: expiry does not hide a notice installed during the clearing write`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
const inner = art.template.$noticeInner
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent')
const original = window.setTimeout
let expire
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
expire = callback
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
Object.defineProperty(inner, 'textContent', {
configurable: true,
get() {
return descriptor.get.call(this)
},
set(value) {
descriptor.set.call(this, value)
if (value === '')
art.notice.show = 'nested'
},
})
expire()
const result = { text: inner.textContent, visible: art.notice.show }
window.setTimeout = original
art.notice.destroy()
art.destroy()
return result
})).toEqual({ text: 'nested', visible: core === 'candidate' })
})
}
test('candidate: retained callbacks and assignments cannot revive destroyed notice resources', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(() => {
const art = window.art
const original = window.setTimeout
const callbacks = []
window.setTimeout = function (callback, delay, ...args) {
if (delay === 24733)
callbacks.push(callback)
return original.call(this, callback, delay, ...args)
}
art.notice.show = 'old'
art.notice.show = 'new'
callbacks[0]()
const before = art.template.$noticeInner.textContent
art.destroy(false)
callbacks[1]()
art.notice.show = 'ignored'
window.setTimeout = original
return { before, after: art.template.$noticeInner.textContent, timer: art.notice.timer, calls: callbacks.length }
})).toEqual({ before: 'new', after: 'new', timer: null, calls: 2 })
})
+193
View File
@@ -0,0 +1,193 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, mobile = false) {
await page.addInitScript(mobile => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: mobile ? 'ArtPlayer Android info fixture' : 'ArtPlayer Desktop info fixture' }), mobile)
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}: mask preserves observations before and after its destroy handler`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
expect(await page.evaluate(() => {
const seen = []
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => seen.push(['before', this.template.$state.querySelector('.art-icon-error').style.display]))
},
}] })
art.on('destroy', () => seen.push(['after', art.template.$state.querySelector('.art-icon-error').style.display]))
art.destroy(false)
return seen
})).toEqual([['before', 'none'], ['after', '']])
})
test(`${core}: info preserves fields, native media text and close visibility`, async ({ page }) => {
await setup(page, core)
expect(await page.evaluate(() => {
const art = window.art
art.template.$video.volume = 0.4
art.info.init()
const volume = art.template.$infoPanel.querySelector('[data-video="volume"]').textContent
const source = art.template.$infoPanel.querySelector('[data-video="currentSrc"]').textContent === art.template.$video.currentSrc
art.info.show = true
art.template.$infoClose.click()
const result = { fields: Object.keys(art.info), prototype: Object.getOwnPropertyNames(Object.getPrototypeOf(art.info)), volume, source, visible: art.info.show }
art.destroy()
return result
})).toEqual({ fields: ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'], prototype: ['constructor', 'init'], volume: '0.40', source: true, visible: false })
})
test(`${core}: info retains native nullable string conversion for custom media fields`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
art.template.$infoPanel.append(item)
const values = [null, undefined, true, 1.234, Number.NaN, Infinity, { toString: () => 'custom' }]
const result = values.map((value) => {
art.template.$video.fixtureValue = value
art.info.init()
return item.textContent
})
art.template.$video.fixtureValue = Symbol('not text')
let failed
try {
art.info.init()
}
catch (error) {
failed = error.name
}
art.destroy()
return { result, failed }
})).toEqual({ result: ['', '', 'true', '1.23', 'NaN', 'Infinity', 'custom'], failed: 'TypeError' })
})
test(`${core}: repeated info init owns only one new timer and close handler`, 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 === 24736)
pending.add(timer)
return timer
}
window.clearTimeout = function (timer) {
pending.delete(timer)
return clear.call(this, timer)
}
art.constructor.INFO_LOOP_TIME = 24736
art.info.init()
art.info.init()
let closed = 0
art.on('info', state => !state && closed++)
art.info.show = true
art.template.$infoClose.click()
const timers = pending.size
art.destroy()
const result = { timers, closed, remaining: pending.size }
window.setTimeout = set
window.clearTimeout = clear
return result
})).toEqual({ timers: core === 'candidate' ? 1 : 2, closed: core === 'candidate' ? 1 : 3, remaining: 0 })
})
test(`${core}: info mobile construction waits for an explicit init`, async ({ page }) => {
await setup(page, core, true)
expect(await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
art.template.$video.volume = 0.4
art.info.init()
const after = node.textContent
art.destroy()
return { before, after }
})).toEqual({ before: '', after: '0.40' })
})
test(`${core}: mask click plays native video and destruction displays the terminal icon`, async ({ page }) => {
await setup(page, core)
await page.evaluate(() => window.art.template.$state.click())
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
expect(await page.evaluate(() => {
const art = window.art
const state = art.template.$state.querySelector('.art-icon-state')
const error = art.template.$state.querySelector('.art-icon-error')
art.loading.show = true
const loading = art.loading.show
const icon = Boolean(art.template.$loading.querySelector('.art-icon-loading'))
art.destroy(false)
return { loading, icon, state: state.style.display, error: error.style.display }
})).toEqual({ loading: true, icon: true, state: 'none', error: '' })
})
}
test('candidate: info destruction during a media getter prevents text writes and late init', async ({ page }) => {
await setup(page, 'candidate')
const result = await page.evaluate(() => {
const art = window.art
const node = art.template.$infoPanel.querySelector('[data-video="volume"]')
const before = node.textContent
Object.defineProperty(art.template.$video, 'volume', { configurable: true, get() {
art.destroy(false)
return 0.8
} })
art.info.init()
const after = node.textContent
art.info.init()
return { before, after, destroyed: art.isDestroy }
})
expect(result.after).toBe(result.before)
expect(result.destroyed).toBe(true)
})
test('candidate: reentrant info text conversion cannot write after player destruction', async ({ page }) => {
await setup(page, 'candidate', true)
expect(await page.evaluate(() => {
const art = window.art
const item = document.createElement('span')
item.dataset.video = 'fixtureValue'
item.textContent = 'before'
art.template.$infoPanel.append(item)
art.template.$video.fixtureValue = { toString() {
art.destroy(false)
return 'late'
} }
art.info.init()
return { text: item.textContent, destroyed: art.isDestroy }
})).toEqual({ text: 'before', destroyed: true })
})
test('candidate: mask finalization follows an earlier throwing observer without replacing its error', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
expect(await page.evaluate(() => {
const failure = new Error('early observer failed')
const art = new window.Artplayer({ container: '.player', url: '', layers: [{
name: 'observer',
html: '',
mounted() {
this.on('destroy', () => {
throw failure
})
},
}] })
let original = false
try {
art.destroy(false)
}
catch (error) {
original = error === failure
}
return { original, destroyed: art.isDestroy, state: art.template.$state.querySelector('.art-icon-state').style.display, error: art.template.$state.querySelector('.art-icon-error').style.display }
})).toEqual({ original: true, destroyed: true, state: 'none', error: '' })
})
+112
View File
@@ -0,0 +1,112 @@
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 { lock, miniProgressBar, Emitter, beginLifecycle, getScope, ownEntry, releaseEntry } = await loadModules({
lock: 'packages/artplayer/src/plugins/lock',
miniProgressBar: 'packages/artplayer/src/plugins/miniProgressBar',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
})
function fixture(t) {
class Element {
style = {}
children = []
appendChild(child) {
this.children.push(child)
this.lastElementChild = child
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
const classes = new Set()
const classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
const element = new Element()
let option
const art = Object.assign(new Emitter(), {
template: { $player: { classList } },
isLock: false,
icons: { lock: new Element(), unlock: new Element() },
layers: { add(value) {
option = value
ownEntry(art, element)
option.mounted(element)
return element
} },
})
beginLifecycle(art)
const plugin = lock(art)
return { art, plugin, classes, classList, element, click: () => option.click(), scope: getScope(art) }
}
test('lock preserves state/class/flag ordering, repeated notifications and icon visibility', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
const seen = []
f.art.on('lock', state => seen.push([state, f.plugin.state, f.art.isLock]))
f.plugin.state = true
f.plugin.state = true
assert.equal(f.art.icons.lock.style.display, 'inline-flex')
assert.equal(f.art.icons.unlock.style.display, 'none')
f.click()
assert.equal(f.art.icons.lock.style.display, 'none')
assert.equal(f.art.icons.unlock.style.display, 'inline-flex')
assert.deepEqual(seen, [[true, true, true], [true, true, true], [false, false, false]])
f.scope.dispose()
})
test('lock icon subscriptions are removed with the layer but the live state API remains usable', (t) => {
const f = fixture(t)
releaseEntry(f.element)
f.plugin.state = true
assert.equal(f.plugin.state, true)
assert.equal(f.art.isLock, true)
assert.equal(f.art.icons.lock.style.display, 'none')
assert.equal(f.art.e.lock, undefined)
f.scope.dispose()
})
test('destroyed lock does not emit or mutate state through retained setters and clicks', (t) => {
const f = fixture(t)
f.scope.dispose()
let events = 0
f.art.on('lock', () => events++)
f.plugin.state = true
f.click()
assert.equal(f.plugin.state, false)
assert.equal(f.art.isLock, false)
assert.equal(events, 0)
})
test('destruction during lock class mutation stops the subsequent flag and event', (t) => {
const f = fixture(t)
const original = f.classList.add
f.classList.add = (name) => {
original(name)
f.scope.dispose()
}
let events = 0
f.art.on('lock', () => events++)
f.plugin.state = true
assert.equal(f.art.isLock, false)
assert.equal(events, 0)
})
test('miniProgressBar preserves its control-state class and releases the subscription', (t) => {
const f = fixture(t)
assert.deepEqual(miniProgressBar(f.art), { name: 'mini-progress-bar' })
f.art.emit('control', false)
assert.equal(f.classes.has('art-mini-progress-bar'), true)
f.art.emit('control', true)
assert.equal(f.classes.has('art-mini-progress-bar'), false)
f.scope.dispose()
f.art.emit('control', false)
assert.equal(f.classes.has('art-mini-progress-bar'), false)
assert.equal(f.art.e.control, undefined)
})
+229
View File
@@ -0,0 +1,229 @@
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 { fastForward, Emitter, beginLifecycle, getScope, beginSource } = await loadModules({
fastForward: 'packages/artplayer/src/plugins/fastForward',
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' },
})
function fixture(t) {
let id = 0
const timers = new Map()
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))
const classes = new Set()
const video = new EventTarget()
const art = Object.assign(new Emitter(), {
template: { $video: video, $player: { classList: {
add: value => classes.add(value),
remove: value => classes.delete(value),
contains: value => classes.has(value),
} } },
constructor: { FAST_FORWARD_TIME: 100, FAST_FORWARD_VALUE: 3 },
playing: true,
isLock: false,
playbackRate: 1.5,
proxy(target, name, callback) {
target.addEventListener(name, callback)
const remove = () => target.removeEventListener(name, callback)
getScope(art).add(() => {
remove()
})
return remove
},
})
beginLifecycle(art)
beginSource(art)
const plugin = fastForward(art)
const start = (count = 1) => {
const event = new Event('touchstart')
Object.defineProperty(event, 'touches', { value: Array.from({ length: count }, (_, identifier) => ({ identifier })) })
video.dispatchEvent(event)
}
const flush = () => {
const callbacks = [...timers.values()]
timers.clear()
for (const { callback } of callbacks)
callback()
}
return { art, plugin, start, flush, timers, video, scope: getScope(art) }
}
test('fastForward preserves name, state, live constants and previous playback rate', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.plugin), ['name', 'state'])
assert.equal(f.plugin.name, 'fastForward')
assert.equal(f.plugin.state, false)
f.art.constructor.FAST_FORWARD_TIME = 200
f.start()
assert.equal([...f.timers.values()][0].delay, 200)
f.art.constructor.FAST_FORWARD_VALUE = 4
f.flush()
assert.equal(f.art.playbackRate, 4)
assert.equal(f.plugin.state, true)
f.art.emit('document:touchend', new Event('touchend'))
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
test('fastForward only schedules a single playing unlocked touch', (t) => {
const f = fixture(t)
f.start(2)
f.art.playing = false
f.start()
f.art.playing = true
f.art.isLock = true
f.start()
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('repeated touch starts do not leave timers or overwrite the original rate', (t) => {
const f = fixture(t)
f.start()
f.start()
assert.equal(f.timers.size, 1)
f.flush()
f.start(2)
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
for (const event of ['document:touchmove', 'document:touchend', 'document:touchcancel', 'video:pause', 'destroy']) {
test(`fastForward cancels pending and active presses on ${event}`, (t) => {
const f = fixture(t)
f.start()
const stale = [...f.timers.values()][0].callback
f.art.emit(event, new Event(event))
assert.equal(f.timers.size, 0)
stale()
assert.equal(f.art.playbackRate, 1.5)
f.start()
f.flush()
assert.equal(f.plugin.state, true)
f.art.emit(event, new Event(event))
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
}
test('native touchcancel, lock, source replacement and destruction release the press', (t) => {
const f = fixture(t)
f.start()
f.video.dispatchEvent(new Event('touchcancel'))
assert.equal(f.timers.size, 0)
f.start()
f.flush()
f.art.isLock = true
f.art.emit('lock', true)
assert.equal(f.art.playbackRate, 1.5)
f.art.isLock = false
f.start()
f.flush()
beginSource(f.art)
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.start()
f.flush()
f.scope.dispose()
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.start()
f.art.emit('lock', false)
assert.equal(f.timers.size, 0)
})
test('destruction inside the playback rate setter cannot leave an active class', (t) => {
const f = fixture(t)
let rate = 1.5
Object.defineProperty(f.art, 'playbackRate', {
get: () => rate,
set(value) {
rate = value
if (value === 3)
f.scope.dispose()
},
})
f.start()
f.flush()
assert.equal(rate, 1.5)
assert.equal(f.plugin.state, false)
assert.equal(f.timers.size, 0)
})
test('failed rate assignments release press state and preserve the thrown value', (t) => {
const f = fixture(t)
const failure = new Error('rate refused')
Object.defineProperty(f.art, 'playbackRate', {
get: () => 1.5,
set(value) {
if (value === 3)
throw failure
},
})
f.start()
assert.throws(f.flush, error => error === failure)
assert.equal(f.plugin.state, false)
assert.equal(f.timers.size, 0)
f.scope.dispose()
})
test('source replacement cancels pending work and callback-time flags are rechecked', (t) => {
const f = fixture(t)
f.start()
const stale = [...f.timers.values()][0].callback
beginSource(f.art)
assert.equal(f.timers.size, 0)
stale()
assert.equal(f.art.playbackRate, 1.5)
f.start()
f.art.playing = false
f.flush()
assert.equal(f.plugin.state, false)
f.art.playing = true
f.start()
f.art.isLock = true
f.flush()
assert.equal(f.art.playbackRate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
test('a new press started during rate restoration supersedes the outer restart', (t) => {
const f = fixture(t)
f.start()
f.flush()
let rate = 3
let reentered = false
Object.defineProperty(f.art, 'playbackRate', {
get: () => rate,
set(value) {
rate = value
if (value === 1.5 && !reentered) {
reentered = true
f.start()
f.flush()
}
},
})
f.start()
assert.equal(f.timers.size, 0)
assert.equal(f.plugin.state, true)
assert.equal(rate, 3)
f.art.emit('document:touchend', new Event('touchend'))
assert.equal(rate, 1.5)
assert.equal(f.plugin.state, false)
f.scope.dispose()
})
+163
View File
@@ -0,0 +1,163 @@
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 { Notice, Emitter, beginLifecycle, getScope } = await loadModules({
Notice: 'packages/artplayer/src/notice',
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()
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))
const classes = new Set()
const classList = {
add: value => classes.add(value),
remove: value => classes.delete(value),
contains: value => classes.has(value),
}
const inner = { textContent: '' }
const art = Object.assign(new Emitter(), {
template: { $player: { classList }, $noticeInner: inner },
constructor: { NOTICE_TIME: 100 },
})
beginLifecycle(art)
const scope = getScope(art)
const notice = new Notice(art)
return { art, scope, notice, inner, classList, timers }
}
test('notice preserves fields, descriptors, text, live delay and hide semantics', (t) => {
const f = fixture(t)
assert.deepEqual(Object.keys(f.notice), ['art', 'timer'])
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(f.notice)), ['constructor', 'destroy', 'show'])
assert.equal(f.notice.show, false)
assert.equal(f.notice.timer, null)
f.notice.show = new Error(' example ')
assert.equal(f.inner.textContent, 'example')
assert.equal(f.notice.show, true)
assert.equal(f.timers.get(f.notice.timer).delay, 100)
f.art.constructor.NOTICE_TIME = 200
f.notice.show = ' next '
assert.equal(f.inner.textContent, ' next ')
assert.equal(f.timers.size, 1)
assert.equal(f.timers.get(f.notice.timer).delay, 200)
f.notice.show = ''
assert.equal(f.notice.show, false)
assert.equal(f.inner.textContent, ' next ')
assert.equal(f.timers.size, 1)
f.timers.get(f.notice.timer).callback()
assert.equal(f.inner.textContent, '')
})
test('manual notice destroy cancels its timer and permits reuse while alive', (t) => {
const f = fixture(t)
f.notice.show = 'visible'
assert.equal(f.notice.destroy(), undefined)
assert.equal(f.notice.timer, null)
assert.equal(f.timers.size, 0)
assert.equal(f.notice.show, true)
assert.equal(f.inner.textContent, 'visible')
f.notice.destroy()
f.notice.show = 'reused'
assert.equal(f.timers.size, 1)
f.art.emit('destroy')
assert.equal(f.timers.size, 0)
f.notice.show = 'again'
assert.equal(f.timers.size, 1)
f.scope.dispose()
f.notice.show = 'ignored'
assert.equal(f.timers.size, 0)
assert.equal(f.inner.textContent, 'again')
})
test('notice does not allocate a timer after destruction during DOM text writes', (t) => {
const f = fixture(t)
Object.defineProperty(f.inner, 'textContent', {
get: () => '',
set() {
f.scope.dispose()
},
})
f.notice.show = 'destroy during setter'
assert.equal(f.notice.timer, null)
assert.equal(f.timers.size, 0)
assert.equal(f.notice.show, false)
})
test('obsolete notice timer callbacks cannot erase a newer message', (t) => {
const f = fixture(t)
f.notice.show = 'old'
const stale = f.timers.get(f.notice.timer).callback
f.notice.show = 'new'
stale()
assert.equal(f.inner.textContent, 'new')
assert.equal(f.notice.show, true)
f.scope.dispose()
stale()
assert.equal(f.inner.textContent, 'new')
})
test('notice expiry does not hide a message installed by a nested DOM write', (t) => {
const f = fixture(t)
f.notice.show = 'old'
const expire = f.timers.get(f.notice.timer).callback
let value = 'old'
Object.defineProperty(f.inner, 'textContent', {
get: () => value,
set(next) {
value = next
if (next === '')
f.notice.show = 'nested'
},
})
expire()
assert.equal(f.inner.textContent, 'nested')
assert.equal(f.notice.show, true)
assert.equal(f.timers.size, 1)
})
test('nested notice writes keep only the newest timer', (t) => {
const f = fixture(t)
let value = ''
Object.defineProperty(f.inner, 'textContent', {
get: () => value,
set(next) {
value = next
if (next === 'outer')
f.notice.show = 'inner'
},
})
f.notice.show = 'outer'
assert.equal(f.inner.textContent, 'inner')
assert.equal(f.notice.show, true)
assert.equal(f.timers.size, 1)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('notice message updates do not invoke a consumer override of the public destroy method', (t) => {
const f = fixture(t)
let calls = 0
const original = f.notice.destroy
f.notice.destroy = function () {
calls++
return original.call(this)
}
f.notice.show = 'first'
f.notice.show = 'second'
assert.equal(calls, 0)
assert.equal(f.inner.textContent, 'second')
f.notice.destroy()
assert.equal(calls, 1)
f.scope.dispose()
})
+284
View File
@@ -0,0 +1,284 @@
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 { Info, Loading, Mask, Emitter, beginLifecycle, getScope, destroyInstance } = await loadModules({
Info: 'packages/artplayer/src/info',
Loading: 'packages/artplayer/src/loading',
Mask: 'packages/artplayer/src/mask',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
destroyInstance: { file: 'packages/artplayer/src/lifecycle/instance', name: 'destroyInstance' },
})
function fixture(t) {
class Element {
style = {}
children = []
textContent = ''
dataset = {}
appendChild(child) {
this.children.push(child)
this.lastElementChild = child
}
querySelectorAll() {
return this.children
}
}
const original = Object.getOwnPropertyDescriptor(globalThis, 'Element')
Object.defineProperty(globalThis, 'Element', { configurable: true, value: Element })
t.after(() => original ? Object.defineProperty(globalThis, 'Element', original) : Reflect.deleteProperty(globalThis, 'Element'))
let id = 0
const timers = new Map()
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))
const classes = new Set()
const player = new Element()
player.classList = { add: value => classes.add(value), remove: value => classes.delete(value), contains: value => classes.has(value) }
const panel = new Element()
const time = new Element()
time.dataset.video = 'currentTime'
const source = new Element()
source.dataset.video = 'src'
panel.children.push(time, source)
const listeners = new Set()
const art = Object.assign(new Emitter(), {
constructor: { INFO_LOOP_TIME: 100 },
template: { $player: player, $infoPanel: panel, $infoClose: new Element(), $video: { currentTime: 1.234, src: 'first' }, $loading: new Element(), $state: new Element() },
icons: { loading: new Element(), state: new Element(), error: new Element() },
play: () => Promise.resolve(),
})
art.events = {
proxy(target, name, callback) {
const listener = { target, name, callback }
listeners.add(listener)
const cleanup = () => listeners.delete(listener)
getScope(art).add(() => {
cleanup()
})
return cleanup
},
remove: cleanup => cleanup(),
}
art.proxy = art.events.proxy
beginLifecycle(art)
const click = (target) => {
for (const listener of [...listeners]) {
if (listener.target === target)
listener.callback(new Event('click'))
}
}
const flush = () => {
const pending = [...timers.values()]
timers.clear()
for (const timer of pending)
timer.callback()
}
return { art, time, source, timers, listeners, click, flush, scope: getScope(art), Element }
}
test('info retains component fields, immediate numeric formatting, live delay and close behavior', (t) => {
const f = fixture(t)
const info = new Info(f.art)
assert.deepEqual(Object.keys(info), ['id', 'art', 'cache', 'add', 'remove', 'update', 'name'])
assert.deepEqual(Object.getOwnPropertyNames(Object.getPrototypeOf(info)), ['constructor', 'init'])
assert.equal(f.time.textContent, '1.23')
assert.equal(f.source.textContent, 'first')
assert.equal(f.timers.size, 1)
f.art.template.$video.currentTime = 3
f.art.constructor.INFO_LOOP_TIME = 200
f.flush()
assert.equal(f.time.textContent, '3.00')
assert.equal([...f.timers.values()][0].delay, 200)
info.show = true
f.click(f.art.template.$infoClose)
assert.equal(info.show, false)
f.scope.dispose()
assert.equal(f.timers.size, 0)
})
test('info reinitialization replaces its poll and listener rather than multiplying work', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const stale = [...f.timers.values()][0].callback
info.init()
info.init()
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.art.template.$video.currentTime = 5
stale()
assert.equal(f.time.textContent, '1.23')
f.flush()
assert.equal(f.time.textContent, '5.00')
f.scope.dispose()
})
test('closed info init cannot attach listeners or write media values', (t) => {
const f = fixture(t)
const info = new Info(f.art)
f.scope.dispose()
f.art.template.$video.currentTime = 9
info.init()
assert.equal(f.time.textContent, '1.23')
assert.equal(f.listeners.size, 0)
assert.equal(f.timers.size, 0)
})
test('destruction during info media reads stops all subsequent DOM writes and scheduling', (t) => {
const f = fixture(t)
const info = new Info(f.art)
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
f.scope.dispose()
return 20
},
})
info.init()
assert.equal(f.time.textContent, '1.23')
assert.equal(f.timers.size, 0)
})
test('manual destroy emission stops info work, while a live manual init may restart it', (t) => {
const f = fixture(t)
const info = new Info(f.art)
f.art.emit('destroy')
assert.equal(f.timers.size, 0)
info.init()
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('failed info reads propagate the original error and release the failed initialization', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const failure = new Error('media getter failed')
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
throw failure
},
})
assert.throws(() => info.init(), error => error === failure)
assert.equal(f.timers.size, 0)
assert.equal(f.listeners.size, 0)
f.scope.dispose()
})
test('loading and mask preserve component visibility and actual icon identity', async (t) => {
const f = fixture(t)
const loading = new Loading(f.art)
const mask = new Mask(f.art)
assert.equal(f.art.template.$loading.children[0], f.art.icons.loading)
assert.deepEqual(f.art.template.$state.children, [f.art.icons.state, f.art.icons.error])
assert.equal(f.art.icons.error.style.display, 'none')
loading.show = true
mask.show = true
assert.equal(loading.show, true)
assert.equal(mask.show, true)
let plays = 0
f.art.play = () => {
plays++
return Promise.reject(new Error('play refused'))
}
f.click(f.art.template.$state)
await Promise.resolve()
assert.equal(plays, 1)
f.art.emit('destroy')
assert.equal(f.art.icons.state.style.display, 'none')
assert.equal(f.art.icons.error.style.display, null)
f.scope.dispose()
})
test('mask root cleanup shows its terminal icon even when no destroy event is emitted', (t) => {
const f = fixture(t)
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
const retained = [...f.listeners][0].callback
let plays = 0
f.art.play = () => {
plays++
}
f.scope.dispose()
retained(new Event('click'))
assert.equal(plays, 0)
assert.equal(f.art.icons.state.style.display, 'none')
assert.equal(f.art.icons.error.style.display, null)
assert.equal(f.art.e.destroy, undefined)
})
test('info reinitialization captures replacement panel and close nodes without retaining the old ones', (t) => {
const f = fixture(t)
const info = new Info(f.art)
const oldClose = f.art.template.$infoClose
const panel = new f.Element()
const item = new f.Element()
item.dataset.video = 'src'
panel.children.push(item)
f.art.template.$infoPanel = panel
f.art.template.$infoClose = new f.Element()
info.init()
assert.equal(item.textContent, 'first')
info.show = true
f.click(oldClose)
assert.equal(info.show, true)
f.click(f.art.template.$infoClose)
assert.equal(info.show, false)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('info getter reentry leaves only the newer poll alive', (t) => {
const f = fixture(t)
const info = new Info(f.art)
let nested = false
Object.defineProperty(f.art.template.$video, 'currentTime', {
get() {
if (!nested) {
nested = true
info.init()
return 20
}
return 30
},
})
info.init()
assert.equal(f.time.textContent, '30.00')
assert.equal(f.timers.size, 1)
assert.equal(f.listeners.size, 1)
f.scope.dispose()
})
test('mask retains destroy observer order while still finalizing after an earlier observer throws', (t) => {
const f = fixture(t)
f.art.reset = () => {}
f.art.template.destroy = () => {}
const seen = []
f.art.on('destroy', () => seen.push(['before', f.art.icons.error.style.display]))
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
f.art.on('destroy', () => seen.push(['after', f.art.icons.error.style.display]))
destroyInstance(f.art, [f.art], false, false)
assert.deepEqual(seen, [['before', 'none'], ['after', null]])
})
test('mask final cleanup survives a throwing earlier destroy observer', (t) => {
const f = fixture(t)
f.art.reset = () => {}
f.art.template.destroy = () => {}
const failure = new Error('early destroy observer')
f.art.on('destroy', () => {
throw failure
})
const mask = new Mask(f.art)
assert.equal(mask.name, 'mask')
assert.throws(() => destroyInstance(f.art, [f.art], false, false), error => error === failure)
assert.equal(f.art.icons.error.style.display, null)
assert.equal(f.art.e.destroy.length, 1)
})
+15
View File
@@ -0,0 +1,15 @@
import type { AutoPlaybackHost } from '../../packages/artplayer/src/plugins/autoPlayback'
import autoPlayback from '../../packages/artplayer/src/plugins/autoPlayback'
declare const host: AutoPlaybackHost
const plugin = autoPlayback(host)
const times: Record<string, number> = plugin.times
const remaining: Record<string, number> = plugin.delete('example')
const cleared: void = plugin.clear()
// @ts-expect-error Record deletion takes the persisted string key.
plugin.delete({ id: 'example' })
// @ts-expect-error Playback times contain numbers, not text.
const invalid: Record<string, string> = plugin.times
// @ts-expect-error The plugin needs both recording and prompt resources.
autoPlayback({ storage: host.storage })
export { cleared, invalid, remaining, times }
+15
View File
@@ -0,0 +1,15 @@
import type { LockHost } from '../../packages/artplayer/src/plugins/lock'
import type { MiniProgressHost } from '../../packages/artplayer/src/plugins/miniProgressBar'
import lock from '../../packages/artplayer/src/plugins/lock'
import miniProgressBar from '../../packages/artplayer/src/plugins/miniProgressBar'
declare const host: LockHost & MiniProgressHost
const plugin = lock(host)
plugin.state = true
const state: boolean = plugin.state
const mini: { name: string } = miniProgressBar(host)
// @ts-expect-error Lock state takes a boolean.
plugin.state = 'locked'
// @ts-expect-error The miniature progress plugin has no public state accessor.
miniProgressBar(host).state = true
export { mini, state }
+12
View File
@@ -0,0 +1,12 @@
import type { FastForwardHost } from '../../packages/artplayer/src/plugins/fastForward'
import fastForward from '../../packages/artplayer/src/plugins/fastForward'
declare const host: FastForwardHost
const plugin = fastForward(host)
const visible: boolean = plugin.state
const name: string = plugin.name
// @ts-expect-error Fast-forward state is read-only.
plugin.state = true
// @ts-expect-error A playing flag does not provide the plugin's media/event host.
fastForward({ playing: true })
export { name, visible }
+22
View File
@@ -0,0 +1,22 @@
import type Notice from '../../packages/artplayer/src/notice'
import type Artplayer from '../../packages/artplayer/types/artplayer'
declare const notice: Notice
declare const art: Artplayer
const visible: boolean = notice.show
notice.show = 'message'
notice.show = new Error('message')
notice.show = false
notice.show = ''
const timer: number | null = notice.timer
const destroyed: void = notice.destroy()
// @ts-expect-error The runtime getter returns visibility, not message text.
const text: string = notice.show
// @ts-expect-error True is not a declared message input.
notice.show = true
// @ts-expect-error Arbitrary objects are not declared message inputs.
notice.show = {}
// Preserve old legal consumers until CORE-21 coordinates an explicit precise view.
const legacy: string | Error | false = art.notice.show
art.notice.show = false
export { destroyed, legacy, text, timer, visible }
+20
View File
@@ -0,0 +1,20 @@
import type { InfoHost } from '../../packages/artplayer/src/info/poll'
import type { LoadingHost } from '../../packages/artplayer/src/loading'
import type { MaskHost } from '../../packages/artplayer/src/mask'
import Info from '../../packages/artplayer/src/info'
import Loading from '../../packages/artplayer/src/loading'
import Mask from '../../packages/artplayer/src/mask'
declare const host: InfoHost & LoadingHost & MaskHost
const info = new Info(host)
const initialized: void = info.init()
const loading = new Loading(host)
const mask = new Mask(host)
loading.show = true
mask.show = false
const visible: boolean = info.show
// @ts-expect-error A polling host needs its media and panel resources.
const invalid = new Info({ constructor: { INFO_LOOP_TIME: 1 } })
// @ts-expect-error Component visibility is boolean.
loading.show = 'visible'
export { initialized, invalid, visible }