refactor(core): CORE-11 split media events and scope reconnect lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 03:19:52 +08:00
1 parent 5e31274f1d
commit b8039ad1f1
27 files changed
+4844 -475

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var h,u={exports:{}};var m=(h||(h=1,u.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),u.exports);const f=p(m),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var h,u={exports:{}};var m=(h||(h=1,u.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),u.exports);const f=p(m),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+224 -146
View File
@@ -2770,105 +2770,241 @@ function durationMix(art) {
}
});
}
function eventInit(art) {
const {
i18n,
notice,
option,
constructor,
proxy,
template: { $player, $video, $poster }
} = art;
let reconnectTime = 0;
function forwardMediaEvents(art) {
const { proxy, template: { $video } } = art;
for (let index = 0; index < config$1.events.length; index++) {
proxy($video, config$1.events[index], (event) => {
art.emit(`video:${event.type}`, event);
if (!isClosing(art))
art.emit(`video:${event.type}`, event);
});
}
art.on("video:canplay", () => {
reconnectTime = 0;
art.loading.show = false;
}
function listenMedia(art, name, callback, once = false) {
const guarded = (event) => {
if (!isClosing(art))
callback(event);
};
art[once ? "once" : "on"](name, guarded);
getScope(art).add(() => {
art.off(name, guarded);
});
art.once("video:canplay", () => {
art.loading.show = false;
art.controls.show = true;
art.mask.show = true;
}
function showMediaUI(art, changes, active) {
for (const [name, value] of changes) {
if (isClosing(art) || active && !active())
return;
art[name].show = value;
}
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function installEnded(art) {
const { option } = art;
listenMedia(art, "video:ended", () => {
if (option.loop) {
const active = captureSource(art);
art.seek = 0;
if (isClosing(art) || !active())
return;
silencePromise(art.play());
if (active())
showMediaUI(art, [["controls", false], ["mask", false]], active);
} else {
showMediaUI(art, [["controls", true], ["mask", true]]);
}
});
}
function installPlaybackUI(art, reconnect) {
const { template: { $poster } } = art;
listenMedia(art, "video:loadedmetadata", () => {
art.emit("resize");
if (isMobile)
showMediaUI(art, [["loading", false], ["controls", true], ["mask", true]]);
});
listenMedia(art, "video:loadstart", () => {
reconnect.loadStart();
showMediaUI(art, [["loading", true], ["mask", false], ["controls", true]]);
});
listenMedia(art, "video:pause", () => {
showMediaUI(art, [["controls", true], ["mask", true]]);
});
listenMedia(art, "video:play", () => {
showMediaUI(art, [["mask", false]]);
if (!isClosing(art))
setStyle($poster, "display", "none");
});
listenMedia(art, "video:playing", () => showMediaUI(art, [["mask", false]]));
listenMedia(art, "video:progress", () => {
if (art.playing)
showMediaUI(art, [["loading", false]]);
});
listenMedia(art, "video:seeked", () => showMediaUI(art, [["loading", false], ["mask", true]]));
listenMedia(art, "video:seeking", () => showMediaUI(art, [["loading", true], ["mask", false]]));
listenMedia(art, "video:timeupdate", () => showMediaUI(art, [["mask", false]]));
listenMedia(art, "video:waiting", () => showMediaUI(art, [["loading", true], ["mask", false]]));
}
function installReadiness(art, reconnect) {
listenMedia(art, "video:canplay", () => {
reconnect.reset();
showMediaUI(art, [["loading", false]]);
});
listenMedia(art, "video:canplay", () => {
showMediaUI(art, [["loading", false], ["controls", true], ["mask", true]]);
if (isClosing(art))
return;
art.isReady = true;
art.emit("ready");
});
art.on("video:ended", () => {
if (option.loop) {
art.seek = 0;
silencePromise(art.play());
art.controls.show = false;
art.mask.show = false;
} else {
art.controls.show = true;
art.mask.show = true;
}, true);
}
function createReconnect(art) {
const { option, constructor, i18n, notice, template: { $player } } = art;
let source = getSourceScope(art);
let attempts = 0;
let pending;
const run = async (error2) => {
if (isClosing(art))
return;
const current2 = getSourceScope(art);
if (current2 !== source) {
source = current2;
attempts = 0;
}
});
art.on("video:error", async (error2) => {
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
if (pending && !pending.closed)
return;
const attempt = current2.child();
pending = attempt;
attempt.add(() => {
if (pending === attempt)
pending = void 0;
});
const active = () => !isClosing(art) && !attempt.closed && current2 === getSourceScope(art);
const retry = attempts < constructor.RECONNECT_TIME_MAX;
try {
if (!retry) {
showMediaUI(art, [["mask", true], ["loading", false], ["controls", true]], active);
if (!active())
return;
addClass($player, "art-error");
}
if (!await wait(attempt, constructor.RECONNECT_SLEEP_TIME) || !active())
return;
reconnectTime += 1;
art.url = option.url;
notice.show = `${i18n.get("Reconnect")}: ${reconnectTime}`;
art.emit("error", error2, reconnectTime);
} else {
art.mask.show = true;
art.loading.show = false;
art.controls.show = true;
addClass($player, "art-error");
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return;
notice.show = i18n.get("Video Load Failed");
if (retry) {
const url = option.url;
if (!active())
return;
attempts += 1;
const attemptNumber = attempts;
attempt.dispose();
const operation = beginSource(art);
source = getSourceScope(art);
assignUrl(art, operation, url);
if (!operation.active())
return;
const message = `${i18n.get("Reconnect")}: ${attemptNumber}`;
if (!operation.active())
return;
notice.show = message;
if (operation.active())
art.emit("error", error2, attemptNumber);
} else {
const message = i18n.get("Video Load Failed");
if (active())
notice.show = message;
}
} finally {
attempt.dispose();
}
});
art.on("video:loadedmetadata", () => {
art.emit("resize");
if (isMobile) {
art.loading.show = false;
art.controls.show = true;
art.mask.show = true;
};
return {
reset() {
attempts = 0;
pending?.dispose();
removeClass($player, "art-error");
},
loadStart() {
removeClass($player, "art-error");
},
schedule(error2) {
void run(error2).catch((failure) => {
console.warn("ArtPlayer reconnect failed:", failure);
});
}
});
art.on("video:loadstart", () => {
art.loading.show = true;
art.mask.show = false;
art.controls.show = true;
});
art.on("video:pause", () => {
art.controls.show = true;
art.mask.show = true;
});
art.on("video:play", () => {
art.mask.show = false;
setStyle($poster, "display", "none");
});
art.on("video:playing", () => {
art.mask.show = false;
});
art.on("video:progress", () => {
if (art.playing) {
art.loading.show = false;
}
});
art.on("video:seeked", () => {
art.loading.show = false;
art.mask.show = true;
});
art.on("video:seeking", () => {
art.loading.show = true;
art.mask.show = false;
});
art.on("video:timeupdate", () => {
art.mask.show = false;
});
art.on("video:waiting", () => {
art.loading.show = true;
art.mask.show = false;
});
};
}
function eventInit(art) {
forwardMediaEvents(art);
const reconnect = createReconnect(art);
installReadiness(art, reconnect);
installEnded(art);
listenMedia(art, "video:error", reconnect.schedule);
installPlaybackUI(art, reconnect);
}
function flipMix(art) {
const {
@@ -3412,64 +3548,6 @@ function playingMix(art) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
function captureSource(owner) {
const operation = current.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active());
}
function beginSource(owner) {
const previous = current.get(owner);
const scope = getScope(owner).child();
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.scope.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function playMix(art) {
const {
i18n,
+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: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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+48 -12
View File
@@ -99,15 +99,15 @@ when changing defaults, schema or resolution; also rerun installed-package valid
## Current TypeScript boundary: utilities
| Module | Responsibility and constraints |
| --- | --- |
| `src/utils/index.ts` | Existing export barrel; no added runtime names or wrapper methods |
| `src/utils/format.ts` | Clamp, capitalization, clock formatting and HTML entity conversion; reusable private lookup maps |
| `src/utils/property.ts` | Native defineProperty alias, own-property inspection and recursive merge; keys are written as own data properties |
| `src/utils/time.ts` | Sleep, trailing debounce and leading throttle; infer argument tuple/receiver, preserve scheduling and synchronous return behavior |
| `src/utils/error.ts` | ArtPlayerError, truthiness guard and internal rejection handling; public media promises are handled elsewhere |
| `src/utils/file.ts` | Historical extension parsing and transient download anchor lifecycle |
| `src/utils/subtitle.ts` | Existing SRT/ASS-to-VTT text conversions and VTT Blob creation |
| Module | Responsibility and constraints |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `src/utils/index.ts` | Existing export barrel; no added runtime names or wrapper methods |
| `src/utils/format.ts` | Clamp, capitalization, clock formatting and HTML entity conversion; reusable private lookup maps |
| `src/utils/property.ts` | Native defineProperty alias, own-property inspection and recursive merge; keys are written as own data properties |
| `src/utils/time.ts` | Sleep, trailing debounce and leading throttle; infer argument tuple/receiver, preserve scheduling and synchronous return behavior |
| `src/utils/error.ts` | ArtPlayerError, truthiness guard and internal rejection handling; public media promises are handled elsewhere |
| `src/utils/file.ts` | Historical extension parsing and transient download anchor lifecycle |
| `src/utils/subtitle.ts` | Existing SRT/ASS-to-VTT text conversions and VTT Blob creation |
These modules do not import the player, UI components or each other except for the
barrel. Existing DOM and browser capability utilities remain JS and are re-exported
@@ -133,7 +133,7 @@ throw stops that dispatch and propagates unchanged. Off accepts either a once wr
or its original callback; duplicate registrations of a callback are removed together.
The once wrapper unsubscribes before invoking user code and now records consumption,
so a nested dispatch cannot execute a captured copy twice. This fixes a demonstrated
old defect without changing ordinary snapshot behavior. Event names such as __proto__
old defect without changing ordinary snapshot behavior. Event names such as **proto**
and toString use own properties rather than inherited Object.prototype values.
Shared contracts in test/contracts/emitter.js run against published, workspace and
@@ -318,6 +318,42 @@ finding, not a claim that a standalone debounce can know when its owner is destr
## Verification and maintenance
### Media events and reconnect (CORE-11)
`player/eventInit.ts` preserves registration order while assembling
`media/events/{forward,readiness,playback,reconnect,listen,types}.ts`.
Forwarding keeps the configured names, original Event object and existing proxy.
Owned Emitter handlers detach on destruction; public subscriptions remain intact.
UI changes stop if a synchronous component callback closes the instance, so a
first-ready control callback cannot cause a ghost ready after destroy.
Source ownership is now layered:
```text
instance scope
current source scope
source operation scope (switch completion/failure)
pending reconnect scope
```
The source scope survives a rejected switch, allowing the current resource to retry.
Playback guards capture both source and operation identity, including late play
after a failed switch has already removed its operation from the current map.
Changing URL releases the old source scope and all its pending work. Reconnect
coalesces duplicate errors into one pending attempt, preserves its first Event,
uses the existing delay/limit, and resets on canplay or an independent new source.
It still reads option.url when the retry starts; reentrant source replacement
invalidates that attempt. Attempt numbers are captured before source assignment
so synchronous proxy canplay cannot change the reported number. Recovery cancels
pending failure notices and clears art-error. Internal asynchronous observer errors
are reported through console.warn with the original thrown value.
`test/media-events.test.js` covers forwarding, sequence, cancellation, retry budget,
failed-switch recovery and reentry. Its browser counterpart uses real HTTP 503 and
local media, compares old/new stale retry and ghost-ready behavior, and checks the
mobile metadata branch with an Android UA (not a physical device). Proxy-owned SDK
events still need adapter-level source identity; native Event has no source token.
### Playback properties (CORE-10)
The toggle, currentTime, seek/forward/backward, volume/muted, playbackRate,
@@ -365,8 +401,8 @@ guards deferred invocation, ignores obsolete returned failures, and cancels its
continuations. Direct assignment failures are reported with console.warn; switch
callers receive the original rejection. `playMix.ts` captures the current source:
its native result/rejection is preserved, while obsolete notice/event/mutex effects
are suppressed. Object URL ownership, other playback migration and reconnect
generations remain CORE-19/10/11 work.
are suppressed. CORE-10 migrates playback properties and CORE-11 owns reconnect
generations; object URL ownership remains CORE-19 work.
`test/source.test.js` checks cancellation, reentry, synchronous events and cleanup;
`test/browser/source.spec.js` compares published/candidate real-media switching and
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var o=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var a=t(r);if("boolean"===a)return"boolean";if("string"===a)return"string";if("number"===a)return"number";if("symbol"===a)return"symbol";if("function"===a)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(a=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return a.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function a(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return n(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),n(s,l,c),i(s,l,c),a(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),n(l,c,d),i(l,c,d),a(l,c,d)})}}(t,e,o),t}function n(t,e,o){if("string"===r(e)){var a=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return a===t}):e.toLowerCase().trim()===a))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(a,"'"))}}function i(t,e,o){if("function"===r(e)){var a=e(t,r(t),o);if(!0!==a){var n=r(a);throw"string"===n?new Error(a):"error"===n?a:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(a,"'"))}}}return a.kindOf=r,a}()),r.exports);const a=t(o),n="5.4.1",i={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var h,u={exports:{}};var m=(h||(h=1,u.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),u.exports);const f=p(m),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,o=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,s=(t,r,o)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[r]=o,l=(t,e)=>{for(var r in e||(e={}))a.call(e,r)&&s(t,r,e[r]);if(n)for(var r of n(e))i.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,o(e)),d=(t,e,r)=>new Promise((o,n)=>{var a=t=>{try{s(r.next(t))}catch(e){n(e)}},i=t=>{try{s(r.throw(t))}catch(e){n(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(a,i);s((r=r.apply(t,e)).next())});function p(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var h,u={exports:{}};var m=(h||(h=1,u.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var n=t(r);if("boolean"===n)return"boolean";if("string"===n)return"string";if("number"===n)return"number";if("symbol"===n)return"symbol";if("function"===n)return"GeneratorFunction"===o(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(o(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(n=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return n.slice(8,-1).toLowerCase().replace(/\s/g,"")};function o(t){return t.constructor?t.constructor.name:null}function n(t,e){var o=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return a(t,e,o),i(t,e,o),function(t,e,o){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=o.slice();c.push(r),a(s,l,c),i(s,l,c),n(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(o.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=o.slice();d.push(s),a(l,c,d),i(l,c,d),n(l,c,d)})}}(t,e,o),t}function a(t,e,o){if("string"===r(e)){var n=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return n===t}):e.toLowerCase().trim()===n))throw new Error("[Type Error]: '".concat(o.join("."),"' require '").concat(e,"' type, but got '").concat(n,"'"))}}function i(t,e,o){if("function"===r(e)){var n=e(t,r(t),o);if(!0!==n){var a=r(n);throw"string"===a?new Error(n):"error"===a?n:new Error("[Validator Error]: The scheme for '".concat(o.join("."),"' validator require return true, but got '").concat(n,"'"))}}}return n.kindOf=r,n}()),u.exports);const f=p(m),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+224 -146
View File
@@ -2770,105 +2770,241 @@ function durationMix(art) {
}
});
}
function eventInit(art) {
const {
i18n,
notice,
option,
constructor,
proxy,
template: { $player, $video, $poster }
} = art;
let reconnectTime = 0;
function forwardMediaEvents(art) {
const { proxy, template: { $video } } = art;
for (let index = 0; index < config$1.events.length; index++) {
proxy($video, config$1.events[index], (event) => {
art.emit(`video:${event.type}`, event);
if (!isClosing(art))
art.emit(`video:${event.type}`, event);
});
}
art.on("video:canplay", () => {
reconnectTime = 0;
art.loading.show = false;
}
function listenMedia(art, name, callback, once = false) {
const guarded = (event) => {
if (!isClosing(art))
callback(event);
};
art[once ? "once" : "on"](name, guarded);
getScope(art).add(() => {
art.off(name, guarded);
});
art.once("video:canplay", () => {
art.loading.show = false;
art.controls.show = true;
art.mask.show = true;
}
function showMediaUI(art, changes, active) {
for (const [name, value] of changes) {
if (isClosing(art) || active && !active())
return;
art[name].show = value;
}
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function installEnded(art) {
const { option } = art;
listenMedia(art, "video:ended", () => {
if (option.loop) {
const active = captureSource(art);
art.seek = 0;
if (isClosing(art) || !active())
return;
silencePromise(art.play());
if (active())
showMediaUI(art, [["controls", false], ["mask", false]], active);
} else {
showMediaUI(art, [["controls", true], ["mask", true]]);
}
});
}
function installPlaybackUI(art, reconnect) {
const { template: { $poster } } = art;
listenMedia(art, "video:loadedmetadata", () => {
art.emit("resize");
if (isMobile)
showMediaUI(art, [["loading", false], ["controls", true], ["mask", true]]);
});
listenMedia(art, "video:loadstart", () => {
reconnect.loadStart();
showMediaUI(art, [["loading", true], ["mask", false], ["controls", true]]);
});
listenMedia(art, "video:pause", () => {
showMediaUI(art, [["controls", true], ["mask", true]]);
});
listenMedia(art, "video:play", () => {
showMediaUI(art, [["mask", false]]);
if (!isClosing(art))
setStyle($poster, "display", "none");
});
listenMedia(art, "video:playing", () => showMediaUI(art, [["mask", false]]));
listenMedia(art, "video:progress", () => {
if (art.playing)
showMediaUI(art, [["loading", false]]);
});
listenMedia(art, "video:seeked", () => showMediaUI(art, [["loading", false], ["mask", true]]));
listenMedia(art, "video:seeking", () => showMediaUI(art, [["loading", true], ["mask", false]]));
listenMedia(art, "video:timeupdate", () => showMediaUI(art, [["mask", false]]));
listenMedia(art, "video:waiting", () => showMediaUI(art, [["loading", true], ["mask", false]]));
}
function installReadiness(art, reconnect) {
listenMedia(art, "video:canplay", () => {
reconnect.reset();
showMediaUI(art, [["loading", false]]);
});
listenMedia(art, "video:canplay", () => {
showMediaUI(art, [["loading", false], ["controls", true], ["mask", true]]);
if (isClosing(art))
return;
art.isReady = true;
art.emit("ready");
});
art.on("video:ended", () => {
if (option.loop) {
art.seek = 0;
silencePromise(art.play());
art.controls.show = false;
art.mask.show = false;
} else {
art.controls.show = true;
art.mask.show = true;
}, true);
}
function createReconnect(art) {
const { option, constructor, i18n, notice, template: { $player } } = art;
let source = getSourceScope(art);
let attempts = 0;
let pending;
const run = async (error2) => {
if (isClosing(art))
return;
const current2 = getSourceScope(art);
if (current2 !== source) {
source = current2;
attempts = 0;
}
});
art.on("video:error", async (error2) => {
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
if (pending && !pending.closed)
return;
const attempt = current2.child();
pending = attempt;
attempt.add(() => {
if (pending === attempt)
pending = void 0;
});
const active = () => !isClosing(art) && !attempt.closed && current2 === getSourceScope(art);
const retry = attempts < constructor.RECONNECT_TIME_MAX;
try {
if (!retry) {
showMediaUI(art, [["mask", true], ["loading", false], ["controls", true]], active);
if (!active())
return;
addClass($player, "art-error");
}
if (!await wait(attempt, constructor.RECONNECT_SLEEP_TIME) || !active())
return;
reconnectTime += 1;
art.url = option.url;
notice.show = `${i18n.get("Reconnect")}: ${reconnectTime}`;
art.emit("error", error2, reconnectTime);
} else {
art.mask.show = true;
art.loading.show = false;
art.controls.show = true;
addClass($player, "art-error");
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return;
notice.show = i18n.get("Video Load Failed");
if (retry) {
const url = option.url;
if (!active())
return;
attempts += 1;
const attemptNumber = attempts;
attempt.dispose();
const operation = beginSource(art);
source = getSourceScope(art);
assignUrl(art, operation, url);
if (!operation.active())
return;
const message = `${i18n.get("Reconnect")}: ${attemptNumber}`;
if (!operation.active())
return;
notice.show = message;
if (operation.active())
art.emit("error", error2, attemptNumber);
} else {
const message = i18n.get("Video Load Failed");
if (active())
notice.show = message;
}
} finally {
attempt.dispose();
}
});
art.on("video:loadedmetadata", () => {
art.emit("resize");
if (isMobile) {
art.loading.show = false;
art.controls.show = true;
art.mask.show = true;
};
return {
reset() {
attempts = 0;
pending?.dispose();
removeClass($player, "art-error");
},
loadStart() {
removeClass($player, "art-error");
},
schedule(error2) {
void run(error2).catch((failure) => {
console.warn("ArtPlayer reconnect failed:", failure);
});
}
});
art.on("video:loadstart", () => {
art.loading.show = true;
art.mask.show = false;
art.controls.show = true;
});
art.on("video:pause", () => {
art.controls.show = true;
art.mask.show = true;
});
art.on("video:play", () => {
art.mask.show = false;
setStyle($poster, "display", "none");
});
art.on("video:playing", () => {
art.mask.show = false;
});
art.on("video:progress", () => {
if (art.playing) {
art.loading.show = false;
}
});
art.on("video:seeked", () => {
art.loading.show = false;
art.mask.show = true;
});
art.on("video:seeking", () => {
art.loading.show = true;
art.mask.show = false;
});
art.on("video:timeupdate", () => {
art.mask.show = false;
});
art.on("video:waiting", () => {
art.loading.show = true;
art.mask.show = false;
});
};
}
function eventInit(art) {
forwardMediaEvents(art);
const reconnect = createReconnect(art);
installReadiness(art, reconnect);
installEnded(art);
listenMedia(art, "video:error", reconnect.schedule);
installPlaybackUI(art, reconnect);
}
function flipMix(art) {
const {
@@ -3412,64 +3548,6 @@ function playingMix(art) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
function captureSource(owner) {
const operation = current.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active());
}
function beginSource(owner) {
const previous = current.get(owner);
const scope = getScope(owner).child();
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.scope.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function playMix(art) {
const {
i18n,
@@ -0,0 +1,13 @@
import type { MediaEventHost } from './types'
import config from '../../config'
import { isClosing } from '../../lifecycle/instance'
export function forwardMediaEvents(art: MediaEventHost): void {
const { proxy, template: { $video } } = art
for (let index = 0; index < config.events.length; index++) {
proxy($video, config.events[index]!, (event: Event) => {
if (!isClosing(art))
art.emit(`video:${event.type}`, event)
})
}
}
@@ -0,0 +1,21 @@
import type { MediaEventName, MediaEvents, MediaListener, MediaUI } from './types'
import { getScope, isClosing } from '../../lifecycle/instance'
export function listenMedia(art: MediaEvents, name: MediaEventName, callback: MediaListener, once = false): void {
const guarded: MediaListener = (event) => {
if (!isClosing(art))
callback(event)
}
art[once ? 'once' : 'on'](name, guarded)
getScope(art).add(() => {
art.off(name, guarded)
})
}
export function showMediaUI(art: MediaUI, changes: [keyof MediaUI, boolean][], active?: () => boolean): void {
for (const [name, value] of changes) {
if (isClosing(art) || (active && !active()))
return
art[name].show = value
}
}
@@ -0,0 +1,53 @@
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { captureSource } from '../../source/operation'
import { isMobile, setStyle, silencePromise } from '../../utils'
import { listenMedia, showMediaUI } from './listen'
export function installEnded(art: MediaEventHost): void {
const { option } = art
listenMedia(art, 'video:ended', () => {
if (option.loop) {
const active = captureSource(art)
art.seek = 0
if (isClosing(art) || !active())
return
silencePromise(art.play())
if (active())
showMediaUI(art, [['controls', false], ['mask', false]], active)
}
else {
showMediaUI(art, [['controls', true], ['mask', true]])
}
})
}
export function installPlaybackUI(art: MediaEventHost, reconnect: Reconnect): void {
const { template: { $poster } } = art
listenMedia(art, 'video:loadedmetadata', () => {
art.emit('resize')
if (isMobile)
showMediaUI(art, [['loading', false], ['controls', true], ['mask', true]])
})
listenMedia(art, 'video:loadstart', () => {
reconnect.loadStart()
showMediaUI(art, [['loading', true], ['mask', false], ['controls', true]])
})
listenMedia(art, 'video:pause', () => {
showMediaUI(art, [['controls', true], ['mask', true]])
})
listenMedia(art, 'video:play', () => {
showMediaUI(art, [['mask', false]])
if (!isClosing(art))
setStyle($poster, 'display', 'none')
})
listenMedia(art, 'video:playing', () => showMediaUI(art, [['mask', false]]))
listenMedia(art, 'video:progress', () => {
if (art.playing)
showMediaUI(art, [['loading', false]])
})
listenMedia(art, 'video:seeked', () => showMediaUI(art, [['loading', false], ['mask', true]]))
listenMedia(art, 'video:seeking', () => showMediaUI(art, [['loading', true], ['mask', false]]))
listenMedia(art, 'video:timeupdate', () => showMediaUI(art, [['mask', false]]))
listenMedia(art, 'video:waiting', () => showMediaUI(art, [['loading', true], ['mask', false]]))
}
@@ -0,0 +1,17 @@
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { listenMedia, showMediaUI } from './listen'
export function installReadiness(art: MediaEventHost, reconnect: Reconnect): void {
listenMedia(art, 'video:canplay', () => {
reconnect.reset()
showMediaUI(art, [['loading', false]])
})
listenMedia(art, 'video:canplay', () => {
showMediaUI(art, [['loading', false], ['controls', true], ['mask', true]])
if (isClosing(art))
return
art.isReady = true
art.emit('ready')
}, true)
}
@@ -0,0 +1,88 @@
import type ResourceScope from '../../lifecycle/scope'
import type { MediaEventHost, Reconnect } from './types'
import { isClosing } from '../../lifecycle/instance'
import { wait } from '../../lifecycle/resources'
import { assignUrl, beginSource, getSourceScope } from '../../source/operation'
import { addClass, removeClass } from '../../utils'
import { showMediaUI } from './listen'
export function createReconnect(art: MediaEventHost): Reconnect {
const { option, constructor, i18n, notice, template: { $player } } = art
let source = getSourceScope(art)
let attempts = 0
let pending: ResourceScope | undefined
const run = async (error: unknown) => {
if (isClosing(art))
return
const current = getSourceScope(art)
if (current !== source) {
source = current
attempts = 0
}
if (pending && !pending.closed)
return
const attempt = current.child()
pending = attempt
attempt.add(() => {
if (pending === attempt)
pending = undefined
})
const active = () => !isClosing(art) && !attempt.closed && current === getSourceScope(art)
const retry = attempts < constructor.RECONNECT_TIME_MAX
try {
if (!retry) {
showMediaUI(art, [['mask', true], ['loading', false], ['controls', true]], active)
if (!active())
return
addClass($player, 'art-error')
}
if (!await wait(attempt, constructor.RECONNECT_SLEEP_TIME) || !active())
return
if (retry) {
const url = option.url
if (!active())
return
attempts += 1
const attemptNumber = attempts
// End the old timer before creating the retry's new source generation.
attempt.dispose()
const operation = beginSource(art)
source = getSourceScope(art)
assignUrl(art, operation, url)
if (!operation.active())
return
const message = `${i18n.get('Reconnect')}: ${attemptNumber}`
if (!operation.active())
return
notice.show = message
if (operation.active())
art.emit('error', error, attemptNumber)
}
else {
const message = i18n.get('Video Load Failed')
if (active())
notice.show = message
}
}
finally {
attempt.dispose()
}
}
return {
reset() {
attempts = 0
pending?.dispose()
removeClass($player, 'art-error')
},
loadStart() {
removeClass($player, 'art-error')
},
schedule(error) {
void run(error).catch((failure) => {
console.warn('ArtPlayer reconnect failed:', failure)
})
},
}
}
@@ -0,0 +1,43 @@
export type MediaEventName = `video:${string}`
export type MediaListener = (event?: unknown) => void
export interface MediaEvents {
on: (name: MediaEventName, callback: MediaListener) => unknown
once: (name: MediaEventName, callback: MediaListener) => unknown
off: (name: MediaEventName, callback: MediaListener) => unknown
emit: {
(name: MediaEventName, event: Event): unknown
(name: 'ready' | 'resize'): unknown
(name: 'error', error: unknown, attempt: number): unknown
}
}
export interface MediaUI {
loading: { set show(value: boolean) }
controls: { set show(value: boolean) }
mask: { set show(value: boolean) }
}
export interface MediaEventHost extends MediaEvents, MediaUI {
template: {
$video: EventTarget
$player: Pick<HTMLElement, 'classList'>
$poster: Pick<HTMLElement, 'style'>
}
proxy: (target: EventTarget, name: string, callback: (event: Event) => void) => unknown
isReady: boolean
readonly playing: boolean
url: string | null
set seek(time: number)
play: () => unknown
option: { url: string, loop: boolean }
i18n: { get: (key: string) => string }
notice: { set show(message: string) }
constructor: { RECONNECT_TIME_MAX: number, RECONNECT_SLEEP_TIME: number }
}
export interface Reconnect {
reset: () => void
loadStart: () => void
schedule: (error: unknown) => void
}
-159
View File
@@ -1,159 +0,0 @@
import config from '../config'
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { addClass, isMobile, setStyle, silencePromise } from '../utils'
export default function eventInit(art) {
const {
i18n,
notice,
option,
constructor,
proxy,
template: { $player, $video, $poster },
} = art
let reconnectTime = 0
for (let index = 0; index < config.events.length; index++) {
proxy($video, config.events[index], (event) => {
art.emit(`video:${event.type}`, event)
})
}
// art.on('video:abort', () => {
// });
art.on('video:canplay', () => {
reconnectTime = 0
art.loading.show = false
})
art.once('video:canplay', () => {
art.loading.show = false
art.controls.show = true
art.mask.show = true
art.isReady = true
art.emit('ready')
})
// art.on('video:canplaythrough', () => {
// });
// art.on('video:durationchange', () => {
// });
// art.on('video:emptied', () => {
// });
art.on('video:ended', () => {
if (option.loop) {
art.seek = 0
silencePromise(art.play())
art.controls.show = false
art.mask.show = false
}
else {
art.controls.show = true
art.mask.show = true
}
})
art.on('video:error', async (error) => {
if (reconnectTime < constructor.RECONNECT_TIME_MAX) {
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
reconnectTime += 1
art.url = option.url
notice.show = `${i18n.get('Reconnect')}: ${reconnectTime}`
art.emit('error', error, reconnectTime)
}
else {
art.mask.show = true
art.loading.show = false
art.controls.show = true
addClass($player, 'art-error')
if (!await wait(getScope(art), constructor.RECONNECT_SLEEP_TIME) || getScope(art).closed)
return
notice.show = i18n.get('Video Load Failed')
}
})
// art.on('video:loadeddata', () => {
// });
art.on('video:loadedmetadata', () => {
art.emit('resize')
if (isMobile) {
art.loading.show = false
art.controls.show = true
art.mask.show = true
}
})
art.on('video:loadstart', () => {
art.loading.show = true
art.mask.show = false
art.controls.show = true
})
art.on('video:pause', () => {
art.controls.show = true
art.mask.show = true
})
art.on('video:play', () => {
art.mask.show = false
setStyle($poster, 'display', 'none')
})
art.on('video:playing', () => {
art.mask.show = false
})
art.on('video:progress', () => {
if (art.playing) {
art.loading.show = false
}
})
// art.on('video:ratechange', () => {
// });
art.on('video:seeked', () => {
art.loading.show = false
art.mask.show = true
})
art.on('video:seeking', () => {
art.loading.show = true
art.mask.show = false
})
// art.on('video:stalled', () => {
// });
// art.on('video:suspend', () => {
// });
art.on('video:timeupdate', () => {
art.mask.show = false
})
// art.on('video:volumechange', () => {
// });
art.on('video:waiting', () => {
art.loading.show = true
art.mask.show = false
})
}
@@ -0,0 +1,15 @@
import type { MediaEventHost } from '../media/events/types'
import { forwardMediaEvents } from '../media/events/forward'
import { listenMedia } from '../media/events/listen'
import { installEnded, installPlaybackUI } from '../media/events/playback'
import { installReadiness } from '../media/events/readiness'
import { createReconnect } from '../media/events/reconnect'
export default function eventInit(art: MediaEventHost): void {
forwardMediaEvents(art)
const reconnect = createReconnect(art)
installReadiness(art, reconnect)
installEnded(art)
listenMedia(art, 'video:error', reconnect.schedule)
installPlaybackUI(art, reconnect)
}
+16 -3
View File
@@ -13,15 +13,28 @@ export interface SourceOperation {
const current = new WeakMap<object, SourceOperation>()
const assignments = new WeakMap<object, SourceOperation>()
const sources = new WeakMap<object, ResourceScope>()
export function getSourceScope(owner: object): ResourceScope {
return sources.get(owner) || getScope(owner)
}
export function captureSource(owner: object): () => boolean {
const operation = current.get(owner)
const source = sources.get(owner)
return () => current.get(owner) === operation && (!operation || operation.active())
&& sources.get(owner) === source && (!source || !source.closed)
}
export function beginSource(owner: object): SourceOperation {
const previous = current.get(owner)
const scope = getScope(owner).child()
const previous = sources.get(owner)
const source = getScope(owner).child()
const scope = source.child()
sources.set(owner, source)
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner)
})
const operation: SourceOperation = {
scope,
assigned: false,
@@ -35,7 +48,7 @@ export function beginSource(owner: object): SourceOperation {
operation.onAssigned = undefined
operation.onError = undefined
})
previous?.scope.dispose()
previous?.dispose()
return operation
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
# CORE-11 媒体事件、ready 与重连
状态:完成;源码、构建、安装消费与本地三浏览器检查通过。
## 模块与生命周期
player/eventInit.ts 仅负责按原顺序装配;media/events 下分为 types、listen、forward、readiness、playback、reconnect 六个 TS 模块。转发仍使用原 proxy 和配置事件名,发出同一个原生 Event 对象。内部 Emitter 监听归实例作用域,销毁时只移除自己的监听,用户订阅和原 snapshot 顺序保留。
source/operation.ts 现在分开资源代次与切源操作。实例作用域拥有当前资源作用域,后者拥有 SourceOperation.scope 及重连等待。切源失败可以结束操作并拒绝调用方 Promise,同时保留当前资源的自动重连;新的 URL 赋值或 destroy 则释放整个旧资源作用域。不能把重连绑定在失败即释放的 switch 作用域上。
播放有效性同时捕获资源与操作身份,覆盖“失败切源已清除操作后,再开始播放并销毁”的组合路径;迟到 Promise 仍保留原值/拒绝,过期通知、play 和互斥暂停停止执行。
## 兼容和缺陷修正
- 保留 API-04/05/07:配置事件顺序、原 Event、ready 的正常顺序和次数、resize/mobile UI 顺序、loop 和 play 拒绝处理。
- 同一资源同时只排一个重试,保留第一条待处理错误。按原常量等待、保留原错误参数,最多按预算重试;canplay 重置预算。重试次数在开始该次重试时固定,避免同步代理 canplay 把错误回调的次数变成 0。
- 保留等待结束时读取 option.url 的行为,使应用能在 native error 回调更新重试目标;若该 getter 重入切换资源,旧任务立即退出。
- BASE-LIFE-07:旧待办重连在用户已换源后仍会重新加载并发 error。现在新 URL、canplay 或销毁会取消过期重试/失败通知;独立新资源使用新预算,恢复时移除 art-error。
- BASE-LIFE-08:旧 ready 流程在 control 回调销毁实例后仍能继续标记 isReady 并发 ready。现在每步 UI 检查关闭状态,停止后续 ready;用户其他事件订阅仍保留。
- 内部重连异步回调若抛错,通过 console.warn 带原异常报告,避免无人接收的 Promise 拒绝。直接用户 Emitter 回调的同步异常传播不统一吞掉。
## 验证与限制
test/media-events.test.js 通过真实 EventTarget 和受控计时验证预算、合并、资源代次、失败切源、恢复/销毁、UI 重入、同步代理和原错误身份;test/types/media-events.ts 验证最小媒体事件宿主。test/browser/media-events.spec.js 使用真实 HTTP 503 与本地媒体恢复,比较发布/候选,并验证 Android UA 元数据路径。
媒体事件没有资源 ID。第三方 customType/proxy 已启动的外部 SDK 任务及其伪装成当前资源的事件仍由适配器管理;这里不宣称能取消任意外部代码。UA 检查不是实体移动设备验收。
证据见 [media-events-validation.json](../baselines/media-events-validation.json),包括完整源码/测试/包文件 SHA、实际安装消费者和逐项浏览器结果。
| 验证 | 结果 |
| --- | --- |
| yarn ci:check | 158 项:129 单元、4 工程、25 基线;54 个生产 TS 文件严格编译 |
| 媒体事件专项 | 新增 13 项 Node,涵盖 EventTarget、定时器、源/操作生命周期及真实 playMix |
| 安装包 | 核心/chapter 的 27 项运行时通过;五组类型模式零诊断 |
| 安装 UMD | 三浏览器 222 项通过,无跳过、重试或 flaky |
| 安装 legacy | 同套三浏览器 222 项通过,无跳过、重试或 flaky |
| 新旧差异 | 真实 HTTP 恢复/旧重连取消、ghost ready、移动 UA 顺序均有发布/候选对照 |
BASE-LIFE-07/08 已关闭,旧发布基线未改。HTTP 503 是受控失败,产生的请求/控制台记录保留在原始浏览器证据;未处理 JS 异常为零。早期通过报告不替代包含源级播放守卫的最终包指纹与完整复验。
未运行远端 CI、实体设备或所有生态包的发布验收。无依赖、锁文件或版本变化;本任务单独本地 commit。回退须连同 source/operation 的资源代次改动、事件模块、产物和文档整体回退。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 168 / doing 0 / blocked 0 / done 46 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 167 / doing 0 / blocked 0 / done 47 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -114,7 +114,7 @@
| CORE-08 | artplayer<br>整理插件管理器与扩展类型 | CORE-03, CORE-07 | 注册流程、同步/异步结果、插件/事件扩展接口 | 旧插件同步可见性、重名与命名、销毁中异步插件保持明确语义;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-03 并增加候选正向回归;接续 CORE-07 / BASE-TYPE-04 的同步/异步注册返回声明冲突,内部返回类型精确,保留历史消费者 | H | done |
| CORE-09 | artplayer<br>整理 URL 与切源操作 | CORE-04, CORE-06, CORE-08 | url/switch/customType 的操作身份及取消方案 | 并发切源、错误、同 URL、销毁和 Promise 结算有契约;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-01/02/06 并增加候选正向回归 | H | done |
| CORE-10 | artplayer<br>迁移播放与时间/状态属性 | CORE-09 | play/pause/toggle/seek/volume/rate/duration 等模块 | 旧返回值、拒绝、mutex、位置恢复、属性描述符与事件通过;接续 CORE-07 / BASE-TYPE-04 的 toggle void 与实际 Promise 分支冲突,不吞拒绝 | H | done |
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | todo |
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | done |
| CORE-12 | artplayer<br>迁移模板及公开资源入口 | CORE-06, CORE-04, BASE-04 | template/icons/i18n/style、SSR 与 proxy 挂载 | DOM/CSS/global/样式注入、i18n 子路径及 SSR import 保持;不把 SSR import/useSSR 保持扩大为服务端构造播放器;复跑 BASE-05 的 SSR import/模板、浏览器 useSSR 节点复用和非浏览器构造错误;接续 VENDOR-01/02,核对 screenfull/hint 复制版本、修改与分发通知,保留 DOM/CSS/fullscreen 行为 | H | todo |
| CORE-13 | artplayer<br>迁移组件注册与控件 | CORE-12, CORE-08 | Component/control/layer/contextmenu 的资源与 TS 边界 | add/update/remove、name、selector、mounted/beforeUnmount 通过;处理 BASE-DOM-02 窄容器控件裁切,保留 controls.update 的旧 undefined 返回值 | H | todo |
| CORE-14 | artplayer<br>拆分设置树、渲染和布局 | CORE-13 | setting model/selection/render/layout 的分批实现 | 不强行统一控件返回值,嵌套选择、默认高亮、更新清理通过;结合 BASE-DOM-02 验证窄容器设置定位;接续 BASE-TYPE-07 的 find null、add/update item、remove undefined 声明冲突 | H | todo |
@@ -445,6 +445,7 @@
- CORE-08: [记录](changes/2026-09-11-CORE-08-plugin-manager.md) [记录](baselines/plugins-validation.json)
- CORE-09: [记录](changes/2026-09-11-CORE-09-source-operations.md) [记录](baselines/source-operations-validation.json)
- CORE-10: [记录](changes/2026-09-11-CORE-10-playback-properties.md) [记录](baselines/playback-properties-validation.json)
- CORE-11: [记录](changes/2026-09-11-CORE-11-media-events.md) [记录](baselines/media-events-validation.json)
- 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)
+4
View File
@@ -1,5 +1,9 @@
# 进度与证据
## 当前实施:CORE-11 媒体事件、ready 与重连完成
事件入口迁移 TS,拆出转发、ready、播放 UI、重连、订阅和宿主类型;资源代次与切源操作分离,失败切源仍可重试,换源/恢复/销毁取消过期工作。158 项 Node/基线、安装 UMD 与 legacy 各 222 项三浏览器、打包 27 项运行时及五组类型检查通过。见 [交付](changes/2026-09-11-CORE-11-media-events.md)。关闭新增 BASE-LIFE-07/08,保留原 Event、重试次数和正常 ready 顺序;第三方 SDK 事件仍由适配器负责。当前 214 项,47 完成、167 待办;核心 49 个 TS 文件、chapter 5 个。下一项 CORE-12 模板及公开资源入口。
## 当前实施:CORE-10 播放与时间/状态属性完成
八个 mixin 迁移 TS,新增最小播放属性宿主;保持转换、通知、存储、进度和状态优先级。新增 PlaybackControls 精确视图,旧 toggle 声明仍兼容。145 项 Node/基线、安装 UMD 与 legacy 各 195 项三浏览器、打包 27 项运行时和五组类型检查通过。见 [交付](changes/2026-09-11-CORE-10-playback-properties.md)。发现 BASE-TYPE-08 的只写属性虚构 getter 声明,已交 CORE-21;没有添加运行时 getter 来掩盖它。当前 214 项,46 完成、168 待办;核心 42 个 TS 文件、chapter 5 个。下一项 CORE-11 媒体事件、ready 与重连。
+2
View File
@@ -52,3 +52,5 @@
| BASE-TYPE-06 | resolved / 已复现 | Public Option requires url and excludes numeric control HTML accepted by existing JS | CORE-07 |
| BASE-TYPE-07 | open / 已复现 | Setting returns, subtitle contextual payloads and notice getter conflict with historical declarations | CORE-07, CORE-14, CORE-15, CORE-18, CORE-21 |
| BASE-TYPE-08 | open / 已复现 | Write-only player commands have fictitious public getter declarations | CORE-21 |
| BASE-LIFE-07 | resolved / 已复现 | Queued reconnect survives source replacement and reloads the new resource | CORE-11 |
| BASE-LIFE-08 | resolved / 已复现 | Ready continues after a control callback destroys the instance | CORE-11 |
+46
View File
@@ -891,6 +891,52 @@
],
"compatibleResolution": "Keep historical consumer acceptance, model source command reads as undefined, and provide an accurate public facade without inventing runtime getters.",
"closureCriteria": "seek/forward/backward/switch/quality reads and writes have an explicit accurate compatibility strategy validated against old TypeScript consumers and real package descriptors."
},
{
"id": "BASE-LIFE-07",
"title": "Queued reconnect survives source replacement and reloads the new resource",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-11"
],
"evidence": [
"test/media-events.test.js",
"test/browser/media-events.spec.js",
"refactor/changes/2026-09-11-CORE-11-media-events.md"
],
"compatibleResolution": "Keep ordinary event order and error identity; scope retries to the current source and stop internal UI/ready work when its instance closes.",
"closureCriteria": "Real published/candidate regression comparisons, controlled lifecycle tests and installed UMD/legacy validation pass without altering the frozen published baseline.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-11-media-events.md",
"refactor/baselines/media-events-validation.json",
"test/media-events.test.js",
"test/browser/media-events.spec.js"
],
"resolutionRationale": "Published/candidate real-browser comparisons reproduce stale retry or ghost ready and validate candidate cancellation. Owned source lifetimes preserve retries after switch rejection, while instance closure stops UI work; original Event/count and ordinary readiness order remain tested."
},
{
"id": "BASE-LIFE-08",
"title": "Ready continues after a control callback destroys the instance",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-11"
],
"evidence": [
"test/media-events.test.js",
"test/browser/media-events.spec.js",
"refactor/changes/2026-09-11-CORE-11-media-events.md"
],
"compatibleResolution": "Keep ordinary event order and error identity; scope retries to the current source and stop internal UI/ready work when its instance closes.",
"closureCriteria": "Real published/candidate regression comparisons, controlled lifecycle tests and installed UMD/legacy validation pass without altering the frozen published baseline.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-11-media-events.md",
"refactor/baselines/media-events-validation.json",
"test/media-events.test.js",
"test/browser/media-events.spec.js"
],
"resolutionRationale": "Published/candidate real-browser comparisons reproduce stale retry or ghost ready and validate candidate cancellation. Owned source lifetimes preserve retries after switch rejection, while instance closure stops UI work; original Event/count and ordinary readiness order remain tested."
}
]
}
+5 -2
View File
@@ -949,11 +949,14 @@
"dependsOn": [
"CORE-10"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "媒体转发、重连、UI 响应职责分离",
"acceptance": "readiness 次数/顺序、过期重连、原错误参数有回归",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-11-media-events.md",
"baselines/media-events-validation.json"
]
},
{
"id": "CORE-12",
+131
View File
@@ -0,0 +1,131 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: HTTP failure retries the updated option URL with the original event and one ready`, async ({ page, request }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((id) => {
window.Artplayer.RECONNECT_SLEEP_TIME = 0
window.Artplayer.RECONNECT_TIME_MAX = 1
window.createPlayer(`/test/fail.mp4?case=${id}`)
const { art } = window
window.recovery = { attempts: [], ready: 0, identities: [], order: [] }
let original
art.on('video:error', (event) => {
original = event
window.recovery.order.push('native-error')
art.option.url = `/test/pattern.mp4?case=${id}`
})
art.on('error', (event, attempt) => {
window.recovery.attempts.push(attempt)
window.recovery.identities.push(event === original && event.target === art.video)
window.recovery.order.push('retry')
})
art.on('ready', () => {
window.recovery.ready++
window.recovery.order.push('ready')
})
}, caseId)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(() => ({ ...window.recovery, path: new URL(window.art.video.currentSrc).pathname, duration: window.art.duration, mediaError: window.art.video.error?.code || 0 }))
expect(result.attempts).toEqual([1])
expect(result.identities).toEqual([true])
expect(result.ready).toBe(1)
expect(result.order).toEqual(['native-error', 'retry', 'ready'])
expect(result.path).toBe('/test/pattern.mp4')
expect(result.duration).toBeGreaterThan(0)
expect(result.mediaError).toBe(0)
const requests = await (await request.get(`/test/requests.json?case=${caseId}`)).json()
expect(requests.some(item => item.path === '/test/fail.mp4')).toBe(true)
expect(requests.some(item => item.path === '/test/pattern.mp4')).toBe(true)
await testInfo.attach('reconnect-http', { body: JSON.stringify({ result, requests }), contentType: 'application/json' })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: replacing a failed source cancels candidate retries`, async ({ page }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate((id) => {
window.Artplayer.RECONNECT_SLEEP_TIME = 0
window.Artplayer.RECONNECT_TIME_MAX = 1
window.createPlayer(`/test/fail.mp4?case=${id}`)
window.retries = 0
window.art.on('error', () => window.retries++)
window.art.once('video:error', () => {
window.art.url = `/test/pattern.mp4?case=${id}`
})
}, caseId)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
// The zero-delay retry's timer queue is observed after the replacement canplay.
const retries = await page.evaluate(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
return window.retries
})
expect(retries).toBe(core === 'candidate' ? 0 : 1)
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: rejected real-media switch still allows current-source reconnect', async ({ page }, testInfo) => {
const caseId = encodeURIComponent(testInfo.testId)
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(id => window.createPlayer(`/test/pattern.mp4?initial=${id}`), caseId)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate((id) => {
const { art, Artplayer } = window
Artplayer.RECONNECT_SLEEP_TIME = 0
Artplayer.RECONNECT_TIME_MAX = 1
window.switchRecovery = { rejected: false, sameError: false, attempts: [], restart: [] }
let original
art.on('video:error', (event) => {
original = event
art.option.url = `/test/pattern.mp4?recovered=${id}`
})
art.on('error', (event, count) => window.switchRecovery.attempts.push([event === original, count]))
art.on('restart', url => window.switchRecovery.restart.push(url))
art.switchUrl(`/test/fail.mp4?case=${id}`).catch((error) => {
window.switchRecovery.rejected = true
window.switchRecovery.sameError = error === original
})
}, caseId)
await expect.poll(() => page.evaluate(() => window.switchRecovery.restart.length)).toBe(1)
expect(await page.evaluate(() => window.switchRecovery)).toEqual({ rejected: true, sameError: true, attempts: [[true, 1]], restart: [`/test/pattern.mp4?recovered=${caseId}`] })
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
await page.evaluate(() => window.art.destroy())
})
for (const core of ['published', 'candidate']) {
test(`${core}: destroying from first-ready controls prevents a candidate ghost ready event`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const result = await page.evaluate(() => {
const art = new window.Artplayer({ container: '.player', url: '' })
let ready = 0
art.on('ready', () => ready++)
art.on('control', () => art.destroy(false))
art.emit('video:canplay')
return { ready, isReady: art.isReady, isDestroy: art.isDestroy, instances: window.Artplayer.instances.length }
})
expect(result).toEqual({ ready: core === 'candidate' ? 0 : 1, isReady: core !== 'candidate', isDestroy: true, instances: 0 })
})
}
test.describe('mobile UA metadata path (not physical-device validation)', () => {
test.use({ userAgent: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/120.0.0.0 Mobile Safari/537.36' })
for (const core of ['published', 'candidate']) {
test(`${core}: metadata emits resize before mobile visibility changes`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
const order = await page.evaluate(() => {
const art = new window.Artplayer({ container: '.player', url: '' })
const seen = []
art.on('resize', () => seen.push('resize'))
art.on('control', value => seen.push(['control', value]))
art.on('video:loadedmetadata', () => seen.push('metadata'))
art.emit('video:loadedmetadata')
art.destroy()
return seen
})
expect(order).toEqual(['resize', ['control', true], 'metadata'])
})
}
})
+318
View File
@@ -0,0 +1,318 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { mock, test } from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { loadModules } from './helpers/load.js'
const { Emitter, config, eventInit, urlMix, switchMix, playMix, beginLifecycle, getScope, getSourceScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
config: 'packages/artplayer/src/config/index',
eventInit: 'packages/artplayer/src/player/eventInit',
urlMix: 'packages/artplayer/src/player/urlMix',
switchMix: 'packages/artplayer/src/player/switchMix',
playMix: 'packages/artplayer/src/player/playMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
getSourceScope: { file: 'packages/artplayer/src/source/operation', name: 'getSourceScope' },
})
function createArt() {
const ui = []
const writes = []
const forwarded = []
const classes = new Set()
const video = new EventTarget()
let src = ''
Object.defineProperty(video, 'src', { configurable: true, get: () => src, set(value) {
src = value
writes.push(value)
} })
const visibility = name => ({ get show() {
return false
}, set show(value) {
ui.push([name, value])
} })
const art = Object.assign(new Emitter(), {
template: { $video: video, $poster: { style: {} }, $player: { classList: { add: value => classes.add(value), remove: value => classes.delete(value) } } },
option: { url: 'initial.mp4', type: '', customType: {}, loop: false },
constructor: { RECONNECT_TIME_MAX: 2, RECONNECT_SLEEP_TIME: 10 },
notice: { show: '' },
i18n: { get: key => key },
isReady: false,
playing: false,
currentTime: 3,
aspectRatio: '16:9',
playbackRate: 1,
pause: () => {},
play: mock.fn(() => Promise.resolve()),
loading: visibility('loading'),
controls: visibility('controls'),
mask: visibility('mask'),
proxy(target, name, callback) {
forwarded.push([target, name, callback])
target.addEventListener(name, callback)
getScope(art).add(() => {
target.removeEventListener(name, callback)
})
},
})
beginLifecycle(art)
urlMix(art)
switchMix(art)
eventInit(art)
art.url = art.option.url
return { art, video, ui, writes, forwarded, classes, close: () => getScope(art).dispose() }
}
async function tick(t, milliseconds = 10) {
t.mock.timers.tick(milliseconds)
await setImmediate()
}
test('media forwarding uses configured names, original targets and exact native event objects', () => {
const fixture = createArt()
const { art, video, forwarded } = fixture
assert.deepEqual(forwarded.map(item => item[1]), config.events)
assert(forwarded.every(item => item[0] === video))
const event = new Event('volumechange')
const observed = []
art.on('video:volumechange', value => observed.push(value))
video.dispatchEvent(event)
assert.deepEqual(observed, [event])
fixture.close()
// Even an externally retained forwarding callback must respect the closed owner.
forwarded.find(item => item[1] === 'volumechange')[2](event)
assert.deepEqual(observed, [event])
})
test('readiness and media UI retain callback order and ready is emitted once', () => {
const { art, ui, close } = createArt()
const order = []
art.on('resize', () => order.push('resize'))
art.on('ready', () => order.push(['ready', art.isReady]))
art.on('video:canplay', () => order.push('canplay'))
art.emit('video:loadstart')
assert.deepEqual(ui, [['loading', true], ['mask', false], ['controls', true]])
ui.length = 0
art.emit('video:loadedmetadata')
art.emit('video:canplay')
art.emit('video:canplay')
assert.deepEqual(order, ['resize', ['ready', true], 'canplay', 'canplay'])
assert.deepEqual(ui, [['loading', false], ['loading', false], ['controls', true], ['mask', true], ['loading', false]])
close()
})
test('reconnect coalesces errors, preserves the first error identity and respects the attempt limit', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, writes, classes, close } = createArt()
const errors = []
art.on('error', (...args) => errors.push(args))
const first = new Event('error')
art.emit('video:error', first)
art.emit('video:error', new Event('error'))
await tick(t)
assert.deepEqual(writes, ['initial.mp4', 'initial.mp4'])
assert.deepEqual(errors, [[first, 1]])
assert.equal(art.notice.show, 'Reconnect: 1')
const second = new Event('error')
art.emit('video:error', second)
await tick(t)
assert.deepEqual(errors, [[first, 1], [second, 2]])
art.emit('video:error', new Event('error'))
assert(classes.has('art-error'))
await tick(t)
assert.equal(writes.length, 3)
assert.equal(errors.length, 2)
assert.equal(art.notice.show, 'Video Load Failed')
close()
})
test('source replacement cancels the old retry and starts a fresh attempt budget', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, writes, close } = createArt()
const attempts = []
art.on('error', (_error, count) => attempts.push(count))
art.emit('video:error', new Event('error'))
await tick(t)
const old = getSourceScope(art)
art.emit('video:error', new Event('error'))
art.url = 'replacement.mp4'
art.notice.show = 'new source'
assert.equal(old.closed, true)
await tick(t)
assert.deepEqual(writes, ['initial.mp4', 'initial.mp4', 'replacement.mp4'])
assert.equal(art.notice.show, 'new source')
art.emit('video:error', new Event('error'))
await tick(t)
assert.deepEqual(attempts, [1, 1])
assert.equal(writes.at(-1), 'replacement.mp4')
close()
})
test('a failed switch rejects while its current source can still reconnect', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, writes, close } = createArt()
const error = new Event('error')
const switching = art.switchUrl('failed.mp4')
const source = getSourceScope(art)
const rejected = assert.rejects(switching, actual => actual === error)
art.emit('video:error', error)
await rejected
assert.equal(source.closed, false)
await tick(t)
assert.deepEqual(writes, ['initial.mp4', 'failed.mp4', 'failed.mp4'])
assert.equal(art.notice.show, 'Reconnect: 1')
close()
})
test('canplay cancels pending failure notices and clears recovered error UI', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, writes, classes, close } = createArt()
art.constructor.RECONNECT_TIME_MAX = 0
art.emit('video:error', new Event('error'))
assert(classes.has('art-error'))
art.emit('video:loadstart')
assert.equal(classes.has('art-error'), false)
art.notice.show = 'recovered'
art.emit('video:canplay')
await tick(t)
assert.equal(art.notice.show, 'recovered')
assert.equal(writes.length, 1)
close()
})
test('destroy during readiness stops remaining UI work and removes only owned media handlers', () => {
const { art, ui, close } = createArt()
const ready = mock.fn()
const userCanplay = mock.fn()
art.on('ready', ready)
art.on('video:canplay', userCanplay)
art.controls = { get show() {
return false
}, set show(value) {
ui.push(['controls', value])
close()
} }
art.emit('video:canplay')
assert.equal(art.isReady, false)
assert.equal(ready.mock.callCount(), 0)
assert.equal(userCanplay.mock.callCount(), 1)
assert.deepEqual(ui, [['loading', false], ['loading', false], ['controls', true]])
art.emit('video:canplay')
assert.equal(userCanplay.mock.callCount(), 2)
assert.deepEqual(Object.keys(art.e).sort(), ['ready', 'video:canplay'])
})
test('destroy cancels a pending reconnect and exhausted failure notice', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
for (const maximum of [0, 2]) {
const { art, writes, close } = createArt()
art.constructor.RECONNECT_TIME_MAX = maximum
art.emit('video:error', new Event('error'))
close()
art.notice.show = 'closed'
await tick(t)
assert.deepEqual(writes, ['initial.mp4'])
assert.equal(art.notice.show, 'closed')
assert.deepEqual(Object.keys(art.e), [])
}
})
test('owned reconnect listener failures are reported with the original error and never left unhandled', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const warning = t.mock.method(console, 'warn', () => {})
const { art, close } = createArt()
const failure = new Error('user retry observer')
art.on('error', () => {
throw failure
})
art.emit('video:error', new Event('error'))
await tick(t)
assert.equal(warning.mock.callCount(), 1)
assert.deepEqual(warning.mock.calls[0].arguments, ['ArtPlayer reconnect failed:', failure])
close()
})
test('loop playback handles rejection and stops when seek reentrantly replaces the source', async () => {
const { art, ui, close } = createArt()
art.option.loop = true
art.play = mock.fn(() => Promise.reject(new DOMException('interrupted', 'AbortError')))
art.emit('video:ended')
await setImmediate()
assert.equal(art.play.mock.callCount(), 1)
assert.deepEqual(ui, [['controls', false], ['mask', false]])
ui.length = 0
Object.defineProperty(art, 'seek', { get: () => undefined, set() {
art.url = 'reentrant.mp4'
} })
art.emit('video:ended')
assert.equal(art.play.mock.callCount(), 1)
assert.deepEqual(ui, [])
close()
})
test('retry count remains stable when a proxy synchronously reports canplay during assignment', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, video, close } = createArt()
const counts = []
art.on('error', (_error, count) => counts.push(count))
const descriptor = Object.getOwnPropertyDescriptor(video, 'src')
Object.defineProperty(video, 'src', {
get: descriptor.get,
set(value) {
descriptor.set.call(video, value)
video.dispatchEvent(new Event('canplay'))
},
})
art.emit('video:error', new Event('error'))
await tick(t)
assert.deepEqual(counts, [1])
assert.equal(art.isReady, true)
close()
})
test('an option URL getter that replaces the source cannot let an obsolete retry overwrite it', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, writes, close } = createArt()
let requested = 'initial.mp4'
let reentered = false
Object.defineProperty(art.option, 'url', {
get() {
if (!reentered) {
reentered = true
art.url = 'newest.mp4'
}
return requested
},
set(value) { requested = value },
})
art.emit('video:error', new Event('error'))
await tick(t)
assert.deepEqual(writes, ['initial.mp4', 'newest.mp4'])
close()
})
test('source lifetime guards late play after a failed switch has removed its operation', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] })
const { art, video, close } = createArt()
const error = new Event('error')
const rejected = assert.rejects(art.switchUrl('failed.mp4'), actual => actual === error)
art.emit('video:error', error)
await rejected
let resolveNative
video.play = () => new Promise(resolve => resolveNative = resolve)
const pauseOther = mock.fn()
art.constructor.instances = [{ pause: pauseOther }]
art.option.mutex = true
playMix(art)
const plays = mock.fn()
art.on('play', plays)
const pending = art.play()
close()
art.notice.show = 'closed'
resolveNative(42)
assert.equal(await pending, 42)
assert.equal(art.notice.show, 'closed')
assert.equal(plays.mock.callCount(), 0)
assert.equal(pauseOther.mock.callCount(), 0)
})
+17
View File
@@ -0,0 +1,17 @@
import type { MediaEventHost } from '../../packages/artplayer/src/media/events/types'
import eventInit from '../../packages/artplayer/src/player/eventInit'
declare const art: MediaEventHost
eventInit(art)
art.emit('ready')
art.emit('resize')
art.emit('video:custom', new Event('custom'))
art.emit('error', new Error('source'), 1)
// @ts-expect-error Media forwarding retains the Event object, not an arbitrary payload.
art.emit('video:canplay', 'canplay')
// @ts-expect-error Retry errors include the attempt count.
art.emit('error', new Error('source'))
// @ts-expect-error UI visibility does not accept a source URL.
art.loading.show = 'video.mp4'
// @ts-expect-error A structural proxy still needs EventTarget listener capabilities.
art.template.$video = { src: 'video.mp4' }