refactor(core): CORE-09 isolate source operations and settle cancelled switches

This commit is contained in:
Harvey Zhao committed 2026-09-11 02:34:26 +08:00
1 parent 874dac9f22
commit 44f4e083a9
29 files changed
+4512 -255

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,a=Object.getOwnPropertySymbols,n=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={}))n.call(e,r)&&s(t,r,e[r]);if(a)for(var r of a(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,a)=>{var n=t=>{try{s(r.next(t))}catch(e){a(e)}},i=t=>{try{s(r.throw(t))}catch(e){a(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(n,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 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}()),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
+251 -62
View File
@@ -1167,8 +1167,8 @@ function isClosing(owner) {
return state2.destroying || state2.scope.closed;
}
function ownContainer(owner, container, rollback) {
const current = containers.get(container);
if (current && current !== owner)
const current2 = containers.get(container);
if (current2 && current2 !== owner)
throw new Error("Cannot mount multiple instances on the same dom element");
const state2 = stateOf(owner);
state2.rollback = rollback;
@@ -3420,6 +3420,64 @@ 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,
@@ -3430,11 +3488,21 @@ function playMix(art) {
} = art;
def(art, "play", {
async value() {
const active = captureSource(art);
const result = await $video.play();
notice.show = i18n.get("Play");
if (!active())
return result;
const message = i18n.get("Play");
if (!active())
return result;
notice.show = message;
if (!active())
return result;
art.emit("play");
if (option.mutex) {
for (let index = 0; index < instances2.length; index++) {
if (!active())
break;
const instance = instances2[index];
if (instance !== art) {
instance.pause();
@@ -3617,54 +3685,140 @@ function subtitleOffsetMix(art) {
}
});
}
function switchMix(art) {
function switchUrl(url, currentTime) {
return new Promise((resolve, reject) => {
if (url === art.url) {
resolve();
function listenSource(scope, art, name, callback, once = true) {
let fired = false;
let release;
const guarded = (event) => {
if (scope.closed || fired)
return;
if (once) {
fired = true;
release();
}
callback(event);
};
art[once ? "once" : "on"](name, guarded);
release = scope.add(() => {
art.off(name, guarded);
});
return guarded;
}
function switchSource(art, url, currentTime) {
return new Promise((resolve, reject) => {
if (isClosing(art) || url === art.url) {
resolve();
return;
}
const operation = beginSource(art);
const scope = operation.scope.child();
let settled = false;
const settle = (failed = false, error2) => {
if (settled)
return;
}
settled = true;
operation.onError = void 0;
operation.onAssigned = void 0;
scope.dispose();
if (failed)
reject(error2);
else
resolve();
if (failed)
operation.scope.dispose();
};
scope.add(() => {
settle();
});
const active = () => !settled && operation.active();
const fail = (error2) => settle(true, error2);
operation.onError = fail;
try {
const { playing, aspectRatio: aspectRatio2, playbackRate: playbackRate2 } = art;
art.pause();
art.url = url;
art.notice.show = "";
const handlers = {};
handlers.error = (error2) => {
art.off("video:canplay", handlers.canplay);
art.off("video:loadedmetadata", handlers.metadata);
reject(error2);
};
handlers.metadata = () => {
art.currentTime = currentTime;
};
handlers.canplay = async () => {
art.off("video:error", handlers.error);
art.playbackRate = playbackRate2;
art.aspectRatio = aspectRatio2;
if (playing) {
await silencePromise(art.play());
if (!active())
return;
const readiness = scope.child();
const handlers = {
"video:error": fail,
"video:loadedmetadata": () => {
if (!active())
return;
try {
art.currentTime = currentTime;
} catch (error2) {
fail(error2);
}
},
"video:canplay": () => {
if (!active())
return;
readiness.dispose();
const resume = async () => {
art.playbackRate = playbackRate2;
if (!active())
return;
art.aspectRatio = aspectRatio2;
if (!active())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active())
return;
art.notice.show = "";
settle();
};
void resume().catch(fail);
}
art.notice.show = "";
resolve();
};
art.once("video:error", handlers.error);
art.once("video:loadedmetadata", handlers.metadata);
art.once("video:canplay", handlers.canplay);
});
}
def(art, "switchQuality", {
value: (url) => {
return switchUrl(url, art.currentTime);
const capture = scope.child();
const queued = [];
const names = Object.keys(handlers);
for (const name of names) {
listenSource(capture, art, name, (event) => {
if (operation.acceptingEvents)
queued.push([name, event]);
}, false);
}
assignUrl(art, operation, url);
if (!active())
return;
art.notice.show = "";
if (!active())
return;
const activate = () => {
capture.dispose();
if (!url) {
settle();
return;
}
const dispatch = {};
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name]);
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break;
dispatch[name](event);
}
queued.length = 0;
};
if (operation.assigned)
activate();
else
operation.onAssigned = activate;
} catch (error2) {
fail(error2);
}
});
def(art, "switchUrl", {
value: (url) => {
return switchUrl(url, 0);
}
});
def(art, "switch", {
set: art.switchUrl
});
}
function switchMix(art) {
const switchUrl = (url) => switchSource(art, url, 0);
def(art, "switchQuality", { value: (url) => switchSource(art, url, art.currentTime) });
def(art, "switchUrl", { value: switchUrl });
def(art, "switch", { set: switchUrl });
}
function themeMix(art) {
def(art, "theme", {
@@ -3767,40 +3921,75 @@ function typeMix(art) {
});
}
function urlMix(art) {
const {
option,
template: { $video }
} = art;
const { option, template: { $video } } = art;
def(art, "url", {
get() {
return $video.src;
},
async set(newUrl) {
if (newUrl) {
if (isClosing(art))
return;
const operation = takeAssignment(art);
operation.acceptingEvents = false;
try {
if (!newUrl) {
if (await wait(operation.scope) && operation.active())
art.loading.show = true;
return;
}
const oldUrl = art.url;
const typeName = option.type || getExt(newUrl);
const typeCallback = option.customType[typeName];
if (typeName && typeCallback) {
if (!await wait(getScope(art)) || getScope(art).closed)
if (!await wait(operation.scope) || !operation.active())
return;
art.loading.show = true;
typeCallback.call(art, $video, newUrl, art);
} else {
URL.revokeObjectURL(oldUrl);
$video.src = newUrl;
}
if (!operation.active())
return;
const capture = operation.scope.child();
let ready = false;
let failed = false;
listenSource(capture, art, "video:canplay", () => {
ready = !failed;
});
listenSource(capture, art, "video:error", () => {
failed = true;
});
try {
operation.acceptingEvents = true;
if (typeName && typeCallback) {
const result = typeCallback.call(art, $video, newUrl, art);
void Promise.resolve(result).catch((error2) => failSource(operation, error2));
} else {
if (oldUrl)
URL.revokeObjectURL(oldUrl);
if (!operation.active())
return;
$video.src = newUrl;
}
} finally {
capture.dispose();
}
if (!operation.active())
return;
if (oldUrl !== art.url) {
art.option.url = newUrl;
if (art.isReady && oldUrl) {
art.once("video:canplay", () => {
art.emit("restart", newUrl);
});
if (operation.active() && art.isReady && oldUrl) {
const restart = () => {
if (operation.active())
art.emit("restart", newUrl);
};
if (ready)
restart();
else if (!failed)
listenSource(operation.scope, art, "video:canplay", restart);
}
}
} else {
if (!await wait(getScope(art)) || getScope(art).closed)
return;
art.loading.show = true;
} catch (error2) {
failSource(operation, error2);
} finally {
finishAssignment(operation);
}
}
});
+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: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": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+28
View File
@@ -318,6 +318,34 @@ finding, not a claim that a standalone debounce can know when its owner is destr
## Verification and maintenance
### Source operations (CORE-09)
`player/urlMix.ts` and `player/switchMix.ts` preserve the public descriptors.
`source/types.ts` defines minimal structural media/host capabilities;
`source/operation.ts` owns a current generation and its resource scope;
`source/listen.ts` owns individual media subscriptions; `source/switch.ts`
coordinates assignment, readiness, state restoration and Promise settlement.
A new URL assignment supersedes the prior operation, including direct `art.url`
writes. Superseded/destroyed switches fulfill with undefined; actual source errors
reject with their original value. Fulfillment does not prove the source became
active. Same-string URL calls remain no-ops. Internal resume rejection stays
handled while public play retains rejection. Synchronous proxy events are buffered
until assignment finishes, and operation cleanup does not remove user listeners.
customType still receives the real media object and player. Its arbitrary external
SDK work cannot be cancelled generically: adapters must own that cleanup. The core
guards deferred invocation, ignores obsolete returned failures, and cancels its own
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.
`test/source.test.js` checks cancellation, reentry, synchronous events and cleanup;
`test/browser/source.spec.js` compares published/candidate real-media switching and
destruction and verifies candidate customType and resume-failure behavior.
Run `yarn test:unit` for shared published/workspace utility contracts and controlled
timers. `test/utils.test.js` also accepts `ARTPLAYER_TEST_CORE` to test an actual
UMD, legacy or ESM file. `test/types/utils-source.ts` checks source inference, receiver
+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,a=Object.getOwnPropertySymbols,n=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={}))n.call(e,r)&&s(t,r,e[r]);if(a)for(var r of a(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,a)=>{var n=t=>{try{s(r.next(t))}catch(e){a(e)}},i=t=>{try{s(r.throw(t))}catch(e){a(e)}},s=t=>t.done?o(t.value):Promise.resolve(t.value).then(n,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 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}()),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
+251 -62
View File
@@ -1167,8 +1167,8 @@ function isClosing(owner) {
return state2.destroying || state2.scope.closed;
}
function ownContainer(owner, container, rollback) {
const current = containers.get(container);
if (current && current !== owner)
const current2 = containers.get(container);
if (current2 && current2 !== owner)
throw new Error("Cannot mount multiple instances on the same dom element");
const state2 = stateOf(owner);
state2.rollback = rollback;
@@ -3420,6 +3420,64 @@ 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,
@@ -3430,11 +3488,21 @@ function playMix(art) {
} = art;
def(art, "play", {
async value() {
const active = captureSource(art);
const result = await $video.play();
notice.show = i18n.get("Play");
if (!active())
return result;
const message = i18n.get("Play");
if (!active())
return result;
notice.show = message;
if (!active())
return result;
art.emit("play");
if (option.mutex) {
for (let index = 0; index < instances2.length; index++) {
if (!active())
break;
const instance = instances2[index];
if (instance !== art) {
instance.pause();
@@ -3617,54 +3685,140 @@ function subtitleOffsetMix(art) {
}
});
}
function switchMix(art) {
function switchUrl(url, currentTime) {
return new Promise((resolve, reject) => {
if (url === art.url) {
resolve();
function listenSource(scope, art, name, callback, once = true) {
let fired = false;
let release;
const guarded = (event) => {
if (scope.closed || fired)
return;
if (once) {
fired = true;
release();
}
callback(event);
};
art[once ? "once" : "on"](name, guarded);
release = scope.add(() => {
art.off(name, guarded);
});
return guarded;
}
function switchSource(art, url, currentTime) {
return new Promise((resolve, reject) => {
if (isClosing(art) || url === art.url) {
resolve();
return;
}
const operation = beginSource(art);
const scope = operation.scope.child();
let settled = false;
const settle = (failed = false, error2) => {
if (settled)
return;
}
settled = true;
operation.onError = void 0;
operation.onAssigned = void 0;
scope.dispose();
if (failed)
reject(error2);
else
resolve();
if (failed)
operation.scope.dispose();
};
scope.add(() => {
settle();
});
const active = () => !settled && operation.active();
const fail = (error2) => settle(true, error2);
operation.onError = fail;
try {
const { playing, aspectRatio: aspectRatio2, playbackRate: playbackRate2 } = art;
art.pause();
art.url = url;
art.notice.show = "";
const handlers = {};
handlers.error = (error2) => {
art.off("video:canplay", handlers.canplay);
art.off("video:loadedmetadata", handlers.metadata);
reject(error2);
};
handlers.metadata = () => {
art.currentTime = currentTime;
};
handlers.canplay = async () => {
art.off("video:error", handlers.error);
art.playbackRate = playbackRate2;
art.aspectRatio = aspectRatio2;
if (playing) {
await silencePromise(art.play());
if (!active())
return;
const readiness = scope.child();
const handlers = {
"video:error": fail,
"video:loadedmetadata": () => {
if (!active())
return;
try {
art.currentTime = currentTime;
} catch (error2) {
fail(error2);
}
},
"video:canplay": () => {
if (!active())
return;
readiness.dispose();
const resume = async () => {
art.playbackRate = playbackRate2;
if (!active())
return;
art.aspectRatio = aspectRatio2;
if (!active())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active())
return;
art.notice.show = "";
settle();
};
void resume().catch(fail);
}
art.notice.show = "";
resolve();
};
art.once("video:error", handlers.error);
art.once("video:loadedmetadata", handlers.metadata);
art.once("video:canplay", handlers.canplay);
});
}
def(art, "switchQuality", {
value: (url) => {
return switchUrl(url, art.currentTime);
const capture = scope.child();
const queued = [];
const names = Object.keys(handlers);
for (const name of names) {
listenSource(capture, art, name, (event) => {
if (operation.acceptingEvents)
queued.push([name, event]);
}, false);
}
assignUrl(art, operation, url);
if (!active())
return;
art.notice.show = "";
if (!active())
return;
const activate = () => {
capture.dispose();
if (!url) {
settle();
return;
}
const dispatch = {};
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name]);
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break;
dispatch[name](event);
}
queued.length = 0;
};
if (operation.assigned)
activate();
else
operation.onAssigned = activate;
} catch (error2) {
fail(error2);
}
});
def(art, "switchUrl", {
value: (url) => {
return switchUrl(url, 0);
}
});
def(art, "switch", {
set: art.switchUrl
});
}
function switchMix(art) {
const switchUrl = (url) => switchSource(art, url, 0);
def(art, "switchQuality", { value: (url) => switchSource(art, url, art.currentTime) });
def(art, "switchUrl", { value: switchUrl });
def(art, "switch", { set: switchUrl });
}
function themeMix(art) {
def(art, "theme", {
@@ -3767,40 +3921,75 @@ function typeMix(art) {
});
}
function urlMix(art) {
const {
option,
template: { $video }
} = art;
const { option, template: { $video } } = art;
def(art, "url", {
get() {
return $video.src;
},
async set(newUrl) {
if (newUrl) {
if (isClosing(art))
return;
const operation = takeAssignment(art);
operation.acceptingEvents = false;
try {
if (!newUrl) {
if (await wait(operation.scope) && operation.active())
art.loading.show = true;
return;
}
const oldUrl = art.url;
const typeName = option.type || getExt(newUrl);
const typeCallback = option.customType[typeName];
if (typeName && typeCallback) {
if (!await wait(getScope(art)) || getScope(art).closed)
if (!await wait(operation.scope) || !operation.active())
return;
art.loading.show = true;
typeCallback.call(art, $video, newUrl, art);
} else {
URL.revokeObjectURL(oldUrl);
$video.src = newUrl;
}
if (!operation.active())
return;
const capture = operation.scope.child();
let ready = false;
let failed = false;
listenSource(capture, art, "video:canplay", () => {
ready = !failed;
});
listenSource(capture, art, "video:error", () => {
failed = true;
});
try {
operation.acceptingEvents = true;
if (typeName && typeCallback) {
const result = typeCallback.call(art, $video, newUrl, art);
void Promise.resolve(result).catch((error2) => failSource(operation, error2));
} else {
if (oldUrl)
URL.revokeObjectURL(oldUrl);
if (!operation.active())
return;
$video.src = newUrl;
}
} finally {
capture.dispose();
}
if (!operation.active())
return;
if (oldUrl !== art.url) {
art.option.url = newUrl;
if (art.isReady && oldUrl) {
art.once("video:canplay", () => {
art.emit("restart", newUrl);
});
if (operation.active() && art.isReady && oldUrl) {
const restart = () => {
if (operation.active())
art.emit("restart", newUrl);
};
if (ready)
restart();
else if (!failed)
listenSource(operation.scope, art, "video:canplay", restart);
}
}
} else {
if (!await wait(getScope(art)) || getScope(art).closed)
return;
art.loading.show = true;
} catch (error2) {
failSource(operation, error2);
} finally {
finishAssignment(operation);
}
}
});
+12 -1
View File
@@ -1,5 +1,6 @@
import type { PlayHost } from '../media/hosts'
import type { PlaybackMethods } from '../media/types'
import { captureSource } from '../source/operation'
import { def } from '../utils'
export default function playMix<Media extends Pick<PlaybackMethods, 'play'>>(art: PlayHost<Media>): asserts art is PlayHost<Media> & { play: () => Promise<Awaited<ReturnType<Media['play']>>> } {
@@ -13,12 +14,22 @@ export default function playMix<Media extends Pick<PlaybackMethods, 'play'>>(art
def(art, 'play', {
async value() {
const active = captureSource(art)
const result = await $video.play()
notice.show = i18n.get('Play')
if (!active())
return result
const message = i18n.get('Play')
if (!active())
return result
notice.show = message
if (!active())
return result
art.emit('play')
if (option.mutex) {
for (let index = 0; index < instances.length; index++) {
if (!active())
break
const instance = instances[index]!
if (instance !== art) {
instance.pause()
@@ -1,63 +0,0 @@
import { def, silencePromise } from '../utils'
export default function switchMix(art) {
function switchUrl(url, currentTime) {
return new Promise((resolve, reject) => {
if (url === art.url) {
resolve()
return
}
const { playing, aspectRatio, playbackRate } = art
art.pause()
art.url = url
art.notice.show = ''
const handlers = {}
handlers.error = (error) => {
art.off('video:canplay', handlers.canplay)
art.off('video:loadedmetadata', handlers.metadata)
reject(error)
}
handlers.metadata = () => {
art.currentTime = currentTime
}
handlers.canplay = async () => {
art.off('video:error', handlers.error)
art.playbackRate = playbackRate
art.aspectRatio = aspectRatio
if (playing) {
await silencePromise(art.play())
}
art.notice.show = ''
resolve()
}
art.once('video:error', handlers.error)
art.once('video:loadedmetadata', handlers.metadata)
art.once('video:canplay', handlers.canplay)
})
}
def(art, 'switchQuality', {
value: (url) => {
return switchUrl(url, art.currentTime)
},
})
def(art, 'switchUrl', {
value: (url) => {
return switchUrl(url, 0)
},
})
def(art, 'switch', {
set: art.switchUrl,
})
}
@@ -0,0 +1,10 @@
import type { SwitchHost, SwitchMethods } from '../source/types'
import { switchSource } from '../source/switch'
import { def } from '../utils'
export default function switchMix(art: SwitchHost): asserts art is SwitchHost & SwitchMethods {
const switchUrl = (url: string) => switchSource(art, url, 0)
def(art, 'switchQuality', { value: (url: string) => switchSource(art, url, art.currentTime) })
def(art, 'switchUrl', { value: switchUrl })
def(art, 'switch', { set: switchUrl })
}
-48
View File
@@ -1,48 +0,0 @@
import { getScope } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { def, getExt } from '../utils'
export default function urlMix(art) {
const {
option,
template: { $video },
} = art
def(art, 'url', {
get() {
return $video.src
},
async set(newUrl) {
if (newUrl) {
const oldUrl = art.url
const typeName = option.type || getExt(newUrl)
const typeCallback = option.customType[typeName]
if (typeName && typeCallback) {
if (!await wait(getScope(art)) || getScope(art).closed)
return
art.loading.show = true
typeCallback.call(art, $video, newUrl, art)
}
else {
URL.revokeObjectURL(oldUrl)
$video.src = newUrl
}
if (oldUrl !== art.url) {
art.option.url = newUrl
if (art.isReady && oldUrl) {
art.once('video:canplay', () => {
art.emit('restart', newUrl)
})
}
}
}
else {
if (!await wait(getScope(art)) || getScope(art).closed)
return
art.loading.show = true
}
},
})
}
+87
View File
@@ -0,0 +1,87 @@
import type { SourceMedia, UrlHost } from '../source/types'
import { isClosing } from '../lifecycle/instance'
import { wait } from '../lifecycle/resources'
import { listenSource } from '../source/listen'
import { failSource, finishAssignment, takeAssignment } from '../source/operation'
import { def, getExt } from '../utils'
export default function urlMix<Media extends SourceMedia, Host extends UrlHost<Media, Host>>(art: Host): void {
const { option, template: { $video } } = art
def(art, 'url', {
get() {
return $video.src
},
async set(newUrl: string) {
if (isClosing(art))
return
const operation = takeAssignment(art)
operation.acceptingEvents = false
try {
if (!newUrl) {
if (await wait(operation.scope) && operation.active())
art.loading.show = true
return
}
const oldUrl = art.url
const typeName = option.type || getExt(newUrl)
const typeCallback = option.customType[typeName]
if (typeName && typeCallback) {
if (!await wait(operation.scope) || !operation.active())
return
art.loading.show = true
}
if (!operation.active())
return
// Proxies can synchronously dispatch readiness from their src setter.
const capture = operation.scope.child()
let ready = false
let failed = false
listenSource(capture, art, 'video:canplay', () => {
ready = !failed
})
listenSource(capture, art, 'video:error', () => {
failed = true
})
try {
operation.acceptingEvents = true
if (typeName && typeCallback) {
const result = typeCallback.call(art, $video, newUrl, art)
// Preserve immediate customType invocation semantics while owning rejections.
void Promise.resolve(result).catch(error => failSource(operation, error))
}
else {
if (oldUrl)
URL.revokeObjectURL(oldUrl)
if (!operation.active())
return
$video.src = newUrl
}
}
finally {
capture.dispose()
}
if (!operation.active())
return
if (oldUrl !== art.url) {
art.option.url = newUrl
if (operation.active() && art.isReady && oldUrl) {
const restart = () => {
if (operation.active())
art.emit('restart', newUrl)
}
if (ready)
restart()
else if (!failed)
listenSource(operation.scope, art, 'video:canplay', restart)
}
}
}
catch (error) {
failSource(operation, error)
}
finally {
finishAssignment(operation)
}
},
})
}
+21
View File
@@ -0,0 +1,21 @@
import type ResourceScope from '../lifecycle/scope'
import type { SourceEvent, SourceEvents, SourceListener } from './types'
export function listenSource(scope: ResourceScope, art: SourceEvents, name: SourceEvent, callback: SourceListener, once = true): SourceListener {
let fired = false
let release: () => void
const guarded: SourceListener = (event) => {
if (scope.closed || fired)
return
if (once) {
fired = true
release()
}
callback(event)
}
art[once ? 'once' : 'on'](name, guarded)
release = scope.add(() => {
art.off(name, guarded)
})
return guarded
}
@@ -0,0 +1,79 @@
import type ResourceScope from '../lifecycle/scope'
import type { UrlTarget } from './types'
import { getScope, isClosing } from '../lifecycle/instance'
export interface SourceOperation {
scope: ResourceScope
assigned: boolean
acceptingEvents: boolean
onAssigned?: () => void
onError?: (error: unknown) => void
active: () => boolean
}
const current = new WeakMap<object, SourceOperation>()
const assignments = new WeakMap<object, SourceOperation>()
export function captureSource(owner: object): () => boolean {
const operation = current.get(owner)
return () => current.get(owner) === operation && (!operation || operation.active())
}
export function beginSource(owner: object): SourceOperation {
const previous = current.get(owner)
const scope = getScope(owner).child()
const operation: SourceOperation = {
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 = undefined
operation.onError = undefined
})
previous?.scope.dispose()
return operation
}
export function takeAssignment(owner: object): SourceOperation {
const operation = assignments.get(owner)
assignments.delete(owner)
return operation || beginSource(owner)
}
export function finishAssignment(operation: SourceOperation): void {
operation.assigned = true
const callback = operation.onAssigned
operation.onAssigned = undefined
if (operation.active())
callback?.()
}
export function assignUrl(owner: UrlTarget, operation: SourceOperation, url: string): void {
assignments.set(owner, operation)
operation.acceptingEvents = true
try {
owner.url = url
}
finally {
// Also support a host-provided URL setter without a core URL mixin.
if (assignments.get(owner) === operation) {
assignments.delete(owner)
finishAssignment(operation)
}
}
}
export function failSource(operation: SourceOperation, error: unknown): void {
if (!operation.active())
return
if (operation.onError)
operation.onError(error)
else
console.warn('Failed to initialize ArtPlayer source:', error)
operation.scope.dispose()
}
+121
View File
@@ -0,0 +1,121 @@
import type { SourceEvent, SourceListener, SwitchHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { listenSource } from './listen'
import { assignUrl, beginSource } from './operation'
export function switchSource(art: SwitchHost, url: string, currentTime: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (isClosing(art) || url === art.url) {
resolve()
return
}
const operation = beginSource(art)
const scope = operation.scope.child()
let settled = false
const settle = (failed = false, error?: unknown) => {
if (settled)
return
settled = true
operation.onError = undefined
operation.onAssigned = undefined
scope.dispose()
if (failed)
reject(error)
else
resolve()
if (failed)
operation.scope.dispose()
}
scope.add(() => {
settle()
})
const active = () => !settled && operation.active()
const fail = (error: unknown) => settle(true, error)
operation.onError = fail
try {
const { playing, aspectRatio, playbackRate } = art
art.pause()
if (!active())
return
const readiness = scope.child()
const handlers = {
'video:error': fail,
'video:loadedmetadata': () => {
if (!active())
return
try {
art.currentTime = currentTime
}
catch (error) {
fail(error)
}
},
'video:canplay': () => {
if (!active())
return
// Detach readiness handlers before awaiting a possibly pending play.
readiness.dispose()
const resume = async () => {
art.playbackRate = playbackRate
if (!active())
return
art.aspectRatio = aspectRatio
if (!active())
return
if (playing) {
// The public play promise still rejects; this internal resume is best effort.
try {
await art.play()
}
catch {}
}
if (!active())
return
art.notice.show = ''
settle()
}
void resume().catch(fail)
},
}
const capture = scope.child()
const queued: [SourceEvent, unknown][] = []
const names = Object.keys(handlers) as SourceEvent[]
for (const name of names) {
listenSource(capture, art, name, (event) => {
if (operation.acceptingEvents)
queued.push([name, event])
}, false)
}
assignUrl(art, operation, url)
if (!active())
return
art.notice.show = ''
if (!active())
return
const activate = () => {
capture.dispose()
if (!url) {
settle()
return
}
const dispatch = {} as Record<SourceEvent, SourceListener>
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name])
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break
dispatch[name](event)
}
queued.length = 0
}
if (operation.assigned)
activate()
else
operation.onAssigned = activate
}
catch (error) {
fail(error)
}
})
}
+44
View File
@@ -0,0 +1,44 @@
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay'
export type SourceListener = (event?: unknown) => void
export interface SourceEvents {
on: (name: SourceEvent, callback: SourceListener) => unknown
once: (name: SourceEvent, callback: SourceListener) => unknown
off: (name: SourceEvent, callback: SourceListener) => unknown
}
export interface SourceMedia {
src: string | null
}
export interface UrlTarget {
url: string | null
}
export interface UrlHost<Media extends SourceMedia, Host> extends UrlTarget, SourceEvents {
template: { $video: Media }
option: {
url: string
type: string
customType: Record<string, ((this: Host, video: Media, url: string, art: Host) => unknown) | undefined>
}
loading: { set show(value: boolean) }
isReady: boolean
emit: (name: 'restart', url: string) => unknown
}
export interface SwitchHost extends UrlTarget, SourceEvents {
currentTime: number
readonly playing: boolean
aspectRatio: string
playbackRate: number
pause: () => unknown
play: () => unknown
notice: { set show(value: string) }
}
export interface SwitchMethods {
switchUrl: (url: string) => Promise<void>
switchQuality: (url: string) => Promise<void>
set switch(url: string)
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,49 @@
# CORE-09 URL 与切源操作
状态:完成;源码、生成产物、安装消费与本地三浏览器检查通过。全项目发布验收仍待后续任务。
## 行为决策
涉及 API-02/03/04/05/07/12。旧 switchUrl/switchQuality 返回 Promise<void>,无取消结果类型。保持方法名、描述符、绑定行为、switch setter 与 switchUrl 的函数身份;保持严格字符串同 URL 快速返回。
每次真实 URL 赋值创建内部操作作用域。新的 switch 或直接 art.url 赋值使旧操作失效,销毁清理所有操作。被替代/销毁的 Promise 以 undefined 正常兑现,不新增 AbortError 拒绝;兑现仅表示该调用已结束,不能证明资源曾成功激活。需要确认当前源的应用仍通过 art.url、媒体事件及当前状态判断。实际媒体错误与 customType 抛出/拒绝保留原错误值;内部恢复播放失败仍正常结束切换,公开 play() 的拒绝不改变。
连续切源时只允许最新操作恢复位置、速率、比例、清理通知和发出 restart。普通原生事件注册顺序保持;源 setter 或 customType 同步发出媒体事件时,暂存到赋值完成后处理,防止先前遗漏。仅清除本操作监听器,不清空用户 Emitter 订阅。空 URL 保持旧 loading 行为且不替换媒体源;对应 switch 调用结束,不无限等待一个没有启动的加载。
## 模块
- player/urlMix.ts:公开 URL 描述符、customType 调用及 restart。
- player/switchMix.ts:公开切源方法门面。
- source/types.ts:最小媒体、宿主、事件和方法类型。
- source/operation.ts:当前操作、URL 赋值令牌、失效与失败。
- source/listen.ts:操作拥有的事件订阅与单次调用。
- source/switch.ts:切源、事件暂存、状态恢复和 Promise 结算。
## 兼容边界与责任
customType 保留真实 video、art、this 和三个参数,不用代理对象替换。其内部已启动的 SDK、请求或用户 Promise 可以继续访问真实对象;核心只能取消尚未调用的回调、忽略过期返回/拒绝并清理自身订阅,不能声称取消任意外部代码。直接 URL 赋值没有可消费的 Promise,当前回调错误通过带原错误值的 console.warn 报告;switch 调用通过自身 Promise 拒绝报告。
媒体事件本身没有操作 ID;customType/proxy 仍负责让自己的事件与当前资源一致。核心的操作身份检查不能鉴别第三方继续向真实 video 或 art 发送的伪装成当前事件的过期回调。
补测真实 playMix 曾复现旧恢复把新通知覆盖成 Play;因此 playMix 也捕获当前源身份,晚到结果仍按原值兑现或拒绝,但不再写通知、发 play 或触发互斥暂停。同步正常路径及公开返回值保持。测试既使用受控覆盖函数,也使用实际 playMix,不能用前者代替后者。
旧 URL 的 revokeObjectURL 行为暂保留;对象 URL 的完整归属审计归 CORE-19。播放模块其余迁移归 CORE-10;过期重连归 CORE-11。不要把本任务的操作监听隔离当作这些模块也已全部处理。
## 验证与交付
证据见 [source-operations-validation.json](../baselines/source-operations-validation.json),记录完整源码/测试/包文件 SHA、安装检查、浏览器版本与逐项结果。
| 验证 | 结果 |
| --- | --- |
| yarn ci:check | 137 项:108 单元、4 工程、25 基线;严格编译 38 个生产 TS 文件 |
| 切源专项 Node | 新增 17 项,覆盖过期、销毁、同 URL、重入、同步事件、错误身份、真实 playMix 和回调身份 |
| 安装包类型 | TS 5.9.3 四组消费模式、TS 4.3.5 一组,均零诊断 |
| 安装包运行时 | 实际 tarball 仓库外安装,27 项通过;范围为核心及 chapter |
| 安装 UMD | Chromium/Firefox/WebKit 共 183 项,通过,无跳过/重试 |
| 安装 legacy | 同一套三浏览器共 183 项,通过,无跳过/重试 |
测试入口:test/source.test.js、test/playback.test.js、test/types/source.ts、test/browser/source.spec.js。BASE-LIFE-01/02/06 已关闭,旧发布基线原样保留。原生 play 的受控拒绝/延迟用于验证 Promise 处理,不作为真实自动播放授权或 codec 支持证明。playMix 补充身份检查前的 180 项 UMD 通过只是阶段证据;最终以包含该修复的 183 项报告及匹配包指纹为准。
未执行远端 GitHub Actions、实体设备或全部生态包发布验收;这些不计为本任务已完成。
不升级版本、不改变依赖和锁文件。本任务完成后单独本地提交。回退按该任务完整提交回退源码、生成产物和文档,不能只恢复 JS 文件而保留调用方的 TS 逻辑。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 170 / doing 0 / blocked 0 / done 44 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 169 / doing 0 / blocked 0 / done 45 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -112,7 +112,7 @@
| CORE-06 | artplayer<br>建立内部媒体与宿主类型 | CORE-05 | 原生 video、canvas shim、UI host 类型 | 不更改公开 art.video 用法,不以 HTMLVideoElement 断言掩盖 shim | M | done |
| CORE-07 | artplayer<br>逐项协调公开声明差异 | CORE-02, CORE-06, BASE-07 | plugins/toggle/cue/setting/static 的旧新类型对照 | 每项有兼容重载/扩展或待决结论,不能删除旧类型样例;处理 BASE-TYPE-01/04,分别保留旧合法声明消费并验证真实返回;协调 BASE-TYPE-05 的 Utils 导出、descriptor 和 timer 旧声明差异 | H | done |
| 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 | todo |
| 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 | todo |
| CORE-11 | artplayer<br>整理媒体事件、ready 与重连 | CORE-10 | 媒体转发、重连、UI 响应职责分离 | readiness 次数/顺序、过期重连、原错误参数有回归 | H | todo |
| 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 |
@@ -443,6 +443,7 @@
- CORE-06: [记录](changes/2026-09-11-CORE-06-media-hosts.md) [记录](baselines/media-hosts-validation.json)
- CORE-07: [记录](changes/2026-09-11-CORE-07-declarations.md) [记录](baselines/declarations-validation.json)
- 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)
- 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-09 URL 与切源操作完成
URL/switch 门面迁移 TS,新增四个 source 模块,统一切源取消、事件清理、同步代理事件和 Promise 结算;真实 playMix 同步接入源身份,避免晚到播放覆盖新状态。137 项 Node/基线、安装 UMD 与 legacy 各 183 项三浏览器检查、打包 27 项运行时和五组类型检查通过。见 [交付](changes/2026-09-11-CORE-09-source-operations.md)。关闭 BASE-LIFE-01/02/06;customType 外部 SDK 清理、对象 URL 归属和过期重连仍由对应后续任务处理。当前 214 项,45 完成、169 待办;核心 33 个 TS 文件、chapter 5 个。下一项 CORE-10 播放与时间/状态属性。
## 当前实施:CORE-08 插件管理器 TS 完成
插件注册、内置装配、命名与内部类型拆成四个 TS 模块;修复销毁后晚到注册,保持 Promise 结算与同步可见性,新增共享类型扩展入口。120 项 Node/基线、安装 UMD 三浏览器 162 项及 legacy 插件/章节/生命周期 90 项通过;严格打包 27 项运行时、五组类型零诊断。见 [交付](changes/2026-09-11-CORE-08-plugin-manager.md)。关闭 BASE-LIFE-03;公开返回声明和各插件自有资源清理仍有后续责任。当前 214 项,44 完成、170 待办;核心 27 个 TS 文件、chapter 5 个。下一项 CORE-09 URL/切源操作。
+3 -3
View File
@@ -4,9 +4,9 @@
| ID | 状态 / 证据等级 | 条目 | 后续任务 |
| --- | --- | --- | --- |
| BASE-LIFE-06 | open / 已复现 | Published switch leaves an unhandled rejection and an unsettled promise when resume fails | CORE-09 |
| BASE-LIFE-01 | open / 已复现 | Superseded switch resolves along with the latest source | CORE-09 |
| BASE-LIFE-02 | open / 已复现 | Switch remains unsettled during the bounded observation after destroy | CORE-09 |
| BASE-LIFE-06 | resolved / 已复现 | Published switch leaves an unhandled rejection and an unsettled promise when resume fails | CORE-09 |
| BASE-LIFE-01 | resolved / 已复现 | Superseded switch resolves along with the latest source | CORE-09 |
| BASE-LIFE-02 | resolved / 已复现 | Switch remains unsettled during the bounded observation after destroy | CORE-09 |
| BASE-LIFE-03 | resolved / 已复现 | Async plugin registers its result after destroy | CORE-08 |
| BASE-LIFE-04 | resolved / 已复现 | Destroying an instance twice unregisters a different live instance | CORE-04 |
| BASE-LIFE-05 | resolved / 已复现 | Failed constructor leaves initialized markup/listeners until explicit probe cleanup | CORE-04 |
+30 -6
View File
@@ -9,7 +9,7 @@
"id": "BASE-LIFE-06",
"title": "Published switch leaves an unhandled rejection and an unsettled promise when resume fails",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"CORE-09"
],
@@ -20,13 +20,21 @@
],
"compatibleResolution": "按对应 CORE 任务修复,保持合法旧调用;异步取消需明确结算策略,不把 pending 或重复销毁当正常兼容要求。",
"closureCriteria": "候选正向回归和旧新差异记录通过;有界观察问题不得仅靠延长等待隐藏。",
"workspaceState": "Current switchMix uses silencePromise and test/playback.test.js covers resume rejection. Published baseline still reproduces the old failure; preserve the existing workspace mitigation during migration."
"workspaceState": "Current switchMix uses silencePromise and test/playback.test.js covers resume rejection. Published baseline still reproduces the old failure; preserve the existing workspace mitigation during migration.",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-09-source-operations.md",
"refactor/baselines/source-operations-validation.json",
"test/source.test.js",
"test/playback.test.js",
"test/browser/source.spec.js"
],
"resolutionRationale": "Owned source generations settle superseded/destroyed calls with undefined, prevent stale restart/restoration and actual playMix effects, retain original load failures and best-effort resume settlement. Published baselines remain unchanged; real-media comparisons and controlled native-play tests cover the candidate."
},
{
"id": "BASE-LIFE-01",
"title": "Superseded switch resolves along with the latest source",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"CORE-09"
],
@@ -35,13 +43,21 @@
"refactor/baselines/lifecycle-coverage.md"
],
"compatibleResolution": "按对应 CORE 任务修复,保持合法旧调用;异步取消需明确结算策略,不把 pending 或重复销毁当正常兼容要求。",
"closureCriteria": "候选正向回归和旧新差异记录通过;有界观察问题不得仅靠延长等待隐藏。"
"closureCriteria": "候选正向回归和旧新差异记录通过;有界观察问题不得仅靠延长等待隐藏。",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-09-source-operations.md",
"refactor/baselines/source-operations-validation.json",
"test/source.test.js",
"test/playback.test.js",
"test/browser/source.spec.js"
],
"resolutionRationale": "Owned source generations settle superseded/destroyed calls with undefined, prevent stale restart/restoration and actual playMix effects, retain original load failures and best-effort resume settlement. Published baselines remain unchanged; real-media comparisons and controlled native-play tests cover the candidate."
},
{
"id": "BASE-LIFE-02",
"title": "Switch remains unsettled during the bounded observation after destroy",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"CORE-09"
],
@@ -50,7 +66,15 @@
"refactor/baselines/lifecycle-coverage.md"
],
"compatibleResolution": "按对应 CORE 任务修复,保持合法旧调用;异步取消需明确结算策略,不把 pending 或重复销毁当正常兼容要求。",
"closureCriteria": "候选正向回归和旧新差异记录通过;有界观察问题不得仅靠延长等待隐藏。"
"closureCriteria": "候选正向回归和旧新差异记录通过;有界观察问题不得仅靠延长等待隐藏。",
"resolutionEvidence": [
"refactor/changes/2026-09-11-CORE-09-source-operations.md",
"refactor/baselines/source-operations-validation.json",
"test/source.test.js",
"test/playback.test.js",
"test/browser/source.spec.js"
],
"resolutionRationale": "Owned source generations settle superseded/destroyed calls with undefined, prevent stale restart/restoration and actual playMix effects, retain original load failures and best-effort resume settlement. Published baselines remain unchanged; real-media comparisons and controlled native-play tests cover the candidate."
},
{
"id": "BASE-LIFE-03",
+5 -2
View File
@@ -911,11 +911,14 @@
"CORE-06",
"CORE-08"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "url/switch/customType 的操作身份及取消方案",
"acceptance": "并发切源、错误、同 URL、销毁和 Promise 结算有契约;处理 baselines/lifecycle-coverage.md 的 BASE-LIFE-01/02/06 并增加候选正向回归",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-09-source-operations.md",
"baselines/source-operations-validation.json"
]
},
{
"id": "CORE-10",
+146
View File
@@ -0,0 +1,146 @@
import { expect, test } from './fixtures.js'
for (const core of ['published', 'candidate']) {
test(`${core}: rapid real-media switches identify the last restart and settle superseded work`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?source=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const restarts = []
const order = []
art.on('restart', url => restarts.push(url))
art.on('video:canplay', () => order.push('canplay'))
art.on('restart', () => order.push('restart'))
let firstSettled = false
const first = art.switchUrl('/test/pattern.mp4?source=first').then(() => {
firstSettled = true
})
const second = art.switchUrl('/test/pattern.mp4?source=second')
await Promise.resolve()
const early = firstSettled
await Promise.all([first, second])
const result = { early, restarts, order, current: new URL(art.video.currentSrc).search, error: art.video.error?.code || 0, time: art.currentTime }
art.destroy()
return result
})
expect(result.early).toBe(core === 'candidate')
expect(result.restarts).toEqual(core === 'candidate'
? ['/test/pattern.mp4?source=second']
: ['/test/pattern.mp4?source=first', '/test/pattern.mp4?source=second'])
expect(result.order[0]).toBe('canplay')
expect(result.current).toBe('?source=second')
expect(result.error).toBe(0)
expect(result.time).toBeCloseTo(0, 1)
})
test(`${core}: destroy settles the candidate switch without waiting for detached media`, async ({ page }) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?destroy=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
let settled = false
let rejected = false
art.switchUrl('/test/pattern.mp4?destroy=pending').then(() => {
settled = true
}, () => {
rejected = true
})
art.destroy()
// Retain the published baseline's bounded pending observation, not an infinite-pending claim.
await new Promise(resolve => setTimeout(resolve, 250))
return { settled, rejected, destroyed: art.isDestroy, instances: window.Artplayer.instances.length }
})
expect(result).toEqual({ settled: core === 'candidate', rejected: false, destroyed: true, instances: 0 })
})
}
test('candidate: quality switch restores position/rate and preserves real customType identities', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?custom=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 3
})
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
const result = await page.evaluate(async () => {
const { art } = window
const calls = []
art.option.type = 'controlled'
art.option.customType.controlled = function (video, url, owner) {
calls.push({ thisIdentity: this === art, ownerIdentity: owner === art, videoIdentity: video === art.video, count: arguments.length, url })
video.src = url
}
art.playbackRate = 1.5
art.aspectRatio = '16:9'
const restart = []
art.on('restart', url => restart.push(url))
await art.switchQuality('/test/pattern.mp4?custom=next')
const result = { calls, restart, time: art.currentTime, rate: art.playbackRate, ratio: art.aspectRatio, paused: art.video.paused, error: art.video.error?.code || 0 }
art.destroy()
return result
})
expect(result.calls).toEqual([{ thisIdentity: true, ownerIdentity: true, videoIdentity: true, count: 3, url: '/test/pattern.mp4?custom=next' }])
expect(result.restart).toEqual(['/test/pattern.mp4?custom=next'])
expect(result.time).toBeCloseTo(3, 1)
expect(result.rate).toBe(1.5)
expect(result.ratio).toBe('16:9')
expect(result.paused).toBe(true)
expect(result.error).toBe(0)
})
test('candidate: real switch settles when the controlled native resume rejects', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?resume=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const error = new DOMException('controlled resume rejection', 'NotAllowedError')
let calls = 0
Object.defineProperty(art.video, 'play', { configurable: true, value() {
calls++
return Promise.reject(error)
} })
const value = await art.switchUrl('/test/pattern.mp4?resume=next')
const result = { settledUndefined: value === undefined, calls, source: new URL(art.video.currentSrc).search, paused: art.video.paused }
delete art.video.play
art.destroy()
return result
})
expect(result).toEqual({ settledUndefined: true, calls: 1, source: '?resume=next', paused: true })
})
test('candidate: late native play completion cannot publish stale player effects', async ({ page }) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?late=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
const plays = []
art.on('play', () => plays.push('play'))
let resolveNative
let started
const invoked = new Promise(resolve => started = resolve)
Object.defineProperty(art.video, 'play', { configurable: true, value() {
started()
return new Promise(resolve => resolveNative = resolve)
} })
const switching = art.switchUrl('/test/pattern.mp4?late=resume')
await invoked
art.url = '/test/pattern.mp4?late=latest'
await switching
art.notice.show = 'Latest source notice'
resolveNative(42)
await new Promise(resolve => setTimeout(resolve, 0))
const result = { plays, notice: art.template.$noticeInner.textContent }
delete art.video.play
art.destroy()
return result
})
expect(result).toEqual({ plays: [], notice: 'Latest source notice' })
})
+2 -1
View File
@@ -1,6 +1,6 @@
import { mock } from 'node:test'
export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchMix }) {
export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchMix, beginLifecycle }) {
return function createArt(play = () => Promise.resolve(), playing = false) {
const art = Object.assign(new Emitter(), {
url: 'old.mp4',
@@ -19,6 +19,7 @@ export function playbackFactory({ Emitter, playMix, pauseMix, toggleMix, switchM
},
},
})
beginLifecycle(art)
Object.defineProperty(art, 'playing', { get: () => !art.template.$video.paused })
for (const mix of [playMix, pauseMix, toggleMix, switchMix])
mix(art)
+2
View File
@@ -13,6 +13,8 @@ const modules = await loadModules({
pauseMix: 'packages/artplayer/src/player/pauseMix',
toggleMix: 'packages/artplayer/src/player/toggleMix',
switchMix: 'packages/artplayer/src/player/switchMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
})
const { silencePromise, Hotkey } = modules
const createArt = playbackFactory(modules)
+364
View File
@@ -0,0 +1,364 @@
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, urlMix, switchMix, playMix, beginLifecycle, getScope } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
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' },
})
function createArt(customType) {
const art = Object.assign(new Emitter(), {
template: { $video: { src: 'old.mp4' } },
option: { url: 'old.mp4', type: customType ? 'custom' : '', customType: { custom: customType } },
isReady: true,
playing: false,
currentTime: 37,
playbackRate: 1.5,
aspectRatio: '16:9',
loading: { show: false },
notice: { show: '' },
pause: mock.fn(),
play: mock.fn(() => Promise.resolve()),
})
beginLifecycle(art)
urlMix(art)
switchMix(art)
return art
}
test('superseded switches settle before readiness and only the latest restores state/restarts', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
const first = art.switchQuality('first.mp4')
art.currentTime = 9
const second = art.switchQuality('second.mp4')
assert.equal(await first, undefined)
art.currentTime = 0
art.emit('video:loadedmetadata')
assert.equal(art.currentTime, 9)
art.emit('video:canplay')
assert.equal(await second, undefined)
assert.deepEqual(restarts, ['second.mp4'])
assert.deepEqual(Object.keys(art.e), ['restart'])
})
test('direct URL assignment cancels an outstanding switch and stale restart listeners', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
const switching = art.switchQuality('switch.mp4')
art.url = 'direct.mp4'
await switching
art.currentTime = 4
art.emit('video:loadedmetadata')
art.emit('video:canplay')
assert.equal(art.currentTime, 4)
assert.deepEqual(restarts, ['direct.mp4'])
})
test('scope disposal settles a pending switch and prevents post-destroy work', async () => {
const art = createArt()
const switching = art.switchUrl('pending.mp4')
getScope(art).dispose()
assert.equal(await switching, undefined)
assert.deepEqual(Object.keys(art.e), [])
art.url = 'closed.mp4'
await art.switchUrl('also-closed.mp4')
assert.equal(art.url, 'pending.mp4')
assert.equal(art.pause.mock.callCount(), 1)
})
test('same URL remains a no-op and switch setter retains the public function identity', async () => {
const art = createArt()
art.notice.show = 'keep'
assert.equal(await art.switchUrl('old.mp4'), undefined)
assert.equal(art.pause.mock.callCount(), 0)
assert.equal(art.notice.show, 'keep')
assert.equal(Object.getOwnPropertyDescriptor(art, 'switch').set, art.switchUrl)
for (const name of ['url', 'switch', 'switchUrl', 'switchQuality']) {
const descriptor = Object.getOwnPropertyDescriptor(art, name)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
}
})
test('a reentrant pause cannot make its superseded switch overwrite the latest URL', async () => {
const art = createArt()
art.pause = () => {
art.url = 'reentrant.mp4'
}
await art.switchUrl('stale.mp4')
assert.equal(art.url, 'reentrant.mp4')
})
test('synchronous media setters cannot lose metadata, readiness or original errors', async () => {
for (const fails of [false, true]) {
const art = createArt()
const error = new Error('synchronous source failure')
let src = 'old.mp4'
Object.defineProperty(art.template.$video, 'src', {
get: () => src,
set(value) {
src = value
if (fails) {
art.emit('video:error', error)
}
else {
art.currentTime = 0
art.emit('video:loadedmetadata')
art.emit('video:canplay')
}
},
})
const switching = art.switchQuality('sync.mp4')
if (fails) {
await assert.rejects(switching, actual => actual === error)
}
else {
await switching
assert.equal(art.currentTime, 37)
}
assert.deepEqual(Object.keys(art.e), [])
}
})
test('late resume fulfillment or rejection cannot clear a newer operation notice', async () => {
for (const rejects of [false, true]) {
const art = createArt()
art.playing = true
let finish
art.play = () => new Promise((resolve, reject) => {
finish = rejects ? reject : resolve
})
const first = art.switchUrl('first.mp4')
art.emit('video:canplay')
art.playing = false
const second = art.switchUrl('second.mp4')
await first
art.notice.show = 'new source notice'
finish(new Error('late play'))
await setImmediate()
assert.equal(art.notice.show, 'new source notice')
art.emit('video:canplay')
await second
assert.deepEqual(Object.keys(art.e), [])
}
})
test('state restoration errors reject without leaking handlers or unhandled promises', async () => {
for (const property of ['currentTime', 'playbackRate', 'aspectRatio']) {
const art = createArt()
const error = new Error(property)
const switching = art.switchQuality('new.mp4')
const previous = art[property]
Object.defineProperty(art, property, {
get: () => previous,
set() {
throw error
},
})
const rejected = assert.rejects(switching, actual => actual === error)
art.emit(property === 'currentTime' ? 'video:loadedmetadata' : 'video:canplay')
await rejected
assert.deepEqual(Object.keys(art.e), [])
}
})
test('customType deferral ignores the old resource and preserves callback identities', async () => {
let called
const art = createArt(function (video, url, owner) {
called = [this, video, url, owner, arguments.length]
video.src = url
owner.emit('video:loadedmetadata')
owner.emit('video:canplay')
})
const switching = art.switchQuality('custom.mp4')
art.currentTime = 0
art.emit('video:canplay')
assert.equal(called, undefined)
await switching
assert.deepEqual(called, [art, art.template.$video, 'custom.mp4', art, 3])
assert.equal(art.currentTime, 37)
assert.equal(art.option.url, 'custom.mp4')
})
test('customType failures reject switches with the original thrown/rejected value', async () => {
for (const asynchronous of [false, true]) {
const error = new Error('customType failed')
const art = createArt(() => {
if (asynchronous)
return Promise.reject(error)
throw error
})
await assert.rejects(art.switchUrl('failed.mp4'), actual => actual === error)
assert.deepEqual(Object.keys(art.e), [])
}
})
test('superseded customType callbacks never start and empty URL switches settle', async () => {
const callback = mock.fn((video, url, art) => {
video.src = url
art.emit('video:canplay')
})
const art = createArt(callback)
const first = art.switchUrl('first.mp4')
const second = art.switchUrl('second.mp4')
await Promise.all([first, second])
assert.equal(callback.mock.callCount(), 1)
assert.equal(callback.mock.calls[0].arguments[1], 'second.mp4')
await art.switchUrl('')
assert.equal(art.loading.show, true)
assert.equal(art.url, 'second.mp4')
})
test('reentrant media listeners and property setters cannot restore stale state', async () => {
for (const boundary of ['metadata', 'rate']) {
const art = createArt()
if (boundary === 'metadata') {
art.once('video:loadedmetadata', () => {
art.url = 'reentrant.mp4'
art.currentTime = 6
})
}
const switching = art.switchQuality('stale.mp4')
art.currentTime = 0
art.aspectRatio = 'default'
if (boundary === 'rate') {
Object.defineProperty(art, 'playbackRate', {
get: () => 1,
set() {
art.url = 'reentrant.mp4'
},
})
}
art.emit(boundary === 'metadata' ? 'video:loadedmetadata' : 'video:canplay')
await switching
assert.equal(art.url, 'reentrant.mp4')
assert.equal(art.aspectRatio, 'default')
if (boundary === 'metadata')
assert.equal(art.currentTime, 6)
getScope(art).dispose()
assert.deepEqual(Object.keys(art.e), [])
}
})
test('direct customType rejection is reported once; obsolete returned failures are ignored', async (t) => {
const warning = t.mock.method(console, 'warn', () => {})
const error = new Error('direct custom source')
let rejectLate
let started
const invoked = new Promise(resolve => started = resolve)
const art = createArt(() => new Promise((resolve, reject) => {
rejectLate = reject
started()
}))
art.url = 'late.mp4'
await invoked
art.option.type = ''
art.url = 'current.mp4'
rejectLate(error)
await setImmediate()
assert.equal(warning.mock.callCount(), 0)
art.option.type = 'custom'
art.option.customType.custom = () => Promise.reject(error)
await Object.getOwnPropertyDescriptor(art, 'url').set.call(art, 'failed.mp4')
await setImmediate()
assert.equal(warning.mock.callCount(), 1)
assert.deepEqual(warning.mock.calls[0].arguments, ['Failed to initialize ArtPlayer source:', error])
assert.deepEqual(Object.keys(art.e), [])
})
test('same-URL calls do not cancel the still-loading original operation', async () => {
const art = createArt()
let firstSettled = false
const first = art.switchQuality('pending.mp4').then(() => firstSettled = true)
await art.switchUrl('pending.mp4')
assert.equal(firstSettled, false)
art.currentTime = 0
art.emit('video:loadedmetadata')
art.emit('video:canplay')
await first
assert.equal(art.currentTime, 37)
assert.equal(art.pause.mock.callCount(), 1)
})
test('synchronous readiness replay consumes metadata once and emits restart once', async () => {
const art = createArt()
const restarts = []
art.on('restart', url => restarts.push(url))
let src = 'old.mp4'
Object.defineProperty(art.template.$video, 'src', {
get: () => src,
set(value) {
src = value
art.emit('video:loadedmetadata')
art.emit('video:loadedmetadata')
},
})
const switching = art.switchQuality('sync.mp4')
assert.equal(art.currentTime, 37)
art.currentTime = 4
art.emit('video:loadedmetadata')
assert.equal(art.currentTime, 4)
art.emit('video:canplay')
art.emit('video:canplay')
await switching
assert.deepEqual(restarts, ['sync.mp4'])
assert.deepEqual(Object.keys(art.e), ['restart'])
})
test('the real play mixin cannot publish stale resume effects after source replacement', async () => {
const art = createArt()
art.i18n = { get: key => key }
art.constructor = { instances: [] }
let resolveNative
art.template.$video.play = () => new Promise(resolve => resolveNative = resolve)
playMix(art)
art.playing = true
const plays = []
art.on('play', () => plays.push('play'))
const switching = art.switchUrl('pending-resume.mp4')
art.emit('video:canplay')
art.url = 'latest.mp4'
await switching
art.notice.show = 'latest notice'
resolveNative(42)
await setImmediate()
assert.equal(art.notice.show, 'latest notice')
assert.deepEqual(plays, [])
})
test('public pending play preserves its result while obsolete notification/event/mutex work stops', async () => {
for (const destroy of [false, true]) {
const art = createArt()
art.i18n = { get: key => key }
const other = { pause: mock.fn() }
art.constructor = { instances: [other] }
art.option.mutex = true
art.url = 'initial.mp4'
let resolveNative
art.template.$video.play = () => new Promise(resolve => resolveNative = resolve)
playMix(art)
const plays = mock.fn()
art.on('play', plays)
const playing = art.play()
if (destroy)
getScope(art).dispose()
else
art.url = 'replacement.mp4'
art.notice.show = 'preserve'
resolveNative(42)
assert.equal(await playing, 42)
assert.equal(art.notice.show, 'preserve')
assert.equal(plays.mock.callCount(), 0)
assert.equal(other.pause.mock.callCount(), 0)
}
})
+36
View File
@@ -0,0 +1,36 @@
import type { CanvasMedia, NativeMedia } from '../../packages/artplayer/src/media/types'
import type { SourceMedia, SwitchHost, UrlHost } from '../../packages/artplayer/src/source/types'
import switchMix from '../../packages/artplayer/src/player/switchMix'
import urlMix from '../../packages/artplayer/src/player/urlMix'
declare const native: NativeMedia
declare const canvas: CanvasMedia
const sourceMedia: SourceMedia[] = [native, canvas]
void sourceMedia
declare const switching: SwitchHost
switchMix(switching)
const pending: Promise<void> = switching.switchQuality('movie.mp4')
void pending
switching.switch = 'movie.mp4'
// @ts-expect-error Source URLs remain strings.
switching.switchUrl(42)
// @ts-expect-error Switching does not resolve with a media element or success boolean.
const wrong: Promise<boolean> = switching.switchUrl('movie.mp4')
void wrong
interface CustomHost extends UrlHost<CanvasMedia, CustomHost> {
extension: number
}
declare const custom: CustomHost
custom.option.customType.canvas = function (video, url, art) {
this satisfies CustomHost
const owner: CustomHost = art
const surface: HTMLCanvasElement = video
const source: string = url
void [owner, surface, source]
// @ts-expect-error A canvas proxy is not a native video element.
const wrongMedia: HTMLVideoElement = video
void wrongMedia
}
urlMix<CanvasMedia, CustomHost>(custom)