refactor(core): CORE-19 type capture and preserve progress lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 14:24:59 +08:00
1 parent 98a1800032
commit ccf77c4ed2
49 files changed
+31570 -681

No files matched your search

+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+467 -223
View File
@@ -496,6 +496,132 @@ const isIOS = /iPad|iPhone|iPod/i.test(userAgent) && !window.MSStream;
const isIOS13 = isIOS || userAgent.includes("Macintosh") && navigator.maxTouchPoints >= 1;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || isIOS13;
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
function requestImage(url, scale, owner) {
return new Promise((resolve, reject) => {
const request = owner ? owner.child() : new ResourceScope();
let complete = false;
let releaseBlob = () => {
};
function fail(error2) {
if (complete)
return;
complete = true;
try {
request.dispose();
} catch (cleanupError) {
console.warn("Additional image cleanup failure:", cleanupError);
}
try {
releaseBlob();
} catch (cleanupError) {
console.warn("Additional image cleanup failure:", cleanupError);
}
reject(error2);
}
function finish2(image) {
if (complete || request.closed)
return;
complete = true;
try {
request.dispose();
resolve(image);
} catch (error2) {
try {
releaseBlob();
} finally {
reject(error2);
}
}
}
request.add(() => {
if (!complete) {
complete = true;
try {
releaseBlob();
} finally {
resolve(void 0);
}
}
});
if (request.closed)
return;
function watch(image, loaded) {
image.onload = () => {
if (!complete && !request.closed) {
try {
loaded();
} catch (error2) {
fail(error2);
}
}
};
image.onerror = () => fail(new Error(`Image load failed: ${url}`));
request.add(() => {
image.onload = null;
image.onerror = null;
if (!complete)
image.removeAttribute("src");
});
}
try {
const image = new Image();
watch(image, () => {
if (!scale || scale === 1) {
finish2(image);
return;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = image.width * scale;
canvas.height = image.height * scale;
if (!context)
throw new TypeError("Canvas 2D context is unavailable");
if (complete || request.closed)
return;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
if (complete || request.closed)
return;
canvas.toBlob((blob) => {
if (complete || request.closed)
return;
try {
if (!blob)
throw new Error("Unable to encode thumbnail image");
const blobUrl = URL.createObjectURL(blob);
let revoked = false;
const revoke = () => {
if (!revoked) {
revoked = true;
URL.revokeObjectURL(blobUrl);
}
};
releaseBlob = owner ? owner.add(revoke) : revoke;
if (complete || request.closed) {
releaseBlob();
return;
}
const scaled = new Image();
watch(scaled, () => finish2(scaled));
if (!request.closed)
scaled.src = blobUrl;
} catch (error2) {
fail(error2);
}
});
});
if (!request.closed)
image.src = url;
} catch (error2) {
fail(error2);
}
});
}
function loadImg(url, scale) {
return requestImage(url, scale, void 0);
}
function loadThumbnailImage(url, scale, owner) {
return requestImage(url, scale, owner);
}
function setStyleText(id2, style) {
let $style = document.getElementById(id2);
if (!$style) {
@@ -598,38 +724,6 @@ function supportsFlex() {
function getRect(el) {
return el.getBoundingClientRect();
}
function loadImg(url, scale) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
if (!scale || scale === 1) {
resolve(img);
} else {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const blobUrl = URL.createObjectURL(blob);
const scaledImg = new Image();
scaledImg.onload = function() {
resolve(scaledImg);
};
scaledImg.onerror = function() {
URL.revokeObjectURL(blobUrl);
reject(new Error(`Image load failed: ${url}`));
};
scaledImg.src = blobUrl;
});
}
};
img.onerror = function() {
reject(new Error(`Image load failed: ${url}`));
};
img.src = url;
});
}
function getComposedPath(event) {
if (event.composedPath)
return event.composedPath();
@@ -1619,6 +1713,75 @@ function playAndPause(option) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function getPosFromEvent(art, event) {
const { $progress } = art.template;
const { left } = getRect($progress);
@@ -1630,14 +1793,20 @@ function getPosFromEvent(art, event) {
return { second, time: time2, width, percentage };
}
function setCurrentTime(art, event, active2 = () => true) {
if (!active2())
return;
if (art.isRotate) {
const percentage = (event.touches[0].clientY - art.top) / art.height;
const second = percentage * art.duration;
if (!active2())
return;
art.emit("setBar", "played", percentage, event);
if (active2())
art.seek = second;
} else {
const { second, percentage } = getPosFromEvent(art, event);
if (!active2())
return;
art.emit("setBar", "played", percentage, event);
if (active2())
art.seek = second;
@@ -1647,34 +1816,51 @@ function installProgressInteractions(art, $control) {
const { $progress } = art.template;
const $indicator = $control.querySelector(".art-progress-indicator");
const { on, proxy } = controlEvents(art, $control);
const scope = entryScope($control);
let action = 0;
const capture = () => {
const sourceActive = captureSource(art);
return () => !scope.closed && !isClosing(art) && sourceActive();
};
const captureAction = () => {
const revision = ++action;
const active2 = capture();
return () => revision === action && active2();
};
if (!isMobile) {
let isDragging = false;
let dragging;
proxy($progress, "click", (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event);
setCurrentTime(art, event, captureAction());
}
});
proxy($progress, "mousemove", (event) => {
const active2 = capture();
const { percentage } = getPosFromEvent(art, event);
art.emit("setBar", "hover", percentage, event);
if (active2())
art.emit("setBar", "hover", percentage, event);
});
proxy($progress, "mouseleave", (event) => {
art.emit("setBar", "hover", 0, event);
});
proxy($progress, "mousedown", (event) => {
isDragging = event.button === 0;
dragging = event.button === 0 ? captureAction() : void 0;
});
on("document:mousemove", (event) => {
if (isDragging) {
const active2 = dragging;
if (active2?.()) {
const { second, percentage } = getPosFromEvent(art, event);
if (!active2() || dragging !== active2)
return;
art.emit("setBar", "played", percentage, event);
art.seek = second;
if (active2() && dragging === active2)
art.seek = second;
} else {
dragging = void 0;
}
});
on("document:mouseup", () => {
if (isDragging) {
isDragging = false;
}
dragging = void 0;
});
}
}
@@ -1814,7 +2000,7 @@ function screenshot$1(option) {
const { icons } = art;
appendElement($control, icons.screenshot);
proxy($control, "click", () => {
art.screenshot();
silencePromise(art.screenshot());
});
}
});
@@ -2278,75 +2464,6 @@ function clickInit(art, events) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function slideDirection(startX, startY, endX, endY) {
const dy = startY - endY;
const dx = endX - startX;
@@ -3473,6 +3590,13 @@ function cssVarMix(art) {
}
});
}
const revisions = /* @__PURE__ */ new WeakMap();
function positionRevision(art) {
return revisions.get(art) || 0;
}
function advancePosition(art) {
revisions.set(art, positionRevision(art) + 1);
}
function currentTimeMix(art) {
const { $video } = art.template;
def(art, "currentTime", {
@@ -3481,6 +3605,7 @@ function currentTimeMix(art) {
const parsed = Number.parseFloat(time2);
if (Number.isNaN(parsed))
return;
advancePosition(art);
$video.currentTime = clamp(parsed, 0, art.duration);
}
});
@@ -4939,46 +5064,66 @@ function rectMix(art) {
}
});
}
function drawFrame(canvas, video) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context)
throw new TypeError("Canvas 2D context is unavailable");
context.drawImage(video, 0, 0);
}
function captureBlobUrl(canvas, resolve, reject) {
canvas.toBlob((blob) => {
try {
if (!blob)
throw new Error("Unable to encode screenshot blob");
resolve(URL.createObjectURL(blob));
} catch (error2) {
reject(error2);
}
});
}
function screenshotMix(art) {
const {
notice,
template: { $video }
} = art;
const $canvas = createElement("canvas");
def(art, "getDataURL", {
value: () => new Promise((resolve, reject) => {
const { notice, template: { $video } } = art;
const canvas = document.createElement("canvas");
function capture(blob) {
const active2 = captureSource(art);
return new Promise((resolve, reject) => {
const fail = (error2) => {
try {
if (!isClosing(art) && active2())
notice.show = error2;
} catch (noticeError) {
reject(noticeError);
return;
}
reject(error2);
};
try {
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
$canvas.getContext("2d").drawImage($video, 0, 0);
resolve($canvas.toDataURL("image/png"));
} catch (err) {
notice.show = err;
reject(err);
drawFrame(canvas, $video);
if (blob) {
captureBlobUrl(canvas, resolve, fail);
} else {
resolve(canvas.toDataURL("image/png"));
}
} catch (error2) {
fail(error2);
}
})
});
def(art, "getBlobUrl", {
value: () => new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
$canvas.getContext("2d").drawImage($video, 0, 0);
$canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
});
} catch (err) {
notice.show = err;
reject(err);
}
})
});
});
}
def(art, "getDataURL", { value: () => capture(false) });
def(art, "getBlobUrl", { value: () => capture(true) });
def(art, "screenshot", {
value: async (name) => {
const active2 = captureSource(art);
const dataUri = await art.getDataURL();
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
download(dataUri, `${fileName}.png`);
art.emit("screenshot", dataUri);
if (!isClosing(art) && active2()) {
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
if (!isClosing(art) && active2())
download(dataUri, `${fileName}.png`);
if (!isClosing(art) && active2())
art.emit("screenshot", dataUri);
}
return dataUri;
}
});
@@ -5061,6 +5206,48 @@ function listenSource(scope, art, name, callback, once = true) {
});
return guarded;
}
const POSITION_TOLERANCE = 0.05;
function positionRestoration(art, target, active2) {
let expected;
let observedSeek = false;
let corrected = false;
let manual = false;
let revision = positionRevision(art);
const manualPosition = () => manual || positionRevision(art) !== revision;
const seeking = () => !!art.template?.$video?.seeking;
return {
restore() {
if (!active2() || manualPosition())
return;
const previousRevision = positionRevision(art);
art.currentTime = target;
revision = positionRevision(art);
if (revision > previousRevision + 1)
manual = true;
if (!active2())
return;
expected = art.currentTime;
if (!active2() || manualPosition())
return;
observedSeek = seeking();
},
manual() {
manual = true;
},
ready() {
if (!active2() || seeking())
return false;
const missed = expected !== void 0 && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE;
if (!manualPosition() && observedSeek && !corrected && expected !== void 0 && missed) {
corrected = true;
if (!active2())
return false;
art.currentTime = expected;
}
return active2() && !seeking();
}
};
}
function switchSource(art, url, currentTime) {
return new Promise((resolve, reject) => {
if (isClosing(art) || url === art.url) {
@@ -5096,41 +5283,63 @@ function switchSource(art, url, currentTime) {
if (!active2())
return;
const readiness = scope.child();
let canPlay = false;
let resuming = false;
let rateRestored = false;
const position = positionRestoration(art, currentTime, active2);
const resume = async () => {
art.aspectRatio = aspectRatio2;
if (!active2())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active2())
return;
art.notice.show = "";
settle();
};
const resumeWhenReady = () => {
if (!active2() || resuming || !canPlay)
return;
try {
if (art.template?.$video?.seeking)
return;
if (!active2())
return;
if (!rateRestored) {
rateRestored = true;
art.playbackRate = playbackRate2;
}
if (!position.ready() || !active2() || resuming)
return;
resuming = true;
readiness.dispose();
void resume().catch(fail);
} catch (error2) {
fail(error2);
}
};
const handlers = {
"video:error": fail,
"video:loadedmetadata": () => {
if (!active2())
return;
try {
art.currentTime = currentTime;
position.restore();
} catch (error2) {
fail(error2);
}
},
"video:canplay": () => {
if (!active2())
return;
readiness.dispose();
const resume = async () => {
art.playbackRate = playbackRate2;
if (!active2())
return;
art.aspectRatio = aspectRatio2;
if (!active2())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active2())
return;
art.notice.show = "";
settle();
};
void resume().catch(fail);
}
canPlay = true;
resumeWhenReady();
},
"video:seeked": resumeWhenReady,
"seek": position.manual
};
const capture = scope.child();
const queued = [];
@@ -5155,7 +5364,7 @@ function switchSource(art, url, currentTime) {
}
const dispatch = {};
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name]);
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== "video:seeked");
for (const [name, event] of queued) {
if (!active2() || readiness.closed)
break;
@@ -5188,69 +5397,106 @@ function themeMix(art) {
}
});
}
function thumbnailLayout(option, geometry) {
const { number, column, width, height, scale } = option;
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry;
const columns = Number(column);
const previewWidth = Number(width) * Number(scale) || imageWidth / columns;
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight);
const index = Math.floor(position / (progressWidth / Number(number)));
const row = Math.floor(index / columns);
const cell = index % columns;
const left = position <= previewWidth / 2 ? 0 : position > progressWidth - previewWidth / 2 ? `${progressWidth - previewWidth}px` : `${position - previewWidth / 2}px`;
return {
height: `${previewHeight}px`,
width: `${previewWidth}px`,
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
left
};
}
function thumbnailsMix(art) {
const {
option,
template: { $progress, $video }
} = art;
let timer = null;
let loding = false;
let image = null;
const { option, template: { $progress, $video } } = art;
let scope = getScope(art).child();
let image;
let loading2 = false;
let control;
let hover;
function reset() {
clearTimeout(timer);
timer = null;
loding = false;
image = null;
const previous = scope;
scope = getScope(art).child();
image = void 0;
loading2 = false;
hover = void 0;
previous.dispose();
}
function showThumbnails(posWidth) {
const $thumbnails = art.controls?.thumbnails;
if (!$thumbnails)
function render() {
const current2 = hover;
const currentScope = scope;
if (!current2 || !image)
return;
const { number, column, width, height, scale } = option.thumbnails;
const width2 = width * scale || image.naturalWidth / column;
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight);
const perWidth = $progress.clientWidth / number;
const perIndex = Math.floor(posWidth / perWidth);
const yIndex = Math.ceil(perIndex / column) - 1;
const xIndex = perIndex % column || column - 1;
setStyle($thumbnails, "backgroundImage", `url(${image.src})`);
setStyle($thumbnails, "height", `${height2}px`);
setStyle($thumbnails, "width", `${width2}px`);
setStyle($thumbnails, "backgroundPosition", `-${xIndex * width2}px -${yIndex * height2}px`);
if (posWidth <= width2 / 2) {
setStyle($thumbnails, "left", 0);
} else if (posWidth > $progress.clientWidth - width2 / 2) {
setStyle($thumbnails, "left", `${$progress.clientWidth - width2}px`);
} else {
setStyle($thumbnails, "left", `${posWidth - width2 / 2}px`);
const active2 = () => !isClosing(art) && !currentScope.closed && scope === currentScope && hover === current2 && current2.sourceActive() && art.controls?.thumbnails === current2.element;
if (!active2())
return;
const position = $progress.clientWidth * current2.percentage;
if (!(position > 0 && position < $progress.clientWidth))
return;
const styles = {
backgroundImage: `url(${image.src})`,
...thumbnailLayout(option.thumbnails, {
imageWidth: image.naturalWidth,
videoWidth: $video.videoWidth,
videoHeight: $video.videoHeight,
progressWidth: $progress.clientWidth,
position
})
};
for (const [key, value] of Object.entries(styles)) {
if (!active2())
return;
setStyle(current2.element, key, value);
}
}
art.on("setBar", async (type, percentage, event) => {
const $thumbnails = art.controls?.thumbnails;
eventSubscriptions(art)("setBar", (type, percentage, event) => {
const element = art.controls?.thumbnails;
const { url, scale } = option.thumbnails;
if (!$thumbnails || !url)
if (isClosing(art) || !element || !url || !(type === "hover" || type === "played" && event && isMobile))
return;
const isMobileDragging = type === "played" && event && isMobile;
if (type === "hover" || isMobileDragging) {
if (!image && !loding) {
loding = true;
image = await loadImg(url, scale);
loding = false;
}
if (!image)
if (control !== element) {
const elementScope = entryScope(element);
if (elementScope.closed)
return;
const width = $progress.clientWidth * percentage;
if (width > 0 && width < $progress.clientWidth) {
showThumbnails(width);
}
control = element;
elementScope.add(() => {
if (control === element) {
control = void 0;
reset();
}
});
}
hover = { percentage, element, sourceActive: captureSource(art) };
if (image) {
render();
} else if (!loading2) {
const currentScope = scope;
loading2 = true;
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
if (scope !== currentScope || currentScope.closed)
return;
loading2 = false;
image = loaded;
render();
}).catch((error2) => {
if (scope === currentScope && !currentScope.closed) {
loading2 = false;
console.warn("ArtPlayer thumbnail load failed:", error2);
}
});
}
});
def(art, "thumbnails", {
get() {
return art.option.thumbnails;
},
set(thumbnails) {
if (thumbnails.url && !art.option.isLive) {
get: () => art.option.thumbnails,
set: (thumbnails) => {
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
art.option.thumbnails = thumbnails;
reset();
}
@@ -5319,8 +5565,6 @@ function urlMix(art) {
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;
+1 -1
View File
@@ -49,7 +49,7 @@
"ci:build": "yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js",
"test:unit": "node --test test/playback.test.js test/dash-control.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
"test:browser:install": "playwright install chromium firefox webkit",
+96 -1
View File
@@ -4,6 +4,101 @@ ArtPlayer keeps its existing constructor, player mixins, plugins and DOM/CSS hoo
The production entry is still `src/index.js`; this document marks actual migrated
boundaries rather than describing the entire core as TypeScript.
## Screenshot capture (CORE-19)
`src/player/screenshotMix.ts` installs the existing immutable getDataURL,
getBlobUrl and screenshot methods. `src/capture/frame.ts` owns synchronous frame
drawing and the asynchronous canvas Blob callback boundary. A capture still draws
before returning its Promise; moving drawing behind await would select a later
video frame. PNG format, extracted-method binding, default filename timing and
download-before-event ordering remain unchanged for live operations.
The public getBlobUrl caller owns URL.revokeObjectURL. Player destruction must not
invalidate a returned URL that a consumer still uses. Asynchronous null Blob or
URL allocation failure now rejects the capture Promise instead of escaping the
callback and leaving it pending. Notice setter failures also reject. Destruction
and source replacement suppress stale notices and screenshot download/event
effects, while an already captured data string or Blob URL still settles normally.
The toolbar consumes its internal rejection after the capture reports a notice;
public calls continue to reject for callers to handle.
Use test/screenshot.test.js, test/types/screenshot.ts and the browser screenshot
spec for capture changes. Browser checks decode real video frames, inspect PNG
pixels, and use a second hostname without CORS headers for native SecurityError.
Installed-package acceptance is recorded in refactor/changes/ for this task.
## Thumbnail previews and image loading (CORE-19)
`src/player/thumbnailsMix.ts` preserves the thumbnails property, configuration
object identity, live restrictions and control DOM. It owns one image cache scope
per configuration, links it to the current thumbnail control and removes its setBar
subscription on destruction. Replacement, control removal and destruction cancel
loading; obsolete completions cannot render. Loading failures release the pending
state so a later interaction may retry. Failures are reported with console.warn
instead of escaping an internal event callback as an unhandled rejection.
The latest hover position wins while an image is pending. A source change prevents
rendering old hover coordinates but does not invalidate an unchanged sprite cache;
a new interaction may use that image. Each style write checks the active control,
configuration and source so reentrant DOM callbacks stop the remaining writes.
`src/thumbnails/layout.ts` contains the sprite and preview geometry. Dimensions,
scale fallback, edge placement and excluded progress endpoints remain unchanged.
The old row/cell calculation was incorrect at exact column multiples and produced
invalid CSS at cell zero. The corrected zero-based crop matches
artplayer-tool-thumbnail's x/index-modulo-column and y/floor-index-over-column
generation. Tests explicitly distinguish this display fix from published behavior.
`src/image/load.ts` implements both image consumers without duplicating decoding:
public utils.loadImg still returns Promise<HTMLImageElement>, and the caller owns
the src Blob URL of a scaled result. The internal loadThumbnailImage receives a
ResourceScope; cancellation fulfills with undefined, and its generated URLs remain
owned until replacement/removal/destroy. No caller-supplied image URL is revoked.
Every request clears load/error handlers; failed scaled decoding revokes its Blob.
Null canvas encoding and asynchronous allocation errors reject public calls. The
public helper is re-exported through utils/dom.js to keep the existing utils shape;
loadThumbnailImage is not added to the public utils barrel.
Use test/thumbnails.test.js and test/types/thumbnails.ts for state, cancellation,
reentry and API types. The browser thumbnails spec uses the numbered
test/browser/media/thumbnail-grid.svg for crop checks and actual image decoding.
Transient retry uses a no-store 503 response; browser caching of malformed successful
responses is not claimed to be controlled by the player. Source-built checks are
intermediate evidence; use the task's final tarball/browser evidence for acceptance.
## Progress and quality restoration (CORE-19)
control/progress/interactions.ts captures the control entry, source and action for
each click or drag. Geometry and setBar callbacks can synchronously remove the
entry, destroy the player, start a new source or start a newer action. Check those
boundaries before emitting or seeking. A source change ends the old drag; a new
mousedown can start a new one. position.ts retains endpoint clamping and the live
progress event before seek, with an optional internal activity predicate.
source/restore-position.ts owns the paused quality position restoration. Native
canplay may arrive while seeking; switch.ts waits for seek completion, restores
rate once and checks the accepted, clamped position. A demonstrated native seek
miss receives at most one correction, allowing 0.05 seconds for media clock
rounding. It does not loop until success. The seeked listener remains until the
correction finishes or the source operation is cancelled. Explicit public seek
supersedes automatic position restoration; reentrant completion cannot resume
twice. media/position-revision.ts also tracks legal public currentTime writes;
a consumer assigning currentTime directly during loading supersedes the automatic
restoration, including writes before metadata. Invalid NaN assignments remain no-ops.
Source cancellation still settles pending work and removes all listeners.
Use test/source.test.js, test/progress.test.js and the browser source and
progress-quality specs. Keep native media events in evidence when changing this
sequence; the original rate-interruption hypothesis was not sufficient to explain
the observed first-seek miss.
urlMix.ts receives media URLs from consumers/adapters and does not revoke them on
replacement. Their creator owns release, including Blob media sources. Screenshot
and public scaled-image URLs similarly belong to callers; internal thumbnail and
subtitle URLs have explicit owned scopes. Real Blob-video decoding is separately
capability-tested: this Windows WebKit runtime rejects the local MP4 and WebM Blob
samples even without ArtPlayer. Do not turn URL readability checks into claims of
native decoding or Apple-device support; see refactor/environment-matrix.md.
## Builtins and prompts (CORE-18)
`src/notice.ts` owns the notice timer and DOM visibility. Its minimal host, private
@@ -1001,7 +1096,7 @@ 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. CORE-10 migrates playback properties and CORE-11 owns reconnect
generations; object URL ownership remains CORE-19 work.
generations; CORE-19 removes revocation of caller-owned media URLs on replacement.
`test/source.test.js` checks cancellation, reentry, synchronous events and cleanup;
`test/browser/source.spec.js` compares published/candidate real-media switching and
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";function t(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var e,r={exports:{}};var n=(e||(e=1,r.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),r.exports);const o=t(n),i="5.4.1",a={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","mediaGroup","muted","networkState","paused","playbackRate","played","preload","readyState","seekable","seeking","src","startDate","textTracks","videoTracks","volume"],methods:["addTextTrack","canPlayType","load","play","pause"],events:["abort","canplay","canplaythrough","durationchange","emptied","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting"],prototypes:["width","height","videoWidth","videoHeight","poster","webkitDecodedFrameCount","webkitDroppedFrameCount","playsInline","webLine truncated
+1 -1
View File
@@ -67,4 +67,4 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.Artplayer=e(),define(function(){return t.Artplayer})):(t="undefined"!==typeof globalThis?globalThis:t||self).Artplayer=e()}(this,function(){"use strict";var t,e=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,i=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,s=(t,r,n)=>r in t?e(t,r,{enumerable:!0,configurable:!0,writable:!0,value:n}):t[r]=n,l=(t,e)=>{for(var r in e||(e={}))i.call(e,r)&&s(t,r,e[r]);if(o)for(var r of o(e))a.call(e,r)&&s(t,r,e[r]);return t},c=(t,e)=>r(t,n(e)),d=(t,e,r)=>new Promise((n,o)=>{var i=t=>{try{s(r.next(t))}catch(e){o(e)}},a=t=>{try{s(r.throw(t))}catch(e){o(e)}},s=t=>t.done?n(t.value):Promise.resolve(t.value).then(i,a);s((r=r.apply(t,e)).next())});function u(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var p,h={exports:{}};var f=(p||(p=1,h.exports=function(){function t(e){return(t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(e)}var e=Object.prototype.toString,r=function(r){if(void 0===r)return"undefined";if(null===r)return"null";var o=t(r);if("boolean"===o)return"boolean";if("string"===o)return"string";if("number"===o)return"number";if("symbol"===o)return"symbol";if("function"===o)return"GeneratorFunction"===n(r)?"generatorfunction":"function";if(function(t){return Array.isArray?Array.isArray(t):t instanceof Array}(r))return"array";if(function(t){return!(!t.constructor||"function"!=typeof t.constructor.isBuffer)&&t.constructor.isBuffer(t)}(r))return"buffer";if(function(t){try{if("number"==typeof t.length&&"function"==typeof t.callee)return!0}catch(e){if(-1!==e.message.indexOf("callee"))return!0}return!1}(r))return"arguments";if(function(t){return t instanceof Date||"function"==typeof t.toDateString&&"function"==typeof t.getDate&&"function"==typeof t.setDate}(r))return"date";if(function(t){return t instanceof Error||"string"==typeof t.message&&t.constructor&&"number"==typeof t.constructor.stackTraceLimit}(r))return"error";if(function(t){return t instanceof RegExp||"string"==typeof t.flags&&"boolean"==typeof t.ignoreCase&&"boolean"==typeof t.multiline&&"boolean"==typeof t.global}(r))return"regexp";switch(n(r)){case"Symbol":return"symbol";case"Promise":return"promise";case"WeakMap":return"weakmap";case"WeakSet":return"weakset";case"Map":return"map";case"Set":return"set";case"Int8Array":return"int8array";case"Uint8Array":return"uint8array";case"Uint8ClampedArray":return"uint8clampedarray";case"Int16Array":return"int16array";case"Uint16Array":return"uint16array";case"Int32Array":return"int32array";case"Uint32Array":return"uint32array";case"Float32Array":return"float32array";case"Float64Array":return"float64array"}if(function(t){return"function"==typeof t.throw&&"function"==typeof t.return&&"function"==typeof t.next}(r))return"generator";switch(o=e.call(r)){case"[object Object]":return"object";case"[object Map Iterator]":return"mapiterator";case"[object Set Iterator]":return"setiterator";case"[object String Iterator]":return"stringiterator";case"[object Array Iterator]":return"arrayiterator"}return o.slice(8,-1).toLowerCase().replace(/\s/g,"")};function n(t){return t.constructor?t.constructor.name:null}function o(t,e){var n=2<arguments.length&&void 0!==arguments[2]?arguments[2]:["option"];return i(t,e,n),a(t,e,n),function(t,e,n){var s=r(e),l=r(t);if("object"===s){if("object"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'object' type, but got '").concat(l,"'"));Object.keys(e).forEach(function(r){var s=t[r],l=e[r],c=n.slice();c.push(r),i(s,l,c),a(s,l,c),o(s,l,c)})}if("array"===s){if("array"!==l)throw new Error("[Type Error]: '".concat(n.join("."),"' require 'array' type, but got '").concat(l,"'"));t.forEach(function(r,s){var l=t[s],c=e[s]||e[0],d=n.slice();d.push(s),i(l,c,d),a(l,c,d),o(l,c,d)})}}(t,e,n),t}function i(t,e,n){if("string"===r(e)){var o=r(t);if("?"===e[0]&&(e=e.slice(1)+"|undefined"),!(-1<e.indexOf("|")?e.split("|").map(function(t){return t.toLowerCase().trim()}).filter(Boolean).some(function(t){return o===t}):e.toLowerCase().trim()===o))throw new Error("[Type Error]: '".concat(n.join("."),"' require '").concat(e,"' type, but got '").concat(o,"'"))}}function a(t,e,n){if("function"===r(e)){var o=e(t,r(t),n);if(!0!==o){var i=r(o);throw"string"===i?new Error(o):"error"===i?o:new Error("[Validator Error]: The scheme for '".concat(n.join("."),"' validator require return true, but got '").concat(o,"'"))}}}return o.kindOf=r,o}()),h.exports);const m=u(f),g="5.4.1",v={properties:["audioTracks","autoplay","buffered","controller","controls","crossOrigin","currentSrc","currentTime","defaultMuted","defaultPlaybackRate","duration","ended","error","loop","meLine truncated
+467 -223
View File
@@ -496,6 +496,132 @@ const isIOS = /iPad|iPhone|iPod/i.test(userAgent) && !window.MSStream;
const isIOS13 = isIOS || userAgent.includes("Macintosh") && navigator.maxTouchPoints >= 1;
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent) || isIOS13;
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
function requestImage(url, scale, owner) {
return new Promise((resolve, reject) => {
const request = owner ? owner.child() : new ResourceScope();
let complete = false;
let releaseBlob = () => {
};
function fail(error2) {
if (complete)
return;
complete = true;
try {
request.dispose();
} catch (cleanupError) {
console.warn("Additional image cleanup failure:", cleanupError);
}
try {
releaseBlob();
} catch (cleanupError) {
console.warn("Additional image cleanup failure:", cleanupError);
}
reject(error2);
}
function finish2(image) {
if (complete || request.closed)
return;
complete = true;
try {
request.dispose();
resolve(image);
} catch (error2) {
try {
releaseBlob();
} finally {
reject(error2);
}
}
}
request.add(() => {
if (!complete) {
complete = true;
try {
releaseBlob();
} finally {
resolve(void 0);
}
}
});
if (request.closed)
return;
function watch(image, loaded) {
image.onload = () => {
if (!complete && !request.closed) {
try {
loaded();
} catch (error2) {
fail(error2);
}
}
};
image.onerror = () => fail(new Error(`Image load failed: ${url}`));
request.add(() => {
image.onload = null;
image.onerror = null;
if (!complete)
image.removeAttribute("src");
});
}
try {
const image = new Image();
watch(image, () => {
if (!scale || scale === 1) {
finish2(image);
return;
}
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
canvas.width = image.width * scale;
canvas.height = image.height * scale;
if (!context)
throw new TypeError("Canvas 2D context is unavailable");
if (complete || request.closed)
return;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
if (complete || request.closed)
return;
canvas.toBlob((blob) => {
if (complete || request.closed)
return;
try {
if (!blob)
throw new Error("Unable to encode thumbnail image");
const blobUrl = URL.createObjectURL(blob);
let revoked = false;
const revoke = () => {
if (!revoked) {
revoked = true;
URL.revokeObjectURL(blobUrl);
}
};
releaseBlob = owner ? owner.add(revoke) : revoke;
if (complete || request.closed) {
releaseBlob();
return;
}
const scaled = new Image();
watch(scaled, () => finish2(scaled));
if (!request.closed)
scaled.src = blobUrl;
} catch (error2) {
fail(error2);
}
});
});
if (!request.closed)
image.src = url;
} catch (error2) {
fail(error2);
}
});
}
function loadImg(url, scale) {
return requestImage(url, scale, void 0);
}
function loadThumbnailImage(url, scale, owner) {
return requestImage(url, scale, owner);
}
function setStyleText(id2, style) {
let $style = document.getElementById(id2);
if (!$style) {
@@ -598,38 +724,6 @@ function supportsFlex() {
function getRect(el) {
return el.getBoundingClientRect();
}
function loadImg(url, scale) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
if (!scale || scale === 1) {
resolve(img);
} else {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width * scale;
canvas.height = img.height * scale;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
const blobUrl = URL.createObjectURL(blob);
const scaledImg = new Image();
scaledImg.onload = function() {
resolve(scaledImg);
};
scaledImg.onerror = function() {
URL.revokeObjectURL(blobUrl);
reject(new Error(`Image load failed: ${url}`));
};
scaledImg.src = blobUrl;
});
}
};
img.onerror = function() {
reject(new Error(`Image load failed: ${url}`));
};
img.src = url;
});
}
function getComposedPath(event) {
if (event.composedPath)
return event.composedPath();
@@ -1619,6 +1713,75 @@ function playAndPause(option) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function getPosFromEvent(art, event) {
const { $progress } = art.template;
const { left } = getRect($progress);
@@ -1630,14 +1793,20 @@ function getPosFromEvent(art, event) {
return { second, time: time2, width, percentage };
}
function setCurrentTime(art, event, active2 = () => true) {
if (!active2())
return;
if (art.isRotate) {
const percentage = (event.touches[0].clientY - art.top) / art.height;
const second = percentage * art.duration;
if (!active2())
return;
art.emit("setBar", "played", percentage, event);
if (active2())
art.seek = second;
} else {
const { second, percentage } = getPosFromEvent(art, event);
if (!active2())
return;
art.emit("setBar", "played", percentage, event);
if (active2())
art.seek = second;
@@ -1647,34 +1816,51 @@ function installProgressInteractions(art, $control) {
const { $progress } = art.template;
const $indicator = $control.querySelector(".art-progress-indicator");
const { on, proxy } = controlEvents(art, $control);
const scope = entryScope($control);
let action = 0;
const capture = () => {
const sourceActive = captureSource(art);
return () => !scope.closed && !isClosing(art) && sourceActive();
};
const captureAction = () => {
const revision = ++action;
const active2 = capture();
return () => revision === action && active2();
};
if (!isMobile) {
let isDragging = false;
let dragging;
proxy($progress, "click", (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event);
setCurrentTime(art, event, captureAction());
}
});
proxy($progress, "mousemove", (event) => {
const active2 = capture();
const { percentage } = getPosFromEvent(art, event);
art.emit("setBar", "hover", percentage, event);
if (active2())
art.emit("setBar", "hover", percentage, event);
});
proxy($progress, "mouseleave", (event) => {
art.emit("setBar", "hover", 0, event);
});
proxy($progress, "mousedown", (event) => {
isDragging = event.button === 0;
dragging = event.button === 0 ? captureAction() : void 0;
});
on("document:mousemove", (event) => {
if (isDragging) {
const active2 = dragging;
if (active2?.()) {
const { second, percentage } = getPosFromEvent(art, event);
if (!active2() || dragging !== active2)
return;
art.emit("setBar", "played", percentage, event);
art.seek = second;
if (active2() && dragging === active2)
art.seek = second;
} else {
dragging = void 0;
}
});
on("document:mouseup", () => {
if (isDragging) {
isDragging = false;
}
dragging = void 0;
});
}
}
@@ -1814,7 +2000,7 @@ function screenshot$1(option) {
const { icons } = art;
appendElement($control, icons.screenshot);
proxy($control, "click", () => {
art.screenshot();
silencePromise(art.screenshot());
});
}
});
@@ -2278,75 +2464,6 @@ function clickInit(art, events) {
}
});
}
const current = /* @__PURE__ */ new WeakMap();
const assignments = /* @__PURE__ */ new WeakMap();
const sources = /* @__PURE__ */ new WeakMap();
function getSourceScope(owner) {
return sources.get(owner) || getScope(owner);
}
function captureSource(owner) {
const operation = current.get(owner);
const source = sources.get(owner);
return () => current.get(owner) === operation && (!operation || operation.active()) && sources.get(owner) === source && (!source || !source.closed);
}
function beginSource(owner) {
const previous = sources.get(owner);
const source = getScope(owner).child();
const scope = source.child();
sources.set(owner, source);
source.add(() => {
if (sources.get(owner) === source)
sources.delete(owner);
});
const operation = {
scope,
assigned: false,
acceptingEvents: false,
active: () => !isClosing(owner) && !scope.closed && current.get(owner) === operation
};
current.set(owner, operation);
scope.add(() => {
if (current.get(owner) === operation)
current.delete(owner);
operation.onAssigned = void 0;
operation.onError = void 0;
});
previous?.dispose();
return operation;
}
function takeAssignment(owner) {
const operation = assignments.get(owner);
assignments.delete(owner);
return operation || beginSource(owner);
}
function finishAssignment(operation) {
operation.assigned = true;
const callback = operation.onAssigned;
operation.onAssigned = void 0;
if (operation.active())
callback?.();
}
function assignUrl(owner, operation, url) {
assignments.set(owner, operation);
operation.acceptingEvents = true;
try {
owner.url = url;
} finally {
if (assignments.get(owner) === operation) {
assignments.delete(owner);
finishAssignment(operation);
}
}
}
function failSource(operation, error2) {
if (!operation.active())
return;
if (operation.onError)
operation.onError(error2);
else
console.warn("Failed to initialize ArtPlayer source:", error2);
operation.scope.dispose();
}
function slideDirection(startX, startY, endX, endY) {
const dy = startY - endY;
const dx = endX - startX;
@@ -3473,6 +3590,13 @@ function cssVarMix(art) {
}
});
}
const revisions = /* @__PURE__ */ new WeakMap();
function positionRevision(art) {
return revisions.get(art) || 0;
}
function advancePosition(art) {
revisions.set(art, positionRevision(art) + 1);
}
function currentTimeMix(art) {
const { $video } = art.template;
def(art, "currentTime", {
@@ -3481,6 +3605,7 @@ function currentTimeMix(art) {
const parsed = Number.parseFloat(time2);
if (Number.isNaN(parsed))
return;
advancePosition(art);
$video.currentTime = clamp(parsed, 0, art.duration);
}
});
@@ -4939,46 +5064,66 @@ function rectMix(art) {
}
});
}
function drawFrame(canvas, video) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const context = canvas.getContext("2d");
if (!context)
throw new TypeError("Canvas 2D context is unavailable");
context.drawImage(video, 0, 0);
}
function captureBlobUrl(canvas, resolve, reject) {
canvas.toBlob((blob) => {
try {
if (!blob)
throw new Error("Unable to encode screenshot blob");
resolve(URL.createObjectURL(blob));
} catch (error2) {
reject(error2);
}
});
}
function screenshotMix(art) {
const {
notice,
template: { $video }
} = art;
const $canvas = createElement("canvas");
def(art, "getDataURL", {
value: () => new Promise((resolve, reject) => {
const { notice, template: { $video } } = art;
const canvas = document.createElement("canvas");
function capture(blob) {
const active2 = captureSource(art);
return new Promise((resolve, reject) => {
const fail = (error2) => {
try {
if (!isClosing(art) && active2())
notice.show = error2;
} catch (noticeError) {
reject(noticeError);
return;
}
reject(error2);
};
try {
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
$canvas.getContext("2d").drawImage($video, 0, 0);
resolve($canvas.toDataURL("image/png"));
} catch (err) {
notice.show = err;
reject(err);
drawFrame(canvas, $video);
if (blob) {
captureBlobUrl(canvas, resolve, fail);
} else {
resolve(canvas.toDataURL("image/png"));
}
} catch (error2) {
fail(error2);
}
})
});
def(art, "getBlobUrl", {
value: () => new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth;
$canvas.height = $video.videoHeight;
$canvas.getContext("2d").drawImage($video, 0, 0);
$canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
});
} catch (err) {
notice.show = err;
reject(err);
}
})
});
});
}
def(art, "getDataURL", { value: () => capture(false) });
def(art, "getBlobUrl", { value: () => capture(true) });
def(art, "screenshot", {
value: async (name) => {
const active2 = captureSource(art);
const dataUri = await art.getDataURL();
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
download(dataUri, `${fileName}.png`);
art.emit("screenshot", dataUri);
if (!isClosing(art) && active2()) {
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`;
if (!isClosing(art) && active2())
download(dataUri, `${fileName}.png`);
if (!isClosing(art) && active2())
art.emit("screenshot", dataUri);
}
return dataUri;
}
});
@@ -5061,6 +5206,48 @@ function listenSource(scope, art, name, callback, once = true) {
});
return guarded;
}
const POSITION_TOLERANCE = 0.05;
function positionRestoration(art, target, active2) {
let expected;
let observedSeek = false;
let corrected = false;
let manual = false;
let revision = positionRevision(art);
const manualPosition = () => manual || positionRevision(art) !== revision;
const seeking = () => !!art.template?.$video?.seeking;
return {
restore() {
if (!active2() || manualPosition())
return;
const previousRevision = positionRevision(art);
art.currentTime = target;
revision = positionRevision(art);
if (revision > previousRevision + 1)
manual = true;
if (!active2())
return;
expected = art.currentTime;
if (!active2() || manualPosition())
return;
observedSeek = seeking();
},
manual() {
manual = true;
},
ready() {
if (!active2() || seeking())
return false;
const missed = expected !== void 0 && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE;
if (!manualPosition() && observedSeek && !corrected && expected !== void 0 && missed) {
corrected = true;
if (!active2())
return false;
art.currentTime = expected;
}
return active2() && !seeking();
}
};
}
function switchSource(art, url, currentTime) {
return new Promise((resolve, reject) => {
if (isClosing(art) || url === art.url) {
@@ -5096,41 +5283,63 @@ function switchSource(art, url, currentTime) {
if (!active2())
return;
const readiness = scope.child();
let canPlay = false;
let resuming = false;
let rateRestored = false;
const position = positionRestoration(art, currentTime, active2);
const resume = async () => {
art.aspectRatio = aspectRatio2;
if (!active2())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active2())
return;
art.notice.show = "";
settle();
};
const resumeWhenReady = () => {
if (!active2() || resuming || !canPlay)
return;
try {
if (art.template?.$video?.seeking)
return;
if (!active2())
return;
if (!rateRestored) {
rateRestored = true;
art.playbackRate = playbackRate2;
}
if (!position.ready() || !active2() || resuming)
return;
resuming = true;
readiness.dispose();
void resume().catch(fail);
} catch (error2) {
fail(error2);
}
};
const handlers = {
"video:error": fail,
"video:loadedmetadata": () => {
if (!active2())
return;
try {
art.currentTime = currentTime;
position.restore();
} catch (error2) {
fail(error2);
}
},
"video:canplay": () => {
if (!active2())
return;
readiness.dispose();
const resume = async () => {
art.playbackRate = playbackRate2;
if (!active2())
return;
art.aspectRatio = aspectRatio2;
if (!active2())
return;
if (playing) {
try {
await art.play();
} catch {
}
}
if (!active2())
return;
art.notice.show = "";
settle();
};
void resume().catch(fail);
}
canPlay = true;
resumeWhenReady();
},
"video:seeked": resumeWhenReady,
"seek": position.manual
};
const capture = scope.child();
const queued = [];
@@ -5155,7 +5364,7 @@ function switchSource(art, url, currentTime) {
}
const dispatch = {};
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name]);
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== "video:seeked");
for (const [name, event] of queued) {
if (!active2() || readiness.closed)
break;
@@ -5188,69 +5397,106 @@ function themeMix(art) {
}
});
}
function thumbnailLayout(option, geometry) {
const { number, column, width, height, scale } = option;
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry;
const columns = Number(column);
const previewWidth = Number(width) * Number(scale) || imageWidth / columns;
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight);
const index = Math.floor(position / (progressWidth / Number(number)));
const row = Math.floor(index / columns);
const cell = index % columns;
const left = position <= previewWidth / 2 ? 0 : position > progressWidth - previewWidth / 2 ? `${progressWidth - previewWidth}px` : `${position - previewWidth / 2}px`;
return {
height: `${previewHeight}px`,
width: `${previewWidth}px`,
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
left
};
}
function thumbnailsMix(art) {
const {
option,
template: { $progress, $video }
} = art;
let timer = null;
let loding = false;
let image = null;
const { option, template: { $progress, $video } } = art;
let scope = getScope(art).child();
let image;
let loading2 = false;
let control;
let hover;
function reset() {
clearTimeout(timer);
timer = null;
loding = false;
image = null;
const previous = scope;
scope = getScope(art).child();
image = void 0;
loading2 = false;
hover = void 0;
previous.dispose();
}
function showThumbnails(posWidth) {
const $thumbnails = art.controls?.thumbnails;
if (!$thumbnails)
function render() {
const current2 = hover;
const currentScope = scope;
if (!current2 || !image)
return;
const { number, column, width, height, scale } = option.thumbnails;
const width2 = width * scale || image.naturalWidth / column;
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight);
const perWidth = $progress.clientWidth / number;
const perIndex = Math.floor(posWidth / perWidth);
const yIndex = Math.ceil(perIndex / column) - 1;
const xIndex = perIndex % column || column - 1;
setStyle($thumbnails, "backgroundImage", `url(${image.src})`);
setStyle($thumbnails, "height", `${height2}px`);
setStyle($thumbnails, "width", `${width2}px`);
setStyle($thumbnails, "backgroundPosition", `-${xIndex * width2}px -${yIndex * height2}px`);
if (posWidth <= width2 / 2) {
setStyle($thumbnails, "left", 0);
} else if (posWidth > $progress.clientWidth - width2 / 2) {
setStyle($thumbnails, "left", `${$progress.clientWidth - width2}px`);
} else {
setStyle($thumbnails, "left", `${posWidth - width2 / 2}px`);
const active2 = () => !isClosing(art) && !currentScope.closed && scope === currentScope && hover === current2 && current2.sourceActive() && art.controls?.thumbnails === current2.element;
if (!active2())
return;
const position = $progress.clientWidth * current2.percentage;
if (!(position > 0 && position < $progress.clientWidth))
return;
const styles = {
backgroundImage: `url(${image.src})`,
...thumbnailLayout(option.thumbnails, {
imageWidth: image.naturalWidth,
videoWidth: $video.videoWidth,
videoHeight: $video.videoHeight,
progressWidth: $progress.clientWidth,
position
})
};
for (const [key, value] of Object.entries(styles)) {
if (!active2())
return;
setStyle(current2.element, key, value);
}
}
art.on("setBar", async (type, percentage, event) => {
const $thumbnails = art.controls?.thumbnails;
eventSubscriptions(art)("setBar", (type, percentage, event) => {
const element = art.controls?.thumbnails;
const { url, scale } = option.thumbnails;
if (!$thumbnails || !url)
if (isClosing(art) || !element || !url || !(type === "hover" || type === "played" && event && isMobile))
return;
const isMobileDragging = type === "played" && event && isMobile;
if (type === "hover" || isMobileDragging) {
if (!image && !loding) {
loding = true;
image = await loadImg(url, scale);
loding = false;
}
if (!image)
if (control !== element) {
const elementScope = entryScope(element);
if (elementScope.closed)
return;
const width = $progress.clientWidth * percentage;
if (width > 0 && width < $progress.clientWidth) {
showThumbnails(width);
}
control = element;
elementScope.add(() => {
if (control === element) {
control = void 0;
reset();
}
});
}
hover = { percentage, element, sourceActive: captureSource(art) };
if (image) {
render();
} else if (!loading2) {
const currentScope = scope;
loading2 = true;
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
if (scope !== currentScope || currentScope.closed)
return;
loading2 = false;
image = loaded;
render();
}).catch((error2) => {
if (scope === currentScope && !currentScope.closed) {
loading2 = false;
console.warn("ArtPlayer thumbnail load failed:", error2);
}
});
}
});
def(art, "thumbnails", {
get() {
return art.option.thumbnails;
},
set(thumbnails) {
if (thumbnails.url && !art.option.isLive) {
get: () => art.option.thumbnails,
set: (thumbnails) => {
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
art.option.thumbnails = thumbnails;
reset();
}
@@ -5319,8 +5565,6 @@ function urlMix(art) {
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;
+28
View File
@@ -0,0 +1,28 @@
export type CaptureMedia = CanvasImageSource & {
videoWidth: number
videoHeight: number
currentTime: number
}
export function drawFrame(canvas: HTMLCanvasElement, video: CaptureMedia): void {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (!context)
throw new TypeError('Canvas 2D context is unavailable')
context.drawImage(video, 0, 0)
}
export function captureBlobUrl(canvas: HTMLCanvasElement, resolve: (url: string) => void, reject: (error: unknown) => void): void {
canvas.toBlob((blob) => {
try {
if (!blob)
throw new Error('Unable to encode screenshot blob')
// The public getBlobUrl caller owns this URL, including after destroy.
resolve(URL.createObjectURL(blob))
}
catch (error) {
reject(error)
}
})
}
@@ -1,4 +1,7 @@
import type { ControlHost } from '../types'
import { entryScope } from '../../component/resources'
import { isClosing } from '../../lifecycle/instance'
import { captureSource } from '../../source/operation'
import { isMobile } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent, setCurrentTime } from './position'
@@ -7,17 +10,30 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
const { $progress } = art.template
const $indicator = $control.querySelector('.art-progress-indicator')
const { on, proxy } = controlEvents(art, $control)
const scope = entryScope($control)
let action = 0
const capture = () => {
const sourceActive = captureSource(art)
return () => !scope.closed && !isClosing(art) && sourceActive()
}
const captureAction = () => {
const revision = ++action
const active = capture()
return () => revision === action && active()
}
if (!isMobile) {
let isDragging = false
let dragging: (() => boolean) | undefined
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
setCurrentTime(art, event, captureAction())
}
})
proxy($progress, 'mousemove', (event) => {
const active = capture()
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
if (active())
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
@@ -25,21 +41,26 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
dragging = event.button === 0 ? captureAction() : undefined
})
on('document:mousemove', (event) => {
if (isDragging) {
const active = dragging
if (active?.()) {
const { second, percentage } = getPosFromEvent(art, event)
if (!active() || dragging !== active)
return
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active() && dragging === active)
art.seek = second
}
else {
dragging = undefined
}
})
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
dragging = undefined
})
}
}
@@ -23,15 +23,21 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
if (!active())
return
if (art.isRotate) {
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
const second = percentage * art.duration
if (!active())
return
art.emit('setBar', 'played', percentage, event)
if (active())
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
if (!active())
return
art.emit('setBar', 'played', percentage, event)
if (active())
art.seek = second
+2 -1
View File
@@ -1,5 +1,6 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { silencePromise } from '../utils'
import { controlEvents } from './resources'
export default function screenshot(option: ControlOption): ControlFactory {
@@ -12,7 +13,7 @@ export default function screenshot(option: ControlOption): ControlFactory {
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
silencePromise(art.screenshot())
})
},
})
+146
View File
@@ -0,0 +1,146 @@
import ResourceScope from '../lifecycle/scope'
function requestImage(url: string, scale: number | undefined, owner: undefined): Promise<HTMLImageElement>
function requestImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined>
function requestImage(url: string, scale: number | undefined, owner: ResourceScope | undefined): Promise<HTMLImageElement | undefined> {
return new Promise((resolve, reject) => {
const request = owner ? owner.child() : new ResourceScope()
let complete = false
let releaseBlob = () => {}
function fail(error: unknown): void {
if (complete)
return
complete = true
try {
request.dispose()
}
catch (cleanupError) {
console.warn('Additional image cleanup failure:', cleanupError)
}
try {
releaseBlob()
}
catch (cleanupError) {
console.warn('Additional image cleanup failure:', cleanupError)
}
reject(error)
}
function finish(image: HTMLImageElement): void {
if (complete || request.closed)
return
complete = true
try {
request.dispose()
resolve(image)
}
catch (error) {
try {
releaseBlob()
}
finally {
reject(error)
}
}
}
request.add(() => {
if (!complete) {
complete = true
try {
releaseBlob()
}
finally {
resolve(undefined)
}
}
})
if (request.closed)
return
function watch(image: HTMLImageElement, loaded: () => void): void {
image.onload = () => {
if (!complete && !request.closed) {
try {
loaded()
}
catch (error) {
fail(error)
}
}
}
image.onerror = () => fail(new Error(`Image load failed: ${url}`))
request.add(() => {
image.onload = null
image.onerror = null
if (!complete)
image.removeAttribute('src')
})
}
try {
const image = new Image()
watch(image, () => {
if (!scale || scale === 1) {
finish(image)
return
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = image.width * scale
canvas.height = image.height * scale
if (!context)
throw new TypeError('Canvas 2D context is unavailable')
if (complete || request.closed)
return
context.drawImage(image, 0, 0, canvas.width, canvas.height)
if (complete || request.closed)
return
canvas.toBlob((blob) => {
if (complete || request.closed)
return
try {
if (!blob)
throw new Error('Unable to encode thumbnail image')
const blobUrl = URL.createObjectURL(blob)
let revoked = false
const revoke = (): undefined => {
if (!revoked) {
revoked = true
URL.revokeObjectURL(blobUrl)
}
}
releaseBlob = owner ? owner.add(revoke) : revoke
if (complete || request.closed) {
releaseBlob()
return
}
const scaled = new Image()
watch(scaled, () => finish(scaled))
if (!request.closed)
scaled.src = blobUrl
}
catch (error) {
fail(error)
}
})
})
if (!request.closed)
image.src = url
}
catch (error) {
fail(error)
}
})
}
// Public callers own a scaled result's image.src object URL.
export function loadImg(url: string, scale?: number): Promise<HTMLImageElement> {
return requestImage(url, scale, undefined)
}
// Internal previews own their result until configuration replacement or destroy.
export function loadThumbnailImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined> {
return requestImage(url, scale, owner)
}
@@ -0,0 +1,9 @@
const revisions = new WeakMap<object, number>()
export function positionRevision(art: object): number {
return revisions.get(art) || 0
}
export function advancePosition(art: object): void {
revisions.set(art, positionRevision(art) + 1)
}
@@ -1,5 +1,6 @@
import type { MediaHost } from '../media/hosts'
import type { Position } from '../media/playback'
import { advancePosition } from '../media/position-revision'
import { clamp, def } from '../utils'
export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'currentTime'>> & Pick<Position, 'duration'>): asserts art is typeof art & Position {
@@ -11,6 +12,7 @@ export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'cu
const parsed = Number.parseFloat(time as string)
if (Number.isNaN(parsed))
return
advancePosition(art)
$video.currentTime = clamp(parsed, 0, art.duration)
},
})
@@ -1,54 +0,0 @@
import { createElement, def, download, secondToTime } from '../utils'
export default function screenshotMix(art) {
const {
notice,
template: { $video },
} = art
const $canvas = createElement('canvas')
def(art, 'getDataURL', {
value: () =>
new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth
$canvas.height = $video.videoHeight
$canvas.getContext('2d').drawImage($video, 0, 0)
resolve($canvas.toDataURL('image/png'))
}
catch (err) {
notice.show = err
reject(err)
}
}),
})
def(art, 'getBlobUrl', {
value: () =>
new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth
$canvas.height = $video.videoHeight
$canvas.getContext('2d').drawImage($video, 0, 0)
$canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob))
})
}
catch (err) {
notice.show = err
reject(err)
}
}),
})
def(art, 'screenshot', {
value: async (name) => {
const dataUri = await art.getDataURL()
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
download(dataUri, `${fileName}.png`)
art.emit('screenshot', dataUri)
return dataUri
},
})
}
@@ -0,0 +1,65 @@
import type { CaptureMedia } from '../capture/frame'
import { captureBlobUrl, drawFrame } from '../capture/frame'
import { isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { def, download, secondToTime } from '../utils'
export interface ScreenshotHost {
template: { $video: CaptureMedia }
notice: { set show(value: unknown) }
getDataURL: () => Promise<string>
emit: (name: 'screenshot', dataUri: string) => unknown
}
export default function screenshotMix(art: ScreenshotHost): void {
const { notice, template: { $video } } = art
const canvas = document.createElement('canvas')
function capture(blob: boolean): Promise<string> {
const active = captureSource(art)
// Draw synchronously as before; awaiting must not capture a later frame.
return new Promise<string>((resolve, reject) => {
const fail = (error: unknown) => {
try {
if (!isClosing(art) && active())
notice.show = error
}
catch (noticeError) {
reject(noticeError)
return
}
reject(error)
}
try {
drawFrame(canvas, $video)
if (blob) {
// Handle failures inside toBlob as well as synchronous canvas errors.
captureBlobUrl(canvas, resolve, fail)
}
else {
resolve(canvas.toDataURL('image/png'))
}
}
catch (error) {
fail(error)
}
})
}
def(art, 'getDataURL', { value: () => capture(false) })
def(art, 'getBlobUrl', { value: () => capture(true) })
def(art, 'screenshot', {
value: async (name?: string): Promise<string> => {
const active = captureSource(art)
const dataUri = await art.getDataURL()
if (!isClosing(art) && active()) {
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
if (!isClosing(art) && active())
download(dataUri, `${fileName}.png`)
if (!isClosing(art) && active())
art.emit('screenshot', dataUri)
}
return dataUri
},
})
}
@@ -1,84 +0,0 @@
import { def, isMobile, loadImg, setStyle } from '../utils'
export default function thumbnailsMix(art) {
const {
option,
template: { $progress, $video },
} = art
let timer = null
let loding = false
let image = null
function reset() {
clearTimeout(timer)
timer = null
loding = false
image = null
}
function showThumbnails(posWidth) {
const $thumbnails = art.controls?.thumbnails
if (!$thumbnails)
return
const { number, column, width, height, scale } = option.thumbnails
const width2 = width * scale || image.naturalWidth / column
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight)
const perWidth = $progress.clientWidth / number
const perIndex = Math.floor(posWidth / perWidth)
const yIndex = Math.ceil(perIndex / column) - 1
const xIndex = perIndex % column || column - 1
setStyle($thumbnails, 'backgroundImage', `url(${image.src})`)
setStyle($thumbnails, 'height', `${height2}px`)
setStyle($thumbnails, 'width', `${width2}px`)
setStyle($thumbnails, 'backgroundPosition', `-${xIndex * width2}px -${yIndex * height2}px`)
if (posWidth <= width2 / 2) {
setStyle($thumbnails, 'left', 0)
}
else if (posWidth > $progress.clientWidth - width2 / 2) {
setStyle($thumbnails, 'left', `${$progress.clientWidth - width2}px`)
}
else {
setStyle($thumbnails, 'left', `${posWidth - width2 / 2}px`)
}
}
art.on('setBar', async (type, percentage, event) => {
const $thumbnails = art.controls?.thumbnails
const { url, scale } = option.thumbnails
if (!$thumbnails || !url)
return
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'hover' || isMobileDragging) {
if (!image && !loding) {
loding = true
image = await loadImg(url, scale)
loding = false
}
if (!image)
return
const width = $progress.clientWidth * percentage
if (width > 0 && width < $progress.clientWidth) {
showThumbnails(width)
}
}
})
def(art, 'thumbnails', {
get() {
return art.option.thumbnails
},
set(thumbnails) {
if (thumbnails.url && !art.option.isLive) {
art.option.thumbnails = thumbnails
reset()
}
},
})
}
@@ -0,0 +1,115 @@
import type { Thumbnails } from '../../types/option'
import type { SubscriptionHost } from '../component/resources'
import { entryScope } from '../component/resources'
import { eventSubscriptions } from '../events/subscriptions'
import { loadThumbnailImage } from '../image/load'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { thumbnailLayout } from '../thumbnails/layout'
import { def, isMobile, setStyle } from '../utils'
interface ThumbnailEvents {
setBar: [type: string, percentage: number, event?: Event]
}
export interface ThumbnailsHost extends SubscriptionHost<ThumbnailEvents> {
option: { thumbnails: Thumbnails, isLive: boolean }
template: { $progress: HTMLElement, $video: { videoWidth: number, videoHeight: number } }
controls?: { thumbnails?: HTMLDivElement }
}
export default function thumbnailsMix(art: ThumbnailsHost): void {
const { option, template: { $progress, $video } } = art
let scope = getScope(art).child()
let image: HTMLImageElement | undefined
let loading = false
let control: HTMLDivElement | undefined
let hover: { percentage: number, element: HTMLDivElement, sourceActive: () => boolean } | undefined
function reset(): void {
const previous = scope
scope = getScope(art).child()
image = undefined
loading = false
hover = undefined
previous.dispose()
}
function render(): void {
const current = hover
const currentScope = scope
if (!current || !image)
return
const active = () => !isClosing(art) && !currentScope.closed && scope === currentScope
&& hover === current && current.sourceActive() && art.controls?.thumbnails === current.element
if (!active())
return
const position = $progress.clientWidth * current.percentage
if (!(position > 0 && position < $progress.clientWidth))
return
const styles = {
backgroundImage: `url(${image.src})`,
...thumbnailLayout(option.thumbnails, {
imageWidth: image.naturalWidth,
videoWidth: $video.videoWidth,
videoHeight: $video.videoHeight,
progressWidth: $progress.clientWidth,
position,
}),
}
for (const [key, value] of Object.entries(styles)) {
if (!active())
return
setStyle(current.element, key, value)
}
}
eventSubscriptions<ThumbnailEvents>(art)('setBar', (type, percentage, event) => {
const element = art.controls?.thumbnails
const { url, scale } = option.thumbnails
if (isClosing(art) || !element || !url || !(type === 'hover' || (type === 'played' && event && isMobile)))
return
if (control !== element) {
const elementScope = entryScope(element)
if (elementScope.closed)
return
control = element
elementScope.add(() => {
if (control === element) {
control = undefined
reset()
}
})
}
hover = { percentage, element, sourceActive: captureSource(art) }
if (image) {
render()
}
else if (!loading) {
const currentScope = scope
loading = true
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
if (scope !== currentScope || currentScope.closed)
return
loading = false
image = loaded
render()
}).catch((error: unknown) => {
if (scope === currentScope && !currentScope.closed) {
loading = false
console.warn('ArtPlayer thumbnail load failed:', error)
}
})
}
})
def(art, 'thumbnails', {
get: () => art.option.thumbnails,
set: (thumbnails: Thumbnails) => {
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
art.option.thumbnails = thumbnails
reset()
}
},
})
}
+1 -2
View File
@@ -50,8 +50,7 @@ export default function urlMix<Media extends SourceMedia, Host extends UrlHost<M
void Promise.resolve(result).catch(error => failSource(operation, error))
}
else {
if (oldUrl)
URL.revokeObjectURL(oldUrl)
// Media URLs are supplied by callers or adapters, which own revocation.
if (!operation.active())
return
$video.src = newUrl
@@ -0,0 +1,49 @@
import type { SwitchHost } from './types'
import { positionRevision } from '../media/position-revision'
// Native media clocks may round within a frame. Larger misses need one correction.
const POSITION_TOLERANCE = 0.05
export function positionRestoration(art: Pick<SwitchHost, 'currentTime' | 'template'>, target: number, active: () => boolean) {
let expected: number | undefined
let observedSeek = false
let corrected = false
let manual = false
let revision = positionRevision(art)
const manualPosition = () => manual || positionRevision(art) !== revision
const seeking = () => !!art.template?.$video?.seeking
return {
restore(): void {
if (!active() || manualPosition())
return
const previousRevision = positionRevision(art)
art.currentTime = target
revision = positionRevision(art)
// Nested public writes supersede the restoration's own setter call.
if (revision > previousRevision + 1)
manual = true
if (!active())
return
// Keep the actual clamped value accepted by the player setter.
expected = art.currentTime
if (!active() || manualPosition())
return
observedSeek = seeking()
},
manual(): void {
manual = true
},
ready(): boolean {
if (!active() || seeking())
return false
const missed = expected !== undefined && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE
if (!manualPosition() && observedSeek && !corrected && expected !== undefined && missed) {
corrected = true
if (!active())
return false
art.currentTime = expected
}
return active() && !seeking()
},
}
}
+50 -26
View File
@@ -2,6 +2,7 @@ import type { SourceEvent, SourceListener, SwitchHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { listenSource } from './listen'
import { assignUrl, beginSource } from './operation'
import { positionRestoration } from './restore-position'
export function switchSource(art: SwitchHost, url: string, currentTime: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
@@ -39,44 +40,67 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
return
const readiness = scope.child()
let canPlay = false
let resuming = false
let rateRestored = false
const position = positionRestoration(art, currentTime, active)
const resume = async () => {
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()
}
const resumeWhenReady = () => {
if (!active() || resuming || !canPlay)
return
try {
// canplay can arrive before the metadata restoration seek has settled.
if (art.template?.$video?.seeking)
return
if (!active())
return
if (!rateRestored) {
rateRestored = true
art.playbackRate = playbackRate
}
if (!position.ready() || !active() || resuming)
return
resuming = true
readiness.dispose()
void resume().catch(fail)
}
catch (error) {
fail(error)
}
}
const handlers = {
'video:error': fail,
'video:loadedmetadata': () => {
if (!active())
return
try {
art.currentTime = currentTime
position.restore()
}
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)
canPlay = true
resumeWhenReady()
},
'video:seeked': resumeWhenReady,
'seek': position.manual,
}
const capture = scope.child()
const queued: [SourceEvent, unknown][] = []
@@ -101,7 +125,7 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
}
const dispatch = {} as Record<SourceEvent, SourceListener>
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name])
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== 'video:seeked')
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break
+2 -1
View File
@@ -1,4 +1,4 @@
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay'
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay' | 'video:seeked' | 'seek'
export type SourceListener = (event?: unknown) => void
export interface SourceEvents {
@@ -28,6 +28,7 @@ export interface UrlHost<Media extends SourceMedia, Host> extends UrlTarget, Sou
}
export interface SwitchHost extends UrlTarget, SourceEvents {
template?: { $video?: { readonly seeking?: boolean } }
currentTime: number
readonly playing: boolean
aspectRatio: string
@@ -0,0 +1,32 @@
import type { Thumbnails } from '../../types/option'
export interface ThumbnailGeometry {
imageWidth: number
videoWidth: number
videoHeight: number
progressWidth: number
position: number
}
export function thumbnailLayout(option: Thumbnails, geometry: ThumbnailGeometry) {
const { number, column, width, height, scale } = option
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry
const columns = Number(column)
const previewWidth = Number(width) * Number(scale) || imageWidth / columns
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight)
const index = Math.floor(position / (progressWidth / Number(number)))
// Match the zero-based cells produced by artplayer-tool-thumbnail.
const row = Math.floor(index / columns)
const cell = index % columns
const left = position <= previewWidth / 2
? 0
: position > progressWidth - previewWidth / 2
? `${progressWidth - previewWidth}px`
: `${position - previewWidth / 2}px`
return {
height: `${previewHeight}px`,
width: `${previewWidth}px`,
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
left,
}
}
+1 -41
View File
@@ -1,5 +1,6 @@
import { isMobile } from './compatibility'
export { loadImg } from '../image/load'
export { setStyleText } from '../style/inject'
/** @param {string} selector @param {ParentNode} parent */
@@ -112,47 +113,6 @@ export function getRect(el) {
return el.getBoundingClientRect()
}
export function loadImg(url, scale) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = function () {
if (!scale || scale === 1) {
resolve(img)
}
else {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width * scale
canvas.height = img.height * scale
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => {
const blobUrl = URL.createObjectURL(blob)
const scaledImg = new Image()
scaledImg.onload = function () {
resolve(scaledImg)
}
scaledImg.onerror = function () {
URL.revokeObjectURL(blobUrl)
reject(new Error(`Image load failed: ${url}`))
}
scaledImg.src = blobUrl
})
}
}
img.onerror = function () {
reject(new Error(`Image load failed: ${url}`))
}
img.src = url
})
}
export function getComposedPath(event) {
if (event.composedPath)
return event.composedPath()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,597 @@
{
"task": "CORE-19",
"status": "partial",
"date": "2026-09-11",
"node": "v24.21.0",
"parentCommit": "98a18000321500f92f228be7b7edff4bda948a56",
"scope": "Capture and thumbnails source migration. Progress/quality and borrowed media URL ownership review, final builds and installed-package exit remain outstanding.",
"fingerprints": {
"package.json": "a288e650a0a65e5c036b8504e8f01f4039e609798066741171045a1f1da4262f",
"yarn.lock": "80c78af925697e0ae55fae48a6a0761c60d231737a389324c82b6efef437f7c4",
"packages/artplayer/ARCHITECTURE.md": "f500068cbf7a6a3259887d4ae21e96da61753558841ab6510904148270b08d58",
"packages/artplayer/src/player/screenshotMix.ts": "fb16737d3bb2735ef0d0aa1ebe536cc3418c27d92e1b66ad4b34e81c165773b2",
"packages/artplayer/src/capture/frame.ts": "eb25d81ec2d40a239103c63cfd80333705251291f2c2da77c740b4d413ea9cd9",
"packages/artplayer/src/control/screenshot.ts": "67be1306991861cb8bb0adec2b58a8468211459f9337895e611972dbb71a6ef5",
"packages/artplayer/src/player/thumbnailsMix.ts": "6c097d7d00779a6d0d3d65cf9ffd126624a48c9bdb98396c84c39f4de4bdf712",
"packages/artplayer/src/thumbnails/layout.ts": "6630439242a880f53fcb374b7b15c7f486405aab8a0fad651b137f02971eeb59",
"packages/artplayer/src/image/load.ts": "b4f1517243249d5fb017b89f993660e8b148906bbf28d08fe5c9f6d3a41e1e88",
"packages/artplayer/src/utils/dom.js": "fd6148ff85ee97607de54652f0f1e4cb5158f628a2d0951f1946a21f2b4e0008",
"test/screenshot.test.js": "740e879c9159c75e62240d37e3a327facb228f54b0b531e9da375c1f184abb24",
"test/thumbnails.test.js": "367882200a3898cce718379f95bb80706d9d5ee06e8d8e3624acada118ccc41c",
"test/types/screenshot.ts": "f329d9b897e9e9e4190ebc6ededfe55ccd0bebf796683615af103c19cf6b3947",
"test/types/thumbnails.ts": "2b01c655ad30be3e9ef6732d13f85dbdf32cf71c734d63537bdb0f82ac69350c",
"test/browser/screenshot.spec.js": "a2b49fbd8616533fc351892c1f6687d588d2bc9350fda4be5475decb97d77a0e",
"test/browser/thumbnails.spec.js": "bc00e6fe6eb6987bd36193f26c69e2377249b53f41dfc1474dd44d6e833a2c09",
"test/browser/media/thumbnail-grid.svg": "bb3f1ec9895d37def77ca4b192d3cd73577a2668558cc99de1588f511d65d3e6",
"test/browser/server.mjs": "2b36e65ed9248f334b6dfb2d54a22cf5a26b3b531d7eb1f93392e38bb14d10cc"
},
"before": {
"file": "refactor/.cache/core19-thumbnails-before.log",
"sha256": "34f9d1cade73482bf90d05775db461a0dbf18b47dfba755127f52f6a873bf531",
"tests": 7,
"passed": 2,
"failed": 5,
"note": "Initial baseline used the old crop expectations; after generator review, final tests explicitly validate corrected zero-based grid cells."
},
"ci": {
"tests": 401,
"unit": 372,
"engineering": 4,
"baseline": 25,
"screenshotTests": 11,
"thumbnailTests": 18,
"productionTs": 205,
"publicTypesUnchanged": true,
"file": "refactor/.cache/core19-thumbnails-ci.log",
"sha256": "c47ada46288646b0a817504b353be4bd3620490af051e986329561f6fa523353"
},
"browser": {
"format": "Source-built UMD; not an installed tarball",
"tests": 63,
"screenshot": 39,
"thumbnail": 24,
"retries": 0,
"skipped": 0,
"unexpectedPageErrors": 0,
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"knownRejections": [
{
"browser": "chromium",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "chromium",
"core": "candidate",
"rejections": []
},
{
"browser": "firefox",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "firefox",
"core": "candidate",
"rejections": []
},
{
"browser": "webkit",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "webkit",
"core": "candidate",
"rejections": []
}
],
"artifacts": {
"/candidate/artplayer.js": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "a5a5f9283af11841dc5f6d234012a16738f68d4549c704ef9bb0da93fe2a8493",
"bytes": 323694
},
"/candidate/artplayer-plugin-chapter.js": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer-plugin-chapter\\src\\index.ts"
},
"sha256": "3e8d0b7e7a90b5dbadf4cb108fd770d294f8a8a92ea089c41d0766c98bbc8230",
"bytes": 8433
},
"/published/artplayer.js": {
"source": {
"kind": "npm-release",
"version": "5.4.0",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"member": "package/dist/artplayer.js"
},
"sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f",
"bytes": 134037
},
"/published/artplayer-plugin-chapter.js": {
"source": {
"kind": "npm-release",
"version": "1.1.0",
"integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
"member": "package/dist/artplayer-plugin-chapter.js"
},
"sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
"bytes": 4973
}
},
"cases": [
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-45f5a568a618398c2003",
"file": "screenshot.spec.js",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-df9c2bbb8d025d633416",
"file": "screenshot.spec.js",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-0a6dee5b9d177600d5a8",
"file": "screenshot.spec.js",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-8f64836f1c03f8d717e4",
"file": "screenshot.spec.js",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-b4214300e6ac634d4655",
"file": "screenshot.spec.js",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-792a2dbdf0f914f96ecd",
"file": "screenshot.spec.js",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-22a5ed94932418b4b372",
"file": "screenshot.spec.js",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-a632ad23423ba0506c16",
"file": "screenshot.spec.js",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-77348d8cffa16737f1dd",
"file": "screenshot.spec.js",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-623fad4c93a929006ed5",
"file": "screenshot.spec.js",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-9e300009a29a3180767b",
"file": "screenshot.spec.js",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-4bd04bd342c6bdf7a25b",
"file": "screenshot.spec.js",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-e0a456d9599734bf4dcf",
"file": "screenshot.spec.js",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-103e07624403b62f7105",
"file": "screenshot.spec.js",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-d07a101b915332333405",
"file": "screenshot.spec.js",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-f9ac6b159f7b68e08d1f",
"file": "screenshot.spec.js",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-8f9455d971851037e69b",
"file": "screenshot.spec.js",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-2ee0a5945c50d9e45f02",
"file": "screenshot.spec.js",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-f19f61a7a1c98a24d88c",
"file": "screenshot.spec.js",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-65dd2ec52a2eb98f201a",
"file": "screenshot.spec.js",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-6b93df0891d1e9824971",
"file": "screenshot.spec.js",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-50103daef0af74cb00cc",
"file": "screenshot.spec.js",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-26c20a0c8613a9c27c04",
"file": "screenshot.spec.js",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-95ac367f79cfe12d6100",
"file": "screenshot.spec.js",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-0fa775711c5f64752304",
"file": "screenshot.spec.js",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-ee450bc7384fa2bf264f",
"file": "screenshot.spec.js",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-c9c9cf5e121268cd3c46",
"file": "screenshot.spec.js",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-ca43428c5f3aaaeb4a6b",
"file": "screenshot.spec.js",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-9f3df52110132996acf1",
"file": "screenshot.spec.js",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-d6a14bd3846cace85be0",
"file": "screenshot.spec.js",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-f815992e0057af92552e",
"file": "screenshot.spec.js",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-c1936aa08e32e5571887",
"file": "screenshot.spec.js",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-e4a6e8d9ad57f6261d57",
"file": "screenshot.spec.js",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-64493da78eb55d39fce4",
"file": "screenshot.spec.js",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-0658f99f3c9a04fd2918",
"file": "screenshot.spec.js",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-43884fe589ffc5240a6c",
"file": "screenshot.spec.js",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-de63a41620e9a6fbb8a6",
"file": "screenshot.spec.js",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-9c57f1479f12611e3650",
"file": "screenshot.spec.js",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-db263e0db17b09f39961",
"file": "screenshot.spec.js",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-0b84034df4da7a0602cb",
"file": "thumbnails.spec.js",
"title": "published: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-27b7b29e95483cbd0627",
"file": "thumbnails.spec.js",
"title": "published: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-c501a3337d7f3459c0bd",
"file": "thumbnails.spec.js",
"title": "published: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-7590e561a95171418094",
"file": "thumbnails.spec.js",
"title": "candidate: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-ef943cb95bbec2be8e9f",
"file": "thumbnails.spec.js",
"title": "candidate: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-35d168c9c28975e08fe1",
"file": "thumbnails.spec.js",
"title": "candidate: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-15f9f09a8e027b4f1453",
"file": "thumbnails.spec.js",
"title": "candidate: removing thumbnails revokes the scaled image and a replacement control can reload",
"status": "passed"
},
{
"browser": "chromium",
"id": "c863e7942d336f9fc9b4-7ac5238b9dd76e5bd284",
"file": "thumbnails.spec.js",
"title": "candidate: image failure permits a retry through the same thumbnail configuration",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-cbdca6544e577990fbe6",
"file": "thumbnails.spec.js",
"title": "published: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-ae0fe968cf56f04deadf",
"file": "thumbnails.spec.js",
"title": "published: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-3ea053475afbb979f768",
"file": "thumbnails.spec.js",
"title": "published: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-e717f553b51ae759828d",
"file": "thumbnails.spec.js",
"title": "candidate: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-36518c4875e5c6c5eb4d",
"file": "thumbnails.spec.js",
"title": "candidate: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-e4dbbda8c6e5556ff597",
"file": "thumbnails.spec.js",
"title": "candidate: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-3aa635296202313cceb6",
"file": "thumbnails.spec.js",
"title": "candidate: removing thumbnails revokes the scaled image and a replacement control can reload",
"status": "passed"
},
{
"browser": "firefox",
"id": "c863e7942d336f9fc9b4-7889e72271358857e901",
"file": "thumbnails.spec.js",
"title": "candidate: image failure permits a retry through the same thumbnail configuration",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-80303351d5af14d7bcba",
"file": "thumbnails.spec.js",
"title": "published: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-6303b616b2bb5f58f229",
"file": "thumbnails.spec.js",
"title": "published: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-b18b5d8413c96e250a43",
"file": "thumbnails.spec.js",
"title": "published: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-cdb048780d3502ba5f34",
"file": "thumbnails.spec.js",
"title": "candidate: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-3cd51c32262f07831a10",
"file": "thumbnails.spec.js",
"title": "candidate: scaled internal thumbnails and public image results have distinct Blob URL owners",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-bdd8944a779d3e0838d2",
"file": "thumbnails.spec.js",
"title": "candidate: a delayed image follows the latest hover rather than the first request position",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-3ea1350b66dcf3b9e182",
"file": "thumbnails.spec.js",
"title": "candidate: removing thumbnails revokes the scaled image and a replacement control can reload",
"status": "passed"
},
{
"browser": "webkit",
"id": "c863e7942d336f9fc9b4-7e155f3c5195944a8163",
"file": "thumbnails.spec.js",
"title": "candidate: image failure permits a retry through the same thumbnail configuration",
"status": "passed"
}
],
"file": "refactor/.cache/core19-capture-thumbnail-browser-final.json",
"sha256": "78fa8caf62f1acadeec505c96cea3d8b082f999f6cad99e2182938046525b1b5"
},
"earlierFailure": {
"file": "refactor/.cache/core19-thumbnails-browser.json",
"sha256": "4f7be130c6ed4bdd63f447769b18f777eb6e09f2b094d5a283d14093a35582e9",
"passed": 62,
"failed": 1,
"description": "Firefox retry case using a malformed successful response failed. Fixture now explicitly uses a no-store transient HTTP 503, with two network attempts verified. No timeout increase or broad error suppression."
},
"remaining": [
"Progress callback/drag source lifetime review",
"Quality boundaries",
"Borrowed media URL ownership review",
"Build, installed tarball and full UMD/legacy browser exit",
"Final risk closure, task status and independent local commit"
]
}
+371
View File
@@ -0,0 +1,371 @@
{
"task": "CORE-19",
"status": "partial",
"date": "2026-09-11",
"parentCommit": "98a18000321500f92f228be7b7edff4bda948a56",
"node": "v24.21.0",
"scope": "Screenshot source migration only. Thumbnail migration, progress/quality review, generated distribution and installed-package validation remain outstanding.",
"fingerprints": {
"packages/artplayer/src/player/screenshotMix.ts": "fb16737d3bb2735ef0d0aa1ebe536cc3418c27d92e1b66ad4b34e81c165773b2",
"packages/artplayer/src/capture/frame.ts": "eb25d81ec2d40a239103c63cfd80333705251291f2c2da77c740b4d413ea9cd9",
"packages/artplayer/src/control/screenshot.ts": "67be1306991861cb8bb0adec2b58a8468211459f9337895e611972dbb71a6ef5",
"test/screenshot.test.js": "740e879c9159c75e62240d37e3a327facb228f54b0b531e9da375c1f184abb24",
"test/types/screenshot.ts": "f329d9b897e9e9e4190ebc6ededfe55ccd0bebf796683615af103c19cf6b3947",
"test/browser/screenshot.spec.js": "a2b49fbd8616533fc351892c1f6687d588d2bc9350fda4be5475decb97d77a0e",
"package.json": "97a0860bb31a4ddae2db96854da1d5531e2d0c14c3b87e487336eb7edb64f2d5",
"yarn.lock": "80c78af925697e0ae55fae48a6a0761c60d231737a389324c82b6efef437f7c4",
"packages/artplayer/ARCHITECTURE.md": "00bcea6fa0424121f98d107448b7a1fd07bab32accbdd2f79cef8f60bafa48ae"
},
"before": {
"log": "refactor/.cache/core19-screenshot-before.log",
"sha256": "0e05e3a1abf0a4039eeaf243e89cfd0b094be41a862f269c823ae18af9bdde94",
"tests": 7,
"passed": 3,
"failed": 4,
"source": "HEAD screenshotMix.js before migration"
},
"ci": {
"tests": 383,
"unit": 354,
"engineering": 4,
"baseline": 25,
"productionTs": 202,
"screenshotTests": 11,
"publicTypesUnchanged": true,
"log": "refactor/.cache/core19-screenshot-ci.log",
"sha256": "84b2b0725cf85bedaebada67dba574af47a7d959a6dcc960183d789577bc9fa6"
},
"browser": {
"format": "source-built UMD, not installed tarball",
"tests": 39,
"retries": 0,
"skipped": 0,
"unexpectedPageErrors": 0,
"versions": {
"chromium": "153.0.8010.12",
"firefox": "155.0",
"webkit": "26.6"
},
"knownRejections": [
{
"browser": "chromium",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "chromium",
"core": "candidate",
"rejections": []
},
{
"browser": "firefox",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "firefox",
"core": "candidate",
"rejections": []
},
{
"browser": "webkit",
"core": "published",
"rejections": [
"controlled screenshot draw failure"
]
},
{
"browser": "webkit",
"core": "candidate",
"rejections": []
}
],
"artifacts": {
"/candidate/artplayer.js": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer\\src\\index.js"
},
"sha256": "ef9fadfd897b2062d3f5661a0bb5640bf46ead4e3cae4ef6147114fe51250745",
"bytes": 319634
},
"/candidate/artplayer-plugin-chapter.js": {
"source": {
"kind": "workspace-build",
"entry": "packages\\artplayer-plugin-chapter\\src\\index.ts"
},
"sha256": "3e8d0b7e7a90b5dbadf4cb108fd770d294f8a8a92ea089c41d0766c98bbc8230",
"bytes": 8433
},
"/published/artplayer.js": {
"source": {
"kind": "npm-release",
"version": "5.4.0",
"integrity": "sha512-2B+plbx8N2yNsjK4nJU3+EOG8TULm1LRZk/QPkWRAMEX2Ee/MSnZG/WJYz8kcoZxZuLKcQ3uXifqLuPxZOH29A==",
"member": "package/dist/artplayer.js"
},
"sha256": "f1db1fa3406aa6d41baa7c554749429a8e79f99d56a794bde23345eb9c07573f",
"bytes": 134037
},
"/published/artplayer-plugin-chapter.js": {
"source": {
"kind": "npm-release",
"version": "1.1.0",
"integrity": "sha512-ji66Lr2K2S+OjFMuMby3yq+pefWlOC51Sbpop/DIr2N6w3F0smrF+YZbshN1Dy8/Tx1TYpT7N6WkkZWy5Jix1w==",
"member": "package/dist/artplayer-plugin-chapter.js"
},
"sha256": "c2aaee11bf1db765433b01d9f8dd3978e41fa4d39655db00ebd03fc0be3e35e0",
"bytes": 4973
}
},
"cases": [
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-45f5a568a618398c2003",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-df9c2bbb8d025d633416",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-0a6dee5b9d177600d5a8",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-8f64836f1c03f8d717e4",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-b4214300e6ac634d4655",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-792a2dbdf0f914f96ecd",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-22a5ed94932418b4b372",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-a632ad23423ba0506c16",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-77348d8cffa16737f1dd",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-623fad4c93a929006ed5",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-9e300009a29a3180767b",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-4bd04bd342c6bdf7a25b",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "chromium",
"id": "b6aa675bc40ab83f8367-e0a456d9599734bf4dcf",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-103e07624403b62f7105",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-d07a101b915332333405",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-f9ac6b159f7b68e08d1f",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-8f9455d971851037e69b",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-2ee0a5945c50d9e45f02",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-f19f61a7a1c98a24d88c",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-65dd2ec52a2eb98f201a",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-6b93df0891d1e9824971",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-50103daef0af74cb00cc",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-26c20a0c8613a9c27c04",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-95ac367f79cfe12d6100",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-0fa775711c5f64752304",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "firefox",
"id": "b6aa675bc40ab83f8367-ee450bc7384fa2bf264f",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-c9c9cf5e121268cd3c46",
"title": "published: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-ca43428c5f3aaaeb4a6b",
"title": "published: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-9f3df52110132996acf1",
"title": "published: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-d6a14bd3846cace85be0",
"title": "published: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-f815992e0057af92552e",
"title": "published: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-c1936aa08e32e5571887",
"title": "candidate: captures decoded PNG pixels and consumer-owned Blob URLs",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-e4a6e8d9ad57f6261d57",
"title": "candidate: screenshot preserves download filename, event data and Promise result",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-64493da78eb55d39fce4",
"title": "candidate: actual cross-origin video rejects capture with SecurityError and notice",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-0658f99f3c9a04fd2918",
"title": "candidate: source replacement does not download a stale captured frame",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-43884fe589ffc5240a6c",
"title": "candidate: screenshot toolbar rejection has exact historical error accounting",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-de63a41620e9a6fbb8a6",
"title": "candidate: null toBlob callback rejects and reports without a page error",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-9c57f1479f12611e3650",
"title": "candidate: destroy during screenshot await suppresses the queued download and event",
"status": "passed"
},
{
"browser": "webkit",
"id": "b6aa675bc40ab83f8367-db263e0db17b09f39961",
"title": "candidate: cross-origin screenshot toolbar handles its internal rejected Promise",
"status": "passed"
}
],
"log": "refactor/.cache/core19-screenshot-browser-v2.json",
"sha256": "117aa85284e99d5fd5c2fc030e5ada2f68df4df321702ee7048a6f95badc283d"
},
"remaining": [
"Thumbnail generation, loading errors and internal Blob ownership",
"Quality/progress boundary regression",
"Full CORE-19 type/build/installed tarball browser validation",
"Close registered risks only with final evidence",
"Complete task documentation and separate local commit"
]
}
@@ -0,0 +1,89 @@
# CORE-19 进度、质量、截图与缩略图
## 范围与状态
基线 `98a18000`,分支 `codex/compatible-modernization`。源码和完整任务验收完成;下文分阶段 doing/待验证描述保留历史过程。关联 API-02/03/04/05/08/11/12。
任务开始时,进度与质量已在 CORE-13/17 迁移 TS,本任务复核其计算及边界;截图、缩略图当时仍为 JS。本任务拆出图像捕获和布局计算,明确异步请求与资源归属,保持公共声明、方法描述符、PNG 返回值、截图事件、文件名和缩略图 DOM/CSS。
## 旧实现事实与待验证问题
- screenshotMix 在 Promise executor 内捕获绘制异常,但 toBlob 的异步回调没有异常捕获。空 Blob 或 createObjectURL 异常可能留下 pending Promise;先用受控回调复现。
- getBlobUrl 返回的 URL 交给消费者使用;不能在播放器销毁时自动撤销一个仍被消费者使用的 URL。调用方负责 URL.revokeObjectURL。
- screenshot 等待 getDataURL 后没有检查销毁或换源;需防止晚到截图触发下载/事件,保持已经产生的字符串结果。
- thumbnailsMix 在配置替换后仍接受旧 loadImg 结果;加载失败没有清理 loading 标志。缩放 loadImg 内创建的 Blob URL 无成功释放路径,需明确内部所有权。
- 缩略图历史行列计算需建立固定输入基线并核对生成工具;后续已复现裁剪错误,修复差异见下文,不能作为未声明的布局改动。
## 计划验证
受控 Node 用例覆盖同步绘制、异步失败/空 Blob、并发、销毁和替换。真实浏览器比较发布版/候选版 PNG 内容、截图文件名/事件、跨域失败、缩略图布局与加载顺序;复验质量和进度。严格类型检查、正常构建、实际 tarball 消费和安装产物浏览器检查完成前不标 done。
不新增依赖或更改版本。测试脚本按需接入已有 Node runner。包内 ARCHITECTURE.md 随实施维护。任务完成后独立提交 `refactor(core): CORE-19 type capture and preserve progress lifetimes`;回退该任务提交并按原工具链重建产物。
## 截图阶段实现与实测
- 新增 capture/frame.ts,截图门面迁移到 player/screenshotMix.ts。公共声明不变;绘制仍在调用栈同步完成,避免 await 之后取到不同帧。默认文件名仍读取等待之后的 currentTime,后缀始终追加 .png。
- 异步 null Blob/createObjectURL 失败明确拒绝;同步 toBlob/drawImage 异常仍在返回 Promise 前更新 notice。缺少 2D context 保留 TypeError 类别并提供明确消息。notice setter 自身抛错也变成拒绝。
- 返回 Blob URL 不随播放器销毁撤销,由调用者管理。已捕获结果照常结算,销毁/切源后不下载、不发 screenshot 事件、不覆盖 notice。错误事件监听器的异常仍遵循原 Promise 传播机制。
- 内部截图工具栏用 silencePromise 消费拒绝;公开 screenshot/getDataURL/getBlobUrl 不吞错。发布版工具栏的受控失败每个浏览器产生一次未处理拒绝;用严格 Error 对象身份登记该已知历史错误,不屏蔽其他 pageerror。
- 这是截图阶段的历史记录,随后缩略图已继续迁移;不要把截图阶段证据当成整个 CORE-19 完成。
阶段证据见 baselines/screenshot-partial.json。初始旧代码 7 项中 3 通过、4 失败;候选截图专项扩展到 11 项通过。CI 354 单元 + 4 工程 + 25 基线 = 383 项;202 个生产 TS 严格检查,五组消费者模式通过。源码构建 UMD 的三浏览器截图 39 项通过,无重试、跳过或意外 pageerror;真实跨域使用相同本地服务的不同 hostname,无 CORS 放行。
本阶段不是安装 tarball 验收,没有重建正式 dist/docs 分发产物;对应完整验收仍在本任务中待执行。BASE-LIFE-36/37/38 保持 open,等待任务最终证据。没有新增依赖或修改 yarn.lock,仅将 screenshot.test.js 接入既有 test:unit 脚本。
## 缩略图阶段实施
thumbnailsMix.ts 管理配置代次、缓存、最新悬停位置和控件生命周期;thumbnails/layout.ts 负责纯几何。image/load.ts 复用公开 loadImg 与内部加载,不复制两套缩放逻辑。公开 utils 只继续导出 loadImg,内部 loadThumbnailImage 不加入公开对象。public loadImg 的名称、形参数量、Promise<HTMLImageElement> 和成功 Blob 所有权保留;其内部 onload/onerror 完成后清理。
旧实现首轮 7 项中 2 通过、5 失败。已补到 18 个有效 Node 用例,覆盖配置覆盖、失败重试、控件移除、重复清理、晚到编码、源切换、公开/内部 URL 所有权、空 Blob、构造和 DOM 写入重入。初始基线保留当时的旧裁剪规则断言;进一步核对生成工具后,裁剪回归改为验证正确格子,并用浏览器明确比较旧/新差异。
BASE-DOM-19 的证据来自生成工具 creatScreenshotDate 与真实编号 SVG:生成工具按 `(index % column, floor(index / column))` 排列。旧播放器在 index=column 时选到上一行末格,index=0 时写出双负号 CSS,浏览器保留之前的位置。候选修正这两类显示缺陷;不改参数、DOM、尺寸、缩放、预览边缘定位和 0/1 进度端点处理。
三浏览器缩略图 24 项通过;首次截图/缩略图合并执行 63 项中 Firefox 的重试夹具失败,其余 62 项通过。首次夹具用成功 200 返回错误图像,不能可靠表达临时网络失败;改成明确 no-store 的 503 后,同 URL 第二次请求正常图像在三浏览器通过,不增加等待、不为请求追加 cache-busting 参数。随后还补了缩放 Image 构造中销毁的保护,当前源码需再走整轮检查,最终结果以新的阶段证据为准。
BASE-LIFE-39/40/41 和 BASE-DOM-19 保持 open,等待任务最终安装产物验收。已将 thumbnails.test.js 接入 test:unit;没有依赖或锁文件变化。截图阶段 screenshot-partial.json 保留历史指纹,不能用来代表当前新增图片模块后的全量候选。
在该历史阶段,进度和源 URL 所有权尚待复核;后续实施和实测见下文。
## 截图与缩略图阶段的最终重跑
本阶段实际执行的当前源码检查:`yarn ci:check` 共 401 项(372 单元 + 4 工程 + 25 基线),生产 TS 205 个;公开声明与 HEAD 相比未改动。`yarn test:browser test/browser/thumbnails.spec.js test/browser/screenshot.spec.js` 在 Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6 一次完整通过 63 项,无重试/跳过/意外 pageerror。精确登记了旧截图工具栏每引擎一次受控拒绝;临时 503 是有意网络失败,不宣称控制台没有任何错误输出。
`baselines/capture-thumbnails-partial.json` 固定本阶段源码/测试/夹具/工具链指纹、报告哈希、测试 ID、浏览器版本、候选/发布代码指纹和早期 Firefox 夹具失败。实际候选渲染截图已检查:编号 99 预览在进度右端,符合最后一格与右边缘约束。阶段记录不替代最终安装产物验收,也不关闭 CORE-19。
## 进度、媒体 URL 和清晰度复核
progress/interactions.ts 现在按控件、源、交互动作检查有效性。几何读取和 setBar 回调后重新检查,防止 remove/destroy/切源/新点击重入后继续 seek;旧拖动在切源后停止,新按下可重新开始。position.ts 保留坐标钳制、live 事件和正常 setBar-before-seek 顺序。新增 10 项 progress Node 检查并接入 test:unit。
urlMix.ts 删除对传入旧媒体 URL 的无条件 revokeObjectURL:调用方或 adapter 创建的 URL 由创建方释放。真实 Node Blob fetch 与浏览器换源、销毁后的字节读取对比旧版错误和候选结果。截图、公开 loadImg 与内部 thumbnail/subtitle 的不同资源归属均有独立验证。
质量 selector 增补首个 default、空数组、引用和只写描述符边界。真实点击同时检查源、标签、暂停位置、速率与 customType 参数身份。WebKit 原生事件记录发现首次恢复 seek 接受位置后却落到近零。曾试验等待 seeked 后恢复 rate,以及延后至 canplay 再 seek,均不能单独解决;不能声称 rate 是已证实根因。显式第二次 seek 有效,因此拆出 source/restore-position.ts:等待原生 seek 完成、恢复速率一次、对超过 0.05 秒的偏差最多修正一次,尊重实际钳制目标和用户 seek,并在源失效时停止。第二次也失败时不无限循环,不伪造成功位置。
第一次修正实验还暴露 seeked 监听只消费一次导致 Promise 挂起;监听持续至操作结束后,WebKit 15 项通过。新增受控用例验证第二次 seeked 结算、最多一次修正、用户覆盖、钳制/时钟舍入、getter 重入、同步 seeked 重入只恢复一次。三引擎截图/缩略图/进度/质量/源专项最终源码运行 126 项通过;仍以最终安装产物检查作为任务完成门槛。
原生 Blob 探针同时发现本机 Windows WebKit 26.6 不支持所测 MP4/WebM Blob 样本(错误 4、宽度 0),独立于 ArtPlayer。该引擎仅在此场景证明 HTTP 播放、实际 Blob 赋值和字节所有权,附件标明 decodedBlob=false。BASE-ENV-01 / REL-03 / REVIEW-02 保留真实 Apple 设备验证;不将能力缺口写成通过。Chromium/Firefox 实际解码 Blob。具体矩阵见 environment-matrix.md。
## 最终打包前的兼容补查
run-ECwxyz 安装 UMD 1455 项已通过,legacy 验证期间进一步发现自动修正只识别 seek 事件,会漏掉合法的直接 art.currentTime 写入。补上 media/position-revision.ts,在当前时间 setter 接受合法输入时记录代次;等待期间的直接赋值(包括 metadata 前)和嵌套赋值优先于自动恢复,NaN 仍无操作。专门回归使用真实 currentTimeMix 门面验证两个时序,而不是只验证内部 helper。该补查更改了候选源码,run-ECwxyz 及其 UMD/legacy 结果仅保留为历史证据,不能作为最终发布候选,需重新 CI、打包和安装浏览器验证。
新增原生浏览器重入用例:初次补查在旧候选三个引擎均复现用户写入 4 后又被自动写回 2。代次修复后 Chromium/Firefox 到达 4;WebKit 一次仍结束在约 2,另一次原生 setter 诊断结束在 4。这个重入用例因此把应用契约明确为原生 currentTime setter 序列只有恢复位置和用户新位置两次,禁止第三次写回旧值;完整记录实际时钟/seeked/速率 setter,不把可变原生落点当作应用覆盖的证据,也不宣称任意 native seek 精确度。普通清晰度位置、速率和暂停状态仍由已有独立用例进行真实数值断言,未取消该验证。
采用最终 setter 契约重跑旧候选时,Chromium/Firefox 两项失败、WebKit 一项通过,说明旧缺陷触发依赖原生时序,不能声称 WebKit 每次都会复现。相同用例在新 tarball 的三个引擎均通过。早期数值失败、原生诊断和新旧 setter 契约报告全部保留,并由最终证据记录哈希与各次结果。
run-bmh0dN 首轮完整 UMD 为 1457 通过、1 失败:旧 hotkey 用例尚未按方向键,准备阶段的原生 seek=1 在 Windows WebKit 落到 1.29903,没有页面错误。用例改为直接从真实暂停位置按左右方向键,新增每次请求等于当前位置 ± SEEK_STEP、事件数量为二及真实时间分别增加/减小、seek 完成的断言;保留播放/暂停、音量和 Escape。没有改播放器、放宽原生 seek 容差或延长等待。此 fixture 变更需要最终完整浏览器重跑,原失败报告保留。
## 最终任务验收与交接
固定 Node 24.21.0 / Yarn 1.22.22。CI 421 项(392 单元、4 工程、25 基线)通过,207 个生产 TS 文件(核心 202 + chapter 5)严格检查通过。新增截图/缩略图类型夹具及现有五组消费者模式通过;公开 types 目录未改动。
正式构建后,run-bmh0dN 的实际 tarball 消费通过 27 项运行时和 5 组类型检查。安装后的 UMD、legacy 在 Chromium 153.0.8010.12、Firefox 155.0、WebKit 26.6 各完整通过 1458 项,其中各 129 项截图/缩略图/进度/质量/源专项;无重试、跳过或意外 pageerror。已知旧截图工具栏和旧续播拒绝按精确对象身份计入证据,不屏蔽未知错误。原生桌面 fullscreen、可用引擎 PiP 及其余核心回归继续通过。
[最终证据](../baselines/capture-progress-validation.json) 核对当前全部核心源码、包内文件、UMD/legacy/ESM、docs 副本、22 个语言产物、声明和第三方许可;记录测试指纹、报告哈希、原生 Blob 能力与质量事件。中间失败报告保留,不能冒充最终产物结果。
关闭 BASE-LIFE-36 至 44 和 BASE-DOM-19 共十项。BASE-ENV-01、兼容公共类型精确视图及真实设备/多轮发布复盘继续保留;第二次原生 seek 仍不准确时不作无限修正。新增三个单元文件并接入 test:unit,源/组件文件增补关联回归;没有新增依赖、锁文件或版本变化。
当前 214 项:55 完成、159 待办,下一项 CORE-20 收敛入口、初始化与剩余自有 JS。按任务独立本地 commit 交付,不推送、不发布。回退只撤销本任务源码、测试、文档/风险和构建生成产物,保留 CORE-18 及之前交付。
CORE-20 读取入口:src/index.js、player/index.js、player/optionInit.js、config/index.js、storage.js、utils/dom.js、utils/compatibility.js,以及 player 中 airplayMix/attrMix/cssVarMix/typeMix/themeMix/posterMix.js。当前共 13 个剩余自有 JS;libs/screenfull.js 属第三方来源文件,须按既有 vendor 记录处理,不能随手重写为 TS。先核对初始化顺序、静态接口和描述符,再收敛门面类型。
+6
View File
@@ -55,6 +55,12 @@ Node10 是 TS 模块解析模式名,不代表支持 Node.js 10。现代 es2020
| artplayer-tool-thumbnail / 4.4.0 | 视频抽帧、下载图片/VTT、seek/错误/取消;本地 MP4 | BASE-DIST-01 的入口/声明问题待核实;Canvas/下载/移动待 | PKG-TOOL-THUMB-01/02/04/05/06 |
| artplayer-vitepress / 1.1.0 | ENV-SITE;Monaco/vConsole、示例 CDN/声明、构建静态资源 | 本地站点构建已验;编辑器实际执行/移动 HTML/旧链接待,npm 意图待核实 | SITE-01/02/03/04/05/06、EX-01/02/03 |
## CORE-19 本机 Blob 媒体能力证据
2026-09-11,Windows 上 Playwright WebKit 26.6 的独立原生 video 探针,对本地 MP4 和 WebM 转成 Blob URL 后均返回 MEDIA_ERR_SRC_NOT_SUPPORTED(code 4),videoWidth=0;探针不经过 ArtPlayer。相同检查在 Chromium 153.0.8010.12、Firefox 155.0 可解码。精确样本哈希与引擎版本由 browser-evidence manifest 和 native-blob-capabilities 附件记录。
WebKit 仍验证真实 HTTP 播放、Blob URL 实际赋值、切源/销毁后的字节可读性,borrowed-url-evidence 明确 decodedBlob=false;这不能充当 Blob 视频解码通过。BASE-ENV-01 保留 open,REL-03/REVIEW-02 需在真实 Safari/Apple 设备复验样本、播放、seek、切源和资源归属,区分系统 codec 能力与候选回归。本机结论不外推全部 WebKit 或 Safari。
## 报告和发布判定
已有报告位置见上文及 baselines/;新验证将命令、源码 SHA/候选 tarball integrity、包/核心/SDK/浏览器/OS/设备版本、样本哈希与 codec、能力检测、通过/失败/缺环境、trace 位置和限制写入对应 changes/任务记录,CI 大型 trace/录像留 artifact。缺环境报告写清获得何种设备/服务后执行哪个场景,不能虚构负责人姓名或设备版本。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 214 项,范围 22 个包及工作区/示例。
状态:todo 160 / doing 0 / blocked 0 / done 54 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 159 / doing 0 / blocked 0 / done 55 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -122,7 +122,7 @@
| CORE-16 | artplayer<br>迁移全屏、PiP、mini 与尺寸模式 | CORE-11, CORE-12 | 显示模式服务和原公开属性门面 | 模式互斥/恢复位置、手势权限、移动方向和退出失败通过 | H | done |
| CORE-17 | artplayer<br>迁移键盘、手势、焦点和全局事件 | CORE-13, CORE-16 | events/hotkey 的明确 document 与资源归属 | 跨 document 重绑、触摸/键盘、快捷键及销毁无重复响应;修复 BASE-PERF-01 pending resize debounce 销毁后执行,并验证正常 resize 不变 | H | done |
| CORE-18 | artplayer<br>迁移内置插件和提示模块 | CORE-14, CORE-16, CORE-17 | autoPlayback/autoOrientation/fastForward/lock/miniProgressBar 及 info/notice/loading/mask | 默认启用条件、storage 格式、定时器/动画清理通过;结合 BASE-PERF-01 防止销毁后重新安排 notice timer;接续 BASE-TYPE-07 的 notice.show boolean getter 与历史读类型冲突 | M | done |
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | todo |
| CORE-19 | artplayer<br>整理进度、质量、缩略图和截图 | CORE-10, CORE-13, CORE-16 | 相关 player/control 功能与纯计算分离 | 截图跨域失败、缩略图布局、quality/进度边界保持 | M | done |
| CORE-20 | artplayer<br>收敛核心入口与依赖方向 | CORE-14, CORE-15, CORE-18, CORE-19 | Artplayer 门面、初始化依赖与最终目录 | 所有核心自有源码迁移,公开描述符/静态接口/事件差分通过 | H | todo |
| CORE-21 | artplayer<br>生成核心声明并校验包内容 | CORE-20, ENG-07 | 由 TS 生成的既有入口声明与三种发布产物 | 内部类型不泄漏,旧 JS/TS 消费者与 i18n 通过;逐项完成 CORE-07 留下的 BASE-TYPE-04/05/07 声明方案,旧合法消费者与实际返回分别留证;不能以虚假的交叉返回类型或改变运行时来掩盖冲突,未决项阻止核心声明出口;处理 CORE-10 / BASE-TYPE-08 的 setter-only 属性虚构 getter 声明,并复核 PlaybackControls 精确视图与旧 toggle 声明共存策略 | H | todo |
| CORE-23 | artplayer<br>检查并补齐键盘、焦点与可访问名称 | CORE-13, CORE-14, CORE-17, ENG-05, BASE-04 | 主要控件/设置/模式退出的键盘与焦点回归、名称和字幕可用性检查及必要兼容修正 | 保持旧快捷键和 DOM/CSS 钩子;真实浏览器验证,不以静态属性检查代替交互;处理 BASE-DOM-01 的主要控件 Tab 不可达,保留既有名称与快捷键 | H | todo |
@@ -453,6 +453,7 @@
- CORE-16: [记录](changes/2026-09-11-CORE-16-display.md) [记录](baselines/display-validation.json)
- CORE-17: [记录](changes/2026-09-11-CORE-17-input.md) [记录](baselines/input-validation.json)
- CORE-18: [记录](changes/2026-09-11-CORE-18-builtins-prompts.md) [记录](baselines/builtins-prompts-validation.json)
- CORE-19: [记录](changes/2026-09-11-CORE-19-capture-thumbnails.md) [记录](baselines/capture-progress-validation.json)
- PKG-CHAPTER-01: [记录](changes/2026-09-10-PKG-CHAPTER-01-contract.md) [记录](baselines/chapter-contract.md)
- PKG-CHAPTER-02: [记录](changes/2026-09-10-PKG-CHAPTER-02-tests.md) [记录](baselines/chapter-validation.json)
- PKG-CHAPTER-03: [记录](changes/2026-09-10-PKG-CHAPTER-03-typescript-modules.md) [记录](baselines/chapter-migration-validation.json)
+8
View File
@@ -1,5 +1,13 @@
# 进度与证据
## 当前实施:CORE-19 进度、质量、截图与缩略图完成
截图和缩略图门面迁移 TS,拆清图像捕获、加载与布局。进度交互防止回调重入/跨源拖动继续 seek;质量切换对原生首次 seek 偏差最多修正一次,保持用户覆盖、取消与 Promise 结算。媒体/公开图片 URL 由调用方拥有,内部资源在配置、控件或实例结束时释放。保留公开 API、类型、正常事件顺序和 DOM/CSS;整列与首格缩略图裁剪是明确记录的显示修复。
CI 421 项、207 个生产 TS 严格检查、真实 tarball 27 项运行时及五组类型通过;安装 UMD/legacy 各 1458 项三浏览器全量通过,各含 129 项本任务和关联源专项。源码、包文件、三个分发格式、语言、docs 副本和许可指纹一致。见 [实施记录](changes/2026-09-11-CORE-19-capture-thumbnails.md) 和 [最终证据](baselines/capture-progress-validation.json)。
关闭十项已复现差异。BASE-ENV-01 保留 Windows WebKit 本机 Blob 解码限制,真实 Apple 设备仍交 REL-03/REVIEW-02;公共类型精确视图交 CORE-21。当前 214 项:55 完成、159 待办,下一项 CORE-20。本任务独立本地提交,无推送或发布。
## 当前实施:CORE-18 内置插件与提示模块完成
notice、autoPlayback、fastForward、lock、miniProgressBar、info、loading、mask 已迁移 TS,拆清记录、提示、长按与轮询归属;autoOrientation 组合复验通过。修复重复响应、陈旧回调和清理,同时保持公开结果、存储格式、DOM 与 destroy 观察顺序。
+11
View File
@@ -100,3 +100,14 @@
| BASE-LIFE-33 | resolved / 已复现 | Auto-playback restarts retain obsolete resume clicks, subscriptions and timers | CORE-18 |
| BASE-LIFE-34 | resolved / 已复现 | Lock icon and miniature progress subscriptions outlive their UI or player | CORE-18 |
| BASE-LIFE-35 | resolved / 已复现 | Info repeated initialization retains polling and close callbacks; destroyed reads continue writing | CORE-18 |
| BASE-LIFE-36 | resolved / 已复现 | Screenshot Blob callback failures escape and leave public promises pending | CORE-19 |
| BASE-LIFE-37 | resolved / 已复现 | Screenshot completion can download or emit after destruction or source replacement | CORE-19 |
| BASE-LIFE-38 | resolved / 已复现 | Screenshot toolbar leaves internal capture failures as unhandled rejections | CORE-19 |
| BASE-LIFE-39 | resolved / 已复现 | Thumbnail image completions can overwrite newer configuration or hover state and outlive controls | CORE-19 |
| BASE-LIFE-40 | resolved / 已复现 | Image load failures leave thumbnail loading frozen and canvas callback failures escape public loadImg promises | CORE-19 |
| BASE-LIFE-41 | resolved / 已复现 | Scaled thumbnail Blob URLs survive replacement, control removal and destruction | CORE-19 |
| BASE-DOM-19 | resolved / 已复现 | Thumbnail row boundaries and first-cell return do not match the generated zero-based sprite grid | CORE-19 |
| BASE-LIFE-42 | resolved / 已复现 | Progress callbacks and active drags can seek an obsolete source or removed control | CORE-19 |
| BASE-LIFE-43 | resolved / 已复现 | Replacing a media source revokes a caller-owned Blob URL still in use | CORE-19 |
| BASE-LIFE-44 | resolved / 已复现 | Native quality restoration can settle at a different position after its first seek | CORE-19 |
| BASE-ENV-01 | open / 已复现 | Windows WebKit native Blob video samples lack decoding support; Apple-device validation remains outstanding | REL-03, REVIEW-02 |
+240
View File
@@ -1998,6 +1998,246 @@
"test/browser/prompt-components.spec.js"
],
"resolutionRationale": "Owned prompt/input resources prevent stale callbacks and survive reentry/removal/destruction; normal public API and event ordering are retained. Final installed UMD and legacy each pass 1350 browser cases, including 207 CORE-18 cases, plus actual tarball runtime/type consumers. Physical mobile and release-review gates remain outstanding for the overall refactor."
},
{
"id": "BASE-LIFE-36",
"title": "Screenshot Blob callback failures escape and leave public promises pending",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/screenshot.test.js"
],
"compatibleResolution": "Preserve legal capture results and error propagation, while fixing asynchronous failure and stale lifecycle effects.",
"closureCriteria": "Candidate unit, real-browser and installed-package regressions plus compatibility review pass.",
"workspaceState": "Before migration, null Blob and thrown createObjectURL escape the asynchronous callback. Candidate Node regression passes; final installed-package evidence remains outstanding. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-37",
"title": "Screenshot completion can download or emit after destruction or source replacement",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/screenshot.test.js",
"test/browser/screenshot.spec.js"
],
"compatibleResolution": "Preserve legal capture results and error propagation, while fixing asynchronous failure and stale lifecycle effects.",
"closureCriteria": "Candidate unit, real-browser and installed-package regressions plus compatibility review pass.",
"workspaceState": "Before migration, screenshot await resumes download and event effects after disposal or source replacement. Candidate guards preserve the captured string result while suppressing stale effects. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-38",
"title": "Screenshot toolbar leaves internal capture failures as unhandled rejections",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/browser/screenshot.spec.js",
"refactor/baselines/screenshot-partial.json"
],
"compatibleResolution": "Consume the internal toolbar Promise after the existing notice, while preserving rejection of every public capture method.",
"closureCriteria": "Exact historical Error identity is reproduced and candidate toolbar, cross-origin and installed-package regressions pass.",
"workspaceState": "Published toolbar produces one exactly identified rejection per engine; source candidate produces none. Real cross-origin toolbar validation passes. Installed-package exit remains outstanding. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-39",
"title": "Thumbnail image completions can overwrite newer configuration or hover state and outlive controls",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/thumbnails.test.js",
"test/browser/thumbnails.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve public configuration identity, dimensions, layout positioning and public URL ownership; repair documented crop and lifecycle defects.",
"closureCriteria": "Candidate type, unit, actual image browser and installed-package validation pass with explicit historical differences.",
"workspaceState": "Five of seven initial Node cases fail before migration. Candidate generation/source/control ownership guards and latest-hover rendering now pass focused Node and real-image browser checks. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-40",
"title": "Image load failures leave thumbnail loading frozen and canvas callback failures escape public loadImg promises",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/thumbnails.test.js",
"test/browser/thumbnails.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve public configuration identity, dimensions, layout positioning and public URL ownership; repair documented crop and lifecycle defects.",
"closureCriteria": "Candidate type, unit, actual image browser and installed-package validation pass with explicit historical differences.",
"workspaceState": "Owned image requests now settle on cancellation or failure and release handlers; the public utility retains HTMLImageElement results and callback errors reject. Transient no-store HTTP failure can retry in all three engines. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-41",
"title": "Scaled thumbnail Blob URLs survive replacement, control removal and destruction",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/thumbnails.test.js",
"test/browser/thumbnails.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve public configuration identity, dimensions, layout positioning and public URL ownership; repair documented crop and lifecycle defects.",
"closureCriteria": "Candidate type, unit, actual image browser and installed-package validation pass with explicit historical differences.",
"workspaceState": "Candidate internal URLs belong to the thumbnail configuration/control. Public loadImg and screenshot Blob URL ownership stays with callers. Final installed-package evidence remains outstanding. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-DOM-19",
"title": "Thumbnail row boundaries and first-cell return do not match the generated zero-based sprite grid",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/thumbnails.test.js",
"test/browser/thumbnails.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve public configuration identity, dimensions, layout positioning and public URL ownership; repair documented crop and lifecycle defects.",
"closureCriteria": "Candidate type, unit, actual image browser and installed-package validation pass with explicit historical differences.",
"workspaceState": "artplayer-tool-thumbnail uses index modulo column and floor(index / column). Published renderer selects the previous row last cell at exact multiples and writes invalid double-negative CSS for the first cell. Corrected candidate crop is compared against a numbered real SVG grid. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-42",
"title": "Progress callbacks and active drags can seek an obsolete source or removed control",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/source.test.js",
"test/progress.test.js",
"test/browser/progress-quality.spec.js",
"test/browser/source.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve legal progress and quality APIs, callback order, source results and caller ownership; stop obsolete work and bound native position correction.",
"closureCriteria": "Unit, strict types and installed-package browser matrices pass with explicit baseline differences and native evidence.",
"workspaceState": "Published real progress interactions continue seeking after setBar reentry; candidate guards entry, source and action identity and ends a drag on source replacement. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-43",
"title": "Replacing a media source revokes a caller-owned Blob URL still in use",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/source.test.js",
"test/progress.test.js",
"test/browser/progress-quality.spec.js",
"test/browser/source.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve legal progress and quality APIs, callback order, source results and caller ownership; stop obsolete work and bound native position correction.",
"closureCriteria": "Unit, strict types and installed-package browser matrices pass with explicit baseline differences and native evidence.",
"workspaceState": "Published URL setter revokes the supplied old URL. Node fetch and three-browser byte ownership checks reproduce the consumer failure; candidate preserves caller ownership after switching and destruction. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-LIFE-44",
"title": "Native quality restoration can settle at a different position after its first seek",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"CORE-19"
],
"evidence": [
"test/source.test.js",
"test/progress.test.js",
"test/browser/progress-quality.spec.js",
"test/browser/source.spec.js",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"compatibleResolution": "Preserve legal progress and quality APIs, callback order, source results and caller ownership; stop obsolete work and bound native position correction.",
"closureCriteria": "Unit, strict types and installed-package browser matrices pass with explicit baseline differences and native evidence.",
"workspaceState": "Windows WebKit native event traces show the first seek reporting the accepted target then completing near zero. Waiting or deferring alone is insufficient. Candidate waits for native completion and performs at most one correction; a persistent seeked listener prevents a pending Promise after that correction. Final CORE-19 CI and installed-package acceptance passed; see resolution evidence.",
"resolutionEvidence": [
"refactor/baselines/capture-progress-validation.json",
"refactor/changes/2026-09-11-CORE-19-capture-thumbnails.md"
],
"resolutionRationale": "Core source, strict types, unit cases, real images/media, actual installed tarball and full UMD/legacy matrices pass. Explicit historical defect differences are documented; no API/type removal or environment waiver."
},
{
"id": "BASE-ENV-01",
"title": "Windows WebKit native Blob video samples lack decoding support; Apple-device validation remains outstanding",
"confirmation": "reproduced",
"status": "open",
"owners": [
"REL-03",
"REVIEW-02"
],
"evidence": [
"test/browser/progress-quality.spec.js",
"refactor/environment-matrix.md"
],
"compatibleResolution": "Keep byte ownership and real HTTP playback evidence distinct from native Blob decoding; do not infer Safari or Apple-device support from Windows WebKit.",
"closureCriteria": "Record actual Safari and Apple device versions and native sample capabilities, then compare published/candidate load, playback, seek, source replacement and Blob ownership.",
"workspaceState": "Independent video probes on Windows WebKit 26.6 return error 4 and width zero for both local MP4 and WebM Blob samples. Chromium and Firefox decode the tested Blob sample. WebKit ownership tests explicitly report decodedBlob=false.",
"resolutionEvidence": []
}
]
}
+5 -2
View File
@@ -1111,11 +1111,14 @@
"CORE-13",
"CORE-16"
],
"status": "todo",
"status": "done",
"risk": "M",
"deliverable": "相关 player/control 功能与纯计算分离",
"acceptance": "截图跨域失败、缩略图布局、quality/进度边界保持",
"evidence": []
"evidence": [
"changes/2026-09-11-CORE-19-capture-thumbnails.md",
"baselines/capture-progress-validation.json"
]
},
{
"id": "CORE-20",
+10 -6
View File
@@ -57,22 +57,26 @@ test('candidate: native keyboard play/pause, seek, volume and web-fullscreen Esc
await page.evaluate(() => {
window.art.isFocus = true
window.nativeHotkeys = []
window.nativeHotkeySeeks = []
window.art.on('hotkey', event => window.nativeHotkeys.push(event.code))
window.art.on('seek', (time, requested) => window.nativeHotkeySeeks.push(requested))
})
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0.1)).toBe(true)
await page.keyboard.press('Space')
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused && !window.art.template.$video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 1
})
await expect.poll(() => page.evaluate(() => !window.art.template.$video.seeking && Math.abs(window.art.currentTime - 1) < 0.1)).toBe(true)
const start = await page.evaluate(() => ({ time: window.art.currentTime, step: window.Artplayer.SEEK_STEP }))
await page.keyboard.press('ArrowRight')
expect(await page.evaluate(() => window.nativeHotkeys.at(-1))).toBe('ArrowRight')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(5)
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(start.time + start.step, 1)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(start.time)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
const forward = await page.evaluate(() => window.art.currentTime)
await page.keyboard.press('ArrowLeft')
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(2)
expect(await page.evaluate(() => window.nativeHotkeySeeks.at(-1))).toBeCloseTo(forward - start.step, 1)
expect(await page.evaluate(() => window.nativeHotkeySeeks.length)).toBe(2)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeLessThan(forward)
await expect.poll(() => page.evaluate(() => window.art.template.$video.seeking)).toBe(false)
const volume = await page.evaluate(() => {
window.art.volume = 0.5
return window.art.volume
+202
View File
@@ -0,0 +1,202 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600">
<rect x="0" y="0" width="100" height="60" fill="hsl(0 70% 45%)"/>
<text x="35" y="38" fill="white" font-size="25">0</text>
<rect x="100" y="0" width="100" height="60" fill="hsl(137.508 70% 45%)"/>
<text x="135" y="38" fill="white" font-size="25">1</text>
<rect x="200" y="0" width="100" height="60" fill="hsl(275.016 70% 45%)"/>
<text x="235" y="38" fill="white" font-size="25">2</text>
<rect x="300" y="0" width="100" height="60" fill="hsl(52.524 70% 45%)"/>
<text x="335" y="38" fill="white" font-size="25">3</text>
<rect x="400" y="0" width="100" height="60" fill="hsl(190.03200000000004 70% 45%)"/>
<text x="435" y="38" fill="white" font-size="25">4</text>
<rect x="500" y="0" width="100" height="60" fill="hsl(327.5400000000001 70% 45%)"/>
<text x="535" y="38" fill="white" font-size="25">5</text>
<rect x="600" y="0" width="100" height="60" fill="hsl(105.048 70% 45%)"/>
<text x="635" y="38" fill="white" font-size="25">6</text>
<rect x="700" y="0" width="100" height="60" fill="hsl(242.55600000000004 70% 45%)"/>
<text x="735" y="38" fill="white" font-size="25">7</text>
<rect x="800" y="0" width="100" height="60" fill="hsl(20.064000000000078 70% 45%)"/>
<text x="835" y="38" fill="white" font-size="25">8</text>
<rect x="900" y="0" width="100" height="60" fill="hsl(157.57200000000012 70% 45%)"/>
<text x="935" y="38" fill="white" font-size="25">9</text>
<rect x="0" y="60" width="100" height="60" fill="hsl(295.08000000000015 70% 45%)"/>
<text x="35" y="98" fill="white" font-size="25">10</text>
<rect x="100" y="60" width="100" height="60" fill="hsl(72.58800000000019 70% 45%)"/>
<text x="135" y="98" fill="white" font-size="25">11</text>
<rect x="200" y="60" width="100" height="60" fill="hsl(210.096 70% 45%)"/>
<text x="235" y="98" fill="white" font-size="25">12</text>
<rect x="300" y="60" width="100" height="60" fill="hsl(347.60400000000004 70% 45%)"/>
<text x="335" y="98" fill="white" font-size="25">13</text>
<rect x="400" y="60" width="100" height="60" fill="hsl(125.11200000000008 70% 45%)"/>
<text x="435" y="98" fill="white" font-size="25">14</text>
<rect x="500" y="60" width="100" height="60" fill="hsl(262.62000000000035 70% 45%)"/>
<text x="535" y="98" fill="white" font-size="25">15</text>
<rect x="600" y="60" width="100" height="60" fill="hsl(40.128000000000156 70% 45%)"/>
<text x="635" y="98" fill="white" font-size="25">16</text>
<rect x="700" y="60" width="100" height="60" fill="hsl(177.63599999999997 70% 45%)"/>
<text x="735" y="98" fill="white" font-size="25">17</text>
<rect x="800" y="60" width="100" height="60" fill="hsl(315.14400000000023 70% 45%)"/>
<text x="835" y="98" fill="white" font-size="25">18</text>
<rect x="900" y="60" width="100" height="60" fill="hsl(92.65200000000004 70% 45%)"/>
<text x="935" y="98" fill="white" font-size="25">19</text>
<rect x="0" y="120" width="100" height="60" fill="hsl(230.1600000000003 70% 45%)"/>
<text x="35" y="158" fill="white" font-size="25">20</text>
<rect x="100" y="120" width="100" height="60" fill="hsl(7.66800000000012 70% 45%)"/>
<text x="135" y="158" fill="white" font-size="25">21</text>
<rect x="200" y="120" width="100" height="60" fill="hsl(145.17600000000039 70% 45%)"/>
<text x="235" y="158" fill="white" font-size="25">22</text>
<rect x="300" y="120" width="100" height="60" fill="hsl(282.6840000000002 70% 45%)"/>
<text x="335" y="158" fill="white" font-size="25">23</text>
<rect x="400" y="120" width="100" height="60" fill="hsl(60.19200000000001 70% 45%)"/>
<text x="435" y="158" fill="white" font-size="25">24</text>
<rect x="500" y="120" width="100" height="60" fill="hsl(197.70000000000027 70% 45%)"/>
<text x="535" y="158" fill="white" font-size="25">25</text>
<rect x="600" y="120" width="100" height="60" fill="hsl(335.2080000000001 70% 45%)"/>
<text x="635" y="158" fill="white" font-size="25">26</text>
<rect x="700" y="120" width="100" height="60" fill="hsl(112.71600000000035 70% 45%)"/>
<text x="735" y="158" fill="white" font-size="25">27</text>
<rect x="800" y="120" width="100" height="60" fill="hsl(250.22400000000016 70% 45%)"/>
<text x="835" y="158" fill="white" font-size="25">28</text>
<rect x="900" y="120" width="100" height="60" fill="hsl(27.732000000000426 70% 45%)"/>
<text x="935" y="158" fill="white" font-size="25">29</text>
<rect x="0" y="180" width="100" height="60" fill="hsl(165.2400000000007 70% 45%)"/>
<text x="35" y="218" fill="white" font-size="25">30</text>
<rect x="100" y="180" width="100" height="60" fill="hsl(302.7480000000005 70% 45%)"/>
<text x="135" y="218" fill="white" font-size="25">31</text>
<rect x="200" y="180" width="100" height="60" fill="hsl(80.25600000000031 70% 45%)"/>
<text x="235" y="218" fill="white" font-size="25">32</text>
<rect x="300" y="180" width="100" height="60" fill="hsl(217.76400000000012 70% 45%)"/>
<text x="335" y="218" fill="white" font-size="25">33</text>
<rect x="400" y="180" width="100" height="60" fill="hsl(355.27199999999993 70% 45%)"/>
<text x="435" y="218" fill="white" font-size="25">34</text>
<rect x="500" y="180" width="100" height="60" fill="hsl(132.78000000000065 70% 45%)"/>
<text x="535" y="218" fill="white" font-size="25">35</text>
<rect x="600" y="180" width="100" height="60" fill="hsl(270.28800000000047 70% 45%)"/>
<text x="635" y="218" fill="white" font-size="25">36</text>
<rect x="700" y="180" width="100" height="60" fill="hsl(47.79600000000028 70% 45%)"/>
<text x="735" y="218" fill="white" font-size="25">37</text>
<rect x="800" y="180" width="100" height="60" fill="hsl(185.3040000000001 70% 45%)"/>
<text x="835" y="218" fill="white" font-size="25">38</text>
<rect x="900" y="180" width="100" height="60" fill="hsl(322.8120000000008 70% 45%)"/>
<text x="935" y="218" fill="white" font-size="25">39</text>
<rect x="0" y="240" width="100" height="60" fill="hsl(100.32000000000062 70% 45%)"/>
<text x="35" y="278" fill="white" font-size="25">40</text>
<rect x="100" y="240" width="100" height="60" fill="hsl(237.82800000000043 70% 45%)"/>
<text x="135" y="278" fill="white" font-size="25">41</text>
<rect x="200" y="240" width="100" height="60" fill="hsl(15.33600000000024 70% 45%)"/>
<text x="235" y="278" fill="white" font-size="25">42</text>
<rect x="300" y="240" width="100" height="60" fill="hsl(152.84400000000005 70% 45%)"/>
<text x="335" y="278" fill="white" font-size="25">43</text>
<rect x="400" y="240" width="100" height="60" fill="hsl(290.35200000000077 70% 45%)"/>
<text x="435" y="278" fill="white" font-size="25">44</text>
<rect x="500" y="240" width="100" height="60" fill="hsl(67.86000000000058 70% 45%)"/>
<text x="535" y="278" fill="white" font-size="25">45</text>
<rect x="600" y="240" width="100" height="60" fill="hsl(205.3680000000004 70% 45%)"/>
<text x="635" y="278" fill="white" font-size="25">46</text>
<rect x="700" y="240" width="100" height="60" fill="hsl(342.8760000000002 70% 45%)"/>
<text x="735" y="278" fill="white" font-size="25">47</text>
<rect x="800" y="240" width="100" height="60" fill="hsl(120.38400000000001 70% 45%)"/>
<text x="835" y="278" fill="white" font-size="25">48</text>
<rect x="900" y="240" width="100" height="60" fill="hsl(257.89200000000073 70% 45%)"/>
<text x="935" y="278" fill="white" font-size="25">49</text>
<rect x="0" y="300" width="100" height="60" fill="hsl(35.400000000000546 70% 45%)"/>
<text x="35" y="338" fill="white" font-size="25">50</text>
<rect x="100" y="300" width="100" height="60" fill="hsl(172.90800000000036 70% 45%)"/>
<text x="135" y="338" fill="white" font-size="25">51</text>
<rect x="200" y="300" width="100" height="60" fill="hsl(310.41600000000017 70% 45%)"/>
<text x="235" y="338" fill="white" font-size="25">52</text>
<rect x="300" y="300" width="100" height="60" fill="hsl(87.92400000000089 70% 45%)"/>
<text x="335" y="338" fill="white" font-size="25">53</text>
<rect x="400" y="300" width="100" height="60" fill="hsl(225.4320000000007 70% 45%)"/>
<text x="435" y="338" fill="white" font-size="25">54</text>
<rect x="500" y="300" width="100" height="60" fill="hsl(2.9400000000005093 70% 45%)"/>
<text x="535" y="338" fill="white" font-size="25">55</text>
<rect x="600" y="300" width="100" height="60" fill="hsl(140.44800000000032 70% 45%)"/>
<text x="635" y="338" fill="white" font-size="25">56</text>
<rect x="700" y="300" width="100" height="60" fill="hsl(277.95600000000013 70% 45%)"/>
<text x="735" y="338" fill="white" font-size="25">57</text>
<rect x="800" y="300" width="100" height="60" fill="hsl(55.46400000000085 70% 45%)"/>
<text x="835" y="338" fill="white" font-size="25">58</text>
<rect x="900" y="300" width="100" height="60" fill="hsl(192.97200000000066 70% 45%)"/>
<text x="935" y="338" fill="white" font-size="25">59</text>
<rect x="0" y="360" width="100" height="60" fill="hsl(330.4800000000014 70% 45%)"/>
<text x="35" y="398" fill="white" font-size="25">60</text>
<rect x="100" y="360" width="100" height="60" fill="hsl(107.9880000000012 70% 45%)"/>
<text x="135" y="398" fill="white" font-size="25">61</text>
<rect x="200" y="360" width="100" height="60" fill="hsl(245.496000000001 70% 45%)"/>
<text x="235" y="398" fill="white" font-size="25">62</text>
<rect x="300" y="360" width="100" height="60" fill="hsl(23.004000000000815 70% 45%)"/>
<text x="335" y="398" fill="white" font-size="25">63</text>
<rect x="400" y="360" width="100" height="60" fill="hsl(160.51200000000063 70% 45%)"/>
<text x="435" y="398" fill="white" font-size="25">64</text>
<rect x="500" y="360" width="100" height="60" fill="hsl(298.02000000000044 70% 45%)"/>
<text x="535" y="398" fill="white" font-size="25">65</text>
<rect x="600" y="360" width="100" height="60" fill="hsl(75.52800000000025 70% 45%)"/>
<text x="635" y="398" fill="white" font-size="25">66</text>
<rect x="700" y="360" width="100" height="60" fill="hsl(213.03600000000006 70% 45%)"/>
<text x="735" y="398" fill="white" font-size="25">67</text>
<rect x="800" y="360" width="100" height="60" fill="hsl(350.54399999999987 70% 45%)"/>
<text x="835" y="398" fill="white" font-size="25">68</text>
<rect x="900" y="360" width="100" height="60" fill="hsl(128.0520000000015 70% 45%)"/>
<text x="935" y="398" fill="white" font-size="25">69</text>
<rect x="0" y="420" width="100" height="60" fill="hsl(265.5600000000013 70% 45%)"/>
<text x="35" y="458" fill="white" font-size="25">70</text>
<rect x="100" y="420" width="100" height="60" fill="hsl(43.06800000000112 70% 45%)"/>
<text x="135" y="458" fill="white" font-size="25">71</text>
<rect x="200" y="420" width="100" height="60" fill="hsl(180.57600000000093 70% 45%)"/>
<text x="235" y="458" fill="white" font-size="25">72</text>
<rect x="300" y="420" width="100" height="60" fill="hsl(318.08400000000074 70% 45%)"/>
<text x="335" y="458" fill="white" font-size="25">73</text>
<rect x="400" y="420" width="100" height="60" fill="hsl(95.59200000000055 70% 45%)"/>
<text x="435" y="458" fill="white" font-size="25">74</text>
<rect x="500" y="420" width="100" height="60" fill="hsl(233.10000000000036 70% 45%)"/>
<text x="535" y="458" fill="white" font-size="25">75</text>
<rect x="600" y="420" width="100" height="60" fill="hsl(10.608000000000175 70% 45%)"/>
<text x="635" y="458" fill="white" font-size="25">76</text>
<rect x="700" y="420" width="100" height="60" fill="hsl(148.11599999999999 70% 45%)"/>
<text x="735" y="458" fill="white" font-size="25">77</text>
<rect x="800" y="420" width="100" height="60" fill="hsl(285.6240000000016 70% 45%)"/>
<text x="835" y="458" fill="white" font-size="25">78</text>
<rect x="900" y="420" width="100" height="60" fill="hsl(63.132000000001426 70% 45%)"/>
<text x="935" y="458" fill="white" font-size="25">79</text>
<rect x="0" y="480" width="100" height="60" fill="hsl(200.64000000000124 70% 45%)"/>
<text x="35" y="518" fill="white" font-size="25">80</text>
<rect x="100" y="480" width="100" height="60" fill="hsl(338.14800000000105 70% 45%)"/>
<text x="135" y="518" fill="white" font-size="25">81</text>
<rect x="200" y="480" width="100" height="60" fill="hsl(115.65600000000086 70% 45%)"/>
<text x="235" y="518" fill="white" font-size="25">82</text>
<rect x="300" y="480" width="100" height="60" fill="hsl(253.16400000000067 70% 45%)"/>
<text x="335" y="518" fill="white" font-size="25">83</text>
<rect x="400" y="480" width="100" height="60" fill="hsl(30.67200000000048 70% 45%)"/>
<text x="435" y="518" fill="white" font-size="25">84</text>
<rect x="500" y="480" width="100" height="60" fill="hsl(168.1800000000003 70% 45%)"/>
<text x="535" y="518" fill="white" font-size="25">85</text>
<rect x="600" y="480" width="100" height="60" fill="hsl(305.6880000000001 70% 45%)"/>
<text x="635" y="518" fill="white" font-size="25">86</text>
<rect x="700" y="480" width="100" height="60" fill="hsl(83.19600000000173 70% 45%)"/>
<text x="735" y="518" fill="white" font-size="25">87</text>
<rect x="800" y="480" width="100" height="60" fill="hsl(220.70400000000154 70% 45%)"/>
<text x="835" y="518" fill="white" font-size="25">88</text>
<rect x="900" y="480" width="100" height="60" fill="hsl(358.21200000000135 70% 45%)"/>
<text x="935" y="518" fill="white" font-size="25">89</text>
<rect x="0" y="540" width="100" height="60" fill="hsl(135.72000000000116 70% 45%)"/>
<text x="35" y="578" fill="white" font-size="25">90</text>
<rect x="100" y="540" width="100" height="60" fill="hsl(273.228000000001 70% 45%)"/>
<text x="135" y="578" fill="white" font-size="25">91</text>
<rect x="200" y="540" width="100" height="60" fill="hsl(50.736000000000786 70% 45%)"/>
<text x="235" y="578" fill="white" font-size="25">92</text>
<rect x="300" y="540" width="100" height="60" fill="hsl(188.2440000000006 70% 45%)"/>
<text x="335" y="578" fill="white" font-size="25">93</text>
<rect x="400" y="540" width="100" height="60" fill="hsl(325.7520000000004 70% 45%)"/>
<text x="435" y="578" fill="white" font-size="25">94</text>
<rect x="500" y="540" width="100" height="60" fill="hsl(103.26000000000022 70% 45%)"/>
<text x="535" y="578" fill="white" font-size="25">95</text>
<rect x="600" y="540" width="100" height="60" fill="hsl(240.76800000000003 70% 45%)"/>
<text x="635" y="578" fill="white" font-size="25">96</text>
<rect x="700" y="540" width="100" height="60" fill="hsl(18.27600000000166 70% 45%)"/>
<text x="735" y="578" fill="white" font-size="25">97</text>
<rect x="800" y="540" width="100" height="60" fill="hsl(155.78400000000147 70% 45%)"/>
<text x="835" y="578" fill="white" font-size="25">98</text>
<rect x="900" y="540" width="100" height="60" fill="hsl(293.2920000000013 70% 45%)"/>
<text x="935" y="578" fill="white" font-size="25">99</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+234
View File
@@ -0,0 +1,234 @@
import { expect, test } from './fixtures.js'
async function setup(page, core) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
}
for (const core of ['published', 'candidate']) {
for (const mode of ['remove', 'destroy', 'source']) {
test(`${core}: progress click reentry through ${mode} cannot continue an obsolete seek`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate((mode) => {
const art = window.art
let seeks = 0
let bars = 0
art.on('seek', () => seeks++)
art.on('setBar', (type) => {
if (type !== 'played' || bars++)
return
if (mode === 'remove')
art.controls.remove('progress')
else if (mode === 'destroy')
art.destroy()
else
art.url = '/test/pattern.mp4?progress=reentrant'
})
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
const result = { seeks, bars }
art.destroy()
return result
}, mode)
expect(result).toEqual({ seeks: core === 'published' ? 1 : 0, bars: 1 })
})
}
test(`${core}: real progress click keeps setBar before seek and clamps the edges`, async ({ page }) => {
await setup(page, core)
const rect = await page.evaluate(() => {
const art = window.art
window.progressOrder = []
art.on('setBar', (type, percent) => {
if (type === 'played')
window.progressOrder.push(['setBar', percent])
})
art.on('seek', () => window.progressOrder.push(['seek']))
const rect = art.template.$progress.getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
})
await page.mouse.click(rect.x, rect.y)
await expect.poll(() => page.evaluate(() => window.art.video.seeking)).toBe(false)
expect(await page.evaluate(() => window.art.currentTime / window.art.duration)).toBeCloseTo(0.5, 1)
const result = await page.evaluate(() => {
const art = window.art
const firstSeek = window.progressOrder.findIndex(item => item[0] === 'seek')
const previous = window.progressOrder[firstSeek - 1]
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().left - 100 }))
const start = art.currentTime
progress.dispatchEvent(new MouseEvent('click', { clientX: progress.getBoundingClientRect().right + 100 }))
const end = art.currentTime
const duration = art.duration
art.destroy()
return { previous, start, end, duration }
})
expect(result.previous[0]).toBe('setBar')
expect(result.previous[1]).toBeCloseTo(0.5, 1)
expect(result.start).toBe(0)
expect(result.end).toBeCloseTo(result.duration, 1)
})
test(`${core}: a progress drag ends when its source is replaced and can restart on the new source`, async ({ page }) => {
await setup(page, core)
const stale = await page.evaluate(() => {
const art = window.art
window.dragSeeks = 0
art.on('seek', () => window.dragSeeks++)
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
art.url = '/test/pattern.mp4?progress=new-source'
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
return window.dragSeeks
})
expect(stale).toBe(core === 'published' ? 1 : 0)
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 2)).toBe(true)
expect(await page.evaluate(() => {
const art = window.art
const progress = art.template.$progress
progress.dispatchEvent(new MouseEvent('mousedown', { button: 0 }))
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: progress.getBoundingClientRect().left + progress.clientWidth / 2 }))
art.emit('document:mouseup', new MouseEvent('mouseup'))
const seeks = window.dragSeeks
art.destroy()
return seeks
})).toBe(stale + 1)
})
test(`${core}: a caller-owned media Blob URL remains readable after switching and destroying the player`, async ({ page, browserName }, testInfo) => {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(async () => {
window.blobCapabilities = []
let blob
let fallback
for (const extension of ['mp4', 'webm']) {
const candidate = await (await fetch(`/test/pattern.${extension}`)).blob()
fallback ||= candidate
const url = URL.createObjectURL(candidate)
const video = document.createElement('video')
const capability = await new Promise((resolve) => {
video.onloadedmetadata = () => resolve({ extension, width: video.videoWidth, error: 0 })
video.onerror = () => resolve({ extension, width: video.videoWidth, error: video.error.code })
video.src = url
video.load()
})
window.blobCapabilities.push(capability)
video.onloadedmetadata = null
video.onerror = null
video.removeAttribute('src')
video.load()
URL.revokeObjectURL(url)
if (!capability.error) {
blob = candidate
break
}
}
window.blobPlayable = !!blob
blob ||= fallback
window.sharedBlobSize = blob.size
window.sharedBlobUrl = URL.createObjectURL(blob)
window.borrowedRevocations = []
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (url) {
window.borrowedRevocations.push(url)
return original.call(this, url)
}
window.createPlayer(window.blobPlayable ? window.sharedBlobUrl : '/test/pattern.mp4')
})
const capabilities = await page.evaluate(() => window.blobCapabilities)
await testInfo.attach('native-blob-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) })
if (!capabilities.some(item => item.error === 0)) {
expect(browserName).toBe('webkit')
expect(capabilities).toEqual([{ extension: 'mp4', width: 0, error: 4 }, { extension: 'webm', width: 0, error: 4 }])
}
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const result = await page.evaluate(async () => {
const art = window.art
// The Windows WebKit runtime cannot decode either Blob fixture natively.
// Still exercise actual URL assignment/replacement and byte ownership there.
if (!window.blobPlayable)
art.url = window.sharedBlobUrl
const assigned = art.video.src === window.sharedBlobUrl
await art.switchUrl('/test/pattern.mp4?borrowed=replaced')
const read = async () => {
try {
return (await (await fetch(window.sharedBlobUrl)).blob()).size
}
catch (error) {
return error.name
}
}
const afterSwitch = await read()
art.destroy()
const afterDestroy = await read()
const revoked = window.borrowedRevocations.filter(url => url === window.sharedBlobUrl).length
URL.revokeObjectURL(window.sharedBlobUrl)
return { afterSwitch, afterDestroy, revoked, size: window.sharedBlobSize, assigned, decodedBlob: window.blobPlayable }
})
expect(result.size).toBeGreaterThan(100)
expect(result.assigned).toBe(true)
await testInfo.attach('borrowed-url-evidence', { contentType: 'application/json', body: JSON.stringify(result) })
expect(result.revoked).toBe(core === 'published' ? 1 : 0)
expect(result.afterSwitch).toBe(core === 'published' ? 'TypeError' : result.size)
expect(result.afterDestroy).toBe(core === 'published' ? 'TypeError' : result.size)
})
test(`${core}: quality selectors retain default labels, source position and empty-array behavior`, async ({ page, browserName }, testInfo) => {
await setup(page, core)
await page.evaluate(() => {
const art = window.art
window.qualityNativeEvents = []
for (const key of ['currentTime', 'playbackRate', 'src']) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, key)
Object.defineProperty(art.video, key, {
configurable: true,
get() { return descriptor.get.call(this) },
set(value) {
window.qualityNativeEvents.push({ key, value, time: this.currentTime, seeking: this.seeking, source: this.currentSrc })
descriptor.set.call(this, value)
},
})
}
for (const event of ['loadedmetadata', 'seeking', 'seeked', 'canplay', 'ratechange', 'pause', 'playing'])
art.video.addEventListener(event, () => window.qualityNativeEvents.push({ event, time: art.currentTime, seeking: art.video.seeking, source: art.video.currentSrc }))
art.playbackRate = 1.5
art.currentTime = 2
window.qualityRestarts = []
art.on('restart', url => window.qualityRestarts.push(url))
window.qualityOptions = [
{ html: 'Quality A', url: '/test/pattern.mp4?quality=A' },
{ html: 'Quality B', url: '/test/pattern.mp4?quality=B', default: true },
]
art.quality = window.qualityOptions
})
await expect.poll(() => page.evaluate(() => Math.abs(window.art.currentTime - 2) < 0.05 && !window.art.video.seeking)).toBe(true)
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality B')
await page.locator('.art-control-quality').hover()
await page.locator('.art-control-quality .art-selector-item').filter({ hasText: 'Quality A' }).click()
await expect.poll(() => page.evaluate(() => window.art.url.includes('quality=A'))).toBe(true)
await expect.poll(() => page.evaluate(() => window.qualityRestarts)).toEqual(['/test/pattern.mp4?quality=A'])
await expect.poll(() => page.evaluate(() => window.art.video.readyState >= 3 && !window.art.video.seeking)).toBe(true)
await expect(page.locator('.art-control-quality .art-selector-value')).toHaveText('Quality A')
await testInfo.attach('quality-native-events', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.qualityNativeEvents)) })
const restoredTime = await page.evaluate(() => window.art.currentTime)
if (core === 'published' && browserName === 'webkit')
expect(restoredTime).toBeLessThan(0.05)
else
expect(restoredTime).toBeCloseTo(2, 1)
expect(await page.evaluate(() => window.art.playbackRate)).toBe(1.5)
expect(await page.evaluate(() => window.qualityOptions.map(item => !!item.default))).toEqual([true, false])
const empty = await page.evaluate(() => {
const art = window.art
art.quality = []
const result = { text: art.controls.quality.textContent, read: art.quality, hasGetter: !!Object.getOwnPropertyDescriptor(art, 'quality').get }
art.destroy()
return result
})
expect(empty).toEqual({ text: '', read: undefined, hasGetter: false })
})
}
+214
View File
@@ -0,0 +1,214 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, crossOrigin = false, control = false) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(({ crossOrigin, control }) => {
const url = new URL('/test/pattern.mp4', location.href)
if (crossOrigin)
url.hostname = 'localhost'
window.art = new window.Artplayer({ container: '.player', url: url.href, muted: true, screenshot: control })
}, { crossOrigin, control })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.evaluate(() => window.art.play())
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.evaluate(() => window.art.pause())
}
for (const core of ['published', 'candidate']) {
test(`${core}: captures decoded PNG pixels and consumer-owned Blob URLs`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
const data = await art.getDataURL()
const url = await art.getBlobUrl()
const image = new Image()
image.src = data
await image.decode()
const canvas = document.createElement('canvas')
canvas.width = image.width
canvas.height = image.height
const context = canvas.getContext('2d')
context.drawImage(image, 0, 0)
const pixels = context.getImageData(0, 0, image.width, image.height).data
const colors = new Set()
for (let index = 0; index < pixels.length; index += 4)
colors.add(`${pixels[index]},${pixels[index + 1]},${pixels[index + 2]}`)
const expected = [art.video.videoWidth, art.video.videoHeight]
const descriptors = ['getDataURL', 'getBlobUrl', 'screenshot'].map((name) => {
const d = Object.getOwnPropertyDescriptor(art, name)
return [d.writable, d.configurable, d.enumerable]
})
art.destroy()
const blob = await (await fetch(url)).blob()
URL.revokeObjectURL(url)
return { png: data.startsWith('data:image/png;base64,'), dimensions: [image.width, image.height], expected, colors: colors.size, blobType: blob.type, blobSize: blob.size, descriptors }
})
expect(result.png).toBe(true)
expect(result.dimensions).toEqual(result.expected)
expect(result.dimensions[0]).toBeGreaterThan(0)
expect(result.colors).toBeGreaterThan(10)
expect(result.blobType).toBe('image/png')
expect(result.blobSize).toBeGreaterThan(100)
expect(result.descriptors).toEqual(Array.from({ length: 3 }, () => [false, false, false]))
})
test(`${core}: screenshot preserves download filename, event data and Promise result`, async ({ page }) => {
await setup(page, core)
const downloadPromise = page.waitForEvent('download')
const result = await page.evaluate(async () => {
const art = window.art
const events = []
art.on('screenshot', value => events.push(value))
const screenshot = art.screenshot
const value = await screenshot('chosen.png')
art.destroy()
return { count: events.length, same: events[0] === value, png: value.startsWith('data:image/png;base64,') }
})
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('chosen.png.png')
expect(result).toEqual({ count: 1, same: true, png: true })
await download.delete()
})
test(`${core}: actual cross-origin video rejects capture with SecurityError and notice`, async ({ page }) => {
await setup(page, core, true)
const result = await page.evaluate(async () => {
const art = window.art
const errors = []
for (const name of ['getDataURL', 'getBlobUrl', 'screenshot']) {
try {
await art[name]()
errors.push('resolved')
}
catch (error) {
errors.push(error.name)
}
}
const result = { errors, notice: art.notice.show, text: art.template.$noticeInner.textContent.length }
art.destroy()
return result
})
expect(result.errors).toEqual(['SecurityError', 'SecurityError', 'SecurityError'])
expect(result.notice).toBe(true)
expect(result.text).toBeGreaterThan(0)
})
test(`${core}: source replacement does not download a stale captured frame`, async ({ page }) => {
await setup(page, core)
const result = await page.evaluate(async () => {
const art = window.art
let events = 0
let downloads = 0
const original = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = () => {
downloads++
}
art.on('screenshot', () => {
events++
})
try {
const promise = art.screenshot()
art.url = '/test/pattern.mp4?replacement=1'
const result = { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
art.destroy()
return result
}
finally {
HTMLAnchorElement.prototype.click = original
}
})
const oldEffects = core === 'published' ? 1 : 0
expect(result).toEqual({ png: true, events: oldEffects, downloads: oldEffects })
})
test(`${core}: screenshot toolbar rejection has exact historical error accounting`, async ({ page }, testInfo) => {
await setup(page, core, false, true)
await page.evaluate((core) => {
window.captureFailures = []
const failure = new Error('controlled screenshot draw failure')
const original = HTMLCanvasElement.prototype.toDataURL
window.restoreCapture = () => {
HTMLCanvasElement.prototype.toDataURL = original
}
HTMLCanvasElement.prototype.toDataURL = () => {
throw failure
}
if (core === 'published') {
window.addEventListener('unhandledrejection', (event) => {
if (event.reason === failure) {
window.captureFailures.push(event.reason.message)
event.preventDefault()
}
})
}
window.art.notice.show = false
}, core)
await page.locator('.art-control-screenshot').click()
await expect.poll(() => page.evaluate(() => window.art.template.$noticeInner.textContent)).toBe('controlled screenshot draw failure')
if (core === 'published')
await expect.poll(() => page.evaluate(() => window.captureFailures.length)).toBe(1)
const known = await page.evaluate(() => {
window.restoreCapture()
window.art.destroy()
return window.captureFailures
})
expect(known).toEqual(core === 'published' ? ['controlled screenshot draw failure'] : [])
await testInfo.attach('known-screenshot-rejections', { contentType: 'application/json', body: JSON.stringify(known) })
})
}
test('candidate: null toBlob callback rejects and reports without a page error', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const prototype = HTMLCanvasElement.prototype
const original = prototype.toBlob
prototype.toBlob = function (callback) {
queueMicrotask(() => callback(null))
}
try {
await window.art.getBlobUrl()
return 'resolved'
}
catch (error) {
return error.message
}
finally {
prototype.toBlob = original
window.art.destroy()
}
})).toBe('Unable to encode screenshot blob')
})
test('candidate: destroy during screenshot await suppresses the queued download and event', async ({ page }) => {
await setup(page, 'candidate')
expect(await page.evaluate(async () => {
const art = window.art
let events = 0
let downloads = 0
const original = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = () => {
downloads++
}
art.on('screenshot', () => {
events++
})
try {
const promise = art.screenshot()
art.destroy()
return { png: (await promise).startsWith('data:image/png;base64,'), events, downloads }
}
finally {
HTMLAnchorElement.prototype.click = original
}
})).toEqual({ png: true, events: 0, downloads: 0 })
})
test('candidate: cross-origin screenshot toolbar handles its internal rejected Promise', async ({ page }) => {
await setup(page, 'candidate', true, true)
await page.evaluate(() => {
window.art.notice.show = false
})
await page.locator('.art-control-screenshot').click()
await expect.poll(() => page.evaluate(() => window.art.notice.show)).toBe(true)
await page.evaluate(() => window.art.destroy())
})
+1
View File
@@ -61,6 +61,7 @@ for (const extension of ['mp4', 'webm']) {
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.json': 'application/json', '.css': 'text/css', '.mp4': 'video/mp4', '.webm': 'video/webm', '.vtt': 'text/vtt', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }
add('/test/declaration-cues.vtt', fs.readFileSync(path.join(workspace, 'test/browser/media/declaration-cues.vtt')), { kind: 'test-subtitles', file: 'test/browser/media/declaration-cues.vtt' })
add('/test/thumbnail-grid.svg', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-grid.svg')), { kind: 'test-thumbnail-grid', file: 'test/browser/media/thumbnail-grid.svg' })
function send(req, res, bytes, filename) {
res.setHeader('Content-Type', mime[path.extname(filename)] || 'application/octet-stream')
res.setHeader('Cache-Control', 'no-store')
+51
View File
@@ -116,6 +116,57 @@ test('candidate: quality switch restores position/rate and preserves real custom
expect(result.error).toBe(0)
})
test('candidate: direct currentTime during a native quality seek is not overwritten by automatic restoration', async ({ page }, testInfo) => {
await page.goto('/test/player.html?core=candidate&chapter=published')
await page.evaluate(() => window.createPlayer('/test/pattern.mp4?position=initial'))
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(0.1)
await page.locator('#pause').click()
await expect.poll(() => page.evaluate(() => window.art.video.paused && !window.art.video.seeking)).toBe(true)
await page.evaluate(() => {
window.art.currentTime = 2
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && Math.abs(window.art.currentTime - 2) < 0.05)).toBe(true)
const result = await page.evaluate(async () => {
const { art } = window
let overrides = 0
const seeks = []
const writes = []
for (const name of ['currentTime', 'playbackRate']) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, name)
Object.defineProperty(art.video, name, {
configurable: true,
get() { return descriptor.get.call(this) },
set(value) {
writes.push({ name, value, before: this.currentTime, seeking: this.seeking })
descriptor.set.call(this, value)
writes.push({ name: `${name}:after`, value: this.currentTime, seeking: this.seeking })
},
})
}
art.once('video:seeked', () => {
overrides++
art.currentTime = 4
})
art.on('video:seeked', () => seeks.push({ time: art.currentTime, seeking: art.video.seeking }))
await art.switchQuality('/test/pattern.mp4?position=consumer')
const result = { overrides, seeks, writes, time: art.currentTime, paused: art.video.paused, seeking: art.video.seeking }
art.destroy()
return result
})
await testInfo.attach('quality-direct-position', { contentType: 'application/json', body: JSON.stringify(result) })
expect(result.overrides).toBe(1)
// Assert the player write contract separately from native seek precision.
// WebKit may finish this reentrant native seek at a different media clock.
const positions = result.writes.filter(item => item.name === 'currentTime')
expect(positions).toHaveLength(2)
expect(positions[0].value).toBeCloseTo(2, 1)
expect(positions[1].value).toBe(4)
expect(result.paused).toBe(true)
expect(result.seeking).toBe(false)
})
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'))
+165
View File
@@ -0,0 +1,165 @@
import { expect, test } from './fixtures.js'
async function setup(page, core, scale = 1) {
await page.goto(`/test/player.html?core=${core}&chapter=published`)
await page.evaluate(({ scale }) => {
window.art = new window.Artplayer({
container: '.player',
url: '/test/pattern.mp4',
muted: true,
thumbnails: { url: '/test/thumbnail-grid.svg', number: 100, column: 10, width: 100, height: 60, scale },
})
window.hoverThumbnail = (percentage) => {
const art = window.art
const progress = art.template.$progress
const clientX = progress.getBoundingClientRect().left + progress.clientWidth * percentage
art.emit('setBar', 'hover', percentage, new MouseEvent('mousemove', { clientX }))
}
}, { scale })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
}
async function visible(page) {
await expect.poll(() => page.evaluate(() => window.art.controls.thumbnails.style.backgroundImage)).not.toBe('')
}
for (const core of ['published', 'candidate']) {
test(`${core}: thumbnail cells follow the generated grid at row boundaries and on returning to the first cell`, async ({ page }, testInfo) => {
await setup(page, core)
await page.evaluate(() => window.hoverThumbnail(0.1))
await visible(page)
const result = await page.evaluate(() => {
const art = window.art
const element = art.controls.thumbnails
const row = element.style.backgroundPosition
const dimensions = [element.style.width, element.style.height]
window.hoverThumbnail(0.11)
const ordinary = element.style.backgroundPosition
window.hoverThumbnail(0.001)
const first = element.style.backgroundPosition
const left = element.style.left
window.hoverThumbnail(0.999)
const last = element.style.backgroundPosition
const right = Number.parseFloat(element.style.left)
const expectedRight = art.template.$progress.clientWidth - 100
const descriptor = Object.getOwnPropertyDescriptor(art, 'thumbnails')
return { row, ordinary, first, last, left, right, expectedRight, dimensions, immutable: !descriptor.enumerable && !descriptor.configurable }
})
expect(result.row).toBe(core === 'published' ? '-900px 0px' : '0px -60px')
expect(result.ordinary).toBe('-100px -60px')
expect(result.first).toBe(core === 'published' ? '-100px -60px' : '0px 0px')
expect(result.last).toBe('-900px -540px')
expect(result.left).toBe('0px')
expect(result.right).toBe(result.expectedRight)
expect(result.dimensions).toEqual(['100px', '60px'])
expect(result.immutable).toBe(true)
await testInfo.attach('thumbnail-grid', { contentType: 'image/png', body: await page.locator('.player').screenshot() })
await page.evaluate(() => window.art.destroy())
})
test(`${core}: scaled internal thumbnails and public image results have distinct Blob URL owners`, async ({ page }) => {
await setup(page, core, 0.5)
await page.evaluate(() => {
window.thumbnailRevocations = []
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (url) {
window.thumbnailRevocations.push(url)
return original.call(this, url)
}
})
await page.evaluate(() => window.hoverThumbnail(0.2))
await visible(page)
const result = await page.evaluate(async () => {
const art = window.art
const style = art.controls.thumbnails.style
const internal = style.backgroundImage.slice(5, -2)
const image = await window.Artplayer.utils.loadImg('/test/thumbnail-grid.svg', 0.5)
const publicUrl = image.src
const dimensions = [image.naturalWidth, image.naturalHeight]
const previewSize = [style.width, style.height]
art.destroy()
const revokedInternal = window.thumbnailRevocations.filter(url => url === internal).length
const revokedPublic = window.thumbnailRevocations.filter(url => url === publicUrl).length
const blob = await (await fetch(publicUrl)).blob()
URL.revokeObjectURL(publicUrl)
if (!revokedInternal)
URL.revokeObjectURL(internal)
return { dimensions, previewSize, internalBlob: internal.startsWith('blob:'), revokedInternal, revokedPublic, publicType: blob.type }
})
expect(result).toEqual({ dimensions: [500, 300], previewSize: ['50px', '30px'], internalBlob: true, revokedInternal: core === 'published' ? 0 : 1, revokedPublic: 0, publicType: 'image/png' })
})
test(`${core}: a delayed image follows the latest hover rather than the first request position`, async ({ page, request }) => {
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
let release
const gate = new Promise(resolve => release = resolve)
let requested
const received = new Promise(resolve => requested = resolve)
await page.route('**/test/thumbnail-grid.svg?delayed=1', async (route) => {
requested()
await gate
await route.fulfill({ contentType: 'image/svg+xml', body: svg })
})
await setup(page, core)
await page.evaluate(() => {
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?delayed=1' }
window.hoverThumbnail(0.2)
})
await received
await page.evaluate(() => window.hoverThumbnail(0.7))
release()
await visible(page)
const result = await page.evaluate(() => ({ left: Number.parseFloat(window.art.controls.thumbnails.style.left), width: window.art.template.$progress.clientWidth }))
expect(result.left).toBeCloseTo(result.width * (core === 'published' ? 0.2 : 0.7) - 50, 4)
await page.evaluate(() => window.art.destroy())
})
}
test('candidate: removing thumbnails revokes the scaled image and a replacement control can reload', async ({ page }) => {
await setup(page, 'candidate', 0.5)
await page.evaluate(() => window.hoverThumbnail(0.2))
await visible(page)
await page.evaluate(() => {
const art = window.art
const url = art.controls.thumbnails.style.backgroundImage.slice(5, -2)
window.removedThumbnailRevocations = 0
const original = URL.revokeObjectURL
URL.revokeObjectURL = function (value) {
if (value === url)
window.removedThumbnailRevocations++
return original.call(this, value)
}
art.controls.remove('thumbnails')
art.controls.add({ name: 'thumbnails', position: 'top', index: 20 })
window.hoverThumbnail(0.4)
})
await visible(page)
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
await page.evaluate(() => window.art.destroy())
expect(await page.evaluate(() => window.removedThumbnailRevocations)).toBe(1)
})
test('candidate: image failure permits a retry through the same thumbnail configuration', async ({ page, request }) => {
const svg = await (await request.get('/test/thumbnail-grid.svg')).body()
let attempts = 0
await page.route('**/test/thumbnail-grid.svg?retry=1', async (route) => {
attempts++
await route.fulfill({
status: attempts === 1 ? 503 : 200,
headers: { 'Cache-Control': 'no-store' },
contentType: 'image/svg+xml',
body: attempts === 1 ? '<temporary-image-failure/>' : svg,
})
})
await setup(page, 'candidate')
const warning = page.waitForEvent('console', message => message.type() === 'warning' && message.text().includes('ArtPlayer thumbnail load failed'))
await page.evaluate(() => {
window.art.thumbnails = { ...window.art.thumbnails, url: '/test/thumbnail-grid.svg?retry=1' }
window.hoverThumbnail(0.2)
})
await warning
await page.evaluate(() => window.hoverThumbnail(0.3))
await visible(page)
expect(attempts).toBe(2)
await page.evaluate(() => window.art.destroy())
})
+21
View File
@@ -127,6 +127,27 @@ test('quality rejection identity and captured selection closure survive disposal
assert.equal(art.notice.show, '')
})
test('quality setter retains the selected default, empty arrays, option identity and setter-only descriptor', () => {
const { art } = fixture()
const updates = []
art.controls = { update: option => updates.push(option) }
qualityMix(art)
const options = [{ html: 'first', url: 'a' }, { html: 'chosen', url: 'b', default: true }, { html: 'later', url: 'c', default: true }]
art.quality = options
assert.equal(updates[0].html, 'chosen')
assert.equal(updates[0].selector, options)
assert.deepEqual(updates[0].style, { marginRight: '10px' })
const empty = []
art.quality = empty
assert.equal(updates[1].html, '')
assert.equal(updates[1].selector, empty)
const descriptor = Object.getOwnPropertyDescriptor(art, 'quality')
assert.equal(descriptor.get, undefined)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
assert.equal(art.quality, undefined)
})
function fixture() {
const art = new Emitter()
const registered = new Set()
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { Emitter, installProgressInteractions, getPosFromEvent, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry } = await loadModules({
Emitter: 'packages/artplayer/src/utils/emitter',
installProgressInteractions: { file: 'packages/artplayer/src/control/progress/interactions', name: 'installProgressInteractions' },
getPosFromEvent: { file: 'packages/artplayer/src/control/progress/position', name: 'getPosFromEvent' },
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
})
function mouse(name, clientX = 60, button = 0) {
return Object.assign(new Event(name), { clientX, button })
}
function fixture() {
const progress = Object.assign(new EventTarget(), { clientWidth: 100, getBoundingClientRect: () => ({ left: 10 }) })
const indicator = new EventTarget()
const control = Object.assign(new EventTarget(), { querySelector: () => indicator })
const effects = []
const art = Object.assign(new Emitter(), {
template: { $progress: progress },
duration: 200,
isRotate: false,
events: {
proxy(target, name, callback) {
target.addEventListener(name, callback)
return () => target.removeEventListener(name, callback)
},
remove: cleanup => cleanup(),
},
})
Object.defineProperty(art, 'seek', { get: () => 0, set: value => effects.push(['seek', value]) })
art.on('setBar', (kind, percentage) => effects.push(['setBar', kind, percentage]))
beginLifecycle(art)
ownEntry(art, control)
installProgressInteractions(art, control)
return { art, progress, control, indicator, effects }
}
test('progress position clamps outside edges and retains its time and percentage calculations', () => {
const f = fixture()
assert.deepEqual(getPosFromEvent(f.art, mouse('click', 60)), { second: 100, time: '01:40', width: 50, percentage: 0.5 })
assert.equal(getPosFromEvent(f.art, mouse('click', -100)).percentage, 0)
assert.equal(getPosFromEvent(f.art, mouse('click', 1000)).percentage, 1)
f.progress.clientWidth = 0
assert(Number.isNaN(getPosFromEvent(f.art, mouse('click', 10)).percentage))
})
test('live progress clicks and drags preserve setBar before seek and mouseup stops dragging', () => {
const f = fixture()
f.progress.dispatchEvent(mouse('click'))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['seek', 100]])
f.effects.length = 0
f.progress.dispatchEvent(mouse('mousedown'))
f.art.emit('document:mousemove', mouse('mousemove', 85))
f.art.emit('document:mouseup', mouse('mouseup'))
f.art.emit('document:mousemove', mouse('mousemove', 100))
assert.deepEqual(f.effects, [['setBar', 'played', 0.75], ['seek', 150]])
})
for (const mode of ['control removal', 'destruction', 'source replacement']) {
test(`progress click does not seek after ${mode} inside setBar`, () => {
const f = fixture()
f.art.on('setBar', () => {
if (mode === 'control removal')
releaseEntry(f.control)
else if (mode === 'destruction')
getScope(f.art).dispose()
else
beginSource(f.art)
})
f.progress.dispatchEvent(mouse('click'))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
})
}
test('progress drag loses its source when media is replaced and a new mousedown can start again', () => {
const f = fixture()
f.progress.dispatchEvent(mouse('mousedown'))
beginSource(f.art)
f.art.emit('document:mousemove', mouse('mousemove', 85))
assert.deepEqual(f.effects, [])
f.progress.dispatchEvent(mouse('mousedown'))
f.art.emit('document:mousemove', mouse('mousemove', 60))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['seek', 100]])
})
test('progress drag does not seek when its control is removed inside setBar', () => {
const f = fixture()
f.art.on('setBar', () => releaseEntry(f.control))
f.progress.dispatchEvent(mouse('mousedown'))
f.art.emit('document:mousemove', mouse('mousemove', 60))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
})
test('progress geometry reentry cannot emit or seek after disposal', () => {
const f = fixture()
f.progress.getBoundingClientRect = () => {
getScope(f.art).dispose()
return { left: 10 }
}
f.progress.dispatchEvent(mouse('click'))
assert.deepEqual(f.effects, [])
})
test('a newer nested progress click supersedes the earlier seek', () => {
const f = fixture()
let nested = false
f.art.on('setBar', () => {
if (!nested) {
nested = true
f.progress.dispatchEvent(mouse('click', 85))
}
})
f.progress.dispatchEvent(mouse('click', 60))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5], ['setBar', 'played', 0.75], ['seek', 150]])
})
test('right-button mousedown and mouseup inside setBar do not continue dragging', () => {
const f = fixture()
f.progress.dispatchEvent(mouse('mousedown', 60, 2))
f.art.emit('document:mousemove', mouse('mousemove', 85))
assert.deepEqual(f.effects, [])
f.art.on('setBar', () => f.art.emit('document:mouseup', mouse('mouseup')))
f.progress.dispatchEvent(mouse('mousedown'))
f.art.emit('document:mousemove', mouse('mousemove', 60))
assert.deepEqual(f.effects, [['setBar', 'played', 0.5]])
})
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { screenshotMix, beginLifecycle, getScope, beginSource } = await loadModules({
screenshotMix: 'packages/artplayer/src/player/screenshotMix',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
})
function fixture(t) {
const calls = []
const callbacks = []
const context = { drawImage: (...args) => calls.push(['draw', ...args]) }
const canvas = {
width: 0,
height: 0,
getContext: () => context,
toDataURL: type => `data:${type};base64,frame`,
toBlob: callback => callbacks.push(callback),
}
const anchor = { style: {}, click: () => calls.push(['download', anchor.download]), remove: () => calls.push(['remove']) }
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document')
Object.defineProperty(globalThis, 'document', { configurable: true, value: {
createElement: tag => tag === 'canvas' ? canvas : anchor,
body: { appendChild: () => {} },
} })
t.after(() => previous ? Object.defineProperty(globalThis, 'document', previous) : delete globalThis.document)
const video = { videoWidth: 320, videoHeight: 180, currentTime: 62 }
const art = { template: { $video: video }, notice: { show: '' }, emit: (...args) => calls.push(args) }
beginLifecycle(art)
screenshotMix(art)
return { art, canvas, video, context, calls, callbacks, scope: getScope(art) }
}
test('screenshot methods retain immutable bound descriptors, PNG dimensions and filename', async (t) => {
const f = fixture(t)
for (const name of ['getDataURL', 'getBlobUrl', 'screenshot']) {
const descriptor = Object.getOwnPropertyDescriptor(f.art, name)
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
assert.equal(descriptor.writable, false)
}
const screenshot = f.art.screenshot
assert.equal(await screenshot(), 'data:image/png;base64,frame')
assert.equal(f.canvas.width, 320)
assert.equal(f.canvas.height, 180)
assert.deepEqual(f.calls, [
['draw', f.video, 0, 0],
['download', 'artplayer_01:02.png'],
['remove'],
['screenshot', 'data:image/png;base64,frame'],
])
await screenshot('custom.png')
assert.deepEqual(f.calls.at(-3), ['download', 'custom.png.png'])
})
test('capture rejects the original drawing and security errors through both methods', async (t) => {
const f = fixture(t)
const failure = new Error('tainted canvas')
f.context.drawImage = () => {
throw failure
}
await assert.rejects(f.art.getDataURL(), error => error === failure)
await assert.rejects(f.art.getBlobUrl(), error => error === failure)
assert.equal(f.art.notice.show, failure)
assert.equal(f.callbacks.length, 0)
})
test('synchronous toBlob failure updates notice before returning its rejected Promise', async (t) => {
const f = fixture(t)
const failure = new Error('encoding failed synchronously')
f.canvas.toBlob = () => {
throw failure
}
const promise = f.art.getBlobUrl()
assert.equal(f.art.notice.show, failure)
await assert.rejects(promise, error => error === failure)
})
test('missing canvas context rejects both public captures', async (t) => {
const f = fixture(t)
f.canvas.getContext = () => null
await assert.rejects(f.art.getDataURL(), { name: 'TypeError', message: /context/ })
await assert.rejects(f.art.getBlobUrl(), { name: 'TypeError', message: /context/ })
})
test('throwing notice setter becomes a rejection even in an asynchronous callback', async (t) => {
const f = fixture(t)
const failure = new Error('consumer notice setter failed')
Object.defineProperty(f.art.notice, 'show', {
get: () => '',
set() {
throw failure
},
})
const promise = f.art.getBlobUrl()
const rejected = assert.rejects(promise, error => error === failure)
assert.doesNotThrow(() => f.callbacks[0](null))
await rejected
})
test('late capture rejection after destruction does not update notice', async (t) => {
const f = fixture(t)
const promise = f.art.getBlobUrl()
const rejected = assert.rejects(promise, /encode/)
f.scope.dispose()
f.callbacks[0](null)
await rejected
assert.equal(f.art.notice.show, '')
})
test('asynchronous Blob URL failures reject without escaping the canvas callback', async (t) => {
const f = fixture(t)
const failure = new Error('object URL allocation failed')
t.mock.method(URL, 'createObjectURL', () => {
throw failure
})
const promise = f.art.getBlobUrl()
const rejected = assert.rejects(promise, error => error === failure)
assert.doesNotThrow(() => f.callbacks[0](new Blob(['frame'])))
await rejected
assert.equal(f.art.notice.show, failure)
})
test('null Blob rejects without allocating a URL or leaving the promise pending', async (t) => {
const f = fixture(t)
const allocate = t.mock.method(URL, 'createObjectURL')
const promise = f.art.getBlobUrl()
const rejected = assert.rejects(promise, /encode|blob/i)
assert.doesNotThrow(() => f.callbacks[0](null))
await rejected
assert.equal(allocate.mock.callCount(), 0)
})
test('concurrent Blob captures settle independently and transfer URL ownership to consumers', async (t) => {
const f = fixture(t)
const revoke = t.mock.method(URL, 'revokeObjectURL', () => {})
t.mock.method(URL, 'createObjectURL', blob => `blob:${blob.size}`)
const first = f.art.getBlobUrl()
const second = f.art.getBlobUrl()
f.callbacks[1](new Blob(['bb']))
f.scope.dispose()
f.callbacks[0](new Blob(['a']))
assert.deepEqual(await Promise.all([first, second]), ['blob:1', 'blob:2'])
assert.equal(revoke.mock.callCount(), 0)
})
test('destruction during screenshot await preserves its result without download or event', async (t) => {
const f = fixture(t)
const promise = f.art.screenshot()
f.scope.dispose()
assert.equal(await promise, 'data:image/png;base64,frame')
assert.deepEqual(f.calls, [['draw', f.video, 0, 0]])
})
test('source replacement during screenshot await suppresses stale download and event', async (t) => {
const f = fixture(t)
const promise = f.art.screenshot()
beginSource(f.art)
assert.equal(await promise, 'data:image/png;base64,frame')
assert.deepEqual(f.calls, [['draw', f.video, 0, 0]])
})
+209 -1
View File
@@ -4,13 +4,15 @@ 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({
const { Emitter, urlMix, switchMix, playMix, beginLifecycle, getScope, positionRestoration, currentTimeMix } = 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' },
positionRestoration: { file: 'packages/artplayer/src/source/restore-position', name: 'positionRestoration' },
currentTimeMix: 'packages/artplayer/src/player/currentTimeMix',
})
function createArt(customType) {
@@ -33,6 +35,212 @@ function createArt(customType) {
return art
}
test('source assignment does not revoke a caller-owned Blob URL still used elsewhere', async (t) => {
const art = createArt()
const blob = new Blob(['shared media'], { type: 'video/mp4' })
const url = URL.createObjectURL(blob)
t.after(() => URL.revokeObjectURL(url))
art.url = url
art.url = 'next.mp4'
assert.equal(await (await fetch(url)).text(), 'shared media')
getScope(art).dispose()
assert.equal(await (await fetch(url)).text(), 'shared media')
})
test('quality restoration waits for a pending native seek before restoring playback rate', async () => {
const art = createArt()
let settled = false
const switching = art.switchQuality('next.mp4').then(() => {
settled = true
})
art.playbackRate = 1
art.template.$video.seeking = true
art.emit('video:loadedmetadata')
art.emit('video:canplay')
await Promise.resolve()
assert.equal(settled, false)
assert.equal(art.playbackRate, 1)
assert.equal(art.currentTime, 37)
art.template.$video.seeking = false
art.emit('video:seeked')
await switching
assert.equal(settled, true)
assert.equal(art.playbackRate, 1.5)
assert.deepEqual(Object.keys(art.e), [])
})
test('a switch waiting for seeked settles on cancellation and ignores the late seek', async () => {
const art = createArt()
const switching = art.switchQuality('next.mp4')
art.playbackRate = 1
art.template.$video.seeking = true
art.emit('video:canplay')
getScope(art).dispose()
await switching
art.template.$video.seeking = false
art.emit('video:seeked')
assert.equal(art.playbackRate, 1)
assert.deepEqual(Object.keys(art.e), [])
})
test('a missed native quality seek is corrected once and settles after the second seeked', async () => {
const art = createArt()
let time = 37
const writes = []
Object.defineProperty(art, 'currentTime', {
get: () => time,
set(value) {
writes.push(value)
time = value
art.template.$video.seeking = true
},
})
let settled = false
const switching = art.switchQuality('next.mp4').then(() => {
settled = true
})
art.emit('video:loadedmetadata')
art.emit('video:canplay')
time = 0
art.template.$video.seeking = false
art.emit('video:seeked')
await Promise.resolve()
assert.equal(settled, false)
assert.deepEqual(writes, [37, 37])
assert.equal(art.playbackRate, 1.5)
// Even a second native miss must not start an unbounded correction loop.
time = 0
art.template.$video.seeking = false
art.emit('video:seeked')
await switching
assert.deepEqual(writes, [37, 37])
assert.deepEqual(Object.keys(art.e), [])
})
test('public seek during quality loading wins over metadata restoration and native correction', async () => {
for (const beforeMetadata of [false, true]) {
const art = createArt()
const switching = art.switchQuality('next.mp4')
art.template.$video.seeking = true
if (!beforeMetadata)
art.emit('video:loadedmetadata')
art.currentTime = 6
art.emit('seek', 6)
if (beforeMetadata)
art.emit('video:loadedmetadata')
art.template.$video.seeking = false
art.emit('video:canplay')
await switching
assert.equal(art.currentTime, 6)
assert.deepEqual(Object.keys(art.e), [])
}
})
test('direct public currentTime writes during quality loading supersede automatic restoration', async () => {
for (const beforeMetadata of [false, true]) {
const art = createArt()
art.duration = 100
art.template.$video.currentTime = 37
currentTimeMix(art)
const switching = art.switchQuality('next.mp4')
art.template.$video.seeking = true
if (!beforeMetadata)
art.emit('video:loadedmetadata')
art.currentTime = 6
if (beforeMetadata)
art.emit('video:loadedmetadata')
art.template.$video.seeking = false
art.emit('video:canplay')
await switching
assert.equal(art.currentTime, 6)
assert.deepEqual(Object.keys(art.e), [])
}
})
test('position restoration preserves clamping and native frame rounding', () => {
let time = 0
const video = { seeking: false }
const writes = []
const art = {
template: { $video: video },
get currentTime() { return time },
set currentTime(value) {
writes.push(value)
time = Math.min(10, value)
video.seeking = true
},
}
const restoration = positionRestoration(art, 37, () => true)
restoration.restore()
time = 9.99
video.seeking = false
assert.equal(restoration.ready(), true)
assert.deepEqual(writes, [37])
time = 0
assert.equal(restoration.ready(), false)
assert.deepEqual(writes, [37, 10])
})
test('a reentrant position read cannot correct a cancelled or manually superseded seek', () => {
for (const boundary of ['cancel', 'manual']) {
let active = true
let reenter = false
let time = 0
let restoration
const writes = []
const art = {
template: { $video: { seeking: true } },
get currentTime() {
if (reenter) {
if (boundary === 'cancel')
active = false
else
restoration.manual()
}
return time
},
set currentTime(value) {
writes.push(value)
time = value
},
}
restoration = positionRestoration(art, 37, () => active)
restoration.restore()
art.template.$video.seeking = false
time = 6
reenter = true
assert.equal(restoration.ready(), boundary === 'manual')
assert.deepEqual(writes, [37])
}
})
test('synchronous correction seeked reentry resumes a source only once', async () => {
const art = createArt()
art.playing = true
let time = 37
let correcting = false
Object.defineProperty(art, 'currentTime', {
get: () => time,
set(value) {
time = value
art.template.$video.seeking = !correcting
if (correcting)
art.emit('video:seeked')
},
})
const switching = art.switchQuality('next.mp4')
art.emit('video:loadedmetadata')
art.emit('video:canplay')
time = 0
correcting = true
art.template.$video.seeking = false
art.emit('video:seeked')
await switching
assert.equal(time, 37)
assert.equal(art.play.mock.callCount(), 1)
assert.deepEqual(Object.keys(art.e), [])
})
test('superseded switches settle before readiness and only the latest restores state/restarts', async () => {
const art = createArt()
const restarts = []
+340
View File
@@ -0,0 +1,340 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
import { test } from 'node:test'
import { loadModules } from './helpers/load.js'
const { thumbnailsMix, Emitter, beginLifecycle, getScope, beginSource, ownEntry, releaseEntry, loadImg, loadThumbnailImage } = await loadModules({
thumbnailsMix: 'packages/artplayer/src/player/thumbnailsMix',
Emitter: 'packages/artplayer/src/utils/emitter',
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
beginSource: { file: 'packages/artplayer/src/source/operation', name: 'beginSource' },
ownEntry: { file: 'packages/artplayer/src/component/resources', name: 'ownEntry' },
releaseEntry: { file: 'packages/artplayer/src/component/resources', name: 'releaseEntry' },
loadImg: { file: 'packages/artplayer/src/image/load', name: 'loadImg' },
loadThumbnailImage: { file: 'packages/artplayer/src/image/load', name: 'loadThumbnailImage' },
})
const flush = () => new Promise(resolve => setImmediate(resolve))
function fixture(t, overrides = {}) {
const images = []
const callbacks = []
const errors = []
const revoked = []
const warnings = []
class FakeImage {
width = 1000
height = 600
naturalWidth = 1000
src = ''
onload = null
onerror = null
constructor() {
images.push(this)
}
removeAttribute(name) {
if (name === 'src')
this.src = ''
}
}
const canvas = { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toBlob: callback => callbacks.push(callback) }
for (const [name, value] of Object.entries({ Image: FakeImage, document: { createElement: () => canvas } })) {
const previous = Object.getOwnPropertyDescriptor(globalThis, name)
Object.defineProperty(globalThis, name, { configurable: true, value })
t.after(() => previous ? Object.defineProperty(globalThis, name, previous) : delete globalThis[name])
}
t.mock.method(URL, 'createObjectURL', () => 'blob:thumbnail')
t.mock.method(URL, 'revokeObjectURL', url => revoked.push(url))
t.mock.method(console, 'warn', (...args) => warnings.push(args))
const art = Object.assign(new Emitter(), {
option: { thumbnails: { url: 'first.png', number: 100, column: 10, width: 100, height: 60, scale: 1, ...overrides }, isLive: false },
template: { $progress: { clientWidth: 1000 }, $video: { videoWidth: 320, videoHeight: 180 } },
controls: { thumbnails: { style: {} } },
})
beginLifecycle(art)
ownEntry(art, art.controls.thumbnails)
thumbnailsMix(art)
function bar(kind, percentage, event) {
for (const listener of art.e?.setBar || []) {
const result = listener.fn(kind, percentage, event)
if (result?.catch)
result.catch(error => errors.push(error))
}
}
return { art, canvas, images, callbacks, errors, revoked, warnings, scope: getScope(art), bar, hover: percentage => bar('hover', percentage) }
}
test('thumbnail setter preserves option identity, live restrictions and immutable descriptor', (t) => {
const f = fixture(t)
const descriptor = Object.getOwnPropertyDescriptor(f.art, 'thumbnails')
assert.equal(descriptor.enumerable, false)
assert.equal(descriptor.configurable, false)
const option = { url: 'next.png', number: 4 }
f.art.thumbnails = option
assert.equal(f.art.thumbnails, option)
f.art.thumbnails = { url: '' }
assert.equal(f.art.thumbnails, option)
f.art.option.isLive = true
f.art.thumbnails = { url: 'live.png' }
assert.equal(f.art.thumbnails, option)
})
test('thumbnail layout matches generated sprite cells and preserves progress endpoint handling', async (t) => {
const f = fixture(t)
f.hover(0.1)
f.images[0].onload()
await flush()
const style = f.art.controls.thumbnails.style
assert.deepEqual(style, { backgroundImage: 'url(first.png)', height: '60px', width: '100px', backgroundPosition: '-0px -60px', left: '50px' })
f.hover(0.11)
assert.equal(style.backgroundPosition, '-100px -60px')
const before = { ...style }
f.hover(0)
f.hover(1)
f.hover(Number.NaN)
assert.deepEqual(style, before)
f.hover(0.001)
assert.equal(style.backgroundPosition, '-0px -0px')
assert.equal(style.left, 0)
f.hover(0.999)
assert.equal(style.backgroundPosition, '-900px -540px')
assert.equal(style.left, '900px')
})
test('replaced thumbnail configuration cannot be overwritten by an older completed load', async (t) => {
const f = fixture(t)
f.hover(0.2)
const oldLoad = f.images[0].onload
f.art.thumbnails = { ...f.art.thumbnails, url: 'second.png' }
f.hover(0.4)
f.images[1].onload()
await flush()
oldLoad()
await flush()
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(second.png)')
})
test('failed thumbnail load can retry and does not escape the internal event handler', async (t) => {
const f = fixture(t)
f.hover(0.2)
f.images[0].onerror()
await flush()
f.hover(0.3)
assert.equal(f.images.length, 2)
f.images[1].onload()
await flush()
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(first.png)')
assert.deepEqual(f.errors, [])
})
test('destroy cancels pending thumbnail rendering and removes its setBar subscription', async (t) => {
const f = fixture(t)
f.hover(0.2)
const loaded = f.images[0].onload
f.scope.dispose()
loaded()
await flush()
assert.deepEqual(f.art.controls.thumbnails.style, {})
assert.equal(f.art.e?.setBar?.length || 0, 0)
})
test('scaled thumbnail Blob URL is released once on configuration replacement', async (t) => {
const f = fixture(t, { scale: 0.5 })
f.hover(0.2)
f.images[0].onload()
f.callbacks[0](new Blob(['image']))
f.images[1].onload()
await flush()
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(blob:thumbnail)')
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
assert.deepEqual(f.revoked, ['blob:thumbnail'])
f.scope.dispose()
assert.deepEqual(f.revoked, ['blob:thumbnail'])
})
test('pending load renders only the latest hover and suppresses stale source coordinates', async (t) => {
const f = fixture(t)
f.hover(0.2)
f.hover(0.7)
f.images[0].onload()
await flush()
assert.equal(f.art.controls.thumbnails.style.left, '650px')
f.art.thumbnails = { ...f.art.thumbnails, url: 'next.png' }
const before = { ...f.art.controls.thumbnails.style }
f.hover(0.4)
beginSource(f.art)
f.images[1].onload()
await flush()
assert.deepEqual(f.art.controls.thumbnails.style, before)
f.hover(0.5)
assert.equal(f.art.controls.thumbnails.style.backgroundImage, 'url(next.png)')
})
test('removing the thumbnail control releases its image and a replacement starts a new request', async (t) => {
const f = fixture(t, { scale: 0.5 })
f.hover(0.2)
f.images[0].onload()
f.callbacks[0](new Blob(['image']))
f.images[1].onload()
await flush()
releaseEntry(f.art.controls.thumbnails)
assert.deepEqual(f.revoked, ['blob:thumbnail'])
const next = { style: {} }
f.art.controls.thumbnails = next
ownEntry(f.art, next)
f.hover(0.4)
assert.equal(f.images.length, 3)
f.scope.dispose()
})
test('cancelled scaling cannot allocate a Blob URL from a late encoder callback', async (t) => {
const f = fixture(t, { scale: 0.5 })
f.hover(0.2)
f.images[0].onload()
f.scope.dispose()
f.callbacks[0](new Blob(['image']))
await flush()
assert.equal(f.images.length, 1)
assert.deepEqual(f.revoked, [])
assert.deepEqual(f.art.controls.thumbnails.style, {})
})
test('configuration replacement during rendering stops remaining stale style writes', async (t) => {
const f = fixture(t)
let background = ''
Object.defineProperty(f.art.controls.thumbnails.style, 'backgroundImage', {
get: () => background,
set(value) {
background = value
f.art.thumbnails = { ...f.art.thumbnails, url: 'new.png' }
},
})
f.hover(0.2)
f.images[0].onload()
await flush()
assert.equal(background, 'url(first.png)')
assert.equal(f.art.controls.thumbnails.style.width, undefined)
assert.equal(f.art.thumbnails.url, 'new.png')
})
test('public loadImg preserves function name, arity, original image and no-scaling inputs', async (t) => {
const f = fixture(t)
assert.equal(loadImg.name, 'loadImg')
assert.equal(loadImg.length, 2)
for (const scale of [undefined, 0, 1, Number.NaN]) {
const pending = loadImg('direct.png', scale)
const image = f.images.at(-1)
image.onload()
assert.equal(await pending, image)
assert.equal(image.src, 'direct.png')
assert.equal(image.onload, null)
assert.equal(image.onerror, null)
}
assert.equal(f.callbacks.length, 0)
})
test('public scaled loadImg transfers a successfully decoded Blob URL without revoking it', async (t) => {
const f = fixture(t)
const pending = loadImg('public.png', 0.5)
f.images[0].onload()
assert.deepEqual([f.canvas.width, f.canvas.height], [500, 300])
f.callbacks[0](new Blob(['image']))
f.images[1].onload()
assert.equal(await pending, f.images[1])
f.scope.dispose()
assert.deepEqual(f.revoked, [])
assert.equal(f.images[1].src, 'blob:thumbnail')
})
test('internal image cancellation settles undefined and detaches the image handlers', async (t) => {
const f = fixture(t)
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
const image = f.images[0]
const staleLoad = image.onload
f.scope.dispose()
assert.equal(await pending, undefined)
assert.equal(image.onload, null)
assert.equal(image.onerror, null)
assert.equal(image.src, '')
staleLoad()
assert.equal(f.callbacks.length, 0)
assert.equal(await loadThumbnailImage('closed.png', 1, f.scope), undefined)
assert.equal(f.images.length, 1)
})
test('null encoding result rejects public image loading without a callback escape', async (t) => {
const f = fixture(t)
const pending = loadImg('empty.png', 0.5)
const rejected = assert.rejects(pending, /Unable to encode thumbnail image/)
f.images[0].onload()
assert.doesNotThrow(() => f.callbacks[0](null))
await rejected
assert.equal(f.images.length, 1)
})
test('scaled image failure releases its Blob exactly once and preserves the original URL in errors', async (t) => {
const f = fixture(t)
const pending = loadThumbnailImage('failed.png', 0.5, f.scope)
const rejected = assert.rejects(pending, { message: 'Image load failed: failed.png' })
f.images[0].onload()
f.callbacks[0](new Blob(['image']))
f.images[1].onerror()
await rejected
assert.deepEqual(f.revoked, ['blob:thumbnail'])
f.scope.dispose()
assert.deepEqual(f.revoked, ['blob:thumbnail'])
})
test('image scaling preserves thrown draw errors and rejects a missing canvas context', async (t) => {
const f = fixture(t)
const failure = new Error('draw failed')
f.canvas.getContext = () => ({
drawImage() {
throw failure
},
})
const first = loadImg('first.png', 0.5)
const rejected = assert.rejects(first, error => error === failure)
assert.doesNotThrow(() => f.images[0].onload())
await rejected
f.canvas.getContext = () => null
const second = loadImg('second.png', 0.5)
const missing = assert.rejects(second, { name: 'TypeError', message: 'Canvas 2D context is unavailable' })
assert.doesNotThrow(() => f.images[1].onload())
await missing
})
test('destruction during object URL allocation revokes the URL without starting a scaled image', async (t) => {
const f = fixture(t)
t.mock.method(URL, 'createObjectURL', () => {
f.scope.dispose()
return 'blob:cancelled'
})
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
f.images[0].onload()
f.callbacks[0](new Blob(['image']))
assert.equal(await pending, undefined)
assert.deepEqual(f.revoked, ['blob:cancelled'])
assert.equal(f.images.length, 1)
})
test('destruction inside the scaled image constructor does not start a late image request', async (t) => {
const f = fixture(t)
const ImageClass = globalThis.Image
Object.defineProperty(globalThis, 'Image', { configurable: true, value: class extends ImageClass {
constructor() {
super()
if (f.images.length === 2)
f.scope.dispose()
}
} })
const pending = loadThumbnailImage('cancel.png', 0.5, f.scope)
f.images[0].onload()
f.callbacks[0](new Blob(['image']))
assert.equal(await pending, undefined)
assert.deepEqual(f.revoked, ['blob:thumbnail'])
assert.equal(f.images[1].src, '')
assert.equal(f.images[1].onload, null)
})
+18
View File
@@ -0,0 +1,18 @@
import type { CaptureMedia } from '../../packages/artplayer/src/capture/frame'
import type { ScreenshotHost } from '../../packages/artplayer/src/player/screenshotMix'
import type Artplayer from '../../packages/artplayer/types/artplayer'
import screenshotMix from '../../packages/artplayer/src/player/screenshotMix'
declare const art: Artplayer
declare const host: ScreenshotHost
declare const video: HTMLVideoElement
const media: CaptureMedia = video
const data: Promise<string> = art.getDataURL()
const blob: Promise<string> = art.getBlobUrl()
const screenshot: Promise<string> = art.screenshot('frame')
const installed: void = screenshotMix(host)
// @ts-expect-error Screenshot names remain strings.
art.screenshot(123)
// @ts-expect-error Captures return promises, not synchronous data URLs.
const sync: string = art.getDataURL()
export { blob, data, installed, media, screenshot, sync }
+29
View File
@@ -0,0 +1,29 @@
import type { ThumbnailsHost } from '../../packages/artplayer/src/player/thumbnailsMix'
import type { ThumbnailGeometry } from '../../packages/artplayer/src/thumbnails/layout'
import type Artplayer from '../../packages/artplayer/types/artplayer'
import type { Thumbnails } from '../../packages/artplayer/types/option'
import { loadImg, loadThumbnailImage } from '../../packages/artplayer/src/image/load'
import ResourceScope from '../../packages/artplayer/src/lifecycle/scope'
import thumbnailsMix from '../../packages/artplayer/src/player/thumbnailsMix'
import { thumbnailLayout } from '../../packages/artplayer/src/thumbnails/layout'
declare const art: Artplayer
declare const host: ThumbnailsHost
declare const geometry: ThumbnailGeometry
const scope = new ResourceScope()
const installed: void = thumbnailsMix(host)
const option: Thumbnails = { url: 'sprite.png', column: 10, number: 100 }
art.thumbnails = option
const current: Thumbnails = art.thumbnails
const image: Promise<HTMLImageElement> = loadImg('sprite.png', 0.5)
const preview: Promise<HTMLImageElement | undefined> = loadThumbnailImage('sprite.png', 0.5, scope)
const layout = thumbnailLayout(option, geometry)
const width: string = layout.width
const left: number | string = layout.left
// @ts-expect-error Scale remains a number.
loadImg('sprite.png', 'half')
// @ts-expect-error Thumbnail configuration requires a URL.
art.thumbnails = { number: 100 }
// @ts-expect-error An owned request may be cancelled before producing an image.
const guaranteed: Promise<HTMLImageElement> = loadThumbnailImage('sprite.png', 1, scope)
export { current, guaranteed, image, installed, left, preview, width }